# Sometimes Games API 1 / SDK 1.0.2

The public SDK is `@sometimes/games`. Install the versioned tarball from
`https://sometimeschat.com/game-sdk/v1/sometimes-games-1.0.2.tgz`.
The SDK source is in `sdk/`. The Wordle example in `games/wordle/` is published
under @aries and uses the public SDK, its own game files, React, React DOM and
Lucide. It runs through the same host and room services as other game packages.

## Quickstart

1. Sign in at `/developers/dashboard`, register a game, and copy its ID.
2. Run `npm exec --package=https://sometimeschat.com/game-sdk/v1/sometimes-games-1.0.2.tgz -- sometimes-game init my-game --id GAME_ID`.
3. In that folder run `npm install` and `npm run dev` (Node 22+). Open the printed
   loopback URL. The starter is a continuous shared counter with no previews.
4. Edit `client.ts`, `server.ts`, and `game.json`. Restart the simulator after edits.
5. Run `npm run build`. Upload `output/game.sometimes.json`, inspect verification,
   and submit it. An admin reviews the exact digest and separately
   publishes it. Only published approved builds appear in chats.

Download complete Wordle source at `/game-sdk/v1/wordle-source.zip`. Replace its
manifest ID with your registration and give it a distinct name before uploading
as your own game. Keep developer secrets out of client bundles, which players can inspect.

