From 6c674182899c7bb5a4bcaf680b12e5b7bc04790a Mon Sep 17 00:00:00 2001 From: "rujing.yan" Date: Fri, 21 Aug 2026 13:41:29 +0800 Subject: [PATCH 1/2] feat(view-sdk): let a view set its own frame pace, and split the liars-dice reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 16 ++ games/chinese-checkers/view.ts | 115 +++++++------ games/doudizhu/view.ts | 35 ++-- games/gomoku/view.ts | 7 +- games/liars-dice/src/liars-dice.game.ts | 77 ++++++++- games/liars-dice/test/liars-dice.test.ts | 54 +++++- games/liars-dice/view.ts | 28 +--- games/othello/view.ts | 96 ++++++----- packages/game-sdk/src/view.ts | 137 ++++++++++++++- packages/game-sdk/test/view.test.ts | 203 +++++++++++++++++++++++ spec/protocol.md | 30 ++++ 11 files changed, 632 insertions(+), 166 deletions(-) create mode 100644 packages/game-sdk/test/view.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7f6faea..79142e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,22 @@ onPlayers((players) => { /* seat -> {agentId,name,avatar}; place where you like Rules you **MUST** follow: +- **Pacing**: frames arrive when the HOST decides, which on a finished replay can + be as fast as it likes. If a frame takes time to draw — an animation, or a beat + you want the viewer to register — **return a promise** and the SDK holds the + next frame until it settles, and tells the host when you are ready for one: + + ```ts + onFrame(async (frame, root) => { + drawBoard(frame, root) + await animateMove(frame) + await wait(HOLD_MS) // let the finished move sit there + }, { paceMs: HOLD_MS }) // roughly how long a frame takes — a hint for budgeting + ``` + + Do **NOT** hand-roll a queue and a `busy` flag for this; the SDK does it, and a + view that draws instantly should say so (`{ paceMs: 0 }`) so a replay host can + fit more of a long match in. See [spec §5b](spec/protocol.md). - **Identity**: your game logic sees only opaque agent ids. Names/avatars arrive in the view via `onPlayers` — never invent or hardcode them. - **Hidden info**: set `meta.hiddenInfo: true` and make `render` viewer-aware — diff --git a/games/chinese-checkers/view.ts b/games/chinese-checkers/view.ts index a24e10e..4a1a99d 100644 --- a/games/chinese-checkers/view.ts +++ b/games/chinese-checkers/view.ts @@ -438,59 +438,54 @@ function paint(frame: Frame, skip: Set, trail: number[]): void { } } -// —— frame queue —————————————————————————————————————————————————————— +// —— frame handling ———————————————————————————————————————————————————— +// +// The SDK owns the queue and the one-at-a-time discipline; a frame that takes +// time says so by returning a promise, which also tells the host when to send +// the next one. What is left here is what is specific to this board. -const queue: Frame[] = [] let shown: Frame | null = null -let busy = false let lastKey: string | null = null /** Identifies a position; consecutive frames sharing one are the same move. */ const frameKey = (f: Frame): string => `${f.ply ?? -1}|${f.status ?? ''}|${(f.lastPath ?? []).join(',')}` -function draw(raw: unknown, root: HTMLElement): void { +function draw(raw: unknown, root: HTMLElement): void | Promise { ensureDom(root) + if (!canvas) return const frame = raw as Frame // The host's replay timer clamps at the last index and then re-posts that // frame forever (see replayFrames in the SDK). Taking it at face value would - // replay the winning move on a loop, so identical repeats are dropped. + // replay the winning move on a loop, so identical repeats are dropped. They + // are still acked (by returning), so a host waiting on us is not left hanging. const key = frameKey(frame) if (key === lastKey) return lastKey = key - queue.push(frame) - pump() -} - -function pump(): void { - if (busy || queue.length === 0 || !canvas) return - const next = queue.shift()! - const path = next.lastPath ?? [] + const path = frame.lastPath ?? [] // First frame, a pass, or a rewind — snap rather than animate. if (!shown || path.length < 2) { - shown = next - renderHud(next, -1) - setStatus(statusText(next)) - paint(next, new Set(), []) - pump() + shown = frame + renderHud(frame, -1) + setStatus(statusText(frame)) + paint(frame, new Set(), []) return } - busy = true // A frame carries `lastPath` (the move just made) but `side` (whoever is due // next). While the peg is in flight the HUD must follow the seat that owns // it, or the highlight reads as the wrong player for the whole animation. - const mover = next.pegs?.[path[path.length - 1]!] ?? 0 - renderHud(next, mover) - setStatus(moveText(next, mover, path)) - animate(next, path) + const mover = frame.pegs?.[path[path.length - 1]!] ?? 0 + renderHud(frame, mover) + setStatus(moveText(frame, mover, path)) + return animate(frame, path) } -function animate(frame: Frame, path: number[]): void { +function animate(frame: Frame, path: number[]): Promise { const g = canvas?.getContext('2d') - if (!g) return + if (!g) return Promise.resolve() const dest = path[path.length - 1]! const seat = frame.pegs?.[dest] ?? 0 const skip = new Set([dest]) @@ -499,46 +494,48 @@ function animate(frame: Frame, path: number[]): void { const jumping = legs > 1 || path.length > 2 let started = -1 - const tick = (now: number): void => { - if (started < 0) started = now - const t = Math.min(1, (now - started) / total) - - // Which leg we are on, and how far along it. - const walked = t * legs - const leg = Math.min(legs - 1, Math.floor(walked)) - const u = walked - leg - const [ax, ay] = holeXY(frame, path[leg]!) - const [bx, by] = holeXY(frame, path[leg + 1]!) - const ease = u * u * (3 - 2 * u) // smoothstep within the leg - const x = ax + (bx - ax) * ease - const y = ay + (by - ay) * ease - // A jump arcs over the peg it clears; a single step slides flat. - const lift = jumping ? Math.sin(Math.PI * ease) * SCALE * 0.55 : 0 - - paint(frame, skip, path.slice(0, leg + 2)) - peg(g, x, y - lift, seat, lift) - - if (t < 1) { - requestAnimationFrame(tick) - return + return new Promise((resolve) => { + const tick = (now: number): void => { + if (started < 0) started = now + const t = Math.min(1, (now - started) / total) + + // Which leg we are on, and how far along it. + const walked = t * legs + const leg = Math.min(legs - 1, Math.floor(walked)) + const u = walked - leg + const [ax, ay] = holeXY(frame, path[leg]!) + const [bx, by] = holeXY(frame, path[leg + 1]!) + const ease = u * u * (3 - 2 * u) // smoothstep within the leg + const x = ax + (bx - ax) * ease + const y = ay + (by - ay) * ease + // A jump arcs over the peg it clears; a single step slides flat. + const lift = jumping ? Math.sin(Math.PI * ease) * SCALE * 0.55 : 0 + + paint(frame, skip, path.slice(0, leg + 2)) + peg(g, x, y - lift, seat, lift) + + if (t < 1) { + requestAnimationFrame(tick) + return + } + // The peg has landed: hand the highlight over to whoever is due next. + shown = frame + paint(frame, new Set(), path) + renderHud(frame, -1) + setStatus(statusText(frame)) + setTimeout(resolve, HOLD_MS) } - // The peg has landed: hand the highlight over to whoever is due next. - shown = frame - paint(frame, new Set(), path) - renderHud(frame, -1) - setStatus(statusText(frame)) - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) - } - requestAnimationFrame(tick) + requestAnimationFrame(tick) + }) } // —— wiring —————————————————————————————————————————————————————————— -onFrame(draw) +// A hop chain's length varies per move, so this pace is the common case (a +// single hop plus the dwell) rather than a bound — the promise above is what +// actually tells the host when each move has landed. +onFrame(draw, { paceMs: HOP_MS + HOLD_MS }) // Identity can arrive before or after the frames; refresh whatever is on screen. onPlayers((p) => { diff --git a/games/doudizhu/view.ts b/games/doudizhu/view.ts index f73db76..0e20cb1 100644 --- a/games/doudizhu/view.ts +++ b/games/doudizhu/view.ts @@ -293,32 +293,23 @@ function draw(): void { } // —— playback pacing —— -// The platform controls when it pushes frames; on replay it bursts the whole -// match in at once, which flashes by. We buffer frames and play them out no -// faster than HOLD_MS apart. A live match (frames minutes apart) never fills the -// queue, so it stays responsive. +// The platform controls when it pushes frames; on replay it may push the whole +// match in at once, which flashes by. Each play stays up for HOLD_MS, and the +// SDK holds the next frame until then — which is also what tells the host we are +// ready for it, so a host that waits never builds a backlog. +// +// This used to buffer and drain on its own timer, and only held a frame when +// something was already queued behind it. Under a host that sends one frame at a +// time there is never anything queued, so that version would have stopped +// pausing on each play altogether. const HOLD_MS = 1700 // minimum time each step (a play / pass / bid) is shown -const frameQueue: DouFrame[] = [] -let busy = false - -function pump(): void { - if (busy || frameQueue.length === 0) return - lastFrame = frameQueue.shift()! - draw() - if (frameQueue.length > 0) { - busy = true - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) - } -} onFrame((frame, h) => { ensureRoot(h) - frameQueue.push(frame as DouFrame) - pump() -}) + lastFrame = frame as DouFrame + draw() + return new Promise((resolve) => setTimeout(resolve, HOLD_MS)) +}, { paceMs: HOLD_MS }) onPlayers((p) => { players = p draw() // identity applies immediately to the current frame diff --git a/games/gomoku/view.ts b/games/gomoku/view.ts index 41766e1..1fd74d8 100644 --- a/games/gomoku/view.ts +++ b/games/gomoku/view.ts @@ -191,10 +191,15 @@ function render(root: HTMLElement): void { drawBoard() } +// Nothing here animates — a stone is either on the board or it is not — so a +// frame is finished the moment it is drawn. Saying so (`paceMs: 0`) rather than +// staying silent is what lets a replay host fit a long game in: a host with no +// information has to assume the slow end, and assuming ~2s a move for a +// 226-move match meant showing about one move in eight. onFrame((frame, root) => { lastFrame = frame render(root) -}) +}, { paceMs: 0 }) onPlayers((p) => { players = p diff --git a/games/liars-dice/src/liars-dice.game.ts b/games/liars-dice/src/liars-dice.game.ts index 7f90464..f683b64 100644 --- a/games/liars-dice/src/liars-dice.game.ts +++ b/games/liars-dice/src/liars-dice.game.ts @@ -65,6 +65,20 @@ interface State { side: number // mirror of `turn` for SDK convention round: number // increments each time cups are re-rolled reveal: Reveal | null // most recent open-cup result (public) + /** + * A challenge has resolved and the next round has NOT been opened yet. + * + * The challenge used to do both at once, and the single state it returned + * carried 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 made one frame mean three things, and 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. + * + * So the challenge stops at its own outcome, and `openRound` starts the next + * round on the next action. One step, one thing that happened. + */ + pendingRoll: boolean eliminationOrder: number[] // seats in the order they were knocked out finisher: number // winning seat once done, -1 while unfinished } @@ -296,6 +310,27 @@ function decide(v: View, p: Params): Action { return { bid: best } } +/** + * Open the round a resolved challenge left pending: re-roll every living cup, + * count the round, and clear the challenge that ended the last one. + * + * The ONLY caller is `step`, and only after the incoming action has been found + * legal. That order matters: `roll` draws from `ctx.random`, and drawing before a + * `ctx.reject` would advance the random stream on an action that never happened, + * so a replay of the same match would deal different dice. + */ +function openRound(s: State, ctx: Ctx): State { + if (!s.pendingRoll) return s + return { + ...s, + dice: s.dice.map((d, i) => (s.alive[i] ? roll(d.length, ctx) : d)), + pendingRoll: false, + reveal: null, + round: s.round + 1, + lastBid: [null, null, null], // fresh round → clear each seat's shown bid + } +} + /** * The rule kernel both paces run on. `seat` is the mover — resolved from * `ctx.actor` in turn-based pace, from `state.turn` in strategy pace — and is @@ -312,13 +347,18 @@ function step(s: State, seat: number, action: Action, ctx: Ctx): State { const c = count as number const f = face as number if (f < 1 || f > 6 || c < 1) ctx.reject('bad-bid') + // Every check here reads dice COUNTS and the standing bid, never a face, so + // it gives the same answer before and after a pending re-roll — which is what + // lets the whole action be validated before any randomness is drawn. if (c > livingDice(s)) ctx.reject('impossible-bid') // can't claim more dice than exist const next: Bid = { count: c, face: f } if (!raises(s.bid, next)) ctx.reject('bad-bid') // must strictly out-bid the standing bid - const turn = nextAlive(seat, s.alive) - const lastBid = [...s.lastBid] + + const open = openRound(s, ctx) + const turn = nextAlive(seat, open.alive) + const lastBid = [...open.lastBid] lastBid[seat] = next - return { ...s, bid: next, bidder: seat, lastBid, turn, side: turn, reveal: null } + return { ...open, bid: next, bidder: seat, lastBid, turn, side: turn, reveal: null } } // —— challenge the standing bid —— @@ -349,22 +389,27 @@ function step(s: State, seat: number, action: Action, ctx: Ctx): State { return { ...s, phase: 'done', dice, alive, eliminationOrder, reveal, finisher, bid: null, bidder: -1 } } - // Next round: re-roll every living cup; the loser leads (or the next living - // seat if the loser was just knocked out). - const rolled = dice.map((d, i) => (alive[i] ? roll(d.length, ctx) : d)) + /** + * Stop at the outcome. The cups are open, a die is gone, and this round is + * over — but the next one has not started: no re-roll, `round` unchanged, and + * `lastBid` still holding what each seat claimed, so the frame this produces + * can show what was bid alongside what was actually on the table. + * + * `openRound` does the rest on the leader's next action. The loser leads, or + * the next living seat if the loser was just knocked out. + */ const turn = alive[loser] ? loser : nextAlive(loser, alive) return { ...s, - dice: rolled, + dice, alive, eliminationOrder, reveal, + pendingRoll: true, bid: null, bidder: -1, - lastBid: [null, null, null], // fresh round → clear each seat's shown bid turn, side: turn, - round: s.round + 1, } } @@ -408,6 +453,7 @@ export default defineGame({ side: 0, round: 1, reveal: null, + pendingRoll: false, eliminationOrder: [], finisher: -1, }), @@ -481,6 +527,16 @@ export default defineGame({ let status: string if (s.phase === 'done') { status = `Seat ${s.finisher} wins` + } else if (s.pendingRoll && s.reveal) { + // The round that just ENDED — this frame is the challenge being settled, + // not the next round. It used to read "Round · Seat N to open", + // which named the wrong round and said nothing about the challenge that + // was on screen. + const r = s.reveal + const fate = r.eliminated !== null ? `Seat ${r.loser} loses last die — out` : `Seat ${r.loser} loses a die` + status = + `Round ${s.round} · Seat ${r.challenger} challenges ${r.bid.count}×${r.bid.face} · ` + + `${r.actual} on the table · ${fate}` } else if (s.bid) { status = `Round ${s.round} · Seat ${s.bidder} bids ${s.bid.count}×${s.bid.face} · Seat ${s.turn} to act` } else { @@ -498,6 +554,9 @@ export default defineGame({ turn: s.turn, bidder: s.bidder, bid: s.bid, + // Lets the view hold the open-cup beat on its own, rather than inferring + // it from `reveal` being present on a frame that had already moved on. + pendingRoll: s.pendingRoll, lastBid: s.lastBid, reveal: s.reveal, seats, diff --git a/games/liars-dice/test/liars-dice.test.ts b/games/liars-dice/test/liars-dice.test.ts index e857e18..469c1c0 100644 --- a/games/liars-dice/test/liars-dice.test.ts +++ b/games/liars-dice/test/liars-dice.test.ts @@ -75,7 +75,7 @@ describe('liars-dice · bidding', () => { // ———————————————————————————————————————————————————————————————— describe('liars-dice · challenge resolution', () => { - it('bid holds (with wild 1s counted) → challenger loses a die, then re-rolls', () => { + it('bid holds (with wild 1s counted) → challenger loses a die', () => { // face 5 across cups: seat0 [5,5]=2, seat1 [5,1]=2 (the 1 is wild), seat2 [2,2]=0 → actual 4 const s = base({ dice: [[5, 5], [5, 1], [2, 2]], @@ -93,7 +93,57 @@ describe('liars-dice · challenge resolution', () => { expect(n.alive).toEqual([true, true, true]) expect(n.bid).toBeNull() expect(n.turn).toBe(1) // loser leads the next round - expect(n.round).toBe(2) + + // The challenge stops at its own outcome: this state is the open cups, and + // the next round has NOT begun. Both used to happen in this one step, which + // is why a replay jumped from a bid straight past the seat who called it. + expect(n.round).toBe(1) + expect(n.pendingRoll).toBe(true) + }) + + it('opens the next round on the first action after a challenge, not on the challenge', () => { + const settled = base({ + dice: [[5, 5], [5, 1], [2, 2]], + bid: { count: 3, face: 5 }, + bidder: 0, + turn: 1, + round: 1, + }) + const revealed = game.reduce!(settled, { challenge: true }, ctxFor('B')) + const faces = revealed.dice.map((d) => [...d]) + + const opened = game.reduce!(revealed, { bid: { count: 1, face: 2 } }, ctxFor('B')) + expect(opened.round).toBe(2) + expect(opened.pendingRoll).toBe(false) + expect(opened.reveal).toBeNull() // the settled challenge is behind us now + // Re-rolled: same number of dice per seat, drawn again. + expect(opened.dice.map((d) => d.length)).toEqual(faces.map((d) => d.length)) + expect(opened.lastBid[1]).toEqual({ count: 1, face: 2 }) + expect(opened.lastBid[0]).toBeNull() // the ended round's bids are cleared + }) + + it('does not consume randomness when the action that would open the round is illegal', () => { + // A re-roll draws from ctx.random. Drawing it before a reject would advance + // the stream on an action that never happened, so replaying the same match + // would deal different dice from that point on. + const revealed = base({ + dice: [[5, 5], [5, 1], [2, 2]], + bid: null, + bidder: -1, + turn: 1, + round: 1, + pendingRoll: true, + reveal: { challenger: 1, bidder: 0, bid: { count: 3, face: 5 }, actual: 4, dice: [], loser: 1, eliminated: null }, + }) + let draws = 0 + const counting = (id: string) => ({ ...ctxFor(id), random: () => ((draws += 1), 0.5) }) + + expect(() => game.reduce!(revealed, { bid: { count: 99, face: 3 } }, counting('B'))).toThrow('impossible-bid') + expect(() => game.reduce!(revealed, { challenge: true }, counting('B'))).toThrow('nothing-to-challenge') + expect(draws).toBe(0) + + game.reduce!(revealed, { bid: { count: 1, face: 3 } }, counting('B')) + expect(draws).toBeGreaterThan(0) // a legal action does re-roll }) it('bid is a lie → the bidder loses a die', () => { diff --git a/games/liars-dice/view.ts b/games/liars-dice/view.ts index 59d70e7..f79fb29 100644 --- a/games/liars-dice/view.ts +++ b/games/liars-dice/view.ts @@ -467,28 +467,18 @@ function draw(): void { // empties and restarts on the next arrival. const HOLD_MS = 2400 // minimum time a bid / turn frame stays on screen const REVEAL_MS = 6500 // the open-cup "LIAR?" reveal lingers so the result really lands -const frameQueue: DiceFrame[] = [] -let draining = false - -function drainNext(): void { - const frame = frameQueue.shift() - if (!frame) { - draining = false // queue empty → go idle; the next arrival restarts the drain - return - } - lastFrame = frame - draw() - setTimeout(drainNext, frame.reveal ? REVEAL_MS : HOLD_MS) -} onFrame((frame, h) => { ensureRoot(h) - frameQueue.push(frame as DiceFrame) - if (!draining) { - draining = true - drainNext() // draws this frame now, then paces the rest - } -}) + const f = frame as DiceFrame + lastFrame = f + draw() + // A reveal is the moment the whole game turns on, and it needs far longer than + // an ordinary bid. The SDK reports each frame as finished only when this + // resolves, so a host that waits gives the cups time to open instead of + // pushing the next bid over the top of them. + return new Promise((resolve) => setTimeout(resolve, f.reveal ? REVEAL_MS : HOLD_MS)) +}, { paceMs: HOLD_MS }) onPlayers((p) => { players = p draw() // identity applies immediately to the current frame diff --git a/games/othello/view.ts b/games/othello/view.ts index 4977b18..a9495e2 100644 --- a/games/othello/view.ts +++ b/games/othello/view.ts @@ -264,20 +264,18 @@ function lastMoveRing(ctx: CanvasRenderingContext2D, size: number, b: Board): vo ctx.stroke() } -// —— frame queue + animator —— -const queue: Frame[] = [] +// —— animator —— +// +// The queue and `busy` flag this used to keep are the SDK's job now: it draws one +// frame at a time and waits for the promise below, so a move can never be +// overdrawn by the next frame arriving, and the host is told when we are ready +// for another instead of having to guess an interval. let shown: Frame | null = null // last fully-rendered frame -let busy = false -function draw(frame: unknown, root: HTMLElement): void { +function draw(frame: unknown, root: HTMLElement): void | Promise { ensureDom(root) - queue.push(frame as Frame) - pump() -} - -function pump(): void { - if (busy || queue.length === 0 || !canvas) return - const next = queue.shift()! + if (!canvas) return + const next = frame as Frame const to = next.board const from = shown?.board const ctx = canvas.getContext('2d') @@ -294,16 +292,20 @@ function pump(): void { paint(ctx, canvas.width, to, to.palette ?? { 1: BLACK, 2: WHITE }, undefined) lastMoveRing(ctx, canvas.width, to) shown = next - pump() return } - animate(ctx, canvas.width, from, to, next) + return animate(ctx, canvas.width, from, to, next) } /** Animate the diff between two boards: new disc pops in, flipped discs turn over. */ -function animate(ctx: CanvasRenderingContext2D, size: number, from: Board, to: Board, toFrame: Frame): void { - busy = true +function animate( + ctx: CanvasRenderingContext2D, + size: number, + from: Board, + to: Board, + toFrame: Frame +): Promise { const N = to.cols const palette = to.palette ?? { 1: BLACK, 2: WHITE } const step = size / N @@ -331,40 +333,42 @@ function animate(ctx: CanvasRenderingContext2D, size: number, from: Board, to: B turnOverride = placed[0] ? placed[0].c - 1 : null renderHud() - const start = performance.now() - const frame = (now: number) => { - const t = Math.min(1, (now - start) / FLIP_MS) - // Static layer: everything that isn't animating. - paint(ctx, size, to, palette, skip) - // Placed discs grow in (ease-out). - const grow = 1 - Math.pow(1 - t, 3) - for (const p of placed) disc(ctx, at(p.x), at(p.y), r * grow, r * grow, palette[p.c] ?? '#888') - // Flipped discs turn over: width collapses to a line at the halfway point, - // colour swaps as it passes edge-on. - const w = Math.abs(Math.cos(Math.PI * t)) - for (const f of flipped) { - const col = t < 0.5 ? f.a : f.b - disc(ctx, at(f.x), at(f.y), r * w, r, palette[col] ?? '#888') - } - lastMoveRing(ctx, size, to) - - if (t < 1) { - requestAnimationFrame(frame) - } else { - // Move is fully on the board now — hand the highlight to whoever is next. - shown = toFrame - turnOverride = null - renderHud() - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) + return new Promise((resolve) => { + const start = performance.now() + const frame = (now: number) => { + const t = Math.min(1, (now - start) / FLIP_MS) + // Static layer: everything that isn't animating. + paint(ctx, size, to, palette, skip) + // Placed discs grow in (ease-out). + const grow = 1 - Math.pow(1 - t, 3) + for (const p of placed) disc(ctx, at(p.x), at(p.y), r * grow, r * grow, palette[p.c] ?? '#888') + // Flipped discs turn over: width collapses to a line at the halfway point, + // colour swaps as it passes edge-on. + const w = Math.abs(Math.cos(Math.PI * t)) + for (const f of flipped) { + const col = t < 0.5 ? f.a : f.b + disc(ctx, at(f.x), at(f.y), r * w, r, palette[col] ?? '#888') + } + lastMoveRing(ctx, size, to) + + if (t < 1) { + requestAnimationFrame(frame) + } else { + // Move is fully on the board now — hand the highlight to whoever is next. + shown = toFrame + turnOverride = null + renderHud() + // The dwell is part of the frame, not something after it: resolving + // before it elapsed would tell the host we are ready while the move it + // just drew is still the thing being looked at. + setTimeout(resolve, HOLD_MS) + } } - } - requestAnimationFrame(frame) + requestAnimationFrame(frame) + }) } -onFrame(draw) +onFrame(draw, { paceMs: FLIP_MS + HOLD_MS }) // Identity can arrive before or after frames; refresh the HUD whenever it lands. onPlayers((p) => { players = p diff --git a/packages/game-sdk/src/view.ts b/packages/game-sdk/src/view.ts index 3858696..6058361 100644 --- a/packages/game-sdk/src/view.ts +++ b/packages/game-sdk/src/view.ts @@ -22,9 +22,30 @@ interface ViewMessage { __arenaView?: boolean type?: string frame?: unknown + /** Host-assigned frame token, echoed back on `frame-done`. See `onFrame`. */ + seq?: number players?: PlayerInfo[] } +/** + * What a draw callback may return. + * + * Returning a promise is how a view says "this frame is not finished yet". The + * SDK will not draw the next frame, and will not tell the host it is ready for + * one, until the promise settles — so an animation is never cut off by the next + * frame landing on top of it. + */ +export type DrawResult = void | Promise + +/** + * Ceiling on how long the SDK waits for one frame's promise. + * + * A promise that never settles would otherwise wedge the view for good: no + * further frames drawn, no further acks sent, and nothing the host can do about + * it. Generous enough that no honest animation reaches it. + */ +const FRAME_TIMEOUT_MS = 15_000 + /** * Only the parent frame drives a view. * @@ -43,6 +64,25 @@ function fromHost(e: MessageEvent): boolean { return e.source === window.parent } +export interface FrameOptions { + /** + * Roughly how long this view spends on one frame, in ms. A HINT for the host's + * budgeting, not a promise — `frame-done` is the authority on when a frame is + * actually finished. + * + * A replay host has a fixed slot to fill (a feed card is a few tens of + * seconds) and has to decide HOW MUCH of a match fits before it draws + * anything. Without this it has to assume, and one assumption cannot fit every + * view: these range from a view with no animation at all to one that spends + * 6.5 seconds opening dice cups. Assuming the slow end throws away most of a + * fast view's match; assuming the fast end floods a slow one. + * + * Give the typical case, not the worst: a view whose frames are usually quick + * but occasionally long should say the quick number. + */ + paceMs?: number +} + /** * Register a draw callback. Called once per frame the platform posts in; also * signals readiness to the parent so it starts sending frames. @@ -51,21 +91,102 @@ function fromHost(e: MessageEvent): boolean { * import { onFrame } from '@arena/game-sdk/view' * onFrame((frame, root) => { root.innerHTML = ... }) * ``` + * + * ## Slow frames: return a promise + * + * Frames arrive when the HOST decides, which on a finished replay is as fast as + * the host feels like pushing them. If drawing a frame takes time — an + * animation, or a beat you want the viewer to actually register — return a + * promise and the SDK will hold everything until it settles: + * + * ```ts + * onFrame(async (frame, root) => { + * drawBoard(frame, root) + * await animateMove(frame) + * await wait(HOLD_MS) // let the finished move sit there + * }, { paceMs: HOLD_MS }) + * ``` + * + * The SDK then guarantees two things a view used to have to build for itself: + * + * 1. **Serialisation.** Frame N+1 is not drawn until frame N's promise settles. + * Every view that animates had hand-rolled a queue and a `busy` flag for + * this; that is now one implementation instead of one per game. + * 2. **Back-pressure.** `frame-done` goes to the host after each frame settles, + * so a host that waits for it sends the next frame exactly when this view is + * ready. Before, a host could only guess an interval — and a wrong guess is + * not a cosmetic problem: too fast built an unbounded backlog, so what was on + * screen drifted away from where the host believed playback was, pausing + * appeared to do nothing, and a match got cut off mid-animation when the card + * ended. + * + * A host is NOT required to wait for `frame-done` — the older ones do not know + * about it — so the queue still absorbs frames arriving faster than this view + * draws them. Nothing here breaks a view that draws synchronously and returns + * nothing: it simply acks immediately, which is the truth about that view. */ -export function onFrame(draw: (frame: unknown, root: HTMLElement) => void): void { +export function onFrame( + draw: (frame: unknown, root: HTMLElement) => DrawResult, + opts: FrameOptions = {} +): void { + const queue: Array<{ frame: unknown; seq: number | undefined }> = [] + let drawing = false + + /** + * Draw queued frames one at a time, acking each. + * + * The ack carries the host's own token back rather than a count of our own: + * a host that gave up waiting and moved on would otherwise be advanced twice + * by the late ack — once by its own timeout and again when we finally reply. + * Echoing the token lets it recognise and drop a reply it no longer wants. + */ + const pump = async (): Promise => { + if (drawing) return + drawing = true + try { + for (;;) { + const next = queue.shift() + if (!next) return + try { + const result = draw(next.frame, document.body) + if (result) await withTimeout(result) + } catch { + /* a draw error must never break the host, or stall the queue */ + } + window.parent.postMessage({ __arenaView: true, type: 'frame-done', seq: next.seq }, '*') + } + } finally { + drawing = false + } + } + window.addEventListener('message', (e: MessageEvent) => { if (!fromHost(e)) return const d = e.data as ViewMessage | null if (d && d.__arenaView === true && d.type === 'frame') { - try { - draw(d.frame, document.body) - } catch { - /* a draw error must never break the host */ - } + queue.push({ frame: d.frame, seq: d.seq }) + void pump() } }) - // Only the parent talks to us; announce readiness. - window.parent.postMessage({ __arenaView: true, type: 'ready' }, '*') + // Only the parent talks to us; announce readiness, and how fast we draw. + window.parent.postMessage({ __arenaView: true, type: 'ready', paceMs: opts.paceMs }, '*') +} + +/** Resolve when `p` settles, or after `FRAME_TIMEOUT_MS` — whichever comes first. */ +function withTimeout(p: Promise): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, FRAME_TIMEOUT_MS) + void p.then( + () => { + clearTimeout(timer) + resolve() + }, + () => { + clearTimeout(timer) + resolve() + } + ) + }) } /** diff --git a/packages/game-sdk/test/view.test.ts b/packages/game-sdk/test/view.test.ts new file mode 100644 index 0000000..18c2dd2 --- /dev/null +++ b/packages/game-sdk/test/view.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +/** + * `onFrame` is the boundary between a host and an author's renderer, and the + * properties tested here are the ones a host relies on to pace playback: frames + * are drawn one at a time, and `frame-done` means "this frame is finished", not + * "this frame arrived". + * + * Like `preview.test.ts`, this stubs the handful of globals the code touches + * rather than pulling in jsdom — nothing in this package needs a real DOM, and + * the stub makes it explicit which platform calls are part of the contract. + */ + +interface Posted { + __arenaView?: boolean + type?: string + seq?: number + paceMs?: number +} + +let posted: Posted[] +let handlers: Array<(e: MessageEvent) => void> +let parentWindow: object + +/** Deliver a message as the host would. */ +function fromHost(data: unknown): void { + for (const h of handlers) h({ source: parentWindow, data } as unknown as MessageEvent) +} + +function sendFrame(frame: unknown, seq?: number): void { + fromHost({ __arenaView: true, type: 'frame', frame, seq }) +} + +const acks = (): Array => + posted.filter((m) => m.type === 'frame-done').map((m) => m.seq) + +beforeEach(() => { + posted = [] + handlers = [] + parentWindow = { postMessage: (m: Posted) => posted.push(m) } + vi.stubGlobal('window', { + parent: parentWindow, + addEventListener: (type: string, h: (e: MessageEvent) => void) => { + if (type === 'message') handlers.push(h) + }, + }) + vi.stubGlobal('document', { body: { id: 'root' } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +/** Imported per-test: `onFrame` keeps per-registration queue state. */ +async function load() { + return (await import('../src/view.js')).onFrame +} + +describe('onFrame', () => { + it('announces readiness with the declared pace', async () => { + const onFrame = await load() + onFrame(() => {}, { paceMs: 1700 }) + expect(posted).toEqual([{ __arenaView: true, type: 'ready', paceMs: 1700 }]) + }) + + it('draws each frame and acks it with the host token', async () => { + const onFrame = await load() + const seen: unknown[] = [] + onFrame((frame) => { + seen.push(frame) + }) + + sendFrame('a', 1) + sendFrame('b', 2) + await Promise.resolve() + + expect(seen).toEqual(['a', 'b']) + // Echoed, not counted: a host that timed out on frame 1 and moved on has to + // be able to tell a late reply from the reply to what it is waiting for. + expect(acks()).toEqual([1, 2]) + }) + + it('hands the document body to the draw callback', async () => { + const onFrame = await load() + let root: unknown = null + onFrame((_frame, r) => { + root = r + }) + sendFrame('a') + await Promise.resolve() + expect(root).toEqual({ id: 'root' }) + }) + + it('does not draw the next frame until the current one settles', async () => { + const onFrame = await load() + const drawn: number[] = [] + let release: (() => void) | null = null + onFrame((frame) => { + drawn.push(frame as number) + return new Promise((resolve) => { + release = resolve + }) + }) + + sendFrame(1, 1) + sendFrame(2, 2) + sendFrame(3, 3) + await Promise.resolve() + + // This is the guarantee the whole protocol rests on: three frames arrived + // together and only one was drawn. Without it an animation is overdrawn by + // whatever lands next, which is what the host used to have to guess around. + expect(drawn).toEqual([1]) + expect(acks()).toEqual([]) + + release!() + await Promise.resolve() + await Promise.resolve() + expect(drawn).toEqual([1, 2]) + expect(acks()).toEqual([1]) + }) + + it('acks immediately when a draw returns nothing', async () => { + const onFrame = await load() + // A view with no animation is finished the moment it has drawn, and saying so + // is what lets a host fit more of a long match into a fixed slot. + onFrame(() => {}, { paceMs: 0 }) + sendFrame('a', 7) + await Promise.resolve() + expect(acks()).toEqual([7]) + }) + + it('acks a frame whose draw threw, rather than stalling the queue', async () => { + const onFrame = await load() + const drawn: number[] = [] + onFrame((frame) => { + drawn.push(frame as number) + if (frame === 1) throw new Error('author bug') + }) + + sendFrame(1, 1) + sendFrame(2, 2) + await Promise.resolve() + + // A host waiting on an ack that never comes stops playing entirely, so one + // bad frame must not be able to take the rest of the match with it. + expect(drawn).toEqual([1, 2]) + expect(acks()).toEqual([1, 2]) + }) + + it('gives up on a promise that never settles instead of wedging the view', async () => { + vi.useFakeTimers() + const onFrame = await load() + const drawn: number[] = [] + onFrame((frame) => { + drawn.push(frame as number) + return new Promise(() => {}) // never resolves + }) + + sendFrame(1, 1) + sendFrame(2, 2) + await vi.advanceTimersByTimeAsync(14_000) + expect(acks()).toEqual([]) + + await vi.advanceTimersByTimeAsync(2_000) + expect(acks()).toEqual([1]) + expect(drawn).toEqual([1, 2]) + }) + + it('ignores frames that did not come from the host', async () => { + const onFrame = await load() + const drawn: unknown[] = [] + onFrame((frame) => { + drawn.push(frame) + }) + + for (const h of handlers) { + h({ source: { other: true }, data: { __arenaView: true, type: 'frame', frame: 'x' } } as unknown as MessageEvent) + } + await Promise.resolve() + + // A view is author code running in a visitor's browser; "there happens to be + // no other frame right now" is not a property of this file. + expect(drawn).toEqual([]) + expect(acks()).toEqual([]) + }) + + it('ignores host messages that are not frames', async () => { + const onFrame = await load() + const drawn: unknown[] = [] + onFrame((frame) => { + drawn.push(frame) + }) + + fromHost({ __arenaView: true, type: 'players', players: [] }) + fromHost({ type: 'frame', frame: 'unmarked' }) + fromHost(null) + await Promise.resolve() + + expect(drawn).toEqual([]) + }) +}) diff --git a/spec/protocol.md b/spec/protocol.md index 24f8e5a..2f9ec0c 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -76,6 +76,36 @@ Author logic sees only opaque agent ids. The platform exposes live **identity** - Names/avatars are public, so identity is safe to expose even for `hiddenInfo` games (secrets still flow only through the per-viewer `render(state, {viewer})`). +### 5b. Frame pacing (T2 view ↔ host) + +A host decides when to push frames; on a finished replay that can be far faster +than a view draws them. Two messages let the view set the pace instead of the +host guessing it. + +| Direction | Message | Meaning | +|---|---|---| +| view → host | `{__arenaView, type:'ready', paceMs?}` | Ready for frames. `paceMs` is roughly how long one frame takes — a **hint** for budgeting, from `onFrame(draw, { paceMs })`. | +| host → view | `{__arenaView, type:'frame', frame, seq?}` | A frame. `seq` is an opaque host token. | +| view → host | `{__arenaView, type:'frame-done', seq}` | That frame is **finished** — animation played out, dwell elapsed. Echoes the host's `seq`. | + +A view returns a promise from `onFrame` to mark a frame unfinished; the SDK draws +one frame at a time, waits for it, then acks. So: + +- **A view MUST NOT** assume frames are spaced usefully — return a promise if + drawing takes time. +- **A host SHOULD** wait for `frame-done` before sending the next frame, and + **MUST** tolerate its absence (`paceMs` too) — views built against older SDKs + send neither. +- **A host MUST** ignore a `frame-done` whose `seq` is not the one it is waiting + for, or a reply arriving after it gave up will advance playback twice. + +Without this a host can only guess an interval, and one guess cannot fit every +view — these range from no animation at all to 6.5s opening a set of dice cups. +Guessing too fast builds an unbounded backlog, so what is on screen drifts away +from where the host believes playback is, pausing appears to do nothing, and the +match is cut off mid-animation when the slot ends; guessing too slow throws away +most of a fast view's match. + ## 6. Validation gates - **PR (this repo, `pnpm validate`)**: manifest ↔ meta agreement; source scan; From 62ca81438e7fed969d42ab429f4c412fbd2f307b Mon Sep 17 00:00:00 2001 From: "rujing.yan" Date: Fri, 21 Aug 2026 14:56:16 +0800 Subject: [PATCH 2/2] fix(view-sdk): let the host ask for a speed, and keep the dwell conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- games/chinese-checkers/view.ts | 6 +-- games/doudizhu/view.ts | 4 +- games/liars-dice/view.ts | 4 +- games/othello/view.ts | 6 +-- packages/game-sdk/src/view.ts | 79 ++++++++++++++++++++++++++- packages/game-sdk/test/view.test.ts | 83 +++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 12 deletions(-) diff --git a/games/chinese-checkers/view.ts b/games/chinese-checkers/view.ts index 4a1a99d..1d225dc 100644 --- a/games/chinese-checkers/view.ts +++ b/games/chinese-checkers/view.ts @@ -15,7 +15,7 @@ * 4. identity — names and avatars arrive separately via `onPlayers`, and may * land before or after the first frame. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, playbackSpeed, onPlayers } from '@arena/game-sdk/view' import { ARENA_THEME as T } from '@arena/game-sdk/theme' import type { PlayerInfo } from '@arena/game-sdk' @@ -490,7 +490,7 @@ function animate(frame: Frame, path: number[]): Promise { const seat = frame.pegs?.[dest] ?? 0 const skip = new Set([dest]) const legs = path.length - 1 - const total = legs * HOP_MS + const total = (legs * HOP_MS) / playbackSpeed() const jumping = legs > 1 || path.length > 2 let started = -1 @@ -523,7 +523,7 @@ function animate(frame: Frame, path: number[]): Promise { paint(frame, new Set(), path) renderHud(frame, -1) setStatus(statusText(frame)) - setTimeout(resolve, HOLD_MS) + void hold(HOLD_MS).then(resolve) } requestAnimationFrame(tick) diff --git a/games/doudizhu/view.ts b/games/doudizhu/view.ts index 0e20cb1..3e36f04 100644 --- a/games/doudizhu/view.ts +++ b/games/doudizhu/view.ts @@ -13,7 +13,7 @@ * the viewer's own hand, which we still render as a single stack for a tidy table). * Identity (name/avatar) arrives via `onPlayers`; author logic never sees it. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { dwell, onFrame, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' interface SeatView { @@ -308,7 +308,7 @@ onFrame((frame, h) => { ensureRoot(h) lastFrame = frame as DouFrame draw() - return new Promise((resolve) => setTimeout(resolve, HOLD_MS)) + return dwell(HOLD_MS) }, { paceMs: HOLD_MS }) onPlayers((p) => { players = p diff --git a/games/liars-dice/view.ts b/games/liars-dice/view.ts index f79fb29..c5ad6bf 100644 --- a/games/liars-dice/view.ts +++ b/games/liars-dice/view.ts @@ -16,7 +16,7 @@ * viewer's own cup mid-game. Identity (name/avatar) arrives via `onPlayers`; * author logic never sees it. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' import { ARENA_THEME as T } from '@arena/game-sdk/theme' @@ -477,7 +477,7 @@ onFrame((frame, h) => { // an ordinary bid. The SDK reports each frame as finished only when this // resolves, so a host that waits gives the cups time to open instead of // pushing the next bid over the top of them. - return new Promise((resolve) => setTimeout(resolve, f.reveal ? REVEAL_MS : HOLD_MS)) + return hold(f.reveal ? REVEAL_MS : HOLD_MS) }, { paceMs: HOLD_MS }) onPlayers((p) => { players = p diff --git a/games/othello/view.ts b/games/othello/view.ts index a9495e2..a0a06ce 100644 --- a/games/othello/view.ts +++ b/games/othello/view.ts @@ -19,7 +19,7 @@ * it in, so rendering `info.avatar` directly is safe. When a seat has no avatar * (or inlining failed) we fall back to a generated `data:` SVG monogram. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, playbackSpeed, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' // —— pacing knobs (ms) — tune these if replays still feel too fast/slow —— @@ -336,7 +336,7 @@ function animate( return new Promise((resolve) => { const start = performance.now() const frame = (now: number) => { - const t = Math.min(1, (now - start) / FLIP_MS) + const t = Math.min(1, (now - start) / (FLIP_MS / playbackSpeed())) // Static layer: everything that isn't animating. paint(ctx, size, to, palette, skip) // Placed discs grow in (ease-out). @@ -361,7 +361,7 @@ function animate( // The dwell is part of the frame, not something after it: resolving // before it elapsed would tell the host we are ready while the move it // just drew is still the thing being looked at. - setTimeout(resolve, HOLD_MS) + void hold(HOLD_MS).then(resolve) } } requestAnimationFrame(frame) diff --git a/packages/game-sdk/src/view.ts b/packages/game-sdk/src/view.ts index 6058361..464d975 100644 --- a/packages/game-sdk/src/view.ts +++ b/packages/game-sdk/src/view.ts @@ -24,9 +24,28 @@ interface ViewMessage { frame?: unknown /** Host-assigned frame token, echoed back on `frame-done`. See `onFrame`. */ seq?: number + /** Playback rate the host is asking for. See `hold`. */ + speed?: number players?: PlayerInfo[] } +/** + * How fast the host wants playback to run: 1 = as authored, 2 = twice as fast. + * + * A host with a speed control has no way to make a view draw faster on its own — + * the timings live in here. Arena's feed learned that the hard way: unable to + * speed a view up, its 5x setting instead showed every fifth frame, so a card + * game skipped four plays out of five and the replay stopped making sense. + * + * A host that never sends a speed leaves this at 1, which is why the timings + * below read as the authored ones everywhere else. + */ +let speed = 1 + +/** Slowest and fastest a host may drive a view, so one bad value cannot freeze or strobe it. */ +const MIN_SPEED = 0.1 +const MAX_SPEED = 20 + /** * What a draw callback may return. * @@ -100,13 +119,17 @@ export interface FrameOptions { * promise and the SDK will hold everything until it settles: * * ```ts + * import { onFrame, hold } from '@arena/game-sdk/view' * onFrame(async (frame, root) => { * drawBoard(frame, root) * await animateMove(frame) - * await wait(HOLD_MS) // let the finished move sit there + * await hold(HOLD_MS) // let the finished move sit there * }, { paceMs: HOLD_MS }) * ``` * + * Use `hold` rather than your own timer: it is what makes a host's speed control + * work, and a view that ignores it can only be sped up by dropping frames. + * * The SDK then guarantees two things a view used to have to build for itself: * * 1. **Serialisation.** Frame N+1 is not drawn until frame N's promise settles. @@ -146,6 +169,7 @@ export function onFrame( try { for (;;) { const next = queue.shift() + pending = queue.length if (!next) return try { const result = draw(next.frame, document.body) @@ -163,15 +187,66 @@ export function onFrame( window.addEventListener('message', (e: MessageEvent) => { if (!fromHost(e)) return const d = e.data as ViewMessage | null - if (d && d.__arenaView === true && d.type === 'frame') { + if (!d || d.__arenaView !== true) return + if (d.type === 'frame') { queue.push({ frame: d.frame, seq: d.seq }) + pending = queue.length void pump() + } else if (d.type === 'speed' && typeof d.speed === 'number' && Number.isFinite(d.speed)) { + speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, d.speed)) } }) // Only the parent talks to us; announce readiness, and how fast we draw. window.parent.postMessage({ __arenaView: true, type: 'ready', paceMs: opts.paceMs }, '*') } +/** + * Wait `ms` of authored time, scaled by the speed the host asked for. + * + * This is the whole mechanism behind a host's speed control. `setTimeout` in a + * view is a fixed cost the host cannot influence; `hold` is the same pause + * expressed as something it can. At 2x a 1500ms dwell becomes 750ms, and the + * match plays twice as fast with every frame still shown. + * + * A speed change applies from the next `hold`, not retroactively to one already + * running — the wait in progress is at most one frame long, and cancelling it + * mid-animation would jump the board. + */ +export function hold(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms) / speed)) +} + +/** + * A pause that only applies when another frame is already waiting. + * + * `hold` is for time the frame NEEDS — an animation running, a beat the viewer has + * to register. `dwell` is for the padding that only exists to stop a burst of + * frames flashing past: when nothing is queued behind this frame, there is no + * burst to slow down, and waiting anyway just makes a live match lag behind + * reality and delays telling the host we are ready. + * + * Every view in this repo documented that intent ("a live match never fills the + * queue, so it stays responsive") and doudizhu was the one that implemented it, + * by only arming its timer when its queue was non-empty. Folding the queue into + * the SDK lost that, and the detail page — which pushes a frame every 1500ms — + * went from 1500ms a play to 1700ms, ending a measured 20 seconds one play + * behind where it used to be. This is that behaviour, named. + */ +export function dwell(ms: number): Promise { + return pending > 0 ? hold(ms) : Promise.resolve() +} + +/** Frames received but not yet drawn. Maintained by `onFrame`; read by `dwell`. */ +let pending = 0 + +/** + * The speed the host is asking for, for durations `hold` cannot express — a + * requestAnimationFrame tween needs to divide its own duration by this. + */ +export function playbackSpeed(): number { + return speed +} + /** Resolve when `p` settles, or after `FRAME_TIMEOUT_MS` — whichever comes first. */ function withTimeout(p: Promise): Promise { return new Promise((resolve) => { diff --git a/packages/game-sdk/test/view.test.ts b/packages/game-sdk/test/view.test.ts index 18c2dd2..7703ae4 100644 --- a/packages/game-sdk/test/view.test.ts +++ b/packages/game-sdk/test/view.test.ts @@ -168,6 +168,89 @@ describe('onFrame', () => { expect(drawn).toEqual([1, 2]) }) + it('scales a hold by the speed the host asks for', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + let done = false + mod.onFrame(async () => { + await mod.hold(2_000) + done = true + }) + + fromHost({ __arenaView: true, type: 'speed', speed: 4 }) + sendFrame('a', 1) + await vi.advanceTimersByTimeAsync(400) + expect(done).toBe(false) + + // 2000ms of authored time at 4x is 500ms of real time. Without this the only + // way a host could go faster would be to skip frames. + await vi.advanceTimersByTimeAsync(150) + expect(done).toBe(true) + expect(acks()).toEqual([1]) + expect(mod.playbackSpeed()).toBe(4) + }) + + it('clamps a speed a host should not be able to ask for', async () => { + const mod = await import('../src/view.js') + mod.onFrame(() => {}) + + fromHost({ __arenaView: true, type: 'speed', speed: 0 }) + expect(mod.playbackSpeed()).toBeGreaterThan(0) // 0 would stall every hold forever + fromHost({ __arenaView: true, type: 'speed', speed: 10_000 }) + expect(mod.playbackSpeed()).toBeLessThanOrEqual(20) // and this would strobe + fromHost({ __arenaView: true, type: 'speed', speed: Number.NaN }) + expect(Number.isFinite(mod.playbackSpeed())).toBe(true) + + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + expect(mod.playbackSpeed()).toBe(1) + }) + + it('leaves timings as authored for a host that never mentions speed', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + let done = false + mod.onFrame(async () => { + await mod.hold(1_500) + done = true + }) + sendFrame('a', 1) + await vi.advanceTimersByTimeAsync(1_400) + expect(done).toBe(false) + await vi.advanceTimersByTimeAsync(200) + expect(done).toBe(true) + }) + + it('dwells only when a frame is already waiting behind this one', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + // A 1000ms animation the frame genuinely needs, then 1700ms of padding that + // only matters when frames are stacking up. + mod.onFrame(async () => { + await mod.hold(1_000) + await mod.dwell(1_700) + }) + + // Nothing behind it: acked as soon as the animation is done. Waiting the + // extra 1700 anyway made a host that pushes on its own timer fall + // progressively behind — measured as a play lost every 20 seconds. + sendFrame('lone', 1) + await vi.advanceTimersByTimeAsync(1_100) + expect(acks()).toEqual([1]) + + // A frame landing mid-draw IS a backlog, so the padding applies and the + // burst is slowed to something watchable. + sendFrame('x', 2) + await vi.advanceTimersByTimeAsync(500) + sendFrame('y', 3) + await vi.advanceTimersByTimeAsync(600) // 'x' animation done, now dwelling + expect(acks()).toEqual([1]) + + await vi.advanceTimersByTimeAsync(1_800) + expect(acks()).toEqual([1, 2]) + }) + it('ignores frames that did not come from the host', async () => { const onFrame = await load() const drawn: unknown[] = []