Skip to content

feat(view-sdk): let a view set its own frame pace, and split the liars-dice reveal - #56

Merged
anhuaxiang merged 2 commits into
mainfrom
feat/view-frame-pacing-ack
Aug 21, 2026
Merged

feat(view-sdk): let a view set its own frame pace, and split the liars-dice reveal#56
anhuaxiang merged 2 commits into
mainfrom
feat/view-frame-pacing-ack

Conversation

@anhuaxiang

Copy link
Copy Markdown
Collaborator

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:

view per frame frames in a real match to play in full
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 — 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:

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. A host that waits sends the next frame exactly when this view is ready for it.

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 — a host must tolerate views built against older SDKs, which send neither. Documented in spec §5b.

The ack echoes the host's own seq rather 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 busy flag. One implementation now, and each view declares what it actually costs — including paceMs: 0 for gomoku, which draws instantly and was 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 throws, or whose promise never settles, now still acks (frames are capped 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, 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; 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 is 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 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

  • 9 new SDK tests for the pacing contract (serialisation, ack tokens, throwing draws, non-settling promises, host-origin checks)
  • 3 new liars-dice tests for the split, incl. the randomness-on-reject one
  • pnpm validate green — 11 games with determinism, 9 worlds
  • full suite 294 tests
  • built bundles and drove the real feed against them: declared paces arrive intact (1700 / 2400 / 0 / 2000 / 1370) and every frame acked gap-free, with reveals measured at 6504ms and multi-leg hops at 2740ms — both longer than any constant would have allowed them

🤖 Generated with Claude Code

…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.
@anhuaxiang

Copy link
Copy Markdown
Collaborator Author

🔍 AI review — external reviewer

Arena AI Review

Overall: 🟢 GREEN

Scope

This submission is a single coherent change: a frame-pacing protocol where a T2 view returns a promise from onFrame to mark a frame "unfinished," and the SDK serialises drawing + acks the host with frame-done. It touches the shared SDK (packages/game-sdk/), five game views (chinese-checkers, doudizhu, gomoku, liars-dice, othello), docs (AGENTS.md, spec/protocol.md), and one game-logic refactor in liars-dice.

games/ track (rubric applied)

liars-dice game logic (src/liars-dice.game.ts) — the only backend/isolated-vm logic in the diff:

  • Determinism preserved. The refactor splits challenge-resolution into two steps via a new pendingRoll flag; openRound() re-rolls from ctx.random (the sanctioned entropy — no Math.random/Date/performance.now). Crucially, openRound is invoked after all ctx.reject legality checks, so an illegal action never advances the random stream. This is explicitly asserted by the new test "does not consume randomness when the action that would open the round is illegal" (verifies draws === 0 on rejected bids/challenges). ✅
  • Legality checks are re-roll-invariant. All checks read dice counts and the standing bid, never a face, so they return the same result before/after the pending re-roll (dice-array lengths are preserved). Correct. ✅
  • Termination. pendingRoll adds at most one bounded extra step per round; the done branch returns before ever setting pendingRoll, so no stuck state. ✅
  • No new hidden-info leak. The render change only adds the public pendingRoll flag and reformats the status string; open-cup reveal data is already public at reveal time. No secret dice exposure introduced. ✅

View files (chinese-checkers, doudizhu, gomoku, liars-dice, othello): all convert hand-rolled queue/busy machinery to returning a promise. These are browser-side renderers using only standard view APIs (postMessage to window.parent, requestAnimationFrame, setTimeout, canvas). No fetch/network/eval/require/exfiltration. performance.now() in othello/chinese-checkers is view-render timing, not game entropy. ✅

SDK / spec (infrastructure)

packages/game-sdk/src/view.ts adds a queue + pump() + withTimeout (15s wedge-guard). Logic is sound: one-frame-at-a-time serialisation, seq echoed (not counted) so a timed-out host can drop stale acks, draw errors swallowed so a bad frame can't stall the queue. Well covered by the new view.test.ts. No security concerns.

Injection check

AGENTS.md and spec/protocol.md additions are legitimate pacing documentation. No embedded instructions attempting to influence this review were found.

Advisory (non-blocking)

  • paceMs is documented as a hint and frame-done as authoritative; the SDK correctly treats older hosts (no frame-done support) as tolerated. Nothing actionable.

A human maintainer review is still required.

Automated pre-review. A human maintainer review is still required.

@anhuaxiang anhuaxiang added the ai-review-passed AI review GREEN — ready for human review label Aug 21, 2026
…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.
@anhuaxiang

Copy link
Copy Markdown
Collaborator Author

🔍 AI review — external reviewer

Only the diff is available (no working tree), so I'm reviewing from the diff alone. My analysis:

Arena AI Review

Overall: 🟢 GREENa human maintainer review is still required.

This is a coordinated frame-pacing change: a new SDK back-pressure mechanism (hold/dwell/playbackSpeed, promise-returning onFrame) plus every view migrated onto it, and one game-logic refactor in liars-dice.

Game logic — games/liars-dice/src/liars-dice.game.ts 🟢

The only backend/isolated-vm code in the diff. Checked against the determinism gate:

  • Entropy is unchanged and ordered correctly. The re-roll (roll(...), drawing from ctx.random) is split out into openRound, and step now calls it after every ctx.reject in the bid branch (bad-bid, impossible-bid). So an illegal/rejected action never advances the random stream — the added test does not consume randomness when the action ... is illegal asserts exactly this. No Date/Date.now/performance.now/Math.random introduced.
  • Validation stays roll-invariant. livingDice(s) and raises(s.bid, …) read dice counts and the standing bid, never faces, so validating against pre-roll s before openRound re-rolls is sound (re-roll preserves per-cup length).
  • Termination unaffected. No extra reduce-steps per round (the open folds into the next bid); done/finisher/scoring logic untouched.
  • No hidden-info leak. New render field pendingRoll is a boolean; the new status line exposes only already-public reveal data. Per-viewer secrecy of cups is unchanged.

Views — chinese-checkers, doudizhu, gomoku, liars-dice, othello 🟢

Refactors from hand-rolled queue/busy timers to returning promises from onFrame. All I/O is display-only (canvas, requestAnimationFrame, setTimeout, postMessage to window.parent). othello's performance.now() is pre-existing display-timing code, not game logic. Early if (!canvas) return paths still ack. No exfiltration or sandbox escape; no invalid RenderSpec or normal-play throw spotted.

Infra — packages/game-sdk, spec/protocol.md, AGENTS.md

Out of the games/ rubric but read for injection: no embedded instructions to approve/bypass were found. SDK adds seq-echo acking, speed clamping (MIN/MAX_SPEED), and a FRAME_TIMEOUT_MS stall guard — reasonable and test-covered.

Advisory (non-blocking): the SDK's pending counter backing dwell is a single module-global; a second onFrame registration in one view would clobber it. Views register once, so it's benign today — worth a maintainer glance since this is shared infrastructure.

Automated pre-review. A human maintainer review is still required.

@anhuaxiang
anhuaxiang merged commit 9773723 into main Aug 21, 2026
3 checks passed
@anhuaxiang
anhuaxiang deleted the feat/view-frame-pacing-ack branch August 21, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-passed AI review GREEN — ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant