Store the moves, not the board¶
There is a decision you make on day one of a game project that quietly decides what the project can do on day two hundred. It looks like a storage question, so it feels boring, so most teams answer it by reflex: a move lands, save the board. The next move lands, save the board again. By the end of a game you have a tidy stack of snapshots, one per turn, and rendering "the position after move 12" is just reading row 12.
That reflex is wrong, and it is the kind of wrong you only learn to see after it has cost you. This project made the other choice on purpose and wrote it down as a hard rule. Architecture rule 7, stated in the overview docs: record actions, not states. Store the game's initial_state plus the ordered list of actions that were applied to it. Do not store per-move board snapshots as the source of truth. Any position you want -- move 12, move 12 from the loser's point of view, move 12 if a different card had been played -- you reconstruct by replaying the actions.
If you are new to AI-assisted development, this is exactly the species of decision worth slowing down for. It is small, it is made up front, and it is almost impossible to retrofit once a season of games has been recorded the other way. An agent will not flag it for you, because snapshot-per-move passes every test and ships every demo. It only fails later, all at once, when you ask the record to do something a pile of snapshots cannot do. So the discipline has to be a decision, not a discovery.
Rule 7, made visible in the engine¶
The reason this rule is even possible to hold is that the engine is built to make it cheap. Look at the three types in the engine base contract.
A GameState is a frozen dataclass that splits public from private (per-player) data. An Action is a frozen (player_id, action_type, data) triple. And a GameEngine is an abstract base class whose central method has this shape:
def apply_action(self, state: GameState, action: Action) -> GameState: ...
Read that signature like a contract, because it is one. apply_action takes a state and an action and returns a new state. It does not mutate the state it was handed -- the dataclasses are frozen, so it cannot. It does no I/O, opens no socket, writes no log line; the engine is pure Python with zero non-stdlib imports, by another of the project's load-bearing rules. Same (state, action) in, same new state out, every time.
That purity is what turns "replay" from a feature you build into a property you already have. If the only way a position can change is apply_action(state, action) -> state, then the full trajectory of a game is completely determined by its initial_state and the sequence of actions. The board after move 12 is not a thing you stored. It is
fold(apply_action, actions[:12], initial_state)
-- the left fold of apply_action over the first twelve actions, starting from the seed. The snapshots were never the source of truth. They were a cache of a computation you can always redo. The recorder principle in PROJECT_BRIEF.md says exactly this: store initial_state plus the ordered action log, and a scrubbing cache of intermediate boards is allowed only as an optimization layered on top, never as the primary record.
The action log is the durable, reviewable record of what happened -- which makes it a close cousin of the project's other in-repo memory surfaces. The same instinct that keeps game history as a replayable list of actions keeps project history as tickets and run records the next agent can query: the authoritative thing is the small, ordered, append-only record, and everything richer is derived from it.
One log, many readers¶
Here is the payoff that a pile of snapshots cannot match: the same recorded game serves uses that look nothing alike, because each one is just a different way of folding the same actions. PROJECT_BRIEF.md's replay section frames a replay request as a pure function, (action_log, role, time_cursor) -> rendered view -- the log is fixed, and the role filter and the time cursor are just parameters you vary. Three of those readers are concrete reasons the project pays the record-actions tax:
- Hold'em counterfactual review. The headline poker-improvement feature. You replay your own past view of a hand and ask the counterfactual -- what if I had folded here? -- against the exact actions that were applied. That question is only answerable because the record is the sequence of decisions, not a flattened final board. Snapshots tell you what happened; the action log lets you re-run what almost happened.
- Bluff post-mortems. When a hidden-information game ends, the post-mortem can reveal every uncalled bluff, because the actions that were never exposed during play are still sitting in the log, faithfully recorded. The information was hidden at render time by the role filter, not omitted from the record.
- Battleland byte-identical replay. The stochastic engine validates itself by recording a session and then replaying it to a byte-identical state. This one is not a roadmap promise -- it is in the tree today, in the relevant server module.
record_battleland_sessionruns the actions with the server's resolve-then-apply discipline;replay_battleland_sessionre-runs the recorded actions andserialize_statescompares each step'sfull_viewas canonical JSON. If record and replay ever disagree,first_state_diffreports the exact step where they diverged. That is a regression test for determinism built directly out of rule 7.
None of these required a second storage format. Counterfactual review, hidden-information reveal, and determinism verification are three folds over one log. Build the snapshot pile instead and you would be bolting on a bespoke pipeline for each -- and discovering, halfway through the counterfactual one, that the snapshots never recorded the decision you now need to vary.
Why this is the substrate for fairness and verification¶
There is a second reason the action log earns its keep, and it matters more the moment games become competitive. Because the record is small, ordered, and re-runnable, it is auditable. You can take the action log of a tournament game and replay it move for move, checking each applied action against what the server says it applied. A disputed result is not a matter of trust; it is a diff between a fresh replay and the recorded outcome.
A snapshot pile cannot give you that. A snapshot is the server asserting "this is the board now," and to check it you would have to trust the snapshot that is itself the thing in question. An action log is falsifiable: hand it to an independent replayer and the truth recomputes itself. That is the property fairness audits and tournament-replay verification are built on -- the authoritative record is exactly the list of decisions, so re-running the decisions is the audit.
It is also why the action log is a debugging surface and not just an archive. When a recorded run misbehaves, you do not guess from a final board; you replay the ordered actions and watch where the trajectory goes wrong -- the same posture the project takes with its structured cross-device timeline, where the durable, ordered log is what lets a failure three rooms away be reconstructed after the fact instead of reproduced live.
The honest edge: replay is only as exact as your determinism¶
Now the cost, stated plainly, because a post that only sells the upside is selling you something.
Byte-identical replay is a promise the engine can only keep if applying the same actions to the same initial_state always produces the same trajectory. The instant a move depends on a coin flip, that promise is in danger. An unseeded random.random() inside move resolution makes every replay a different game, and "record the actions and replay them" silently stops reconstructing the position you actually played. The counterfactual review compares against a hand that never happened; the Battleland byte-identical check fails for a reason that has nothing to do with a bug.
So record-actions-not-states is not a free lunch. It buys replay only in exchange for two disciplines:
- Deterministic engines.
apply_actionmust be a pure function of(state, action). The project enforces this by keeping the relevant engine module free ofimport randomentirely. - Seeded, server-owned randomness baked into the log. The randomness happens before the engine sees the action. The server resolves the roll through
RandomnessService-- a seeded service that derives a deterministic sub-stream per(game_id, purpose)from one root seed -- and writes the resolved result intoAction.data. By the timeapply_actionruns, the dice are not a source of entropy; they are recorded data riding in the action.
That second point is the whole trick, and battleland_replay.py shows it in two functions side by side. record_battleland_session holds a live RandomnessService and calls it to resolve each strike's dice. replay_battleland_session is documented as replaying the resolved actions "without consulting the randomness service" -- it does not need to, because the roll outcomes are already in the action log from the recording pass. Record consults the RNG once; replay never touches it. The action log carries the resolved randomness forward, which is the only reason a stochastic game can replay to a byte-identical state at all.
This is also why the project treats RandomnessService as a hard contract rather than a convenience: gameplay randomness routing through one seeded, auditable service is the precondition that makes the whole record-and-replay edifice stand up. Skip it -- reach for raw random in one move handler -- and you have not just bent a style rule, you have quietly revoked replay for every game that touches that code path.
Why this is worth the discipline¶
The trade is real, so name it honestly: you give up the convenience of "render move 12 by reading row 12," and you take on the obligation to keep every gameplay draw deterministic and logged. In return you get one small, ordered, auditable record that serves counterfactual review, hidden-information post-mortems, byte-identical determinism checks, and fairness verification -- without a second storage format and without trusting the server's own assertion of the board.
For a developer learning to work alongside AI agents, the meta-lesson is the one worth keeping. The agent will happily implement snapshot-per-move; it passes every test you can write on day one. The decision to record actions instead is a design judgment about what the system must be able to do later, made before any later need is visible. Those are the decisions to make deliberately and write down as rules, because they are the ones nothing downstream will catch for you -- not the test suite, not the demo, and not the agent. Store the moves, not the board, and the board is always one fold away.
Or: subscribe to the newsletter for more posts on building this stuff.