Create a game
games/hello is the reference implementation. Copy its structure; it is small on purpose.
Steps
- Write the spec first. Create
games/<id>/SPEC.md: the rules in plain words, the screens, what is saved, what is scored, how a round ends, and its themes — light and dark like the hub (the usual choice), or one look if the game has one by nature (Theming). Get it reviewed before writing code — it is the source of truth for the game. - Scaffold from
games/hello:package.json— name@tohe/game-<id>, same scripts;public/game.json— newid(= folder name),name,version: 0.1.0, capabilities,themes, size budget;public/icon.svg— square, with a<title>;vite.config.ts— keepbase: './'.
- Register the game: add
"@tohe/game-<id>": "workspace:*"todevDependenciesoftools/games-host/package.json, thenpnpm install. (The assembler refuses unregistered games.) - Write the rules as pure functions in
src/logic.ts— state in, state out; no DOM, no timers, noMath.random(take anRng). Test them insrc/logic.test.ts, including a random-play bot withfast-checkthat asserts the invariants of your game. - Write the two stages (ADR 0020):
src/app.tsconnects (connect({ gameId })) and holds what both share (strings, sounds, the save andpersist());src/main.tsis the menu stage — the kit’screateShellwith the game’s content andstage: () => import('./stage.ts');src/stage.tsis the play stage —start(ctx)reads input, calls the logic, draws, and returnspause,resume,newGame,leave(free everything) andquick(the panel’s live rows). Honourmuted,saveafter every move andsubmitScoreat the end of a round (a run saved mid-way reports withsession.run(id), ADR 0014). With both themes, followsettings.theme(followThemefrom the kit) and keep a palette per theme for your art. Game shell has the skeleton. - Verify:
Terminal window pnpm --filter @tohe/game-<id> testpnpm --filter @tohe/game-<id> typecheckpnpm --filter @tohe/game-<id> buildpnpm --filter @tohe/game-<id> conformance # then look at test-results/conformance/<id>*.png (one per theme) - Give it its public page.
/games/<id>/is made frompublic/game.jsonand the icon, so write thedescriptionfor someone who has never heard of the game (it is the page’s text and its search description), and giveminAgeandtags. Add the game’s how-to (3 to 5 lines), what it is in a few words and who it suits togameCopyinapps/web/src/content/games.ts—pnpm --filter @tohe/web testfails without it — then runpnpm --filter @tohe/routes build(the sitemap lists the page) andpnpm --filter @tohe/web build, which checks the page (Hosts and paths). - Play it in the hub (
pnpm dev, then rebuild@tohe/games-host) on a phone-sized window, then on a phone. Add?perfto the play page’s address to see the frame rate there (the kit’s#perfreadout).
Choosing technology
Default: Phaser 4 through @tohe/game-kit (ADR 0012,
research). games/merge-block is the reference: copy its logic.ts / view.ts /
app.ts / main.ts / stage.ts split.
| Game type | Choice |
|---|---|
| Puzzle, arcade, board, farm, RPG-lite | Phaser 4 + game kit (default) |
| Tiny one-screen games | TypeScript + Canvas 2D, like games/hello |
| UI-heavy (cards, words, quizzes) | DOM with the kit’s overlay, sfx, haptics |
| Large game | Godot 4 web export, single-threaded build (no COOP/COEP needed) |
What the kit gives you, so you do not write it again: createGame (crisp on phones, resize, pause/resume, Canvas
fallback when WebGL fails), sfx (synthesised sounds, iOS unlock), haptics, juice (popIn, squash, pulse,
burst, floatText, shake, confetti), drawTexture + crispImage (art drawn once, see below), overlay (intro / pause / game-over cards in one shared style, on the
design tokens; hintList and choiceList for their content), createShell (the game shell, ADR 0018–0020: the menu stage —
title screen with play / continue, sound, settings and how-to screens, leaderboard, the way back to Tò He, the how-to
before a first game — and the play stage, loaded in the background and created on play, with ☰ and its quick panel
(resume, sound and the game’s looks in place, a new game, the menu), phone Back, 3-2-1 for games against the clock,
the end-of-game card; every game uses it and declares "shell" in game.json), createHud
(☰ first, then the game’s score, its own middle and the best score: counting up, a record lighting up, scores
shortened to fit), bump, newRunId, followTheme + setBackground (light/dark from the hub), formatValue / formatScore.
Telemetry comes with it. Pass the session’s telemetry to the kit, createGame({ …, telemetry: session.telemetry })
and createSfx({ telemetry: session.telemetry }), and the game reports its renderer, frame times, first frame and
blocked audio; the SDK reports uncaught errors by itself. Add what only the game knows with
session.telemetry.count('game.<id>.…') or .metric('game.<id>.…-ms', value), and list the name in
Device telemetry. Never put anything about the player in a name.
Draw once, not every frame. Phaser redraws the scene up to 120 times a second on phones. A Graphics object
rebuilds its shapes on every one of those frames, and a Text redraws and re-uploads its canvas on every change.
Pieces that only change between moves (tiles, blocks with numbers, the board) are textures:
drawTexture(scene, key, w, h, ctx => …) once per look, put in the key, then crispImage. Keep Graphics for
things that really change shape every frame (measurements).
Logic emits events, the view animates them. drop(state, column) (or move, place…) returns the new state and
a list of events; the view turns each event into a short animation and never decides rules. Keep the PRNG state in
the game state, save after every move, and resume on open.
Assets
- Prefer shapes, colours, emoji and numbers drawn in code — they are crisp at any size and weigh nothing.
- Third-party art must be CC0 (e.g. Kenney.nl) and copied into the game folder — never hot-linked.
- Synthesize sounds with Web Audio where possible; keep audio files short and compressed.
Asking an agent to build a game
Use the /new-game skill, or give the agent this brief:
Build a new Tò He game. Idea: «one paragraph». Follow docs/guides/create-a-game.md and docs/contracts/game-contract-v1.md. Start by writing games/
/SPEC.md and stop for review. Then implement it with pure logic + tests + a random-play bot, and finish only when pnpm verifypasses and the conformance screenshot looks right.
A game for several phones (online rooms)
A turn-based game played at a table online (ADR 0016)
adds three things to the steps above; games/tien-len is the reference:
- A rules module:
src/module.tsexportsruleswithsetup,view,act,status,timeoutandbot(the interface), built on the same pure logic the game uses against bots. A second Vite build (vite.rules.config.ts) writes it todist/rules.js. game.json: theroomscapability and block — seats, bot levels, and the house rules as options the hub draws.- An online mode in
stage.ts: whensession.roomis set, draw the views it sends (animate their events), send actions withsession.room.actand show a refusal’s error code in words, andnext()after a hand.
Conformance then checks the module the way the API runs it, and opens the game at a table refereed by it.
Scores the server verifies (ranked runs)
A one-player game whose logic is pure and seeded can put its runs on the public boards
(ADR 0030); games/merge-block is the reference:
- A verify module:
src/verify.tsexportsverifywithreplay({ seed, moves })andbot({ seed, moves })(the interface), built on the game’s own pure logic. A second Vite build (vite.verify.config.ts) writes it todist/verify.js. game.json: therankedcapability and block — the board,minMsPerMove(how fast a person can play at the most) andmaxMoves.- In
stage.ts: asksession.ranked.start()as a new game starts (and play unranked if it answersnull), play from its seed, keep every move in the save, andsession.ranked.finish(run, moves, score)at the end. - Watching a run (ADR 0032): with
session.rankedReplay, open straight into a stage that plays its moves back from its seed with the same logic as the verify module, and saves, scores and hands in nothing (src/replay.ts; the shell’sdirectmode).
Conformance then plays runs with the module’s bot and replays them, checks that the game asks for a ranked run, and watches a run in the game, which must record nothing.