# Shared room client contract, version 1

Design specification only. Implement after GO LIVE. The orchestrator owns the
client, protocol, and service. All game builders consume this same contract.
Freeze changes before dispatch; coordinate any later change with all callers.

## Public module

Serve the ES module at `/shared/room-client.js`. Export `createRoomClient`.
It accepts `{ gameId, rulesVersion, onSnapshot, onStatus }` and returns the
client immediately. It establishes connectivity internally; creation never
blocks local play. The only allowed game IDs are `flappy-buffalo`, `powder-day`,
and `pearl-street-dash`. `rulesVersion` is a nonempty scoring/physics version.
Callbacks must be optional and must never be invoked after `close()`.

| Method | Arguments | Synchronous result and behavior |
| --- | --- | --- |
| `startRun` | `{ runId, seed }` | No result. Attempt asynchronous registration; one active run per client. |
| `getScoringContext` | None | `{ multiplier, eventId }`. Multiplier is 1 or 2; eventId is null outside an authoritative active surge. |
| `recordPickup` | `{ runId, pickupId, basePoints }` | No result. Report a collected snowflake or sun for room progress; never change the game's score. |
| `finishRun` | `{ runId, score, durationTicks }` | No result. Submit the final score once, with bounded retries and a durable acknowledgement. |
| `close` | None | No result. Stop callbacks and release this client's connection/heartbeat resources. |

Games generate random UUID run IDs. Pickup IDs are unique within a run and
stable for that course's collectibles. Seeds are unsigned 32-bit integers.
Scores and ticks are finite nonnegative integers. `basePoints` is 1 for suns
and 3 for snowflakes. Gap, gate, precision, and combo scores are local game
actions and never call `recordPickup`.

Each simulation tick reads scoring context once. A collected item's score is
`basePoints * multiplier`; record it once and separately report the pickup.
Only pickup points are multiplied. Games log scoring-context changes at ticks
if implementing replay. A ghost never calls the room client.

`onStatus` receives `{ connection, runId, runState }`:

- `connection`: `connecting`, `online`, `offline`, or `closed`.
- `runState`: `local`, `registering`, `registered`, `pending`, `submitted`, or
  `rejected`. `runId` is null before the first run.

The client retains pending acknowledged registrations/completions across a
quick restart or navigation; callbacks identify their run so an old submission
cannot overwrite the current run's status. A shared score is submitted only
after acknowledgement. Local personal bests do not depend on that acknowledgement.

## Room snapshots

`onSnapshot` receives one complete object with:

- `protocolVersion`: 1; `roomId`: `boulder-live-v1`.
- `version`: a monotonically increasing integer; `serverTimeMs`: Unix milliseconds.
- `progressEpoch`: integer identifying the current collection window.
- `progress`: an integer from 0 through 100; `target`: 100.
- `surge`: null or `{ eventId, startsAtMs, endsAtMs }`.
- `activePlayers`: count of unique player identities with a recent heartbeat.
- `leaderboards`: a list of `{ gameId, rulesVersion, entries }`, with at most
  10 entries per board. Each entry is `{ nickname, score }` in descending score
  order, with a stable server-assigned tie order.

Full snapshots replace prior state and may skip versions. Ignore older
versions. Use fresh server timestamps for clock synchronization even when the
state version is unchanged. Before the surge feature is enabled, progress is
0 and surge is null; the UI hides that unfinished feature.

## Wire and identity behavior

Reserve `/api/room/session`, `/api/room/runs/start`,
`/api/room/runs/finish`, `/api/room/pickups`, and `/api/room/ws`.
Only the shared client knows the exact wire transport; game agents never call
these endpoints directly. Use protocol version 1 on messages and structured
errors with a stable `code`. Health and unknown routes never create players.

Resolve one persistent same-origin player identity across tabs, then issue
separate run IDs and scoped session credentials. A refreshed connection does
not create a new player. Heartbeat every 10 seconds while visible; a player is
active if any of its connections has a heartbeat within 30 seconds. Hidden
or disconnected phones expire without affecting their recorded results.

Apply a unique batch ID and collection epoch to pickup batches. The server
deduplicates batches and their pickup IDs within a run. It uses receipt-time
event validity; batches received during a surge do not advance progress.
Old epochs are rejected, including batches that first arrive after reconnect.
The final score is the casual client-reported result; server checks bounds and
session ownership, not a claim of verified simulation or cheat prevention.

Offline play uses multiplier 1 and sends no room pickups. Clear unsent pickup
batches on disconnect. Registered runs may retry completion; entirely offline
runs stay local. Expired credentials or rejected runs produce explicit status,
not an infinite retry loop. On navigation, queued completion retries belong to
the shared client; games do not implement a second queue.

Use bounded retries, batches, and storage. These limits, session expiry, and
payload validation are owned by the orchestrator and documented in the actual
implementation after GO LIVE. Keep live state and credentials out of caches.
