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¶
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). NoNamedTuple. - Behaviour lives with its data:
board.drop(piece), notdrop(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 raiseStaleStateError; that is the only allowed exception, and the contract checks for it). - Invariants are checked in
__post_init__with a clearValueError; 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 "therandomworld"); 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 implementsdecide_with_foresight(game, state, foresight)(its plaindecideraises), and readsforesight.events(n), which calls the game'sforesight(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 textparse_worldaccepts (MODEor the mixtureMODE:p, which followsMODEon each event with chancepandrandomotherwise: draw the coin withWorldSpec.use_mode(rng)), and the game exposesworld(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,resampleneeds 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'scertifyso 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 declarescheck_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 instats_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'scheck_panelfail 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 youruns on the terminal clock (games/play.py,RealtimeTerminal): the terminal goes raw, keys are read without blocking, the ticks advance on their own,ppauses andqor 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_last1.8), and the web play page runs the same clock (RealtimeKeysandLiveSessioninweb/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¶
src/grounded_decision/examples/games/__init__.py: add"NAME": "grounded_decision.examples.games.NAME"toGAME_MODULESand"games_NAME": ("NAME", TITLE)to_EXAMPLE_TITLES, and a line to the module docstring.- Rule options: each keyword of
configurebeyond the size is a command-line flag, declared once inRULE_FLAGS(games/options.py): name, help naming the game, kind (int,textorswitch).play,compare,auditandrecordread it, and the gallery prints it back. The web play page's settings list the same options inRuleOption.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. - Make targets in the
Makefile's games section, following the pattern:play-NAME,watch-NAMEandaudit-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,recordand--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:
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).
12. Replays and the gallery¶
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:
src/grounded_decision/session/lessons/NAME.py:analyse(game, state)(numbers, tags, routes and facts on cells) andLESSON, aLessonPlanof scenes; add the game toLESSON_GAMES.make lessonswritesweb/apps/site/static/lessons/NAME.json.web/apps/site/src/lib/learn/pages/NAME.svelte, following the others, and an entry inLEARN_TOPICS($lib/learn/topics.ts).- Every theorem on the page sits in
<Claim test="tests/…::test_name">;make claimsrecords the claimed tests, andtests/test_site_claims.pyfails 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
CellKindneeds a look inweb/packages/board/src/looks.ts(CELL_LOOKSandCELL_NAMESareRecords over the generated enumeration, so the type check fails until it has one) and, if no existing piece look fits, a case inCellPainter.drawPiece(painter.ts). Colours and glyphs come fromtokens.json, generated fromviews.py. - Input. The play page turns clicks and taps into moves from each
legal move's
move_cells, directions from moves namedup/down/left/rightand 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/NAMEand/audit/NAMEare prerendered for every game in the catalogue; the teaching page is section 13. AddNAMEto theGAMESlist ofweb/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 inPlayView.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-gamescaffold 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;
observehides the future; a clairvoyant benchmark where hidden randomness exists. - [ ] Worlds:
randomfirst, 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 inoptions.py, make targets. - [ ]
tests/test_games_NAME.py: the contract, rules, theorems, worlds, regimes, CLI, determinism; portable values intests/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 replayswith 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=1andmake web-e2egreen;make cleanleaves a clean tree.