← Writing

Case study

The model doesn't get to write history

A small local-first civilization game built on the rule I keep using for agents: models and interfaces may propose, but one deterministic layer decides what is true.

· 9 min read

RoleCreator
WhenAug 2026
Stack
TypeScriptWeb WorkerIndexedDBPlayCanvasTLA+

Mara Vale inspects the settlement reserve and finds less food than the ledger says. You can tell her to verify the count, tell her to trade, or say nothing. She might do what you suggest, ignore you, wait, or do something adjacent that she thinks you meant. A few in-game hours later something changes because of what she chose, and the Chronicle tells you what happened, what people believed had happened, and which of those things you actually caused.

That is the whole loop of EONFOLK, a small civilization game I built in August 2026. It runs entirely in a browser tab, needs no account and no model, and stores its world in the browser. The README calls it a "bounded product proof, not a finished or hosted game," and that's accurate: it is pre-alpha, it isn't deployed anywhere, and nobody outside a test harness has played it yet. I'm writing it up because the interesting part isn't the game. It's that the architecture turned out to be the same one I keep arriving at in agent systems, applied somewhere it has nothing to do with work.

The problem

A simulation with AI characters has two easy ways to lie to its player. The first is state: if a model can write the world directly, the world becomes whatever the model last said, and nothing can be replayed. The second is history: a narrator that explains events will happily say "because" where the honest word is "before," and credit the player for things the characters chose. Both are the same failures I worry about in agent systems, where a model's account of what happened drifts from what actually happened.

How it works

The architecture document opens with one sentence: Reality is the sole authority. Models, UI, diagnostics and narration may propose or describe, and none of them may change canonical state.

That sentence splits the program into layers with explicit permissions. Reality validates typed transitions and emits canonical events, and it may not read the UI, the renderer, a model provider or the wall clock. A citizen's Mind holds sourced beliefs, memories and plans, and may not create facts. A Brain proposes one action from a bounded catalog and may not mutate anything. The application authorizes, persists, renders and recovers, and may not bypass the reducer or show state that hasn't been committed.

A command flows left to right. Player counsel and a citizen's Brain both produce proposals, which become a typed command checked against an expected revision. The Reality reducer validates it, checks invariants and emits ordered events with before and after hashes. Persistence commits the batch to IndexedDB with a receipt and a writer fence, and only then installs the new state. Two read-only projections come out of committed events: the Chronicle and the PlayCanvas world view. Nothing points back into Reality.
Every arrow points away from Reality's write path once it leaves the reducer. That one-way shape is the design.

In practice a player's counsel becomes a typed command carrying the revision it expects to apply to. The simulation prepares an immutable transition, checks invariants, and emits ordered events with hashes of the state before and after. The new state is installed only after the batch commits to IndexedDB. A retry with the same idempotency identity gets the original receipt back, and a stale writer fails closed. If that sounds like Irrevon or a payment API, it is. A game whose save file can disagree with what the player saw has the same bug as an agent whose ledger disagrees with the bank.

The persistence protocol is small enough to model, so it is. A TLA+ specification covers genesis, writer fencing, batch commits, snapshots, catch-up and crashes, and the model checker verifies six invariants on every pull request, including that the ledger head and snapshot head always agree and that a crash preserves the durable shape. The model is bounded (four commands, revision four, three fence tokens), so it proves the protocol has no bad interleavings at that size, not that the TypeScript matches it. The tests own that second half.

Why no model ships

The obvious version of this game puts a language model behind every citizen. I tried that first, in a disposable spike: a local Qwen3-Coder 30B produced schema-valid proposals and correctly fell back when its proposal referred to a stale revision. The plumbing worked. But the first proposal took 8.06 seconds cold, and the public justifications it wrote were incomplete or mixed-language.

