Building this · 5 min read
The night is an event log
Three of the features in this product were not built. They fell out of a single decision about how to store a night.
The decision
A night is not stored as a state — who has how many strikes, whose turn it is. It is stored as the list of things that happened, in order:
ADD_PLAYER Ada
ADD_PLAYER Marcus
SET_PAID Ada, true
LOCK_IN [Marcus, Ada, ...], seed 48211
SHOT made
SHOT missed
NEW_RACK
SHOT missed
UNDO
The current state is a pure function of that list. Nothing stores "Marcus has two strikes"; it is computed, every render, by replaying the log from the start.
What falls out of it
Undo. Undo appends an UNDO event and replays. There
is no inverse operation to write and therefore none to get wrong. Undoing a third
strike correctly un-eliminates the player, restores their position in the rotation,
and rewinds the rack — not because anybody wrote that code, but because the replay
simply does not include the shot any more.
Offline. The log is written to the device, synchronously, before anything else happens. The screen redraws from it immediately. The network is not on the path of a shot at all, so losing signal mid-rotation changes nothing you can see.
Syncing. An append-only log with a sequence number syncs by sending everything after the last number the server confirmed. Retries are free, because re-sending an event that is already stored does nothing. There is no merge algorithm, because there is only ever one phone writing.
Support. When somebody says "it gave Marcus a strike he didn't earn", the answer is in the log, in order, with undos included. You can read what actually happened rather than asking them to reproduce it.
What it costs
Replaying from scratch on every render sounds wasteful, and would be if a night were large. A long night is a few thousand tiny objects and the reducer is a plain fold over an array — it is comfortably under a millisecond, and the alternative (incremental state plus a separate undo stack) is more code and more ways to be wrong.
The real cost is discipline. The reducer has to be pure: no clock, no randomness, no network. Every timestamp arrives inside an event. The shuffle at lock-in is seeded, and the seed is stored, so the same log always produces the same running order — which also means somebody can ask whether the draw was fair and get a real answer.
How it is tested
Because the engine is pure, it is testable without a browser, a database or a mock. There are just over three thousand assertions against it, and the most useful one is this: for a long, varied night, after appending each event, undoing it must produce a state deep-equal to the state before it was appended. Every step, every event type.
That single property is what makes it safe to put a one-tap undo in front of somebody who has had three drinks and is being shouted at by their friends.