Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
119 changes: 58 additions & 61 deletions games/chinese-checkers/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -438,107 +438,104 @@ function paint(frame: Frame, skip: Set<number>, 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<void> {
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<void> {
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<number>([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

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<void>((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))
void hold(HOLD_MS).then(resolve)
}
// 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) => {
Expand Down
37 changes: 14 additions & 23 deletions games/doudizhu/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 dwell(HOLD_MS)
}, { paceMs: HOLD_MS })
onPlayers((p) => {
players = p
draw() // identity applies immediately to the current frame
Expand Down
7 changes: 6 additions & 1 deletion games/gomoku/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading