# Spectator SDK v1: the first platform slice

The Broadcast Lab accepts `arena-spectator/1` JSON and renders different games through one shared 3D spectator shell. Circuit Sprint and The Colosseum both export their recorded data into this format. An author can preview a third game through the local JSON picker without changing site code.

The local Broadcast Lab previews presentation JSON without executing uploaded game code or verifying its claimed hash. The hosted entry service also runs external agents and streams public live scenes over SSE, then publishes recorded replays for external matches. Live streaming, synthesized audio, and validated custom Muse models are implemented. General-purpose uploaded renderer bundles and isolated publishing workers remain future work.

## Reference: Museguys

Inspected https://museguys.me/?follow=demo-3/arena on September 23, 2026. Observed named, small 3D competitors; themed obstacle courses; follow/broadcast/course camera choices; standings and section progress; qualification stakes; a continuing watch link; clear-view controls; and a subsequent circular melee arena. The narrow browser view had overlapping panels; clearing overlays made the action readable. No source code or assets were copied, and model identity or backend architecture was not verified.

Implications for Arena: keep the stage prominent, support both courses and compact arenas, make individual Muses easy to recognize/follow, provide readable stakes, and keep complicated overlays out of the main action. Use Roman staging and playful contestants while letting creators develop distinct visual worlds.

## Public contract

Import `defineSpectatorReplay`, `validateSpectatorReplay`, `sampleSpectatorFrame`, and the `SpectatorReplay` type from `@arena/sdk`.

- `schema`: `arena-spectator/1`.
- `game`: id, name, version, short rules and subtitle.
- `provenance`: recordings use `{ mode: "replay", source, replayHash }`; live scenes use `{ mode: "live", source, matchId }` with no replay hash. An imported recording hash is a claim, not proof.
- `ticksPerSecond`: presentation clock; turn-based games can stage exchanges on this clock.
- `actors`: stable id, name, color, simulated/external connection classification, editorial persona. Neither connection classification proves model provenance.
- `scene`: sky/floor colors, overview camera coordinates, and declarative box/sphere/cylinder props. The built-in renderer supplies a shared character rig. Positions are XYZ with Y up; cylinder/sphere scales multiply radius, while box scales define dimensions. `spinY` is an illustrative rotation in radians per second.
- `frames`: strictly increasing ticks, beginning at zero for recordings (live windows may begin later), with public position, heading, animation, score, and status for every actor. Higher score sorts first; adapters must encode rank appropriately. No private observations or hidden game state belong here.
- `moments`: unique ids, chronological ticks, type, factual label, optional actor target, optional editorial commentary. Actors can be targeted by the director for three seconds; explicit spectator follow overrides the director.

Use finite coordinates within ±10,000, positive scales, hex colors, 1–32 actors, at most 800 props, 20,000 frames, and 2,000 moments. Browser imports are capped at 8 MiB. These are initial presentation limits, not a promise that every maximum-size scene will perform well on every phone.

## Authoring example

```ts
const replay = defineSpectatorReplay({
  schema: "arena-spectator/1",
  game: { id: "tiny-arena", name: "Tiny Arena", version: "0.1.0",
    subtitle: "A very small exhibition", rules: "Reach the far end." },
  provenance: { mode: "replay", source: "Your recorded simulation", replayHash: "your-source-hash" },
  ticksPerSecond: 20,
  actors: [{ id: "muse-1", name: "Pip", color: "#aabbcc", kind: "simulated", persona: "An ambitious newcomer" }],
  scene: { sky: "#edd8c4", floor: "#d5b391", overview: [12, 15, 12], lookAt: [0, 0, 0],
    props: [{ shape: "box", position: [0, -0.5, 0], scale: [10, 1, 10], color: "#d8b994" }] },
  frames: [
    { tick: 0, actors: { "muse-1": { position: [0, 0, 0], heading: 0, animation: "run", score: 0, status: "On the way" } } },
    { tick: 100, actors: { "muse-1": { position: [0, 0, 4], heading: 0, animation: "celebrate", score: 1, status: "Finished" } } },
  ],
  moments: [{ id: "finish", tick: 100, type: "finish", actorId: "muse-1", label: "Pip reaches the far end.", commentary: "A short journey. An enormous victory speech." }],
});
```

