Skip to content

feat(games): derive the clock instead of ticking it - #203

Open
Utzig26 wants to merge 1 commit into
feat/movesfrom
feat/clock
Open

feat(games): derive the clock instead of ticking it#203
Utzig26 wants to merge 1 commit into
feat/movesfrom
feat/clock

Conversation

@Utzig26

@Utzig26 Utzig26 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements RFC-004. No timer, no cron, no worker.

Stacked on #202#201#200#199#198.

The mechanism, kept

The lazy clock is the best idea the old code had and it survives almost intact: remaining time is a function of Date.now() evaluated whenever the game is read. Stateless, scales horizontally without coordination, survives a process restart.

The side effect, removed

What did not survive is that findOne() called clock(), which called game.save(). Every read of a game was a write. It violated the semantics of GET, multiplied the mongo load per read, and raced whenever several clients looked at the same game.

projectGame(game, now) is a pure function returning the discounted clocks and, when the flag has fallen, a finished status — without touching the document. GameResponseDto calls it in its constructor, so no read path can forget to project, and none of them write.

The test that closes the debt:

const stamp = before.get('updatedAt').getTime();

await readMoves(table.id).expect(200);
await readMoves(table.id).expect(200);
await readMoves(table.id).expect(200);

expect(after.get('updatedAt').getTime()).toBe(stamp);

The trade, stated

The loss is persisted only on a mutation. Between the flag falling and the next move the stored document still reads A, while every read already reports F with the result. Both sides have a test.

That is exactly what the old design paid a write per read to avoid — for a state nobody queried directly.

The free first move is gone

Seeing a single move in the history, the old code only set turnTime and returned without debiting the time white had spent. The opening move cost nothing. The reference for the first move is now startedAt, recorded when the second player joins.

One deviation from the RFC

The RFC said the 409 for an expired clock would carry the result. It does not. The error envelope has no data field, and inventing one would break the contract precisely in the layer that exists to keep it uniform. The mutation persists the end and answers Time is up; the next read shows the finished game. The RFC is updated with the reason.

Verification

245 tests green, clean strict typecheck and lint.

  • 134 unit, 25 new. None of them sleep — the instant is a parameter, which is the practical payoff of the projection being pure. They cover the side to move being the only one charged, the clock frozen before the first move, expiry landing exactly at zero, the win going to the other side, the increment, and the same document answering differently for different instants.
  • 111 end-to-end, 8 new. They rewind the recorded timestamps rather than waiting, and cover the increment on a real move, the countdown for the side to play, updatedAt untouched across reads, a finished-on-read game still stored as A, and the loss persisted once a move is attempted.

Implements RFC-004. No timer, no cron, no worker: the remaining time is a
function of Date.now() evaluated whenever the game is read.

The mechanism is the one worth keeping from the old code. It is stateless,
scales horizontally without coordination and survives a process restart. The
part not worth keeping was the side effect: findOne called clock, which
called save, so every read of a game was a write. It violated the semantics
of GET, multiplied the load on mongo per read, and raced whenever several
clients looked at the same game.

projectGame is a pure function of the document and an instant. It returns
the discounted clocks and, when the flag has fallen, a finished status with
the win on time, without touching the document. GameResponseDto calls it in
its constructor, so no read path can forget to project and none of them
write.

The loss is only persisted on a mutation. Between the flag falling and the
next move, the stored document still reads A while every read already
reports F with the result. That is the trade the old design paid a write per
read to avoid, for a state nobody queried directly.

The first white move is charged now. The old code, seeing a single move in
the history, only set turnTime and returned without debiting the time white
had spent, so the opening move was free. The reference for the first move is
startedAt, recorded when the second player joins.

The 409 for an expired clock does not carry the result, which the RFC had
said it would. The error envelope has no data field, and inventing one would
break the contract precisely in the layer that exists to keep it uniform.
The mutation persists the end and answers Time is up; the next read shows
the finished game.

25 unit tests, all of them passing the instant as a parameter so none of
them sleep, and 8 end-to-end that rewind the recorded timestamps to move the
clock. The one that closes the debt compares updatedAt before and after
three reads.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant