Skip to content

Add a game

A game in this repository is one Python module that the command line, the terminal viewers, compare, audit, the session protocol, the web site, our own service and the teaching pages all pick up without any game-specific code of their own. This page is for a contributor adding one: it walks from an empty file to a game that is played, watched, compared, audited, recorded, explained and taught, and lists everything a reviewer will check before it is merged. It assumes you have run make setup and read Development and automation; the game pages under Examples show what a finished game looks like to its readers.

Every step names the files it touches and the test that keeps it honest. Breakout was added by following this page from top to bottom, so when a step is unclear, its module (src/grounded_decision/examples/games/breakout*.py) is the worked example.

What you write, and what you get

You write You get, with no other code
The rules (a TurnGame), agents, a gate and its checks grounded-decision play, compare, audit and record, every viewer and style, a person's seat (guarded or not)
cells(state) and a few optional drawing hooks the terminal board in four styles, colour and no-colour, zoom, the fixed panel, the web board
WORLDS and an observe(state) --world MODE and mixtures, the regime rules (clairvoyance refused where it is undefined, "assumption not met" on probabilistic claims)
A lesson module and a teaching page /learn/GAME, with every position and number computed by Python
One entry in GAME_MODULES the game in play --list, the catalogue the web site and our service read, the gallery, the agents reference

1. Start from the scaffold

make new-game NAME=pong
uv run pytest tests/test_games_pong.py

grounded-decision new-game NAME (the same thing without make) copies the template game (games/template_game.py, a race to N: two players add 1, 2 or 3 to a total; whoever reaches N wins) to games/pong.py, and writes tests/test_games_pong.py. The template has every name of the module contract, an exact gate with its proof, an audit oracle and a brute-force test of its theorem, so the new test passes before you edit anything. Keep it passing as you replace the race with your rules: it runs the whole contract (section 10) on every change.

The scaffold never edits shared files; it prints the registration step (section 9) instead.

2. Lay the module out

A small game is one module; a large one splits by responsibility, as the architecture standard asks for every game:

Responsibility Where Breakout Tetris
Geometry and rules: the board, the state, apply GAME_rules.py (or GAME_physics.py) breakout_physics.py, breakout_rules.py tetris_rules.py, tetris_realtime.py
Search and solvers, with their own bounded caches GAME_search.py breakout_search.py in tetris_realtime.py
The TurnGame, agents, gate, worlds, presentation, run(), the contract constants GAME.py breakout.py tetris.py

The rules that every game follows (the standard reviewers apply):

  • Values are @dataclass(frozen=True, slots=True); owned mutable internals are @dataclass(slots=True). No NamedTuple.
  • Behaviour lives with its data: board.drop(piece), not drop(board, piece). A module-level function may not take the board or the state as its first parameter.
  • The state is immutable: apply(state, move) returns a new state and never changes the one it was given (snake, for speed, replaces its world in place and makes reading an old state raise StaleStateError; that is the only allowed exception, and the contract checks for it).
  • Invariants are checked in __post_init__ with a clear ValueError; derived data is cached on the instance, never in module globals.
  • The module docstring maps each concept to the class that owns it, and states what is proved and what is only measured.

3. The module contract

Every name below is read with getattr, so optional names can simply be left out. The canonical lists are the docstrings of games/play.py (module names) and games/views.py (drawing hooks); tests/game_contract.py checks them.

Module-level names