Serialize the result to JSON, open Broadcast Lab, and choose **Preview your game JSON**. Imported strings render as text; no arbitrary scripts or HTML are accepted. Custom avatar URLs are supported under the GLB restrictions below.

## Development

```sh
npm ci
npm run build
npm run dev --workspace @arena/site
# Open /#showcase
```

`packages/site/tools/export-spectator.mjs` adapts the existing fixtures to generated `public/spectator/*.json` files during site build/dev. The adapters own game-specific knowledge; the renderer does not. Circuit Sprint v0.2 exports the moving collision geometry on the same simulation clock as its racer poses; decorative scenery remains illustrative. Older recordings retain their original geometry and behavior. The Colosseum demo now exports a verified real-time platform brawl with physical movement; the older turn-based game remains available as a separate library export.

## Acceptance checks

- Both genres render using the same component and SDK sampler.
- Pause, restart, seek, speed selection, game switch, follow, overview, and overlay hiding work.
- Scores, discrete poses, commentary and director choices do not reveal future frames or events.
- Replay-desk moments remain hidden until their timestamp and replay with two seconds of setup.
- JSON parsing validates roster, references, timeline, numbers, colors, and complexity before 3D allocation.
- Playback preserves each recording’s simulation outcome; commentary is editorial and attributed accordingly. New game versions may change the rules for newly recorded matches.

## Next boundary

Replace local JSON preview with a versioned game registry and isolated publishing jobs; retain this public presentation contract as the spectator output. Extend the existing validated GLB support to custom renderers only with an isolation and resource policy. Validate the entertainment with fresh viewers before interpreting event counts as evidence that a match is funny.


## Recorded course physics (Circuit Sprint v0.2)

The built-in course uses capsule racers and deterministic substeps (at most
1/240 second) against rotating beams, a lifting portcullis, a swinging sphere,
and collapsing support tiles. Contact resolves penetration, stops progress,
and applies recovery or a wipeout; bracing cushions impacts without disabling
colliders. The launch pad supplies vertical velocity and gravity determines
airtime. This is a purpose-built character/obstacle simulation, not a general
rigid-body engine or player-to-player combat system.

`SceneProp.id` marks an animated prop. Every frame then supplies that prop’s
`{ position, rotation, visible }` in `frame.props`; rotation is Euler XYZ in
radians. The SDK validates ids, transforms, complete frame coverage, and
complexity before rendering. Position and rotation interpolate; visibility
remains causal. `scene.floorY` optionally positions the backdrop below a
floating course. The new `brace` and `airborne` actor animations use physical
positions from the simulation. Existing v1 replays without these fields remain
supported. No browser-side physics changes recorded outcomes.

## Platform brawl presentation

`scene.director: "arena"` requests the fixed overview framing for broadcast
mode. Follow controls still override it. Scene props now also support `cone`
and `torus` primitives (unit cone and torus with a 0.075 tube radius).
Optional actor `style: "fighter"` adds a boxing outfit. Poses may include
`damage`, `stocks`, `impactAt` (absolute simulation seconds) and
`invulnerable`; these drive the scoreboard, hit sparks and shield aura.
Uppercut and ground-slam animations use the shared SDK move clock.
The simulation owns all contact, damage and knockout decisions.

### Sound effects

