feat(view-sdk): let a view set its own frame pace, and split the liars-dice reveal - #56
Conversation
…s-dice reveal
A replay host has a fixed slot to fill and has to decide how much of a match
fits before it draws anything. Nothing told it how fast a view draws, so the
Arena feed assumed 2000ms a frame for every one of them. Measured, they range
from no animation at all to 6.5 seconds opening a set of dice cups:
| view | per frame | frames in a real match | to play it all |
|---|---|---|---|
| gomoku | none — instant | 226 | 56s |
| doudizhu | 1700ms | 67 | 114s |
| othello | 500 flip + 1500 hold | 61 | 122s |
| chinese-checkers | 320/leg + 1050 hold | 129 | 177s |
| liars-dice | 2400, or 6500 on a reveal | 17 | 74s |
One number cannot serve that range. Too fast built an unbounded backlog, so what
was on screen drifted away from where the host believed playback was, pausing did
nothing visible, and the match was cut off mid-animation when the card ended. Too
slow made the host drop frames to keep its budget — with a 2000ms assumption it
showed one gomoku move in eight, which is what "the replay looks disconnected"
was describing.
## The view sets the pace
`onFrame(draw, { paceMs })`, and a draw callback may return a promise:
onFrame(async (frame, root) => {
drawBoard(frame, root)
await animateMove(frame)
await wait(HOLD_MS)
}, { paceMs: HOLD_MS })
The SDK draws one frame at a time, waits for the promise, then posts
`frame-done` back. So a host that waits sends the next frame exactly when this
view is ready for it, and no view has to be guessed at.
`paceMs` rides along on `ready` so a host can size its slot before drawing
anything; `frame-done` is the authority on when a frame is actually finished.
Both are optional in the protocol — a host must tolerate views built against
older SDKs, which send neither.
The ack echoes the host's own `seq` rather than counting our own frames: a host
that timed out and moved on would otherwise be advanced twice when the late
reply lands.
## Five views stop hand-rolling this
Every view that animates had built the same queue and `busy` flag. That is now
one implementation instead of five, and each view declares what it actually
costs — including `paceMs: 0` for gomoku, which draws instantly and was the one
being punished hardest by the old assumption.
Two latent bugs fell out:
- doudizhu only held a frame when another was already queued behind it. Under a
host that sends one at a time there never is one, so it would have stopped
pausing on each play altogether.
- a frame whose draw threw, or whose promise never settles, now still acks (the
SDK caps a frame at 15s). A host waiting on an ack that never comes stops
playing, so one bad frame must not take the rest of the match with it.
## liars-dice: one step, one thing that happened
Separately, the challenge step returned a single state carrying the opened cups
AND the next round already under way — re-rolled, `round` incremented, a new seat
to act. Since the platform records one frame per step, that one frame meant
three things: a replay showed a seat bid and then jumped to a different seat
opening a later round, with the "LIAR?" moment labelled as the round *after* the
one it settled.
The challenge now stops at its own outcome and `openRound` starts the next round
on the next action. The reveal frame keeps the ended round's number and its
`lastBid`, so it can show what was claimed beside what was actually on the table:
2 r1 5,5,5 | Round 1 · Seat 0 bids 7×2 · Seat 1 to act
3 r1 REVEAL 4,5,5 | Round 1 · Seat 1 challenges 7×2 · 6 on the table · Seat 0 loses a die
4 r2 4,5,5 | Round 2 · Seat 0 bids 6×2 · Seat 1 to act
The re-roll draws from `ctx.random`, so it happens only after the incoming action
has been found legal — drawing before a `ctx.reject` would advance the random
stream on an action that never happened and deal different dice on replay. Every
legality check reads dice counts and the standing bid, never a face, so it gives
the same answer either side of a pending re-roll. There is a test that counts
draws to hold this.
Verified: 9 new SDK tests for the pacing contract, 3 new liars-dice tests for the
split, `pnpm validate` green (11 games incl. determinism, 9 worlds), and the full
suite at 294 tests.
🔍 AI review — external reviewerArena AI ReviewOverall: 🟢 GREEN ScopeThis submission is a single coherent change: a frame-pacing protocol where a T2 view returns a promise from games/ track (rubric applied)liars-dice game logic (
View files (chinese-checkers, doudizhu, gomoku, liars-dice, othello): all convert hand-rolled queue/ SDK / spec (infrastructure)
Injection check
Advisory (non-blocking)
A human maintainer review is still required. Automated pre-review. A human maintainer review is still required. |
…tional
Two problems with the pacing change, both found by driving the real pages.
## A host could not speed a view up, so Arena's feed skipped frames instead
The animation timings live inside the view, and nothing could influence them. So
the feed's speed control did the only thing left available to it: at 5x it showed
every fifth frame. Which is the bug the whole change set out to fix, back again
under a different trigger — and worse, the speed control REMEMBERS its setting
between sessions, so a 5x chosen once kept skipping four plays in five long
afterwards. Measured on doudizhu: 13 of 67 frames, `indices 5,10,15,…`.
The view can draw faster. It just had to be asked:
- `{type:'speed', speed}` from the host, clamped to 0.1x–20x so one bad value
cannot freeze or strobe a view
- `hold(ms)` — a pause the host can scale, replacing `setTimeout` in a view
- `playbackSpeed()` — for durations `hold` cannot express, like a
requestAnimationFrame tween dividing its own duration
Measured after: 5x runs doudizhu at 340ms a frame (1700/5) and shows all 67
frames; othello 400ms/61 frames; liars-dice 480ms/17. Faster AND more of the
match, with nothing skipped. A host that never sends a speed leaves it at 1.
## The dwell must stay conditional, or a timer-driven host falls behind
Folding each view's queue into the SDK lost a detail doudizhu had and every view
documented: its 1700ms pause was armed ONLY when another frame was already
queued. The comment said why — "a live match never fills the queue, so it stays
responsive".
The competition detail page pushes a frame every 1500ms and ignores acks, so
under the unconditional version it fell behind by 200ms per play. Measured at the
20-second mark it was a full play behind where it used to be: `last [J]` against
`last [K]`.
`dwell(ms)` is that behaviour, named and explained — padding that applies only
when there is a burst to slow down. `hold` remains for time the frame genuinely
needs (an animation running, a beat that has to land), which is what the other
four views use, matching what they did before.
## Detail page, verified rather than assumed
Screenshots at a fixed 20s mark, this build against pre-change `main`, cropped to
the replay area:
| game | pixels differing |
|---|---|
| doudizhu | **0** (was 21114 → 3095 before `dwell`) |
| othello | 0 |
| gomoku | 0 |
| liars-dice | 0 |
| chinese-checkers | same position (106/104 to go in both), differing only in where inside one 320ms hop the screenshot landed |
12 SDK tests now cover the pacing contract, including that a lone frame is not
padded, that a frame landing mid-draw is, and that a scaled hold really is
shorter. `pnpm validate` green, 297 tests.
🔍 AI review — external reviewerOnly the diff is available (no working tree), so I'm reviewing from the diff alone. My analysis: Arena AI ReviewOverall: 🟢 GREEN — a human maintainer review is still required. This is a coordinated frame-pacing change: a new SDK back-pressure mechanism ( Game logic —
|
A replay host has a fixed slot to fill and has to decide how much of a match fits before it draws anything. Nothing told it how fast a view draws, so the Arena feed assumed 2000ms a frame for every one of them. Measured, they span two orders of magnitude:
One number cannot serve that range. Too fast built an unbounded backlog, so what was on screen drifted away from where the host believed playback was, pausing did nothing visible, and the match was cut off mid-animation when the card ended. Too slow made the host drop frames to keep its budget — at a 2000ms assumption it showed one gomoku move in eight, which is what "the replay looks disconnected" was describing.
The view sets the pace
onFrame(draw, { paceMs }), and a draw callback may now return a promise:The SDK draws one frame at a time, waits for the promise, then posts
frame-done. A host that waits sends the next frame exactly when this view is ready for it.paceMsrides along onreadyso a host can size its slot before drawing anything;frame-doneis the authority on when a frame is actually finished. Both are optional — a host must tolerate views built against older SDKs, which send neither. Documented in spec §5b.The ack echoes the host's own
seqrather than counting our frames: a host that timed out and moved on would otherwise be advanced twice when the late reply lands.Five views stop hand-rolling this
Every animating view had built the same queue and
busyflag. One implementation now, and each view declares what it actually costs — includingpaceMs: 0for gomoku, which draws instantly and was punished hardest by the old assumption.Two latent bugs fell out:
liars-dice: one step, one thing that happened
Separately, the challenge step returned a single state carrying the opened cups and the next round already under way — re-rolled,
roundincremented, a new seat to act. Since the platform records one frame per step, one frame meant three things: a replay showed a seat bid and then jumped to a different seat opening a later round, with the "LIAR?" moment labelled as the round after the one it settled.The challenge now stops at its own outcome;
openRoundstarts the next round on the next action. The reveal frame keeps the ended round's number and itslastBid, so it can show what was claimed beside what was actually on the table:The re-roll draws from
ctx.random, so it happens only after the incoming action is found legal — drawing before actx.rejectwould advance the random stream on an action that never happened and deal different dice on replay. Every legality check reads dice counts and the standing bid, never a face, so it answers the same either side of a pending re-roll. A test counts draws to hold this.This is a recording-time change: already-recorded matches keep their old frames, and it takes effect for matches played after this releases.
Verified
pnpm validategreen — 11 games with determinism, 9 worlds🤖 Generated with Claude Code