GDD.md and playtest.json
Two files at the root of a game repo carry what an agent cannot get from the code: GDD.md says what the game is meant to be and feel like, and playtest.json says how to prove it plays. Both are updated in the same commit as the code they describe.
GDD.md
GDD.md is the game's design document and its memory. An agent starts each session knowing only what is written in the repo, so anything worth keeping goes here: what makes this game this game, the decisions already made and why, and every problem a player reported so it never comes back.
The game-design-doc skill (.claude/skills/game-design-doc/SKILL.md) sets the habit for every task:
- Read
GDD.mdfirst. If it is missing, create it fromgdd-template.mdin the skill's folder. - Do the work inside it: its rules hold unless the player asks otherwise.
- Update it before finishing, in the same commit as the code.
The template repo has no GDD.md; the agent writes one on the first task, from your words or a reference.
The format
This is gdd-template.md, the skeleton the agent fills in:
# <Game title>: Game Design Document
**Pitch:** one sentence a player would repeat to a friend.
**Reference:** what it's trying to feel like, and which parts to take from it.
## Pillars
Two to four sentences. When a decision is close, these break the tie.
## The player
- **Character:** who they are and what they look like.
- **Controls:** what each thumb does, and how they learn it.
- **Core loop:** what you do over and over, and why it's fun.
- **Goal and failure:** how you win, what happens when you fail, what you keep.
## Dynamics
| Moment | Trigger | Move | Camera | World | Sound | State |
|---|---|---|---|---|---|---|
State is built, next or not possible yet.
## Camera
The default angle, what the player can change, when it returns, and which moments take over.
## World
Look (palette, materials, light, what's far away), the areas in order, and what's alive in them.
## Sound
Music, ambience, and what you should be able to hear when you close your eyes.
## Rules
Things that must always hold, each one a line. Add one when the player reports something wrong.
## Assets
What's generated, how, and anything to know before regenerating it.
## Not built yet
What's next, in order.
Dynamics: the part that matters most
The skill's main point: a game feels like a tech demo when a character runs and jumps through a static world. The cure is a list of dynamics, small moments where something specific happens, each with all five parts. This is the skill's own example:
| Part | Question | Example: hanging from a cliff |
|---|---|---|
| Trigger | What starts it? | Jumping at a wall too tall to clear |
| Move | What does the character do, and who's in control? | Leaps arms-up, catches the edge, hangs until the player climbs or lets go |
| Camera | Where does the view go? | Swings to the side, so you see the drop |
| World | What reacts? | Pebbles fall, dust puffs |
| Sound | What do you hear? | Palms slap on rock; wind gets louder |
In GDD.md that moment is one row of the Dynamics table:
| Moment | Trigger | Move | Camera | World | Sound | State |
|---|---|---|---|---|---|---|
| Cliff hang | Jumping at a wall too tall to clear | Leaps arms-up, catches the edge, hangs until the player climbs or lets go | Swings to the side to show the drop | Pebbles fall, dust puffs | Palms slap on rock; wind gets louder | next |
The skill aims for one new dynamic every 20 seconds of play, each different. A moment missing its camera, world or sound is half built.
Keeping it useful
- Rules are scar tissue. When you report a problem, the agent fixes it and adds a line under Rules ("walls are solid all the way down") so it stays fixed.
- Change what stopped being true. A wrong GDD is worse than none.
- Keep it short: a page or two of facts, no history of attempts (that is what commits are for), no secrets or player data.
- Reference videos: the skill pulls frames with
ffmpeg -i ref.mp4 -vf fps=2 frames/%03d.jpgand fills the five parts for each moment it sees. Study a reference; never put someone else's characters, art or footage in the game.
Other skills start from the GDD: game-router (genre), game-ui (look), character-game (moves, camera), sound-design (the sound list comes from the dynamics), models and sprites (art direction), saves and realtime-backend (what lasts and what is shared).
playtest.json
playtest.json is a short script that plays the game with real touches and checks that the game answered. The playtest skill (.claude/skills/playtest/SKILL.md) asks for one in every game and for an update whenever screens, button labels or controls change.
A real example
This is the template's own playtest.json, for Sunset Sprint:
{
"steps": [
{ "expect": "Accelerate" },
{ "wait": 4 },
{ "mark": "playing" },
{ "remember": "kilometres per hour" },
{ "hold": "Accelerate", "seconds": 3 },
{ "expectChange": "kilometres per hour" },
{ "repeat": [
{ "hold": "Accelerate", "seconds": 4 },
{ "hold": "Steer right", "seconds": 0.2 },
{ "hold": "Accelerate", "seconds": 4 },
{ "hold": "Steer left", "seconds": 0.2 }
], "forSeconds": 22 },
{ "expect": "Lap" }
]
}
It waits for the gas control, marks the start of play, holds the gas, checks that the speed readout changed, drives around for 22 seconds steering gently both ways, and checks that a lap counter shows.
Steps
| Step | What it does |
|---|---|
{ "press": "RIDE", "timeout": 20 } | Taps what has this label, waiting for it (15 s unless timeout says otherwise). |
{ "hold": "GAS", "seconds": 3 } | Keeps a finger on it. |
{ "tap": [0.5, 0.7] } | Taps a point, as fractions of the screen (for a canvas). |
{ "drag": [[0.2, 0.6], [0.8, 0.6]] } | A quick swipe between two points. |
{ "drag": [[0.25, 0.85], [0.25, 0.45]], "seconds": 3 } | A slow drag that takes that long (10 s at most): how a stick is held over. |
{ "wait": 2 } | Waits, in seconds. |
{ "expect": "Lap 1" } | Fails unless this label shows up in time. |
{ "remember": "hud-score" } | Notes what that label says now. |
{ "expectChange": "hud-score" } | Fails unless it changes from what was remembered. |
{ "mark": "playing" } | Required. Play has started. |
{ "repeat": [...], "forSeconds": 25 } | Loops these steps. A repeat cannot contain another repeat. |
{ "repeat": [...], "until": "combo", "forSeconds": 25 } | Loops until this label shows up (or any of a list); fails if none ever does. |
At the top level, next to steps, an optional "clipSeconds" (5 to 30) shortens the recorded clip for a round too short to fill the usual 12 seconds. Times are positive and at most 300 seconds.
Writing one that works
-
Steps find things by
accessibilityLabelortestID: the exact label first, then one containing the words. Prefer atestID: a label like "0" also matches "BEST 0". Text drawn in a Skia or WebGPU canvas is invisible to the test, so give every control you press and every number you check a label on a React Native view. The same labels make the game usable with a screen reader.import { Text } from 'react-native';export function ScoreLabel({ score }: { score: number }) {return <Text testID="hud-score" accessibilityLabel={`Score ${score}`}>{score}</Text>;} -
Start as a new player. Each test starts with no saves, so write the first-time path (the tutorial, "Begin", not "Continue").
-
Check that the game answered: a score, a lap, a word on screen. A test that only taps and waits proves nothing.
-
Play by rhythm. Each touch takes about a second to send, so a test cannot react. For a reflex game pick an input loop that survives.
-
Mark after the menus, once the player is in control. If a round can end during the test, press the replay button inside the loop.
What runs it
Inside the Moonspell app, after a build lands, a Mac in the hosted pipeline opens the exact build players download on an iOS simulator, plays playtest.json with real touches through agent-device, and records the screen. If every step passes, about 12 seconds of the recording after { "mark": "playing" } become the game's video on the app's feed; if a step fails, the game keeps its cover. The runner is scripts/playtest-game.mts in the Moonspell repo. It needs the Moonspell app, its database and its video account, so it does not run against your own game.
Outside the Moonspell app nothing runs playtest.json for you. It is still worth keeping: it documents the path into play, and the labels it relies on keep the game testable. To check that the file is well formed, run the same parser the pipeline uses, from the root of the Moonspell repo (Node 24 with type stripping):
node --experimental-strip-types --input-type=module -e "import { parsePlaytest } from './src/lib/playtest.ts'; import { readFileSync } from 'node:fs'; const test = parsePlaytest(JSON.parse(readFileSync('templates/crescent-game/playtest.json', 'utf8'))); console.log('ok:', test.steps.length, 'steps')"
It prints ok: 8 steps for the template's file, or throws with the step that is wrong (for example a file without { "mark": "playing" }). Point it at your own game's playtest.json by changing the path. It checks the format only; it does not play the game.