Replays may provide `sounds: SpectatorSound[]`: `{ tick, kind, actorId?, intensity? }`.
Kinds are `swing`, `punch`, `heavy`, `jump`, `land`, `step`, `launch`, `ringout`,
`checkpoint`, `pickup`, `finish`, and `obstacle`. Intensity is 0–1. Supply exact contact,
checkpoint and elimination ticks from the simulation, not commentary text.
The validator accepts at most 10,000 cues and rejects unknown actors or kinds.

`spectatorSounds(replay)` combines these cues with movement and attack sounds derived
from recorded poses. `selectSpectatorSounds(cues, from, to, ticksPerSecond, followedId?)`
selects at most three cues for continuous playback, favors the camera-locked Muse,
and skips backward or large forward seeks. Renderers must also reset their cursor
on explicit seeks (including short ones) and stop voices on pause, mute, or teardown.
The bundled viewer synthesizes effects locally, starts muted, requires a viewer
click to enable audio, limits concurrent voices, and attenuates distant action when
following a Muse. It also supports the background music described below and does not fetch external audio assets.

### Background music

The viewer includes original synthesized instrumental loops: a 132 BPM arcade
score for Circuit Sprint, a 104 BPM minor-key arena score, and a 144 BPM kart score. Music has its own
volume and toggle beneath the shared sound control. It starts only after a viewer
enables sound, stops on pause/mute/hidden tab, and resets its phrase position when
seeking. Playback speed does not shift musical pitch or tempo. Notes are scheduled
against the audio clock with a short lookahead, rather than browser timer timing.
`spectatorMusicNotes(style, step)` and `spectatorMusicTempo(style)` expose the
bounded eight-bar arrangements to other renderers (`sprint`, `arena`, or `kart`).

### Custom Muse models

`SpectatorActor.avatar = { url, sha256 }` selects an immutable appearance. URL must
be public HTTPS with CORS enabled; `sha256` is the lowercase SHA-256 of the GLB.
The renderer checks the hash before loading. All four games (Circuit Sprint, Colosseum, Muse Kart, and Muse Boxing), including
live boxing and the medal podium, use the same loader. Boxing keeps its default
character until the custom model has loaded successfully. Model height is normalized to 2.2 world units, centered at the
feet and facing +Z. Collision sizes, scores, and other game rules never change.

Supported first version: self-contained GLB 2.0, 12 MiB, 60,000 triangles, 200
nodes, eight embedded PNG/JPEG textures (each at most 2048×2048), and 32 animation
clips. Export without compression or glTF extensions. External resource URIs are
rejected. Invalid, unavailable, or changed assets fall back to the standard Muse.
Clips named idle, run/walk, jump, punch, kick, fall, dance/victory/celebrate are
selected by action. Arbitrary skeletons are not retargeted; supply matching clips
for articulated combat. Static models retain their appearance with basic whole-body
movement. The viewer's “Try your character model” accepts a local GLB for this tab
only, with a Muse selector available in every game replay, including Boxing. It works across games and podiums; reloading clears these preview overrides.

For a registered external Muse, prefer managed upload via its existing owner key:

```ts
import { generateMuseAvatar } from "@arena/sdk";
await client.uploadAvatar(key, generateMuseAvatar({ preset: "middle-management", outfit: "#293647" }));
```

Choose `classic`, `middle-management`, `slightly-prepared`, or `the-athlete` for the
plush, pigeon, saucepan knight, or potato. `MUSE_AVATAR_PRESETS` exports their names,
descriptions, and palettes. Each includes distinct idle, recovery, and victory
animations; colors and optional accessories remain customizable. Omitting `preset`
preserves the original traits-based humanoid generator. Appearance never changes stats.

The server validates and stores immutable files on its persistent volume. See
`packages/site/public/avatar-agent.md` and `GET /v1/avatar/spec` for the signed
binary protocol. Existing externally hosted appearances are still supported:

```ts
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const sha256 = createHash('sha256').update(readFileSync('muse.glb')).digest('hex');
await client.setAvatar(key, { url: 'https://your-assets.example/muse-v1.glb', sha256 });
// Remove the custom appearance: await client.setAvatar(key, null);
```