Name Required What it is
ID yes "games_NAME": the game's example id in the example library.
TITLE yes One line: "Games: Pong with an exact intercept gate".
run(*, as_json=False) yes The example: prints a deterministic report (never timing), returns {"id", "title", "records", "summary"}. Its output is a golden snapshot.
GAME yes The game at its default size: an instance, or a zero-argument factory (a class).
agents() yes {name: factory}, weakest first. A factory takes no argument or a keyword seed, and returns a fresh agent each call.
DEFAULT_AGENTS yes One agent name per seat (a single name fills every seat).
GATED_AGENT with a gate The gated agent's name (default "gated").
guarded(proposer) with a gate The gate around any proposer, a person included (--guarded).
DEFAULT_MODE no "watch" (default) or "you" (play GAME seats a person).
configure(width, height, *, ...) no The game at another size (--size) and with rule options by keyword; raises ValueError with a sentence. The keywords beyond the size are the game's rule options (section 9).
SIZES with configure A SizeRule (games/sizes.py): every size the board may take, its default and usual sizes; the game checks sizes with it, --list and the web's size picker show it. None: a fixed size.
OPTIONS no {keyword: OptionRule} (games/options.py): each rule option's range or values, the option values it needs, whether a browser can offer it; --list and the web's settings show it, and a test checks configure takes exactly that.
SIZE_PRESETS no {name: (width, height)} for --size NAME.
BOARDS, STARTS, LAYOUTS no {name: sentence} of variants for --board, --start, --layout; the first is the default.
WORLDS, DEFAULT_WORLD, INDEPENDENT_WORLDS with randomness See section 5.
AGENT_DESCRIPTIONS, AGENT_DETAILS yes One line and one paragraph per agent (play GAME --list, --explain, the agents reference).
CHECK_DESCRIPTIONS yes One sentence per check the gate can run; sizes the panel (one row per check).
EXAMPLE_COMMANDS yes Real commands for play GAME --list; a test parses every one.
advice(state) no Text shown to a person before each move.
oracles(), DEFAULT_ORACLE for audit Judges with name, unit, value(state, move), best(state).
regime_table() for audit The information-regime price table (games/audit.py).
default_agents(players) no Line-ups for games with a choice of player count.
COMPARE_OPENING_PLIES no Random opening plies compare plays before the agents (default 2 for two-player games with a fixed start, else 0).
LIVE_DELAY no Seconds per move in the live view (default 0.05 for one player, 0.6 for more). A configured game's live_delay attribute wins.
PANEL_NOTE_ROWS no Panel rows for notes, when decisions explain themselves at length.

The game object: rules

TurnGame (games/engine.py): name, player_names, initial_state(seed), to_move(state), legal_moves(state), apply(state, move), outcome(state) (a GameOutcome; a finished game names its reason in one sentence), render(state) (plain ASCII, no trailing newline) and describe_move(state, move) (short: the panel shows 60 characters). legal_moves and outcome run on every move: keep them cheap (cache on the state).

