Every game is played thousands of times headless by seeded bots before a person plays it. The sims
measure the difficulty curve, whether skill matters, how soon the first win moment comes and whether
the twist happens; a bands file says what "balanced" means; every balance number lives in one tuning
file. This skill builds the harness once (src/core/game-balance/, test/sims/write-sim-report.ts),
each game's bots, sims and bands, and the check that judges the numbers.
Rules that must hold
- Bots play only through the pure engine contract (
create,listMoves,applyMove,outcome), headless in plain Node viajest.sim.config.js, never through the UI. Why: the same rules the app runs are measured, in seconds, with no React Native mock. - Every run is deterministic: seeds
1…seedsPerCell, the bot's own seeded RNG, noMath.random,Date,performance.now, retries, skips or focus; the report has no timestamps. Why: a rerun gives identical bytes, so a diff of the report shows exactly what a change did, and chosen seeds could shop for numbers. - Every run has a hard move cap, and no run may reach it (
capHits= 0). Why: a stuck or endless game is a rules bug that a bot finds long before a player does. - Use the three standard players:
random(the baseline), a reasonable player (the testing spec'sbot:greedyPolicyby default) andlookahead(the upper bound; it sees the seeded future). In a game that draws during play (dice, cards, spawns), the reasonable player is a rule a person could follow and never peeks at the next draw:greedyPolicywould. Skill must matter: random < reasonable < lookahead by a clear step. Why: if naive play does as well as sensible play, the choices are fake; a peeking player measures nothing a person meets. - A sim asserts properties of at least 100 seeds per cell, never a single outcome, and writes
reports/sim/<game-id>.jsonbefore its assertions. Why: one game proves nothing about balance, and a failing run must still leave numbers to read. - Every balance number lives in
src/games/<game-id>/rules/<game-id>-tuning.ts(real-time games:sim/<game-id>-tuning.ts, a'worklet'module): documented knobs plus a per-difficulty table read throughknobsFor; the rules contain no other balance numbers. Why: one place to tune, and a diff shows every balance change. - Difficulty is one scale, whole numbers 0..100, never a table index.
knobsFor(d)returns the endless row at 100 (ENDLESS_DIFFICULTY, no goal, never won) and otherwise the rowrowFor(d, rows)picks, with areduceover the table; the grid samples the lower bound of each row ([0, 25, 50, 75]for four rows), and an endless mode gets its ownendlessblock at 100, never a step of the curve. Why: levels (0..99), a daily and saved runs all use this scale; a 0..3 index makes every level above 3 play the hardest row. - Balance is a contract in
test/sims/<game-id>/balance-bands.json: the bands hold, the curve moves monotonically byminStep, the skill gap holds, the first payoff comes early, the twist happens, and the endless run is never won and stays inside its own bands. Never widen a band, cut or change seeds, or tune the bot to go green. Why: bands are the design record; moving them hides the problem instead of fixing the game. - The fun-within-seconds kill test: on the easiest level the reasonable player reaches the first
payoff within 1-3 moves (2 for single-tap moves; seconds for real-time games:
runSimBotwithpayoffStepTicks: 120) in at least 90 % of runs, and the twist fires at least once per run. If even the lookahead bot cannot, stop and report to the developer. Why: prototype bots showed ideas fail here structurally (a loop-closing snake caught nothing in 13 foreseen moves; a crash puzzle's twist came 0.25 times per game). - A report counts only for the code it measured: its fingerprint covers the game's
rules/,levels/(without the generatedpack-*.json),sim/,geom/andtesting/folders, the sim file and thesrc/corefiles they import (the sim's import closure), and game logic imports nothing outside them; rerunnpm run test:simafter any change there. Why: stale numbers approve code that no longer exists. - Change the game, not the player: improve a bot's evaluation only to match reasonable human play, never to reach a band; decide the payoff and twist tags before the first run. Why: a bot or a tag tuned to the bands measures nothing.
- A person's play-test is the final judge, and it never blocks: bands stay
"proposed"until a person has played on an iPhone and an Android phone and agreed, then become"approved"with a date. Until then every report lists it under "Human checks (not blocking)", andcheck-balance.mjs --releaseprints aHUMAN CHECK (not blocking)line, not a problem. Why: bots measure pace, not joy; the person's feel wins whenever it comes, and nothing waits for it. - Bot numbers hold on both platforms only through pure, seeded rules and fixed ticks. Sims run in Node (V8), the app on Hermes on iOS and Android; a real-time sim counts fixed ticks, never display frames (60 Hz Android phones, 120 Hz iPhones). Why: a balance claim that depends on the engine, the frame rate or the device is not a claim about the game.
Workflow
- Pick the job. First harness setup: step 2. A game's bots and sims: steps 3-8. Retuning: steps 6-8. Judging a new idea early: read references/fun-and-kill-test.md, then steps 3-5 at the easiest difficulty only. Always finish with steps 9-10.
- Harness (once per app). Read references/bots-and-sims.md. The
game kit (
src/core/game/,src/core/rng/sfc32.ts) comes from game-rules-engine. Runnode ${CLAUDE_SKILL_DIR}/scripts/check-balance.mjs .; for everyharness-missing,harness-outdatedorsim-configline copy the file fromtemplates/verbatim (src/core/game-balance/*with tests,test/sims/write-sim-report.tswith its test,jest.sim.config.js), keep"test:sim": "jest --ci --config jest.sim.config.js"inpackage.json, and make the unit project ofjest.config.jsignore\\.sim\\.test\\.(both are in the canonical configs). Runnpx jest src/core/game-balance test/sims/write-sim-report.test.ts --ci --selectProjects unit(paths first:--selectProjectsswallows every following word). - Tuning file. Read references/tuning-playbook.md first. The
game already has
rules/<game-id>-tuning.tsfrom game-rules-engine's template. Name the knobs for the game (document each per-difficulty knob once, on the knobs type), move every balance number of the rules into it and read them throughTUNINGandknobsFor(difficulty), keeping therowFor+reducelookup. A game with an endless mode adds the endless row and returns it first forisEndlessDifficulty(difficulty): examples/pig/src/games/pig/rules/pig-tuning.ts. - Bot hooks, test-first. Copy
templates/src/games/__GAME_ID__/testing/__GAME_ID__-bot.tsand its test (Lights Out's hooks; replace__GAME_ID__,__GAME_PASCAL__and__GAME_CAMEL__) and rewrite them for the game:evaluate<Game>,scoreOf<Game>(the number the HUD shows),tag<Game>Event(payoff and twist, decided now) and the reasonable player; then set it asbotintesting/<game-id>-testing.ts. A game that draws during play writes the reasonable player as a rule (examples/pig/src/games/pig/testing/pig-bot.ts). When the levels are proven by the reasonable player (a witness), put the evaluation inrules/<game-id>-evaluate.tsand re-export it:levels/may not importtesting/. A real-time game exports itsSimBotGamehooks and theidle,wanderanddodgecommand policies (examples/arena-chase/src/games/arena-chase/testing/arena-chase-bot.ts). - Sim and bands. Copy
templates/test/sims/__GAME_ID__/totest/sims/<game-id>/, replace the placeholders, set the grid to the lower bound of each tuning row (the template's[0, 34, 67]are Lights Out's three), name the policies, and for an endless mode replace"endless": nullwith its block (references/balance-contract.md, "Endless mode"). Runnpx eslint --fixandnpx prettier --writeon it, thennpx jest test/sims/<game-id> --ci --config jest.sim.config.js. The first run writes the report even if the bands fail. Real-time games play throughrunSimBotinstead oftraceBot(examples/arena-chase/test/sims/arena-chase/balance.sim.test.ts). - Read and tune. Fix what is broken first (cap hits, replay differences, no early payoff, no
twist), then tune one knob at a time with the tuning loop and the symptom table, rerunning the sim
after each change and reading
lossReasons. - Write the bands around the tuned numbers with a margin and a
whyper band, and the story innotesin plain words; keep"status": "proposed". - Kill test verdict. If the first payoff or the twist cannot be reached after reasonable tuning, stop and tell the developer (fun-and-kill-test.md, "When a game fails"). Do not polish a game that fails it.
- Run the check (run, fix, rerun until
RESULT: PASS):npm run test:sim, thennode ${CLAUDE_SKILL_DIR}/scripts/check-balance.mjs .(add--game <game-id>for one game). Options, config keys and troubleshooting: references/checker.md. - Report and hand over. Put the plain-language "Bots" line per difficulty (numbers from the
report) in the evidence report and "Play-test on an iPhone and an Android phone" under
"Human checks (not blocking)" until a person has done it; when they agree, set
"status": "approved"and"approvedOn". Before a release run the check with--release: it passes with proposed bands and prints theHUMAN CHECK (not blocking)line to copy into the release report. The sims pass before level-generation-and-solvers generates the level tables; a tuning change after they exist regenerates them in the same commit with aGate-Change:trailer.
Definition of done
- The harness files and their unit tests are in place and green;
npm run test:simexists and never runs insidenpm test. - Each game has a documented tuning file on the 0..100 scale (
rowFor+reduce, an endless row only with an endless mode) with its test, bot hooks with tests (a non-peeking reasonable player when the game draws during play),test/sims/<game-id>/balance.sim.test.tsandbalance-bands.jsonwithnotes(grid 0..99, the endless block when the game has one). -
npm run test:simpasses: no cap hits, identical replays, every band, the curve, the skill gap, the first payoff and the twist; one sim file runs in about 60 s or less. -
reports/sim/<game-id>.jsonis fresh and its numbers are in the evidence report in plain words. - For a release: the check passes with
--release; eachHUMAN CHECK (not blocking)line is in the report's "Human checks (not blocking)" list. -
node ${CLAUDE_SKILL_DIR}/scripts/check-balance.mjs .printsRESULT: PASS
A passing run may print SKIP src/core/game/testing/play-bot.ts [harness-missing] not yet created (game-rules-engine creates it)
before the kit is installed. An app with no game prints
NOT APPLICABLE: the app has no game (no src/games/<game-id>/ folder with rules/ or sim/).
Anti-patterns
- Balancing by playing a few games by hand, or by one seed. Balance is a distribution; run 100+
seeds per cell and read the spread (
p10Moves,p90Moves,lossReasons). - Loosening a band, lowering
minStepor trimming seeds to get green. Change a knob, or bring the developer a design question. - A smarter bot to rescue a failing curve, or a greedy bot that peeks at the next roll. If random ≈ reasonable, the game's choices are weak; fix the rules. A peeking bot never loses a turn's points and proves nothing.
- Renaming the payoff after a failing run. Decide the tags first and write why in
notes. - Balance numbers scattered through the rules (
hearts: 3increate.ts). Move them into the tuning file with a comment. - A sim that needs React Native, a clock or
Math.random. The rules must be pure and seeded. - Trusting a report after changing the rules. The fingerprint makes it stale; rerun the sims.
- Tuning the whole difficulty table at once. Change one knob, rerun, compare the two reports.
- Difficulty as a table index, or 100 in the grid. Use
rowForand theendlessblock. table[index] ?? table[0]. The fallback never runs and fails the per-file branch threshold.- Polishing a game that fails the kill test. Art and sound cannot fix a first payoff that never comes; stop and ask the developer.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/bots-and-sims.md | The harness, what a bot needs, the three players, non-peeking players, bot hooks, tags, the sim file, real-time games, determinism on iOS and Android, speed, debugging, reporting, multi-package | Workflow steps 2, 4 and 5 |
| references/balance-contract.md | The report fields, the fingerprint, the bands file, endless mode, setting bands, approval as a human check, how the check judges | Steps 6, 7 and 10 |
| references/tuning-playbook.md | The tuning file, knob families, the curve, the tuning loop, symptoms and what to turn, the Pig tuning log, levels and daily | Steps 3 and 6 |
| references/fun-and-kill-test.md | Why seconds matter, the kill test, what prototype bots revealed, openings built for an early payoff, choosing payoff and twist, when to stop | Steps 1 and 8 |
| references/checker.md | The checker's folders, options, config keys, every rule, expected SKIP/NOT APPLICABLE/HUMAN CHECK lines, troubleshooting | Step 9, when a line is unclear |
templates/src/core/game-balance/ |
trace-bot, bot-policies, sim-stats, balance-bands, parse-balance-bands, run-sim-bot, each with its test |
Step 2 (copy verbatim) |
| templates/test/sims/write-sim-report.ts | SIM_LAYOUT, rulesFingerprint over the sim's import closure (fingerprintFiles) and writeSimReport |
Step 2 |
| templates/test/sims/write-sim-report.test.ts | Its test: unrelated core or screen files and generated level tables leave the fingerprint unchanged; an imported core file (even through another), a rules file or the level plan changes it | Step 2 |
| templates/jest.sim.config.js | Plain-Node Jest config for *.sim.test.ts (synced canonical file; do not edit here) |
Step 2 |
| templates/test/sims/GAME_ID/balance.sim.test.ts | A game's sim: plays the grid, writes the report, asserts the bands | Step 5 |
| templates/test/sims/GAME_ID/balance-bands.json | The template game's balance contract (Lights Out's measured bands on three rows, endless: null) |
Steps 5 and 7 |
| templates/src/games/GAME_ID/testing/GAME_ID-bot.ts | Lights Out's bot hooks: evaluate (fewer lit cells, a board one press from dark valued as a person sees it), scoreOf, tag, the greedy reasonable player |
Step 4 |
| templates/src/games/GAME_ID/testing/GAME_ID-bot.test.ts | Its test | Step 4 |
templates/src/core/game/ |
Synced copies (assets/shared.json) of game-rules-engine's game-engine.ts, difficulty.ts and testing/play-bot.ts (with tests), so the templates compile alone; never edit here, keep that skill's files |
Never copy over that skill's kit |
templates/src/games/__GAME_ID__/rules/ |
Synced copies of game-rules-engine's template game (Lights Out: types, tuning with its test, create, toggle-cells, list-moves, outcome, apply-move) that the bot template and the self-test need; never edit here | Step 3, as the tuning model |
examples/pig/ |
Pig, the turn-based model: the tuned tuning file with an endless row, a non-peeking reasonable player and its test, the sim, bands with an endless block and the real report (the game's rules are game-rules-engine's Pig example) | Steps 3-7 for a game that draws during play or has an endless mode |
examples/arena-chase/ |
The real-time model: a fixed-step sim and 'worklet' tuning file (+ replay tests), SimBotGame hooks and the idle/wander/dodge command policies (+ tests), a runSimBot sim, bands in ticks and seconds, and its real report |
Steps 3-7 for a real-time game |
scripts/check-balance.mjs |
Checks the sim config, harness, sims, seeds, bot hooks, tuning file, fingerprint scope and bands, and judges each report; --release prints unapproved bands as a HUMAN CHECK line |
Steps 2, 9 and 10 |
scripts/lib/balance.mjs |
Folders and options, the JavaScript port of the fingerprint and the bands rules | When changing the checker |
scripts/selftest.mjs |
Proves the checker passes the templates and both examples and catches every planted bug | After changing the checker, a template or an example |
scripts/check-lib.mjs |
Shared script library, synced (do not edit here) | Never by hand |
assets/shared.json |
Declares the synced files (check-lib, the sim Jest config, game-rules-engine's kit and template rules copies) | When adding a shared file |
assets/template-placeholders.json |
The values the self-test and template verification fill in (lights-out) |
When adding a placeholder |
tests/build-fixtures.mjs |
Writes the overlay cases (ops, EXPECT lines with computed file:line, re-stamped fingerprints) | After changing a template, an example or the checker |
tests/bases/app/ |
The minimal app the fixtures lay the templates and examples on, with Lights Out's real report | When the fixture base changes |
tests/fixtures/ |
check-balance/: good/, one bad-* per rule and planted bug, pass-* (before the kit, wrapped table, release with proposed bands, core growth, packs after sims, installed skills, no game, custom games folder, one game) and error-* cases |
When adding a rule |
Related skills
game-rules-engine- the engine contract, the kit, the determinism policy and the sfc32 PRNG the bots drive.level-generation-and-solvers- solvers, par, stars, level tables and the witness that plays them.realtime-game-loop- the fixed-step sims thatrunSimBotplays headless.new-game-scaffold- where a new game's tuning file, bot hooks and sim start.quality-gates-test:siminside the verify plan and the gated paths.git-commits-and-reporting- the evidence report, Human checks (not blocking) and the Gate-Change trailer.unit-and-component-tests- the Jest projects,jest.sim.config.jsand the coverage gates.audio-and-haptics-feedback- sounds and haptics for the payoff moments the sims find.