For setup instructions, use the [quickstart](https://sometimeschat.com/developers/games/quickstart).
The [first-edit tutorial](https://sometimeschat.com/developers/games/first-game)
demonstrates a change to the starter. The [Wordle walkthrough](https://sometimeschat.com/developers/games/wordle)
explains a complete game, and [troubleshooting](https://sometimeschat.com/developers/games/troubleshooting)
covers common errors.
The Wordle ZIP includes a file map and `npm test`; neither requires the Sometimes
repository. These guides assume basic HTML/CSS/JavaScript and link to introductory
lessons. React is optional; the counter starter uses native DOM controls.
Wordle's code and documentation are MIT licensed; retain its LICENSE and separate
dictionary attribution. Sometimes branding and cover artwork are excluded; use
your own. This license grant applies to the Wordle example, not the whole app.

## Manifest and package

The `game.json` manifest specifies the following fields:

- `api` must be `1`.
- `id` is the UUID assigned when you register the game.
- `version` is an immutable major.minor.patch version.
- `name` contains 1–60 characters and `summary` contains 1–240 characters.
- `players` is legacy SDK 1.0.0 metadata. Use `{ "min": 1, "max": 20 }` in
  new projects. Sometimes ignores this field for admission and catalogue limits;
  every game must support 1–20 players. Existing immutable packages remain valid.
- `client` and `server` name the source entry files.
- `input` lists the supported keyboard, pointer, touch or gamepad controls.
- `cover` optionally names an image asset for the catalogue.
- `capabilities` lists the requested signals, previews, audio, haptics or saves
  permissions. This list can be empty.

The SDK does not require a particular client framework.

The CLI bundles self-contained client JS, CSS and server ES modules. It packages
`assets/` with MIME types/base64 in one JSON file. Upload validates and hashes the
entire canonical package with SHA-256. The immutable digest covers every asset;
server modules are never served to game clients. Each room pins a digest.

Packages are limited to 5,000,000 bytes. Client and server bundles can each contain
up to 1.5 million characters, and CSS can contain up to 200,000 characters.
A package can include up to 32 assets of 2MiB each, with at most 16 audio files.
Supported formats are PNG, JPEG, WebP, PCM WAV and WOFF2. Audio must be mono or
stereo PCM at 8–96kHz and last no more than ten seconds. Images are limited to
4096 pixels per dimension and eight megapixels. The browser validates decoded
sounds before playback. R2 stores packages, while D1 stores ownership, digests,
verification reports and reviews.

## Client (`@sometimes/games`)

`connect<T>()` returns a scoped game client. Await `connected` before reading
`session` or `environment`. Call `ready()` once mounted. The host shows its themed
loader for at least 1.75 seconds and until the client is ready. Games can render
their interface using the DOM, canvas or WebGL.

| API | Contract |
| --- | --- |
| `session` | id, game_id, digest, name, host_id, revision, server_now, ended_at, participants and private projected `state:T`; conversation_id is host routing metadata, not a credential |
| `environment` | local user, visibility/focus, connection, width/height, remaining safeArea, keyboardInset, theme/tokens, reducedMotion, textScale, language/direction and callActive |
| `subscribe(fn)` | rerender on snapshots or environment changes; returns unsubscribe |
| `action(name,payload,{id?,revision?,signal?,timeout?})` | sends an authenticated command, returns acknowledged session; optional AbortSignal cancels waiting, not an already committed mutation |
| `refresh()` | recover authoritative state; subscriptions also recover from Inbox invalidations and heartbeat refreshes |
| `signals.send(channel,value)` | bounded transient JSON; declare signals; coalesce frequent updates and never rely on delivery |
| `signals.subscribe(fn)` | receives game_id, user_id, channel, value, sequence, until; returns unsubscribe |
| `clock.now()`, `clock.remaining(deadline)` | estimated server time and nonnegative milliseconds remaining; server decides all deadlines |
| `shell.minimize()`, `shell.focus()`, `shell.leave()` | request host game presentation or membership change; no call mutation API |
| `profiles.get(playerId)` | roster-authorized avatar data URL, alpha-weighted average color and initial; cache by ID/avatar_version |
| `assets.get(name)` | immutable packaged data URL; no arbitrary network URL |
| `input.keyboard(fn)` | focus-scoped keyboard listener filtering modifiers/composition; returns cleanup |
| `input.gamepads(fn)` | focus-scoped gamepad polling; returns cleanup; native touch/pointer DOM controls remain available |
| `audio` | catalogue, instruments, chime, unlock, play and stop (below) |
| `haptics(kind)` | light/success/error intent; host preference and supported-device fallback |
| `announce(text)` | short host screen-reader announcement, at most240 chars |
| `saves.read()` | null or `{version,value}` for this account/game |
| `saves.write(version,value)` | host consent prompt; versioned JSON up to16KB |
| `saves.delete()` | remove this account/game save |
| `dispose()` | unsubscribe, close transport and reject outstanding requests |

`subscribe` does not invoke its callback immediately. Render once after `connected`,
then subscribe for subsequent updates. Call `ready()` after that initial render.
Mount React components using `useGame` only after `connected` resolves.

Async errors are `GameError` with code/message. Abort/timeout/disposed/not_ready
errors are local; validation, forbidden, conflict, ended and unavailable errors
come from the host. Treat cancellation/timeouts as uncertain outcomes; retry the
same action ID or refresh before presenting success. The host acknowledges an action
only after it commits. The last 256 committed IDs per room are remembered. Games must also
validate durable round/turn/row tokens for correctness beyond that dedup window.

## Rules (`@sometimes/games/server`)

Export `defineGame<S>({...})` as the server entry's default:

- `init(ctx):S` initializes private durable state.
- `action(state,name,data,ctx)` validates every untrusted payload, mutates state,
  or throws `new GameError(code,message)`. Unknown actions must be rejected.
- `players(state,ctx)` handles joins, departures, revocation and role changes.
- `timer(state,ctx)` performs a scheduled transition. It must be safe to repeat.
- `deadline(state,ctx):number|null` returns an absolute next deadline, up to 30 days ahead.
- `view(state,viewer,ctx):Json` explicitly projects one player's permitted state.
- `signal(state,channel,data,ctx):Json|null` validates/redacts a transient event;
  null drops it. It never commits a state mutation.

Context contains room ID, authenticated actor (null for timers), host ID, now,
revision and permitted players. Profiles contain user_id, username, display_name,
avatar_version, active. Do not trust IDs supplied in payloads. Unjoined chat
members get the host peek roster but never a projection or executable client.

The SDK provides `requireHost(ctx)` and `requireRevision(value,ctx)` for permission
and revision checks. `choose` uses cryptographic randomness, `random` provides a
seeded random sequence, and `standings(players,'highest'|'lowest')` sorts scores
with shared ranks for ties. Implement lobbies, settings, phases, rounds, turns,
teams, match completion and rematches in your game's state and actions. Use `view`
to restrict private or team data before it reaches the browser.

State is committed serially per chat. A durable recovery alarm is armed before
saving dependent D1 state. Host departure transfers to the earliest active member.
Presence leases last 90 seconds, and a room ends when it is empty. Sometimes also
ends a room after ten minutes without player interaction, or five minutes with
every active player minimized. These are server-enforced limits. Joining is
specific to an app instance; transferring a game revokes the previous instance's
ability to act or extend its lease. The host handles transfer controls and input
activity, including trusted pointer and keyboard events inside the sandbox.
Ineligible users are removed from context; games must stop retaining their private
profile data. Every game must support 1–20 active players; retained participants
are capped at 128.

## Transport, privacy and isolation

The host authenticates Sometimes cookies, account state, membership and approved
call guests before every read/action. The game iframe receives neither cookies nor
native objects. A verified MessageChannel binds one mounted game frame/session.
The trusted outer frame restricts navigation; the inner iframe has an opaque
origin and only allow-scripts. A fixed HTTPS document works with existing native
navigation policies. Before game code runs, Android bridge globals are sealed
undefined; sealing failure stops execution. iOS rejects bridge messages from
subframes natively; Electron exposes its bridge only to the main frame.
CSP blocks network, nested frames, workers, forms,
external script execution and media. Host-controlled audio replaces raw audio
constructors and media playback. Client assets and avatars use scoped data URLs.

Rules run through Cloudflare WorkerLoader in a fresh isolate for each invocation,
with no bindings, `globalOutbound:null`, a 50ms CPU budget, no subrequests, a 128MiB
platform memory limit and a five-second fetch timeout. Fresh isolates prevent global-state leakage
between rooms or viewers. No untrusted module executes in the main Worker.
The local simulator executes the developer's own code in Node; it is not a sandbox
for packages from strangers. The browser does not provide a hard per-frame CPU
budget; admin review and emergency disable remain necessary for abusive clients.

Reliable transport uses ordered serial mutations with revisioned snapshots and
Inbox invalidations, not an event replay log. Presentation uses committed IDs or
state transitions to avoid duplicate sounds. Reconnect recovers current state.
Transient signals convey temporary updates of up to 1KB. The server drops bursts
sent less than 80ms apart; clients should combine updates and send them at most
once every 120ms. Signals expire after four seconds and have a separate rate limit
of 600 per minute. They reach only joined, authorized peers and are not written
to D1. The transport does not provide physics replication or guaranteed latency.

State and projections are limited to 128KB, and action payloads are limited to
8KB. The client bridge allows 120 requests per second and 24 concurrent requests;
the SDK allows 32 pending requests. Account limits of 120 mutations and 600 reads
per minute also apply. Errors do not change committed state. Players can access
only their own saves. Account exports include build and release history and saves,
which are removed when the account is deleted.

## Audio and optional UI

`game.audio.catalogue` includes start, join, stop, type, delete, invalid, reveal,
win, fail, round, results, tick, peerWin and podium. The host automatically plays
start, join and stop sounds, so game code should not replay them. Available
instruments are wood, bell and sine.

`chime([523,659,784],{instrument:'bell',step:0.1,length:0.22,level:0.06})` creates a
validated pattern. `await play(cueOrPatternOrAsset)` returns a stop function;
`stop()` ends this frame's voices. `unlock()` should follow an input gesture.
Use `asset:filename.wav` for packaged samples. Patterns support up to 16 notes,
frequencies between 40 and 4000Hz, a four-second duration and six simultaneous
voices. Packages can include up to 16 custom clips, each limited to 2MiB and ten
seconds of mono or stereo audio. At most two clips can be decoded concurrently.
Playback respects the user's mute and feedback volume settings. Minimizing the
game does not stop audio; cues can continue while the app is visible. Sounds
requested while the app is hidden or audio is suspended are discarded. Wordle
uses this same synthesizer. Use environment visibility to suspend input and
expensive rendering without suppressing new audio cues.
Test audio alongside calls and autoplay restrictions on physical devices, since
mock tests cannot verify those behaviors.

The optional React module exports `useGame`, `useProfile`, `Avatar`, `Segmented`,
`PlayerList` and `Scores`, which supports highest/lowest score ordering and podium
presentation. Import `@sometimes/games/ui.css` for the base styles. You can also
provide custom layouts, preview renderers and animations. Honor reduced motion,
keyboard access, text scaling and non-color result labels. Wordle retains its
own layout, scoring and board renderer while using the same public host services.

React signatures (`@sometimes/games/react`):

| Helper | Arguments / props |
| --- | --- |
| `useGame(game)` | returns `{session, environment}`; game must be connected |
| `useProfile(game, id, version = 0)` | returns `undefined` while loading, then `{image, color, initial}`; pass `avatar_version` to refresh changed pictures |
| `Avatar` | `{game, person: Player}` |
| `Segmented<T>` | `{name, value, options: {value, label, ariaLabel?}[], onChange, disabled?}`; T is string, number or null |
| `PlayerList` | `{game, players: Player[], host?: string}` |
| `Scores` | `{game, players: (Player & {score:number})[], lowest?: boolean, podium?: boolean}` |

`Player` has `user_id`, `username`, `display_name`, `avatar_version` and `active`.
Helpers render supplied data; they never authorize settings or award scores.
Keep those checks in server rules. The Wordle components show full custom layouts.

Sound patterns use `{notes:number[], step:number, length:number, level:number,
instrument?:'wood'|'bell'|'sine'}`: notes are Hz, step and length are seconds,
level is gain. Prefer `game.audio.chime(notes, options)` to fill validated defaults.
The returned stop callback is for cancellation; calling it immediately after play
cuts the sound off. Catch playback errors without rejecting a successful game action.

## Publication and operations

Registration reserves an ID. Upload verifies package structure, assets, isolated
initialization, roster handling and per-player projections. These checks do not
prove game correctness or absence of deliberately leaked secrets in a projection.
An admin must approve the current registration before build submission, approval
or publication. Uploads remain available while registration is pending. Saving
registration changes returns it to draft and requires approval again; existing
publications and running rooms continue unchanged. Withdrawals, denials and
disabling remain available. The prerequisite is checked atomically with each write.
An admin reviews the immutable digest/code/permissions/test results,
then a separate publish action selects it. Submitted builds can be withdrawn;
feedback remains in history. Never overwrite a version or silently update a room.
Publishing an older approved build rolls back new rooms. Disable removes the game
and stops old rooms on their next read/action/alarm. Account suspension also blocks
publication/start access; review and publication require existing admin authority.

HTTP host routes: `/api/games/catalogue`, `/api/games`, and
`/api/conversations/:chat/game/{start,join,leave,command,heartbeat,signal,client,save-read,save-write,save-delete}`.
These are authenticated host transports; package code uses the SDK instead.
Developer routes: `/api/developers/games/:id/builds` (GET/POST),
`.../builds/:build/{source,submit,withdraw,review,publish,disable}`;
`/api/developers/build-reviews?after=cursor`. Mutations require the current build
status/digest, origin protection, account terms and owner/admin authorization.
The builds GET response includes `registration_status`; only `approved` permits
submission, approval or publication. Other statuses return HTTP 409 for these actions.

## Verification and API scope

The Wordle verification checks host settings, difficulty, 3/5/10/endless rounds,
the three-second countdown, private answers and guesses, six-row duplicate-letter
rules, typing-count previews, 5/3/2/1 scoring, the 30-second grace period and
eight-second results screen. It also checks the separate final podium, sounds,
responsive layouts alongside calls and connection recovery. The counter example
verifies that games can share state without using rounds or player previews.
Tests include server traces, public lifecycle/permission/immutability cases,
real workerd isolation and sandboxed browser flows. Runtime/physical-device
limitations are recorded in the build handoff.

Matchmaking, global leaderboards, achievement services, high-frequency physics,
voice access, arbitrary external networking, native plugins and spectator-specific
moderation are not v1 services. Teams, turns, readiness and rematch can be game
rules now without expanding platform permissions.

References checked on 2026-10-18: [WorkerLoader](https://developers.cloudflare.com/dynamic-workers/api-reference/),
[egress control](https://developers.cloudflare.com/dynamic-workers/usage/egress-control/),
[custom limits](https://developers.cloudflare.com/dynamic-workers/usage/limits/).
The deployed compatibility date is 2026-09-21, matching the pinned runtime.

## Administrative session imports

The optional server `import(previousState, source, ctx)` handler converts legacy
sessions. Only an administrator-created `game_session_imports` mapping can call
it; client actions cannot invoke it. Imported rooms pin the approved package and
retain their room ID, roster and revision. Wordle handles its former built-in
state here, including guesses, answer, scores and due timers. Ordinary updates
keep their original package and do not migrate running rooms.