This signs `avatarChallenge(agentId, updatedAt, avatar)` and posts to
`/v1/agents/avatar`. Updates expire after five minutes and must have a newer timestamp
than the last accepted update. The server stores metadata, not uploaded model files;
use your existing asset host. Match entries snapshot the model reference at entry,
and the replay exporter attaches it to the matching engine actor. Updating a profile
does not rewrite older matches. The profile's public response includes the model
reference, never the private signing key.


## Kart presentation

Actors may use `style: "kart"`. `ActorPose.kart` carries speed, lap/laps, drift,
item, boost/spin/recovery flags and optional pickup/side-swipe timestamps.
`scene.track` supplies the minimap centerline. Props may use `appearance:
"pickup" | "orb" | "peel"`; a pickup contains chronological `claims` in simulation
seconds and `respawnSeconds`. `samplePickupEffect(pickup, seconds)` reproduces
claim bursts and respawn scaling across pauses and backwards seeks. Game-specific
item and hit rules remain in Muse Kart.

## Live spectator messages

`GET /v1/public/matches/:matchId/live` sends named SSE events. Import
`LiveSpectatorMessages`, `LiveSpectatorScene`, `LiveSpectatorFrames`,
`LiveSpectatorEnded`, and `readLiveSpectatorMessage` from `@arena/sdk`.

- `scene`: a complete live scene, with `provenance.matchId` and 1–4 public frames.
- `frames`: `{ frames, pickups }`. Frames replace the current rolling window;
  `pickups` has one claim array per pickup prop in scene order, containing zero
  or one latest claim timestamp. Both updates are accepted atomically.
- `ended`: `{ status: "finished" | "failed" }`. A failed match does not promise
  a replay. Query the public match endpoint for replay availability.

Pass parsed JSON to `readLiveSpectatorMessage(event, value, currentScene)`.
It validates roster/poses, scene identity, monotonic latest ticks, bounded batches,
and pickup timestamps without mutating the previous scene. Late joins may start
at a nonzero tick. Reconnects receive a fresh scene; SSE comments are heartbeats.
Live match identity is not a replay hash or proof of a completed result.

## Round celebrations

Every completed round uses the shared winners’ podium, including live house games,
compact viewers, boxing, and recordings. Failed rounds do not celebrate. Confetti
is enabled by default and respects reduced-motion preferences. Games can set
`game.celebration: { durationSeconds: 8, confetti: true }`; duration is 2–30 seconds
and controls the live podium hold before returning to the current round. Hosted
games set the same options in `defineHostedGame(game, { celebration: {
durationSeconds: 8, confetti: true }, ...adapters })`; the host forwards them to
both live scenes and published replays. Replays
keep their final podium until restarted or rewound. Settings are optional and validated.

Import `spectatorCelebration(replay)` for resolved defaults and
`spectatorWinners(replay, standings?)` for the top three actors. Live `ended`
messages may include `standings`, an ordered array of actor IDs from the official
result (placement order, or winner first then score). Pass the current scene to
`readLiveSpectatorMessage` to validate those references. Keep the completed scene
for the ceremony even while connecting to the next round. Recorded adapters must
encode final placement in their final presentation scores, with higher scores first.

### Podium soundtrack

`spectatorMusicNotes('celebration', step)` and
`spectatorMusicTempo('celebration')` expose the shared original 120 BPM victory
score: rising major-key fanfare, warm chords, bass and light percussion. It loops
across the ceremony for all games. The podium uses an audio clock independent
of the stopped replay clock. Viewers enable sound with a gesture; mute, music
volume, hidden tabs, replay restart and leaving the podium stop or adjust playback.
Live podiums provide an enable/mute button and volume slider, and inherit the
boxing viewer’s sound choice. Local boxing replaces its ringside score when the
podium appears. No remote music assets are downloaded.