Optional rule methods: parse_move(state, text) (a person's notation; without it they type a number) and move_notation(state, move) (the text that names a move, if the description does not parse back); check_names (an attribute: the checks this configured game's gate runs, when it differs from CHECK_DESCRIPTIONS, as Tetris's two modes do; GameModule.check_names_for(game) reads it); controls (real time, section 8).

The game object: information

observe(state) returns what a player may see (no random number generator, no hidden cells); foresight(state, horizon) returns the future for clairvoyant agents; resample(state, seed) the same present with a fresh future (rollouts); bind_agents(agents) returns the game bound to the agents, for a world that responds to them. See sections 4 and 5.

The game object: drawing

Games describe boards by meaning; games/views.py draws them. All are optional except cells, which every style but plain render needs.

Hook What it returns
cells(state) Rows of CellKind, top to bottom, all the same length (pad with OFF_BOARD).
cell_details(state) {(row, column): CellDetail}: order (gradients, a tile's colour), connections (joined bodies, jump paths), heading (an arrow), label (a digit), tint (a seat's territory) and extent (the part of the cell a thing covers, for things that move by less than a cell).
cell_paths(state) Ordered CellPaths (the snake's body), cheaper than a detail per cell.
move_cells(state, move) The cells a move touches, origin first, from the position before it. Highlights the last move and, on an override, the rejected and chosen destinations.
overlays(state) Highlights the game knows (Tetris's ghost piece).
move_overlays(before, move) How the last move is highlighted, instead of origin and destination.
transition(before, move, after) In-between pictures (TransitionFrame) the live view and the web board animate. Never needed for correctness.
move_event(before, move, after) What a move did, in a few words (MoveEvent); the panel keeps the last three.
select(state, text) A person picked a piece: the targets to highlight (Selection).
stats(state) Running counters for the panel. The same keys on every move.
stats_schema Every key stats may ever return, when the start does not have them all.
standings(state), placings(state), ranking(state) Multi-player progress bars, places and rankings.
focus(state), board_shape(state), cells_window(...) Huge boards: what to keep in view, cheaply.
cell_offsets(state), layout Hex boards (games/geometry.py).
coordinates(state), cell_labels(state), row_labels(state) --coords.
sparkline_stat The stat the panel's sparkline follows.

A new kind of thing on a board is a new CellKind member in views.py, with a glyph in every table (UNICODE_GLYPHS, ASCII_GLYPHS, NO_COLOUR_GLYPHS), a colour (FOREGROUND), a PRIORITY for the dense styles, a look on the web board (section 15) and make protocol (section 14). Reuse a kind when the meaning is the same.

4. Agents and information regimes

Give the game a ladder of agents, weakest first: random (seeded), a simple heuristic, a strong search or solver, and gated (a fast proposer behind exact checks). Every agent is a small dataclass with a name and decide(game, state) -> Decision; agents read everything size-dependent from game or state, never from module globals.

Each agent declares its information regime (games/regimes.py) with an attribute regime:

  • Regime.WORST_CASE: assumes nothing about the future; its guarantees hold in every world.
  • Regime.PROBABILISTIC: bets on a model of the future (usually "the random world"); its statements are Clopper–Pearson intervals and are reported with "assumption not met" in any other world.
  • Regime.CLAIRVOYANT: a benchmark that sees the future. It implements decide_with_foresight(game, state, foresight) (its plain decide raises), and reads foresight.events(n), which calls the game's foresight(state, n).

The engine enforces the regimes: online agents receive game.observe(state), which must hold no random number generator (the contract checks it with find_randomness), so they cannot read the future even by accident.

5. Worlds

A game with randomness lets the world pick it (games/worlds.py):

  • WORLDS = {"random": ..., ...}: mode to one sentence, random (the standard game) first; DEFAULT_WORLD = "random".
  • configure(..., world="random") accepts any text parse_world accepts (MODE or the mixture MODE:p, which follows MODE on each event with chance p and random otherwise: draw the coin with WorldSpec.use_mode(rng)), and the game exposes world (the canonical text).
  • INDEPENDENT_WORLDS: the modes whose future is fixed by the seed alone. Clairvoyance is defined only there; the registry refuses a clairvoyant agent anywhere else.
  • An adaptive adversary that simulates the actual agent binds to it through bind_agents; one that best-responds to a shared evaluation (Tetris's Bastet-style pieces, Breakout's capsules) needs no binding, but its future depends on the moves all the same.
  • Draw every random event as a pure function of (seed, index) (random.Random(f"GAME:{tag}:{seed}:{index}")), so simulations that branch agree, resample needs only a new seed, and the same seed plays the same game in CPython and in the browser.

6. The gate

The gated agent is the grounded pattern: a proposer suggests a move, checks test it against facts you can prove, and the move is played if all pass; otherwise a fallback named in advance is played.

  • Each check is a CheckResult(name, passed, explanation); the explanation is one sentence a player understands, with the numbers. Put the checks in a small dataclass (WinCheck(state).checks(move)); GatedAgent(name, proposer, checks, fallback, regime) runs them.
  • The proof obligation: the fallback only ever plays moves that pass the checks whenever such a move exists. State the theorem in the module docstring, prove it there, and test it against brute force on small boards.
  • guarded(proposer) wraps any proposer, a person included; the registry sets the person's certify so the prompt can list the moves the gate would pass.
  • Every check the gate can run is in CHECK_DESCRIPTIONS; a configured game that runs fewer declares check_names.

7. The fixed panel

The panel beside the board keeps the same rows on every frame of a match, from the first to GAME OVER (views.PanelLayout); the live terminal view, the history view and the web panel all rely on it. So:

  • stats(state) returns the same keys on every move. Decide optional keys from the starting position, or list them all in stats_schema. Show a placeholder ("-") rather than leaving a key out.
  • Every check has a row (CHECK_DESCRIPTIONS), every note slot is reserved (PANEL_NOTE_ROWS), long text is cut with …, never wrapped.
  • tests/test_games_panel_layout.py (every registered game, every style, two terminal sizes) and the contract's check_panel fail when a frame's height or a row's column moves.

8. Real-time games

A real-time game is still a TurnGame: one move is one tick, and the mover chooses one action per tick (Tetris in real time, Breakout). Give the configured game a controls attribute, a RealtimeControls (games/realtime.py): the seconds per tick, the idle action played when no key is pressed, and the key bindings (repeat=True for a move that acts every tick while its key is held). repeat_last=True makes a tick with no key, or with a key whose move is not legal, repeat the last move, and the clock wait for the first key (Snake: it keeps its heading, and turning back into the neck is ignored). With it:

  • play GAME --agents you runs on the terminal clock (games/play.py, RealtimeTerminal): the terminal goes raw, keys are read without blocking, the ticks advance on their own, p pauses and q or Ctrl-C quits and restores the terminal. When input is not a terminal (a pipe, a test), each tick reads one line instead.
  • The session protocol sends the controls in SessionInfo.controls (protocol 1.5; repeat_last 1.8), and the web play page runs the same clock (RealtimeKeys and LiveSession in web/packages/models): ticks spaced evenly at the game's pace (the speed menu changes it), the keys pressed or held between two ticks, never the keyboard's auto-repeat; a tap shorter than a tick still acts once, the pause key stops the clock, and so does hiding the page.
  • live_delay (seconds per tick) paces the live view when agents play.

9. Register it

  1. src/grounded_decision/examples/games/__init__.py: add "NAME": "grounded_decision.examples.games.NAME" to GAME_MODULES and "games_NAME": ("NAME", TITLE) to _EXAMPLE_TITLES, and a line to the module docstring.
  2. Rule options: each keyword of configure beyond the size is a command-line flag, declared once in RULE_FLAGS (games/options.py): name, help naming the game, kind (int, text or switch). play, compare, audit and record read it, and the gallery prints it back. The web play page's settings list the same options in RuleOption.ALL (web/packages/models/src/play.ts): add yours there too (a test fails when a game's option is missing). Reuse a flag another game has when the meaning and type are the same.
  3. Make targets in the Makefile's games section, following the pattern: play-NAME, watch-NAME and audit-NAME.

10. Tests every game must have

tests/test_games_NAME.py starts with the contract:

from game_contract import GameContract


def test_meets_the_game_contract() -> None:
    GameContract.load("NAME").check_all()

check_all runs, in order: the required names; fresh agents and valid defaults; every agent, check and world described; worlds (random first, each configures, mixtures); deterministic seeded starts and an apply that keeps its input; deterministic, legal matches with short descriptions and scalar metrics; rectangular cells with in-bounds details, extents, overlays and move cells and a fixed stats schema; a text that names every legal move; no randomness reachable by online agents; gate checks the panel has rows for; a panel whose height never changes; and a session that opens, plays and encodes the same frames twice. tests/test_games_contract.py runs it on every registered game. Then add the game's own tests:

  • rules against hand-made positions, and move counts where known;
  • every theorem against brute force or an exact reference on small boards (the gate's theorem first);
  • determinism by seed; every world (including the regime refusals and the "assumption not met" caveat);
  • the command line: play, compare, audit, record and --list;
  • anything portable across CPython and the browser (exact integer or rational arithmetic, seeded draws) recorded in tests/test_portability.py;
  • a generous performance bound if the game searches.

Everything in tests/test_games*.py also runs inside Pyodide (make web-pyodide-test); mark tests that need a terminal, a subprocess or the clock @pytest.mark.needs_native.

Then, for the example's golden output, run only your snapshot:

UPDATE_SNAPSHOTS=1 uv run pytest tests/test_example_snapshots.py -k games_NAME

and read the new files in tests/snapshots/examples/.

11. The docs page

Write docs/examples/NAME.md to the docs standard: the rules, what each agent does, what the gate proves and what is only measured, real output from grounded-decision play and a real grounded-decision compare table (copied from runs, with the command), and a "Try it" section. Add it to nav in mkdocs.yml, link it from docs/examples/games.md, list games_NAME in docs/examples/index.md, and run make docs-agents (it regenerates docs/reference/agents.md from your descriptions; a test fails when it is stale).

Add a CuratedReplay to GALLERY and a short one to GOLDEN in src/grounded_decision/session/gallery.py, then make replays. A test records every committed replay again and fails when a rule or an agent changed it.

13. Lessons and claims

The teaching page /learn/NAME explains one idea on real positions:

  1. src/grounded_decision/session/lessons/NAME.py: analyse(game, state) (numbers, tags, routes and facts on cells) and LESSON, a LessonPlan of scenes; add the game to LESSON_GAMES.
  2. make lessons writes web/apps/site/static/lessons/NAME.json.
  3. web/apps/site/src/lib/learn/pages/NAME.svelte, following the others, and an entry in LEARN_TOPICS ($lib/learn/topics.ts).
  4. Every theorem on the page sits in <Claim test="tests/…::test_name">; make claims records the claimed tests, and tests/test_site_claims.py fails for a game with no page, a page with no claim, or a claim whose test is missing, skipped or slow.

14. Protocol regeneration

The game reaches the web through the session protocol, which is generated from the Python dataclasses. describe.json (the catalogue) and the Pyodide golden frames cover every registered game; the other golden conversations (CONVERSATIONS in session/artefacts.py) are a fixed choice of games, which you extend only if the new game exercises something they do not. Run make protocol (with make web-setup done, it also writes the TypeScript types) whenever you add a CellKind or an Overlay, change a protocol dataclass or the catalogue's contents, and commit what it writes. A new field on a protocol dataclass is a minor version (PROTOCOL_VERSION) with a default value; the protocol reference has the rules and Add a protocol operation the steps.

15. The web

Nothing per game, except:

  • The board. A new CellKind needs a look in web/packages/board/src/looks.ts (CELL_LOOKS and CELL_NAMES are Records over the generated enumeration, so the type check fails until it has one) and, if no existing piece look fits, a case in CellPainter.drawPiece (painter.ts). Colours and glyphs come from tokens.json, generated from views.py.
  • Input. The play page turns clicks and taps into moves from each legal move's move_cells, directions from moves named up/down/ left/right and placements from moves that cover three or more cells. A game whose moves fit none of these is played from the list of legal moves.
  • Pages. /play/NAME, /compare/NAME and /audit/NAME are prerendered for every game in the catalogue; the teaching page is section 13. Add NAME to the GAMES list of web/tests/e2e/site.spec.ts, so its play page is checked with axe (in both colour schemes) and for console errors. A game with a choice of player count also names its seats in PlayView.svelte (Chinese checkers does). Add a web page covers anything new.

make check WEB=1 and make web-e2e run the web checks.

16. Our own service: nothing to do

grounded-decision serve (grounded_decision.service) answers the session protocol with the package's own Endpoint, which reads the catalogue from GAME_MODULES. A registered game is served the moment it is installed: its catalogue entry, sessions, compare and audit requests go through the same code as in the browser, under the same limits. There is no per-game routing, configuration or deployment step. tests/test_session_protocol.py already opens, steps and analyses every registered game with the clock and threads blocked, so a game that reads the wall clock or starts a thread (which the service and the browser cannot allow) fails there.

Definition of done

  • [ ] make new-game scaffold replaced by the game's rules; module docstring maps concepts to classes and states what is proved and what is measured.
  • [ ] Frozen slotted dataclasses, behaviour with its data, no NamedTuple, no board-first free functions; invariants in __post_init__.
  • [ ] Agents ladder with regimes; observe hides the future; a clairvoyant benchmark where hidden randomness exists.
  • [ ] Worlds: random first, an adversarial and a kind world where meaningful, mixtures, INDEPENDENT_WORLDS.
  • [ ] Gate: checks with explanations, a fallback that satisfies them, the theorem proved in the docstring and tested against brute force.
  • [ ] Fixed panel: stats keys fixed, every check described.
  • [ ] Registered: GAME_MODULES, _EXAMPLE_TITLES, rule flags in options.py, make targets.
  • [ ] tests/test_games_NAME.py: the contract, rules, theorems, worlds, regimes, CLI, determinism; portable values in tests/test_portability.py.
  • [ ] Snapshot of run() reviewed; make docs-agents; docs page with real output and a real compare table, in the nav and the indexes.
  • [ ] make protocol (new kinds or protocol changes), board looks for new kinds, make replays with gallery and golden entries.
  • [ ] Lesson, teaching page, topic and claims (make lessons, make claims).
  • [ ] CHANGELOG "Added" entry; a design decision if you chose between real alternatives.
  • [ ] make check WEB=1 and make web-e2e green; make clean leaves a clean tree.