diff --git a/docs/build-a-game/debugging.md b/docs/build-a-game/debugging.md new file mode 100644 index 0000000..e48139c --- /dev/null +++ b/docs/build-a-game/debugging.md @@ -0,0 +1,216 @@ +--- +sidebar_position: 10 +title: Debugging a live game +description: How to look inside a running game's two stores, the D1 index and its own Durable Object, with eigen-inspect, Wrangler's local explorer, raw SQLite and Drizzle Studio. +--- + +# Debugging a live game + +A game you are developing has state in two places, and knowing which one to +trust is most of debugging it. + +- **D1 is the index.** One row per game, plus participants, users, bots, + ratings and friendships. It answers "which games exist", so it backs + discovery, history and profiles. +- **The game's Durable Object is the session.** One SQLite database per game, + holding the authoritative status, the roster, and the append-only transition + log. It answers "what is happening in this game". + +The DO is the authority. Its D1 row is a **display mirror**, written after the +commit and off the response path, so a lost mirror write leaves D1 stale while +the game plays on correctly. [Storage](../how-it-works/storage.md) covers why +the split is shaped that way. + +That is the whole reason a game-shaped reader exists: "the lobby filled and +nothing happened" is not a question either store answers alone. + +## `eigen-inspect` + +A scaffolded game ships it wired up. It reads the local `.wrangler` state +directly, so it needs no dev server, no browser and no Cloudflare account, and +every database is opened read-only, so it is safe to run while your Worker is +running. + +```bash +cd server +pnpm inspect games # the index, newest first +pnpm inspect game ABC123 # one game, everywhere, with its timeline +pnpm inspect do # every local Durable Object, by game id +``` + +`game` takes a full id, a short code, or an id prefix. It is the one to reach +for: + +```text +Game 14eabf11-df25-4337-b6e1-be2984c8f2a5 CHNTAE + +D1 index status=ready access=private rated=no schema=v1 + seats 2-2 untimed created 2h ago updated 2h ago +config {"target":10} +participants 0:inbox.seenu 1:dev.seenuk + +Durable Object 8fede5b00a07… .wrangler/state/v3/do/my-game-GameDO/8fede5b0….sqlite +DO meta status=ready seats 2-2 createdBy=nth6vOqG… rngSeed=(unset, so no start has committed) +roster 0:inbox.seenu 1:dev.seenuk +alarm none armed +commands 1 recorded + +Timeline + (no transitions: the game has not started, so there is no v0) + +Diagnosis ready and not started: 2/2 seats filled. Nothing happens until the + CREATOR calls POST /api/engine/games/{id}/start. A start is explicit + and creator-only; filling the lobby does not start a game. +``` + +Reading that top to bottom: + +| Line | What it tells you | +|---|---| +| `D1 index` | What discovery and history will show. Absent means the create never landed. | +| `Durable Object` | Whether a session exists at all. A created-but-untouched lobby has none: the DO is created lazily by the first command or socket. | +| `DO meta` | The authoritative status. An unset `rngSeed` proves no start has committed, whatever D1 says. | +| `alarm` | The armed turn deadline. `none armed` is correct for an untimed game and a bug for a timed one mid-turn. | +| `Timeline` | Every committed version with its cause, state, pending seats and which seats hold a stored frame. `(compacted)` frames are normal on a finished game, which re-projects for replay. | +| `Diagnosis` | The next event the game is waiting for. | +| `Mirror drift` | Present only when the two stores disagree, naming the DO as authoritative. | + +Two lines are worth recognising on sight. **`Mirror drift`** means a mirror +write was lost: the game is fine, the lists are stale. +**`outbox N UNAPPLIED finish row(s)`** on a finished game means the D1 finish +apply has not succeeded, so outcomes and ratings are not published yet. + +The rest of the commands cover what the report does not model: + +```bash +pnpm inspect players # local users and bots, by id +pnpm inspect tables --game ABC123 # tables and row counts +pnpm inspect sql "select * from participants" # ad-hoc, against D1 +pnpm inspect sql "select * from transitions" --game ABC123 # against one game's DO +pnpm inspect game ABC123 --json # for a script or an agent +``` + +`--dir ` starts the search for `.wrangler/state` somewhere other than the +current directory; otherwise it walks up, so any directory inside your game +repository works. + +### In a test + +The same reader is a library, which is the honest way to assert on state a +test cannot reach through the API: + +```ts +import { LocalStore } from "@eigeninteractive/testkit/local-state"; + +const store = LocalStore.open(); +try { + const game = store.game(gameId); + expect(game?.meta?.status).toBe("active"); + expect(game?.transitions.at(-1)?.pending).toEqual([1]); + expect(game?.diagnosis.mirrorDrift).toEqual([]); +} finally { + store.close(); +} +``` + +:::note[No install] + +The reader uses Node's built-in `node:sqlite`, so it adds no dependency and +needs no native build. It comes with the testkit, which is already a +`devDependency` of your game, and nothing new reaches your Worker's runtime +dependencies. Node 24 or newer, which the engine already requires. + +::: + +## Wrangler's local explorer + +For browsing tables rather than asking about a game, Wrangler has a first-party +UI over the same files. With `wrangler dev` running, press **`e`**, or open +`/cdn-cgi/explorer` on the dev server. It covers every local binding, D1 and +Durable Object storage included, and is the better tool for poking at a table +you do not have a query for yet. + +## SQL, local and deployed + +The binding name works as the database name, so nothing has to be configured: + +```bash +wrangler d1 execute GAME_DB --local --command "select id, status from games" +wrangler d1 execute GAME_DB --remote --command "select id, status from games" +``` + +`--remote` is how you inspect a **deployed** game's index; the Cloudflare +dashboard has a console over the same database. + +A deployed game's **Durable Object** has no such console. Your options there are +`wrangler tail` for the logs the DO writes as it commits, and adding a read-only +method to your own `GameDO` subclass if you need more. Locally the session is a +file, which is why local debugging is worth the setup. + +## The raw files + +Everything above reads these, and there is no reason not to open them yourself. +Under your Worker directory: + +```text +.wrangler/state/v3/d1/miniflare-D1DatabaseObject/.sqlite the index +.wrangler/state/v3/do/-GameDO/.sqlite one game each +.wrangler/state/v3/do/-GameDO/metadata.sqlite armed alarms +``` + +The Durable Object filename is the object's id, which is +`idFromName(gameId)` **hashed**, so it is not the game id and cannot be turned +back into one by computation. Miniflare records the name it was addressed by in +each database, and that row is the only local file-to-game map there is: + +```bash +sqlite3 .wrangler/state/v3/do/my-game-GameDO/.sqlite \ + "select name from __miniflare_do_name" +``` + +`pnpm inspect do` prints that mapping for every game at once. + +## Drizzle Studio + +The engine's schemas are Drizzle, so a browsable UI over a local file is a +config away. Point `schema` at the store you want and `url` at the file: + +```ts title="drizzle.studio.config.ts" +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + // For one game's session, use .../server/src/do/schema.ts and a DO file. + schema: "node_modules/@eigeninteractive/server/src/d1/schema.ts", + dbCredentials: { url: "file:.wrangler/state/v3/d1/miniflare-D1DatabaseObject/.sqlite" }, +}); +``` + +```bash +pnpm exec drizzle-kit studio --config drizzle.studio.config.ts +``` + +:::warning[Install the driver outside your game's workspace] + +Drizzle Studio needs a SQLite driver, `better-sqlite3` or `@libsql/client`, and +**both are optional peer dependencies of `drizzle-orm`**. Adding either one to +your game re-resolves `drizzle-orm`'s peer set for every package that imports +it, which changes the types your Worker compiles against. It is a real way to +break a build that was fine, and the failure looks nothing like its cause. + +Install `drizzle-kit` and the driver in a scratch directory outside your +repository and run Studio from there, pointing `url` at an absolute path into +your `.wrangler` state. Nothing about your game needs to change. + +::: + +## What to check when + +| Symptom | Where to look | +|---|---| +| A game will not start | `pnpm inspect game `: read `DO meta` status and `Diagnosis`. A start is explicit and creator-only. | +| A game vanished from a list | The index. A private game is never in the public lobby; check `access` and `status`, then look for `Mirror drift`. | +| A turn never times out | `alarm`. No alarm and a non-null `deadline` on the newest transition is the bug; an untimed game arms nothing by design. | +| A finished game shows no ratings | `outbox`. Surviving rows mean the D1 apply has not landed. | +| A move was refused | `commands`, which records each `commandId` with the response that was replayed. | +| Seats look wrong | Compare `roster` (authoritative) against `participants` (mirror). | diff --git a/docs/build-a-game/recipes.md b/docs/build-a-game/recipes.md index 0d85b27..3cb0f4c 100644 --- a/docs/build-a-game/recipes.md +++ b/docs/build-a-game/recipes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 12 title: Recipes for common game shapes description: Sequential, simultaneous, team, elimination and phased games, all expressed through pendingPlayers and computeObservation. --- diff --git a/docs/build-a-game/rendering.md b/docs/build-a-game/rendering.md index 3e1191e..fada57a 100644 --- a/docs/build-a-game/rendering.md +++ b/docs/build-a-game/rendering.md @@ -30,6 +30,7 @@ breaks every game's signature. All JSON parsing is already done. |---|---| | `config` | The parsed config, immutable for the whole game. Cast to your type. | | `frame` | The per-event snapshot: `observation`, `pendingPlayers`, `version`, `timing`. | +| `transition` | The step into `frame` (`from`, `to`), or **null when there is nothing to animate**. | | `gameStatus`, `outcomes` | Lifecycle status; per-seat results (empty until finished). | | `actionPending` | True while a submit awaits its confirming frame; disable input on it. | | `onAction(json)` | Submits a move; returns `Future`. Never throws, because the engine has already surfaced any error. | @@ -62,11 +63,39 @@ that sound: cannot recover causality: a hidden move leaves no visible footprint, two different causes can leave the same one, and a composite resolution collapses into a single diff. -3. **Animate a cue only when you rendered its predecessor.** On a cold load or a - stale rejoin you get a frame whose predecessor you never drew. Show the cue as - static "last move" information, not an animation. Keep the last rendered - `version` in widget state and play the entrance animation only when the - incoming frame is its direct successor. +3. **Animate only when `transition` is non-null.** It is the step into the + current frame, and it is null exactly when animating would be wrong: a cold + load, a stale rejoin, or the opening frame, where the cue is history rather + than an event. Show it statically then. You do not have to track the last + rendered version yourself; the engine already knows whether the player saw the + predecessor. + +```dart +if (ctx.transition case final step?) { + // The player watched this happen: animate from step.from to step.to. +} else { + // Nothing to animate from. Render ctx.frame as the current position. +} +``` + +**Correctness never depends on animating.** The newest frame is always +sufficient on its own, so skipping intermediate frames is never wrong, only less +pretty. That matters in practice: a bot can commit faster than a 600ms deal +animation, the turn clock does not stop while a card is in flight, and a slow +device falls behind. Treat your animations as interruptible and jump to the end +state when a newer frame lands. + +### Several beats in one frame + +One version can hold more than one visual event: a round resolving may reveal +both commits, score them, and deal again. The frame is the atomic unit of +*truth*, but it is the **script** for several animation steps, so embed an +ordered `events` list in the observation and sequence it locally. + +The rule that keeps this honest: **the list is presentation order, never a second +source of state.** The position after the last beat must equal what the rest of +the observation already says, so a client that skips the animation entirely is +still correct. ### The server side of a cue diff --git a/docs/build-a-game/the-contract.md b/docs/build-a-game/the-contract.md index 08ef5e4..5c47c5f 100644 --- a/docs/build-a-game/the-contract.md +++ b/docs/build-a-game/the-contract.md @@ -177,8 +177,8 @@ out of your namespace: input gate, versioning, idempotent retries. - **The waiting room**: create, join (by id or code), leave, cancel, add-bot, start; short codes; guest and friends-access gating. -- **Sockets & reconnection**: one socket per game, pre-game roster snapshots, - versioned frames, gap recovery by range fetch. +- **Sockets & reconnection**: one socket per game carrying one message kind, + the per-seat session snapshot; gap recovery by range fetch. - **Timing**: deadlines, the chess-clock bank, the grace window, the durable alarm. - **Ratings**: OpenSkill, the concurrency-safe CAS, pools, history. You only diff --git a/docs/build-a-game/versions.md b/docs/build-a-game/versions.md index 204e0c5..2504ab5 100644 --- a/docs/build-a-game/versions.md +++ b/docs/build-a-game/versions.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 title: Changing a shipped game description: What to do when the rules change after players are using them. A new unit on both sides, why draining and replay retire at different times, and the checklist. --- diff --git a/docs/build-a-game/with-an-agent.md b/docs/build-a-game/with-an-agent.md index f89a3d2..0f5892c 100644 --- a/docs/build-a-game/with-an-agent.md +++ b/docs/build-a-game/with-an-agent.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 13 title: Working with an agent description: The Claude Code skill for writing EigenInteractive games, the retrieval surface behind these docs, and the mistakes an agent makes on this contract that a reviewer should look for. --- @@ -22,11 +22,10 @@ The engine repository is a plugin marketplace. Install it once: ``` That adds the `building-a-game` skill, which loads when the work is writing or -reviewing an EigenInteractive game: implementing rules, adding a schema -version, writing a -bot brain, or debugging a rejected move. It carries the parts of this contract -that are easy to get wrong and expensive to discover late: the four invariants, -what the engine has already validated before your hook runs, the +reviewing an EigenInteractive game: implementing rules, adding a schema version, +writing a bot brain, or debugging a rejected move. It carries the parts of this +contract that are easy to get wrong and expensive to discover late: the four +invariants, what the engine has already validated before your hook runs, the `computeObservation` projection rule, and a review checklist. It ships from the engine repository, so it moves with the engine rather than @@ -34,21 +33,28 @@ drifting behind it. ## The two platforms underneath -The skill above covers the contract and nothing else, deliberately, because -the two halves it sits between are ordinary Cloudflare Workers and ordinary -Flutter, and both platforms publish their own official skills. Installing them -alongside is what makes an agent useful on the other 90% of a game repository: -a D1 migration that will not apply, a Durable Object alarm, a widget that -rebuilds too often. +The skill above covers the contract and nothing else, deliberately, because the +two halves it sits between are ordinary Cloudflare Workers and ordinary Flutter, +and both platforms publish their own official skills. Installing them alongside +is what makes an agent useful on the other 90% of a game repository: a D1 +migration that will not apply, a Durable Object alarm, a widget that rebuilds +too often. ```text -/plugin install cloudflare@claude-plugins-official +/plugin marketplace add cloudflare/skills +/plugin install cloudflare@cloudflare ``` Cloudflare's covers Workers, Durable Objects, Wrangler and the Agents SDK, and brings MCP servers that search Cloudflare's live documentation, which matters -here for the same reason the retrieval surface below does. It is listed in the -marketplace Claude Code already knows about, so there is nothing to add first. +here for the same reason the retrieval surface below does. Those two lines are +the ones [`cloudflare/skills`](https://github.com/cloudflare/skills) itself +documents, and they track that repository. + +The same plugin is also mirrored in the marketplace Claude Code already knows +about, so `/plugin install cloudflare@claude-plugins-official` works with +nothing to add first. It is the same skills, entered at a pinned commit rather +than at whatever Cloudflare has just merged. ```text /plugin marketplace add flutter/agent-plugins @@ -66,14 +72,14 @@ working because an optional convenience was missing. ## The retrieval surface Every page on this site is also machine-readable, which is what lets an agent -work from what the engine does *now*: +work from what the engine does _now_: -| What | Where | -|---|---| -| Index of every page | [`/llms.txt`](pathname:///llms.txt) | +| What | Where | +| ---------------------- | --------------------------------------------- | +| Index of every page | [`/llms.txt`](pathname:///llms.txt) | | Everything in one file | [`/llms-full.txt`](pathname:///llms-full.txt) | -| Any page as Markdown | append `.md` to its URL | -| The HTTP contract | [`/openapi.json`](pathname:///openapi.json) | +| Any page as Markdown | append `.md` to its URL | +| The HTTP contract | [`/openapi.json`](pathname:///openapi.json) | The generated HTTP reference is deliberately excluded from the `llms` bundles: those pages are component trees, and the raw spec is the better input. @@ -88,22 +94,21 @@ plausible code against an API that was never real. These are the mistakes worth reviewing for specifically, because each one produces code that looks correct and passes a casual read: -- **Re-validating what the engine already enforced.** Turn order, version, - seat ownership and the deadline are checked before `applyAction` is called. - A hand-written `if (playerIndex !== state.turn)` is not a safety net; it is a +- **Re-validating what the engine already enforced.** Turn order, version, seat + ownership and the deadline are checked before `applyAction` is called. A + hand-written `if (playerIndex !== state.turn)` is not a safety net; it is a second, divergent source of truth. See [The hooks](./hooks.md). - **Reaching for wall-clock time or `Math.random()`.** Determinism is not a - style preference here; replay, reconnection and optimistic preview all - depend on it. Randomness comes from the injected `rng`, drawn in a fixed - order. + style preference here; replay, reconnection and optimistic preview all depend + on it. Randomness comes from the injected `rng`, drawn in a fixed order. - **Branching on `schemaVersion` inside a hook.** The engine resolves the version before calling anything, so a version check in a hook body is always wrong. See [Evolving your game](./versions.md). - **Projecting the state instead of the seat's view.** The commonest and most damaging: `computeObservation` returning the full state, or stripping the hidden field while leaving `pendingPlayers` truthful enough to reveal that an - opponent has already committed. See [Hidden - information](./hidden-information.md). + opponent has already committed. See + [Hidden information](./hidden-information.md). - **Putting engine-owned facts in your state.** Whose turn it is, the deadline and the result belong to the engine. State that carries its own `winner` will disagree with the engine's eventually. @@ -121,8 +126,8 @@ pnpm test # in server/, the TypeScript half flutter test # in app/, the Dart half, on the same fixtures ``` -Ask for fixtures alongside rules, not after them: at minimum one legal move -with its expected observation, one illegal move, one game-ending move, and a -case for each `ratingPool` and `botSeatable` branch. A generated hook with no -fixture is the part of the diff to read closely; a generated hook with a fixture -that fails is simply a fix. +Ask for fixtures alongside rules, not after them: at minimum one legal move with +its expected observation, one illegal move, one game-ending move, and a case for +each `ratingPool` and `botSeatable` branch. A generated hook with no fixture is +the part of the diff to read closely; a generated hook with a fixture that fails +is simply a fix. diff --git a/docs/getting-started/manual-setup.md b/docs/getting-started/manual-setup.md index 3fe6f47..c1de079 100644 --- a/docs/getting-started/manual-setup.md +++ b/docs/getting-started/manual-setup.md @@ -44,9 +44,26 @@ server/ ├── test/twin.spec.ts ├── package.json ├── tsconfig.json +├── vitest.config.mts └── wrangler.jsonc ``` +`vitest.config.mts` exists for one option. The fixture runner reads its JSON with +`readFileSync`, so those files are not in Vite's module graph, and `test:watch` +would otherwise ignore a fixture-only edit: + +```ts +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + forceRerunTriggers: [...configDefaults.forceRerunTriggers, "**/src/module/fixtures/**/*.json"], + }, +}); +``` + +Spread the defaults rather than replacing them; that merge is shallow. + Default-export the module from `src/module/index.ts`: ```ts diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 1eeb812..09a2e9e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -73,23 +73,33 @@ rules and fixture tests need nothing more than this. ## Setup the Firebase Project -Scaffolding already wrote the values it could know: `FIREBASE_PROJECT_ID` in -`server/wrangler.jsonc`, and `API_BASE_URL` plus `GOOGLE_WEB_CLIENT_ID` in -`app/app-config.json`. Two are left, and neither is available to any CLI. +**1. Turn on Google sign-in.** Firebase Console → Security → Authentication → +Sign-in method → Get Started → Google → Enable. -**1. Turn on Google sign-in.** Firebase Console → Authentication → Sign-in -method → Google. Then add `localhost` under Settings → Authorized domains. +**2. Populate `GOOGLE_WEB_CLIENT_ID`** If `GOOGLE_WEB_CLIENT_ID` in +`app/app-config.json` is empty, the provider was off when you scaffolded, so +there was no OAuth client to copy. Enabling it creates one; take the value from +**Web SDK configuration** under Firebase Console → Authentication → Sign-in +method → Google -If `GOOGLE_WEB_CLIENT_ID` in `app/app-config.json` is empty, the provider was -off when you scaffolded, so there was no OAuth client to copy. Enabling it -creates one; take the value from **Web SDK configuration** on that same page. - -**2. Get the Web Push key.** Firebase Console → Project settings → **Cloud +**3. Get the Web Push key.** Firebase Console → Settings → General → **Cloud Messaging** → Web configuration, **Generate key pair** if the list is empty. Paste it into `FIREBASE_VAPID_KEY` in `app/app-config.json`. The web app will not start without it. -Both are public values compiled into the app, not secrets. +**4. Get the Admin service-account key.** Firebase Console → Settings → +**Service accounts** → **Generate new private key**. Copy +`server/.dev.vars.example` to `server/.dev.vars` and fill +`FIREBASE_CLIENT_EMAIL` and `FIREBASE_PRIVATE_KEY` from the downloaded JSON, +pasting the private key with its quotes intact. That pair is what push +notifications and account deletion run on; token verification uses +`FIREBASE_PROJECT_ID` alone, so the Worker starts either way, and the two +features fail only when exercised. `wrangler dev` reads `.dev.vars` at startup, +so restart `pnpm dev` afterwards. + +Steps 2 and 3 are public values compiled into the app. Step 4 is not: +`.dev.vars` is a real credential, is git-ignored, and must stay that way. Delete +the downloaded JSON once you have copied the two fields out of it. [Configuration](../ship-it/configure.md) is the full reference for every value on both sides, and what changes when they stop pointing at localhost. @@ -108,8 +118,9 @@ pnpm firebase:configure It configures Android and web with FlutterFire, writes the service worker's Firebase configuration, and fills in `FIREBASE_PROJECT_ID` and `GOOGLE_WEB_CLIENT_ID` exactly as scaffolding would have. It ends by naming the -project, both app IDs, and each value it set, so you can see which of the two -steps above are still owed. +project, both app IDs, and each value it set, so you can see which of the steps +above are still owed. It does not touch steps 3 and 4, which are console values +no CLI can produce. **Commit everything it writes**: `app/firebase.json`, `app/android/app/google-services.json`, `app/lib/firebase_options.dart`, @@ -119,19 +130,6 @@ and Android and web builds fail without them. -
-Optional: push and account deletion locally - -Copy `server/.dev.vars.example` to `server/.dev.vars` and fill it from Firebase -Console → Project settings → Service accounts → **Generate new private key**. - -Those two features are all it powers. Token verification uses -`FIREBASE_PROJECT_ID` alone, so ordinary local play needs none of this. -`.dev.vars` is git-ignored and must stay that way: unlike everything else on -this page, it is a real credential. - -
- ## Run the app ```bash diff --git a/docs/how-it-works/game-sessions.md b/docs/how-it-works/game-sessions.md index 9e80983..a3771b0 100644 --- a/docs/how-it-works/game-sessions.md +++ b/docs/how-it-works/game-sessions.md @@ -15,7 +15,7 @@ The DO's own SQLite database is the game. Six tables: | Table | Lifetime | Purpose | |---|---|---| -| `meta` | permanent | The single game row (id, status, access, schema_version, config, timing, rated, pool, roster bounds, creator, rng seed). Copied once from D1 at lazy-init, then DO-owned. | +| `meta` | permanent | The single game row (id, short code, status, access, schema_version, config, timing, rated, pool, roster bounds, creator, rng seed, final outcomes, and `seq`). Copied once from D1 at lazy-init, then DO-owned. `seq` is the monotonic session counter every commit advances, which is what totally orders the snapshots the socket pushes; `outcomes` is retained rather than drained so a cold open of a finished game is answerable from the DO alone. | | `roster` | permanent | One row per seat (`player_index`, `user_id`/`bot_id`, `type`). The **authoritative** roster; D1's copy is a display mirror. | | `transitions` | permanent | **Append-only, immutable.** One row per version: the opaque `state`, the `action` that produced it, the pending set, deadline, per-player clocks. This table *is* the game's history. | | `frames` | live-only | Per-seat projected observations, for socket gap-recovery and the same-view compare. Drained by the finish compaction (replay re-projects instead). | diff --git a/docs/how-it-works/lifecycle.md b/docs/how-it-works/lifecycle.md index 27fccf2..04e34ac 100644 --- a/docs/how-it-works/lifecycle.md +++ b/docs/how-it-works/lifecycle.md @@ -43,11 +43,13 @@ rules). Highlights: - **Start** is creator-only, commits version 0 via the kernel, and arms the first deadline. -The client opens its WebSocket *before* start. Pre-game, the DO pushes -unversioned, idempotent **roster snapshots** on every change (a reconnect just -gets the current one); versioned frames begin at v0. D1's participants copy is -updated post-commit and is allowed to be briefly stale, and a stale lobby just means -a join can fail cleanly at the DO. +The client opens its WebSocket *before* start. The DO pushes a complete +**session snapshot** on open and after every change, lobby or state, so the +creator's waiting room learns that the roster filled and every seat learns that +the game started without asking. Pre-game a snapshot has no `version` and no +`frame`; both begin at v0. D1's participants copy is updated post-commit and is +allowed to be briefly stale, and a stale lobby just means a join can fail cleanly +at the DO. **create-solo** (`POST /api/engine/games/solo`) collapses "create a private game seated with me + bots, and start it" into one call, returning the caller's diff --git a/docs/how-it-works/storage.md b/docs/how-it-works/storage.md index 9ac1066..0d7f512 100644 --- a/docs/how-it-works/storage.md +++ b/docs/how-it-works/storage.md @@ -38,6 +38,10 @@ batch; see [Account lifecycle](./account-lifecycle.md)). This is deliberate: it keeps every multi-table effect visible in application code rather than hidden in schema triggers. +Because `games` and `participants` are mirrors written after the DO's own commit, +they can lag it. To read both stores at once and see any disagreement between +them, see [Debugging a live game](../build-a-game/debugging.md). + ## Ratings & the concurrency-safe CAS Ratings are OpenSkill, computed **at finish, in D1**, because they depend on diff --git a/docs/how-it-works/transport.md b/docs/how-it-works/transport.md index f892e96..0c6370b 100644 --- a/docs/how-it-works/transport.md +++ b/docs/how-it-works/transport.md @@ -55,45 +55,114 @@ None of it is game code; a game never opens a socket or resolves an identity. one place. The `?v=` cache-buster means `cached_network_image` refreshes on re-upload with no manual invalidation. -## The frame stream +## The session stream A game has **one WebSocket for its whole lifetime** -(`/api/engine/games/{id}/socket`), opened before the game starts. Over it the -client receives: - -- **Roster snapshots** pre-game: unversioned and idempotent, pushed on every - lobby change. A reconnect just gets the current one. -- **A `sync`** on a mid-game open: `{ version }`, the newest committed version - at the moment the socket opened. From v0 the roster is frozen, so this is what - moves; it is what lets a client reconcile in one step instead of guessing. -- **Versioned frames** from v0: each is one seat's projected observation at one - state version (`{ version, data, pendingPlayers, deadline, playerTimes, - outcomes?, ratings? }`). - -Frames are **strictly serial with no gaps**. The client tracks the last version -it holds and reconciles against the `sync`, which costs a request only when it -has to: - -- **Nothing held yet** (a cold open, mid-game): fetch just that one version. A - cold load snaps to the present rather than replaying the game. -- **Already current**: no request at all. This is the common reconnect on a - flaky connection, and is why the server states its version rather than leaving - the client to poll. -- **Behind**: fetch exactly the missing span via - **`GET /games/{id}/frames?from=&to=`** and emit it in order *before* the frame - that revealed the gap, so the game animates through every transition it missed. - -The same range-fetch endpoint serves finished-game **replay** (the server -re-projects from its immutable log), so replay is just the whole range rather than -a missing slice. Reconnection is therefore always sound: reconcile against the -server's stated version, never guess. - -A command's own frame also rides its HTTP response (`CommandAccepted.frame`) and -is fed into the same version-deduped pipeline, so whichever copy arrives second -is dropped. That matters less for latency than it looks, since the socket -terminates at the same Durable Object and is written first, but it is what makes the -socket-less paths work: a freshly created solo game has no socket yet, and a -move submitted while the socket is mid-reconnect would otherwise render nothing. +(`/api/engine/games/{id}/socket`), opened before the game starts, and it carries +exactly one kind of message: a **session snapshot**, the complete live truth +about the game as the receiving seat sees it. + +```jsonc +{ + "type": "session", + "seq": 7, // monotonic per game, incremented by every commit + "gameId": "...", "shortCode": "ABC123", "access": "private", + "schemaVersion": 1, "config": { ... }, + "turnSeconds": null, "budgetSeconds": null, "incrementSeconds": null, + "rated": false, "ratingPool": null, + "minPlayers": 2, "maxPlayers": 2, "createdBy": "...", + "status": "active", // what moves + "players": [ ... ], + "version": 3, // null in the lobby + "frame": { ... } // THIS seat's observation, null in the lobby +} +``` + +Three properties follow, and they are the whole design: + +- **It is sent on open, at every status.** A client never has to guess where a + game is, and never holds a frame without the status it belongs to. A reconnect + needs no announcement: its first message is the current snapshot. +- **It is complete, so it is idempotent.** A client that applies the newest one + it has seen is correct however many it missed. There is nothing to + reconstruct and no second channel to correlate against. +- **It is projected per seat before sending.** `frame` is only ever the + receiving principal's own seat's view, resolved against the roster at send + time, so hidden information cannot leak. A client holding no seat gets the + envelope with `frame: null`, which is how a viewer still learns the game + started. + +It carries the immutable header as well as the moving parts on purpose: a game +screen must not need a second source. The D1 game summary +(`GET /games/{id}`) is the **index**, which backs lists and discovery and is a +mirror written after the game's own commit. A screen bound to one game reads the +session; a list reads the index. Never the other way around, and in particular a +list must never subscribe to a session, which would open one socket per row. + +**Ordering is by `seq`, not `version`**, because a lobby change has no version. +A client applies a snapshot when `seq` exceeds the one it holds, which is what +resolves a command response racing its own socket push, a duplicate delivery, and +a reconnect that missed nothing. One exception, and it is a property of the state +machine rather than a special case: a `finished` or `aborted` snapshot is applied +whatever its `seq`, because those statuses are absorbing and the abort teardown +drops the storage the counter lived in. + +### Gaps still animate + +Frames are append-only server-side, one per seat per version, so a version jump +means the client missed transitions it would rather show than skip. It fetches +exactly the missing span with **`GET /games/{id}/frames?from=&to=`** and plays it +through first, one emission per version, **each carrying the previous envelope**. +Only the real snapshot may move `status`, `players` and `seq`, so a client that +missed a finish animates the moves and *then* shows the outcome, rather than +displaying a finished game while mid-game moves play. + +A cold open does not animate: with no predecessor rendered there is nothing to +animate from, so it snaps to the present. The same range endpoint serves +finished-game **replay** (the server re-projects from its immutable log), so +replay is the whole range rather than a missing slice. + +### Commands answer with the same value + +Every accepted command, from `join` to a move, answers with the caller's own +post-commit session, so the client feeds HTTP responses and socket pushes into +one path and drops whichever arrives second by `seq`. That is what makes the +socket-less paths work: a freshly created solo game has no socket yet, and a move +submitted while the socket is mid-reconnect would otherwise render nothing. + +**`GET /games/{id}/session`** returns the same snapshot over HTTP, for the paths +with no socket at all. + +:::note[Commands are HTTP, deliberately] + +The socket is one-way, and sending moves over it was considered and rejected. +Four reasons, none of which is "a socket cannot do it": + +- **There is no latency to win.** A WebSocket to a Durable Object is established + through the client's nearest colo and proxied to the object's colo; an HTTP + command lands at the same colo and is proxied to the same object. Same two + hops. What a socket send saves is one token verification and one Worker + invocation, at a cadence of one move every few seconds. +- **Bots have no socket.** An external bot posts to `/bot/action` with an HMAC + signature, and the Worker mints the *same* command a human's move mints, with + the same `expectedVersion` and the same seat verification. That is why there is + one action path rather than two. +- **Some commands precede any socket.** `createGame` runs before the Durable + Object exists, `createSolo` starts a game that never had one, and + `joinByCode` is how a client learns the id it would need to open one. +- **Auth stays fresh.** Every command presents a current ID token, verified per + request, so expiry and revocation take effect on the next command. A socket's + principal is resolved once at upgrade and outlives that token. + +It also keeps a command's outcome unambiguous: it is an HTTP status, not +something to correlate against a later broadcast, which is what keeps "the server +said no" distinguishable from "the outcome is unknown". + +If a game ever needs input faster than about one message per second per seat, or +continuous input such as dragging, this is worth reopening. It would be additive: +the socket already carries the authoritative session. + +::: ## Player identity diff --git a/docs/reference/http-surface.md b/docs/reference/http-surface.md index 371a3dd..922d4f7 100644 --- a/docs/reference/http-surface.md +++ b/docs/reference/http-surface.md @@ -38,7 +38,8 @@ the [`openapi.json`](pathname:///openapi.json) spec directly. | `POST /games/{id}/join` · `POST /games/join-by-code` | Join | | `POST /games/{id}/leave` · `/cancel` · `/add-bot` · `/start` | Waiting-room commands | | `POST /games/{id}/action` · `/forfeit` | Active play (carry the caller's `seat`) | -| `GET /games/{id}/socket` | WebSocket upgrade (`?token=` auth); frames + roster snapshots | +| `GET /games/{id}/session` | The caller's current session snapshot, from the game's own Durable Object | +| `GET /games/{id}/socket` | WebSocket upgrade (`?token=` auth); per-seat session snapshots | **Profile / account / devices / social writes:** diff --git a/sidebars.ts b/sidebars.ts index c9326bb..1729fab 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -46,6 +46,7 @@ const sidebars: SidebarsConfig = { "build-a-game/bots", "build-a-game/creation-ui", "build-a-game/testing", + "build-a-game/debugging", "build-a-game/versions", "build-a-game/recipes", "build-a-game/with-an-agent",