Eight seconds per decision for eight citizens is unplayable on a laptop, and text you can't trust is worse in a game whose whole promise is a truthful history. So decision D-005 made a deterministic, integer-scored Standard Brain the complete cognition path for V1. Every decision uses the citizen's visible facts and beliefs, a standing plan, and a bounded set of actions. A model may come back later, but only to propose one typed action at a decision boundary, and only if blinded players still find the deterministic citizens repetitive after the mechanics and interface pass. Even then it has to beat the Standard Brain without hurting fallback, frame rate, privacy or causal trust. Replay never calls a model, because a model can't be relied on to give the same answer twice and the history has to be reproducible.

That decision is what makes the rest possible. A deterministic simulation replays from a snapshot plus an exact interval of events, so leaving and coming back is a matter of catching up through known boundaries rather than regenerating anything.

A history that can't overclaim

The Chronicle is where the game earns trust or loses it. It is a deterministic projection of the event ledger, and every sentence in it resolves to specific events. The causal vocabulary is deliberately narrow: a direct cause, a trigger, or a contributing condition. Something happening before something else is recorded as a temporal relation and never phrased as "because." When one citizen says another did something, that's stored as an allegation attributed to the speaker, not as a fact about the world. A factual mismatch between the Chronicle and the ledger is a P0 bug.

The point is to keep three things apart that games and agent logs usually blur: what happened, what people believed happened, and what the player's advice actually changed. In the Chronicle code, Mara accepting your advice is described as "an input, not her action," and the verification she then chooses is credited to her. A Chronicle that told the player they caused everything would be more flattering and less true.

Where it changed course

The first renderer was a sparse 2D Pixi scene with markers for citizens. It met the performance budget and failed the thing the product needed most, which was the feeling that people live there. The decision log records an override on 21 August that replaced it, and a fixed-camera 3D diorama after it, with one continuous, walkable PlayCanvas scene. The constraint that survived every version: the renderer reads a pure projection of committed state, and frame time, camera position and pointer input never feed the simulation. A full semantic text view exposes the same facts and actions, so the game is playable without WebGL at all.

Results

The release-valid build is the Founder Alpha head from 22 August. Its protected CI run passed verification, the formal model and a full-history secret scan, including a real WebGL smoke test at three viewport sizes. On the target Mac, the deep suite passed all 26 rows: 191 tests in 23 files, all sixteen scripted production journeys, and a fifteen-run performance battery with pooled p95 frame intervals around 9 ms and no external network attempts. Those numbers are in the handoff record. The slowest number is in there too: on the emulated mobile profile, the first meaningful view of the world took 4.4 seconds, against roughly 0.45 seconds on desktop and laptop.

Limits

What isn't verified matters more. The plan defined three human gates, run with unfamiliar participants: can a new player find Mara and understand that she directs herself, can they follow the loop, and do they want to come back. None of them has passed, because none has been run with real people. When I chose to keep building anyway, the repository recorded it as an "operator implementation override," and the foundation plan says that label must never be presented as a measured human pass. The playtest kit (session script, moderator guide, observation templates) exists, and no session has been run with it. The test suite has scripted Playwright personas, and the testing doc says they must not be cited as evidence of fun or attachment. An independent automated reviewer was asked whether the world feels like watching real inhabitants and answered yes, and the decision log files that as an agent result, not a human one.

Two more things a visitor would find on their own. The latest pushes to main since 28 August have failed CI on the browser-journey step, so the release-valid build is older than the branch tip. And it looks like a proof, not a product: in the captures, name labels overlap when citizens cluster and parts of the side panel clip on smaller screens.

What's next

The same split keeps paying for itself in very different places. In an intake tool it is a proposal the operator confirms; in a reconciliation engine it is a settlement that needs the destination's evidence; here it is a reducer that is the only thing allowed to write. Separating "something suggested this" from "this is now true" is what made the game replayable, the history honest, and the eventual model optional. I didn't set out to build a game around that rule, and the game is better for having one.

The next step isn't code. It's running the three human gates with people who have never seen the game, and finding out whether anyone wants to come back to Mara.

Comments

© 2026 Pranav Mishra