# EigenInteractive > An open-source, server-authoritative engine for turn-based multiplayer games. A game ships as a single Cloudflare Worker that owns its own domain, database and players, plus a Flutter app for Android and the web. The implementor writes the rules; the engine owns everything else. This file contains all documentation content in a single document following the llmstxt.org standard. ## Introduction **EigenInteractive** is an open-source, server-authoritative engine for **turn-based multiplayer games**. Your game ships as a single Cloudflare Worker that owns its own domain, database and players, plus a Flutter app for Android and the web that is almost entirely the engine's. You supply the rules. The rules run on the server, so a client's move is always a proposal the server validates — hidden information never leaves the server except as a per-seat projection, clocks are authoritative, and finished games are replayable from an immutable log. ## What you actually write A game is **two same-keyed registries**: TypeScript rules that decide, and Dart rules and a screen that draw. That is the whole surface. | You write | The engine owns | |---|---| | The rules — six hooks, on the server | Persistence, sockets, reconnection, timing, ratings, history, replay | | Client-side legality, optimism, and the board, in Dart | Sign-in, home, lobby, friends, profile, settings, push, deep links | | A creation dialog's declaration | The dialog, the countdown, the finished banner, the whole app shell | For scale: Rock–Paper–Scissors, the reference game, is about 220 lines of TypeScript and 500 of Dart — and none of it mentions turns, deadlines, sockets, versions, persistence or ratings. ## Start here | If you want to… | Go to | |---|---| | Run both halves in the next five minutes | [Quickstart](./getting-started/quickstart.md) | | Add EigenInteractive to existing or separate repositories | [Manual setup](./getting-started/manual-setup.md) | | See a whole game, both languages | [Your first game](./getting-started/your-first-game.md) | | Write your own game | [Build a game](./build-a-game/the-contract.md) | | Get it in front of players | [Ship it](./ship-it/deploy-the-worker.md) | | Understand why it is built this way | [How it works](./how-it-works/overview.md) | | Look something up | [Reference](./reference/http-surface.md) | ## How these docs are organised **Task-first, and each page carries both halves.** A page is a thing you are trying to do — model your payloads, render the board, handle hidden information, ship to a store — and it covers the server *and* the client side of that task together, because they are one change. - **Getting started** — run it, then read it. - **Build a game** — the contract, and each task within it, from payload types through to changing a game that has already shipped. - **Ship it** — deploying the Worker, configuring both halves, deep links, branding, push, and the store. - **How it works** — the engine itself: the kernel, Durable Objects, storage, identity, security, the failure model, the client transport and the app shell. Explanation only; you can build a game without reading any of it. - **Reference** — the HTTP API (generated from `openapi.json`), the TypeScript API (generated from source), the Envelope contract and the error model. :::tip[Reading this with an agent] Every page is also served as plain Markdown — append `.md` to any doc URL. There is an [`/llms.txt`](pathname:///llms.txt) index and an [`/llms-full.txt`](pathname:///llms-full.txt) single-file bundle, and the HTTP contract is available as a raw spec at [`/openapi.json`](pathname:///openapi.json). There is also a Claude Code skill that carries this contract — see [Working with an agent](./build-a-game/with-an-agent.md). ::: --- ## Set up without the scaffolder `create-eigen-game` is a convenience, not a framework requirement. A game is valid when its Worker and app satisfy the two public package contracts and share one generated `game-contract.json`; the directories may live together or in separate repositories. Use this path when you are adding EigenInteractive to an existing app, need independent Worker and app release cycles, or want to own the project layout yourself. ## Create the Worker Start a TypeScript Cloudflare Worker and add the runtime, rules contract, schema library, and test tooling: ```bash mkdir server && cd server npm init -y npm install @eigeninteractive/server @eigeninteractive/rules zod npm install --save-dev @eigeninteractive/testkit wrangler typescript vitest @types/node ``` pnpm works equally well. Keep `@eigeninteractive/rules` as a direct dependency: `server` and `testkit` consume it as a peer so the process has one rules contract instance. Use this minimum application-owned layout: ```text server/ ├── src/ │ ├── index.ts │ └── module/ │ ├── index.ts # default export: GameModule │ ├── v1.ts # one GameRules unit │ └── fixtures/v1/*.json ├── test/twin.spec.ts ├── package.json ├── tsconfig.json └── wrangler.jsonc ``` Default-export the module from `src/module/index.ts`: ```ts import type { GameModule } from "@eigeninteractive/rules"; import { rulesV1 } from "./v1.js"; export default { versions: { 1: rulesV1 } } satisfies GameModule; ``` The Worker entry point only composes your module with engine-owned runtime: ```ts import { BaseGameDO, createEngine } from "@eigeninteractive/server"; import gameModule from "./module/index.js"; export class GameDO extends BaseGameDO { protected readonly gameModule = gameModule; protected d1(env: Env) => env.GAME_DB; } export default createEngine({ gameModule, appName: "My Game", d1: (env: Env) => env.GAME_DB, gameDO: (env: Env) => env.GAME_DO, }); ``` Add the game name and contract commands to `package.json`: ```json { "type": "module", "eigen": { "game": "My Game" }, "scripts": { "contract": "eigen-contract", "contract:check": "eigen-contract --check", "dev": "wrangler dev", "test": "vitest run", "test:watch": "vitest", "typecheck": "wrangler types && tsc --noEmit" } } ``` The `eigen.game` value is the source of generated Dart type names. See [The contract](../build-a-game/the-contract.md) for the rules unit and [Deploy the Worker](../ship-it/deploy-the-worker.md) for the required D1, Durable Object, cron, and migration configuration. ## Create the Flutter app Create a normal Flutter app, then add the package plus the two Firebase libraries imported by the app entry point: ```bash flutter create --empty --platforms android,web --org com.example my_game cd my_game flutter pub add eigen_flutter firebase_core firebase_messaging ``` `flutter_local_notifications`, used by the engine for foreground delivery, requires core-library desugaring in the Android application module. The scaffolder configures this automatically; for a hand-created app, append the following to `android/app/build.gradle.kts`: ```kotlin android { compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } ``` The app imports the framework through its barrel only: ```dart import 'package:eigen_flutter/eigen_flutter.dart'; ``` Create `lib/game/module.dart`, register the same version keys as the TypeScript module, and call `runEngineApp` from `lib/main.dart`. The [Creation UI](../build-a-game/creation-ui.md) and [Rendering](../build-a-game/rendering.md) pages contain the two handwritten Dart pieces. Create `app-config.json` beside `pubspec.yaml` with `API_BASE_URL`, `GOOGLE_WEB_CLIENT_ID`, optional `APP_HOST`, and the public `FIREBASE_VAPID_KEY`. Read them once with `const String.fromEnvironment` in `main.dart`, pass them into `EngineConfig`, and use `--dart-define-from-file=app-config.json` for both Android and web commands. The complete shape and validation rules are in [Configuration](../ship-it/configure.md#the-app). After installing and authenticating the Firebase and FlutterFire CLIs, run the engine's setup executable from the Flutter repository root: ```bash dart run eigen_flutter:configure_firebase ``` It generates FlutterFire's platform files and `web/firebase-config.js` for the messaging worker from the same selected Web app. Keep the scaffold's `firebase-messaging-sw.js` and `flutter_bootstrap.js`; do not duplicate Firebase identifiers by hand. For web, add the Firebase Messaging service worker and register it from a custom `web/flutter_bootstrap.js`; configure a fixed local origin in the Worker and Firebase. The scaffold supplies those files automatically. Manual projects can copy the small setup from [Deploy the web app](../ship-it/deploy-the-web-app.md). Pass the project's public VAPID key into `EngineConfig`; the web app treats a missing key as deployment misconfiguration rather than disabling notifications. On the Worker, set `FIREBASE_PROJECT_ID` and store that project's `FIREBASE_CLIENT_EMAIL` and `FIREBASE_PRIVATE_KEY` as secrets. Those Admin credentials are required for both FCM and complete account deletion; player permission and individual push delivery remain optional at runtime. ## Connect the halves Emit the contract from the Worker: ```bash cd server npm run contract ``` Transfer that exact `game-contract.json` to the app repository, then generate the Dart payloads and copied fixtures: ```bash cd app dart run eigen_flutter:generate_payloads \ --contract path/to/game-contract.json \ --output lib/game/generated/payloads.dart \ --fixtures-output test/fixtures flutter test ``` Commit both generated outputs. In CI, run `npm run contract:check` in the Worker and the Dart generator with `--check` in the app. For separate repositories, promote the contract as an immutable build artifact and pin it by checksum. The app does not need Worker source, and the Worker does not need Flutter source. See [The cross-repository contract](../reference/cross-repo.md). --- ## Prerequisites You build an EigenInteractive game in your own repository, and the engine ships as ordinary packages. **You never clone the engine repositories**, and nothing about the engine installs globally — `create-eigen-game` runs through `npm create`, and `wrangler` arrives as a project dependency. The two Firebase CLIs are the only global installs on this page. What you do need is the toolchain underneath both halves: a JavaScript runtime for the Worker, Flutter for the app, and accounts at the two services the engine is built on. Skip ahead to [check everything at once](#check-everything-at-once) if you already have a Flutter setup that builds Android apps — that is most of this page. ## To write and test game rules This much is enough to scaffold a project, write rules in both languages, and run every test. No account, no device, no Android SDK. | Tool | Version | Why | | --- | --- | --- | | [Node.js](https://nodejs.org/en/download) | 22 or newer | Runs the scaffolder, `wrangler`, and the Worker's tests | | [pnpm](https://pnpm.io/installation) *or* npm | any current release | pnpm is the default the scaffolder assumes; npm works identically | | [Flutter](https://docs.flutter.dev/get-started/install) | 3.44 or newer, bringing Dart 3.12 or newer | The app half, the Dart rules twin, and the payload generator | | [Git](https://git-scm.com/downloads) | any | Flutter uses it internally, and it is your project's history | Dart is not a separate install — it ships inside Flutter, and `flutter --version` prints both. :::info[Scaffolding needs network access] `create-eigen-game` installs both halves as it goes — npm for the Worker, pub.dev for the app — so it needs the network throughout, not just at the start. An interrupted run leaves a partly installed project on disk; delete the directory and start again. ::: You do not pick versions. The scaffolder installs one engine release and the `eigen_flutter` release that was tested against it, so a new project starts on a pair already known to work together rather than on whatever was newest that morning. [Versions and compatibility](../reference/compatibility.md) lists the pairs, and is where to look when you later upgrade one half. ## To run the app The Worker runs locally with no extra tooling — `wrangler dev` is enough. The Flutter app needs two things: a Firebase project, and a platform to run on. ### A Firebase project **A [Firebase project](https://console.firebase.google.com/)** for player identity and push notifications. One project serves both, and it is free to start. This is not something to defer until you deploy: a scaffolded app throws `Firebase is not configured` the moment it launches, because identity is how a player gets a seat at all. Connecting one needs two command-line tools, and this is the one place something is installed globally: ```bash npm install -g firebase-tools # the `firebase` CLI dart pub global activate flutterfire_cli # the `flutterfire` CLI ``` `pnpm firebase:configure` in a scaffolded project drives both. `dart pub global activate` does not put `flutterfire` on your `PATH`, so if your shell cannot find it afterwards, add Dart's global package directory: ```bash export PATH="$PATH":"$HOME/.pub-cache/bin" ``` See [Configure a game](../ship-it/configure.md) for what the configuration step actually writes, and [Push notifications](../ship-it/push.md) for the messaging half. ### A platform A scaffolded project targets two. **Web** needs [Chrome](https://www.google.com/chrome/), which `flutter run -d chrome` drives directly. It is the fastest loop and the one to start with. **Android** needs the full mobile toolchain: | Tool | Version | Notes | | --- | --- | --- | | [Android Studio](https://developer.android.com/studio) | current | The simplest way to get the SDK, platform tools and an emulator. The command-line tools alone also work | | Android SDK | API 36 to compile | The app's `minSdk` is 24, so it runs on Android 7.0 and later | | [JDK](https://adoptium.net/temurin/releases/) | 17 or newer | Gradle compiles against Java 17. Android Studio bundles one | Do not install these by hand and hope. `flutter doctor` inspects all of it and tells you exactly what is missing — see below. :::note[iOS is not part of a scaffolded project] The scaffolder runs `flutter create --platforms android,web`, so there is no Xcode requirement and no macOS requirement. Nothing prevents you adding the iOS platform yourself later; it simply is not set up for you. ::: ## To deploy it **A [Cloudflare account](https://dash.cloudflare.com/sign-up)** for the Worker, its D1 database and its Durable Objects. Free to start, and the one thing on this page genuinely not needed until you deploy. You do *not* install `wrangler` globally — a scaffolded project depends on it directly, and `wrangler login` authenticates through your browser on first use. ## Check everything at once Run this from anywhere. Every line should print a version at or above what the tables list: ```bash node -v # v22.x or newer pnpm -v # or: npm -v git --version flutter --version # Flutter 3.44+ · Dart 3.12+ ``` Then let Flutter audit its own half, which covers the Android SDK, the JDK, device connections and Chrome in one pass: ```bash flutter doctor -v ``` Read the output rather than the summary count. A green **Flutter**, **Android toolchain** and **Chrome** is everything a scaffolded project needs; unrelated categories — Xcode, Linux desktop, Visual Studio — are expected to be missing and do not affect you. Then the two Firebase CLIs, which are separate installs and the pair that `firebase:configure` drives: ```bash firebase --version flutterfire --version ``` ## Now build something [Quickstart](./quickstart.md) scaffolds both halves and gets them running locally. [Your first game](./your-first-game.md) writes Rock–Paper–Scissors end to end, in both languages. Adding EigenInteractive to an app you already have, or want to lay the project out yourself? [Set up without the scaffolder](./manual-setup.md) uses the same public packages and skips `create-eigen-game` entirely — which also means it does not need network access at project-creation time. --- ## Quickstart You build an EigenInteractive game in your own repository. The engine repositories are ordinary dependencies; game implementors do not clone them. ## Prerequisites Node.js 22 or newer, npm or pnpm, and Flutter 3.44 or newer — which brings the Dart 3.12 the client needs. Scaffolding also needs network access: it installs both halves as it goes. A Cloudflare account and Firebase project are needed to run the complete app and deploy, but not to scaffold the project or test game rules. The Android toolchain is needed only to run on Android. [Prerequisites](./prerequisites.md) covers each of these with install links, and gives you one command block that checks the lot. ## Scaffold both halves ```bash # pnpm pnpm create eigen-game my-game # or npm npm create eigen-game@latest my-game ``` `my-game` is the only naming argument. It is a lowercase kebab-case slug; the scaffolder derives `My Game`, `my_game`, and the `MyGame` type prefix from it. It then asks one question — your organization in reverse domain notation, defaulting to `com.example`: ```text Organization in reverse domain notation [com.example]: dev.yourname.games ``` That becomes the Android `applicationId`, which is worth getting right at scaffold time: Google Play treats it as the permanent identity of the app and it cannot be changed after the first upload. Pass `--org dev.yourname.games` to answer it up front, which is also how it works with no terminal attached. The scaffold is committed when it finishes, so your first `git diff` is your first game change rather than the ninety generated files underneath it — launcher icons, splash screens and web icons are all written at scaffold time. `--no-git` skips it, as does scaffolding inside a repository you already have. The engine and `eigen_flutter` versions it writes are fixed in the scaffolder, built and tested as a pair before it ships. So the version of `create-eigen-game` you run decides both — see [Versions and compatibility](../reference/compatibility.md). Use `@latest` rather than a cached older copy. The default is a single repository: ```text my-game/ ├── server/ # Cloudflare Worker and authoritative TypeScript rules └── app/ # Flutter app and presentation rules ``` The scaffold intentionally supports only this combined layout. It composes a canonical C3-style Cloudflare Worker template with `flutter create --empty --platforms android,web`, installs both halves, emits the initial `game-contract.json`, and generates the initial Dart payload types and rules base. The generated files use only public npm/pub.dev contracts, so teams that prefer separate repositories can create either half by hand. The scaffold is convenience, not a runtime requirement. Prefer to create the repositories yourself or add EigenInteractive to an existing app? Follow [Set up without the scaffolder](./manual-setup.md). It uses the same public contracts and supports independent Worker and app repositories. ## Generate the game contract The scaffold has already performed the first generation. After changing `state`, `observation`, `action`, `config`, or a shared fixture, regenerate: ```bash pnpm run contract # from the generated repository root # npm run contract is supported too ``` `game-contract.json` is the game-owned boundary between repositories. It contains every schema version plus the validated twin fixtures. Commit it, publish it as a release artifact, or copy it into the app build; no particular repository layout is assumed. The root command also generates the Dart payload library and fixture copies. The underlying commands remain independently usable when the halves live in separate repositories: ```bash cd app dart run eigen_flutter:generate_payloads \ --contract ../server/game-contract.json \ --output lib/game/generated/payloads.dart \ --fixtures-output test/fixtures flutter test ``` The output is immutable plain Dart with deep value equality, field-aware decode errors, and a typed abstract rules base. A game does not install Freezed, `json_serializable`, `build_runner`, `code_builder`, or `dart_style` for these payloads; the executable owns its generation implementation. ## The development loop The scaffold includes one v1 fixture and both fixture runners. Keep the TypeScript runner watching while editing rules: ```bash cd server pnpm run test:watch # or: npm run test:watch ``` After changing a schema or fixture, refresh the cross-language artifact and the generated Dart side: ```bash cd .. # repository root pnpm run contract cd app flutter test ``` Changing only TypeScript hook behavior does not necessarily change the schemas, but update its shared fixture and run the same sequence: fixtures are part of `game-contract.json`. `wrangler dev` reloads Worker source; it does not regenerate the contract or Dart files. Before anything has shipped, freely edit the seeded v1 unit. Once persisted games or released clients depend on v1, make an incompatible change in a new v2 unit and keep both registry entries. ## Run locally ```bash cd server pnpm dev curl http://localhost:8787/health ``` `wrangler dev` simulates the Worker resources locally. Running the full Flutter app against it additionally requires Firebase configuration; pure rules, fixture, and widget tests do not. Run the browser at the stable origin already allowed by the Worker scaffold: ```bash cd app flutter run -d chrome --web-hostname localhost --web-port 7357 \ --dart-define-from-file=app-config.json ``` Run `pnpm firebase:configure` (or `npm run firebase:configure`) from the generated repository root. It configures Android and Web with FlutterFire and generates the service worker's matching public Firebase configuration. Then finish the required public values and VAPID key in `app-config.json` as shown in [Deploy the web app](../ship-it/deploy-the-web-app.md). Copy `server/.dev.vars.example` to `server/.dev.vars` and fill the Admin credentials from that same Firebase project before running authenticated Worker traffic. The Worker template uses Wrangler-generated `Env` types and a stable `GAME_DB` binding. Wrangler automatically provisions its D1 database on first remote use; the deploy script applies the engine migrations before deploying. ## Keep generation honest Run generation in write mode during development and check mode in CI: ```bash pnpm run contract:check # generated repository root ``` When the two halves are separate repositories, promote one exact `game-contract.json` by checksum. Deploy an Android build supporting a new game schema before server responses start requiring it. Web clients can show the same update state and reload to fetch the latest deployed bundle. Next, read [Your first game](./your-first-game.md) and [Payload types](../build-a-game/schemas.md). --- ## Your first game Rock–Paper–Scissors is the reference implementation, and it is deliberately the *hardest* small case: both players commit at the same time, and neither may see the other's throw. Simultaneous turns, hidden information, and — as it turns out — nothing worth predicting. This page is the whole game, both halves. Roughly 500 lines of Dart and 220 of TypeScript, and none of it mentions turns, deadlines, sockets, versions, persistence, ratings, sign-in, lobbies or replay. ## The rules that decide `eigen-server/examples/rps/src/module/v1.ts`, condensed: ```ts class RpsRulesV1 implements GameRules { readonly schemas = { state: stateSchema, observation: observationSchema, action: actionSchema, config: configSchema, }; initialState(): Envelope { return { state: { round: 1, wins: [0, 0], commits: [null, null], lastRound: null }, pendingPlayers: [0, 1] }; // both seats act at once } applyAction({ state, data, playerIndex, config }: ApplyActionArgs): Envelope { const seat = playerIndex as 0 | 1; const other = (1 - seat) as 0 | 1; const otherMove = state.commits[other]; if (otherMove === null) { // First commit of the round: record it, wait for the opponent. const commits: State["commits"] = [null, null]; commits[seat] = data.move; return { state: { ...state, commits }, pendingPlayers: [other] }; } // Second commit: resolve the round, and maybe the match. const moves = seat === 0 ? [data.move, otherMove] : [otherMove, data.move]; const winner = beats(moves[0], moves[1]) ? 0 : beats(moves[1], moves[0]) ? 1 : null; const wins = [...state.wins]; if (winner !== null) wins[winner] += 1; if (winner !== null && wins[winner] >= config.targetWins) { return { state: { ...state, wins, commits: [null, null], lastRound: { moves, winner } }, pendingPlayers: [], outcome: matchOutcome(winner) }; } return { state: { round: state.round + 1, wins, commits: [null, null], lastRound: { moves, winner } }, pendingPlayers: [0, 1] }; } computeObservation({ state, pending, playerIndex, isReplay }: ComputeObservationArgs<…>): ObservationSlice { if (isReplay || playerIndex === null) { // The match is over — reveal everything. return { data: { round: state.round, wins: state.wins, lastRound: state.lastRound, commits: state.commits }, pendingPlayers: pending }; } const seat = playerIndex as 0 | 1; // Two deliberate omissions that ARE the game: // - the opponent's commit is hidden (only your own move comes back); // - the opponent's pending status is masked (you see only your own). return { data: { round: state.round, wins: state.wins, lastRound: state.lastRound, yourMove: state.commits[seat] }, pendingPlayers: pending.filter((s) => s === seat) }; } ratingPool({ access }: RatingPoolArgs): string | null { return access === "public" ? "standard" : null; } botSeatable(): boolean { return true; } } ``` Everything that makes RPS *RPS* is in `computeObservation`, and it is entirely about what it leaves out. ## The screen that draws `eigen-flutter/example/lib/src/v1/rules.dart` — the same version, the other language: ```dart class RpsRulesV1 extends RpsV1RulesBase { const RpsRulesV1(); // The legality half of applyAction, transcribed — this is what greys out a button. @override bool isValidAction({ required RpsV1Observation obs, required List pending, required RpsV1Action data, required int playerIndex, required RpsV1Config config, }) => pending.contains(playerIndex) && !obs.committedBy(playerIndex); // Always null. See below — this is the interesting one. @override RpsV1Observation? previewAction({ /* same parameters */ }) => null; @override Widget buildContent(GameContentContext context) => RpsBoard(context: context, rules: this); @override String? ratingPool(RatingPoolArgs args) => args.access == GameAccess.public ? 'standard' : null; @override bool botSeatable(BotSeatableArgs args) => true; } ``` The board itself is a `StatefulWidget` reading `context.frame.observation`, drawing three buttons, and calling `context.onAction(...)`. Everything around it — sign-in, home, the lobby, the countdown, the finished banner, replay — is the shell's. ## Three things worth taking away ### The observation is not the state The server stores `commits: [move, move]`. The client never sees that field during play — it sees `yourMove`, its own commit echoed back. The opponent's throw is not hidden by the UI; **it is not in the bytes that reach the device**. The cost of that on the client is one nullable field, because `computeObservation` emits a second shape for replay. That is the entire cost. ### Hiding *pending* is what makes simultaneous play correct `pendingPlayers: pending.filter((s) => s === seat)` looks like a detail. It is the mechanism. Because a hidden commit does not change your projected view, the engine's same-view rule accepts your in-flight submission even though it was computed against an older version — so both players can commit in either order and both land. When the *second* commit resolves the round, the reveal changes every seat's view, so a stale submission is correctly rejected. No lock, no "both players ready" check, no retry. You chose what each seat sees, and the concurrency policy followed. See [Hidden information](../build-a-game/hidden-information.md). ### Sometimes the honest answer is "I cannot predict this" `previewAction` returns null, unconditionally. After you throw, you cannot tell whether the opponent has thrown yet — that is exactly what the masking above hides — so you cannot tell whether your next frame is a quiet echo or a full reveal with a new score. Predicting either would be wrong half the time. The board still feels instant: it holds the tapped move in widget state and resolves it against the `ActionSubmitResult` the submit returns. That is optimism about *your own action*, which you can always know, rather than about *the resulting position*, which here you cannot. ## The file that keeps them agreeing Both repos carry a byte-identical `fixtures/v1/rps.json`, and both run it: ```json { "kind": "action", "name": "first commit of a round is recorded and hidden", "config": { "targetWins": 1 }, "state": { "round": 1, "wins": [0,0], "commits": [null,null], "lastRound": null }, "obs": { "round": 1, "wins": [0,0], "lastRound": null, "yourMove": null }, "pending": [0, 1], "playerIndex": 0, "action": { "move": "rock" }, "expected": { "valid": true, "state": { "round": 1, "wins": [0,0], "commits": ["rock",null], "lastRound": null }, "pending": [1], "outcome": null, "observation": { "round": 1, "wins": [0,0], "lastRound": null, "yourMove": "rock" } } } ``` The TypeScript runner drives `applyAction` and `computeObservation` with `state`; the Dart runner drives the codec and `isValidAction` with **`obs`** — the acting seat's actual view, which for a game with fog is a different payload. A divergence fails a test in whichever language drifted. See [Testing](../build-a-game/testing.md) for the full format and the contract/generator checks that carry the authored server fixtures into the app. ## Next Read [The contract](../build-a-game/the-contract.md) for what you write and what the engine owns, then work down the *Build a game* section in order. --- ## Bots A bot is a registry row (an operator inserts it) whose `type` decides how it moves. The one you write in your game module is the **`engine`** bot — a brain that runs *inside* the engine, no external service: ```ts readonly botActions: Record> = { // keyed by the bot's registry `username` "rps-random": ({ rng }) => { const moves: Move[] = ["rock", "paper", "scissors"]; return { move: moves[Math.floor(rng.next() * moves.length)] }; }, }; ``` When a seated engine bot is due, the engine resolves its row → `username` → this function, runs it post-commit, and self-applies the returned move as that seat's action — validated against `schemas.action` exactly like a human's (an illegal bot move fails that seat's turn and the deadline backstops it; it can't corrupt the game). The brain sees only its seat's observation — the same fog a human gets, so a bot can't read hidden state. Notes: - **Several bots, one brain.** Personalities that share behaviour point their usernames at the same function and differ by their per-row `botConfig` (difficulty, style). Distinct behaviour is a distinct entry. - **`rng` is deterministic** per (game, version, seat) for reproducible tests, but replay uses the *recorded* move, so the brain needn't be pure. - **External and local bots** are engine concepts, not things you code in the game module: `external` bots are hosted elsewhere and woken over a signed webhook; `local` bots are reserved for future offline play. You only write `engine` brains here. ## The client half There is almost none, and that is the point: **client-side bots do not exist**. Every bot is seated by the server, so a game screen renders a bot seat exactly like a human one — same `PlayersContext` entry, same avatar (with a bot badge), same frames arriving over the same socket. Do not branch on seat type to decide whether to show identity. The one member that participates is the Dart `botSeatable` twin, which filters the bot picker locally with no network call. It is display-only; the server enforces the same rule before seating. The one constraint that reaches the creation UI is that **a game seating a bot must be timed** — bot dispatch is single-attempt, so the turn deadline is the only thing that resolves a bot which never moves. See [Creation UI](./creation-ui.md). Registering the bot row is an operator task — see [Registering bots](../ship-it/configure.md#registering-bots). For the transport and HMAC details of external bots, see [Bots](../how-it-works/bots.md). --- ## The creation UI Creation is version-independent — a new game is always created at the newest version your build ships — so it lives on the Dart `GameModule` rather than on a `GameRules` unit. Three members, none of which you write a dialog for: the shell renders the dialog from what you declare. ```dart class RpsModule extends GameModule { const RpsModule(); @override Map get versions => const {1: RpsRulesV1()}; @override GameCreationSpec get creationSpec => const GameCreationSpec( minPlayers: 2, maxPlayers: 2, timingConfigs: { 'Per move': PerActionConfig(maxSeconds: 300, presets: [30, 60, 120]), 'Untimed': UntimedConfig(), }, defaultConfig: {'targetWins': 3}, ); @override Widget? buildCreationConfig({ required ValueChanged> onChanged, }) => _TargetWinsPicker(onChanged: onChanged); @override Widget buildRules(BuildContext context) => const RpsRulesPage(); } ``` ## `creationSpec` - **`timingConfigs` keys become segmented-button labels**, in insertion order, so the first entry is the default. `PerActionConfig` renders presets plus a slider; `BudgetConfig` adds an increment slider. Floors are enforced on both sides (`kMinTurnSeconds` 30 s, `kMinBudgetSeconds` 120 s). - **`BudgetConfig` is only valid for strictly sequential games.** The server rejects a hook envelope with more than one pending seat in a budget-timed game as a game bug. If any phase has multiple pending seats, use a per-action mode for it — or a `turnSeconds` override on that envelope. - **`defaultConfig`** seeds the config map before the player touches anything, so a game with no custom UI still creates a valid game. - **`playersForConfig`** overrides the range when it depends on a creation-time choice — a party game where the host picks 4 or 6, and min == max so joining flips the game to `ready` at exactly the right threshold. ## `buildCreationConfig` Returns a widget for game-specific options, or null if timing and player count are the whole story. It calls `onChanged` on every edit; the dialog stores the latest value in a plain field — not state, since it is never displayed — and sends it with the create request at submit time. Whatever it produces is the game's `config`, and **the server validates it against your `configSchema`**. An out-of-range value is rejected there, so this widget is a convenience, not a gate. ## `buildRules` Non-scrolling how-to-play content for the About page — the page supplies the scroll container, padding and chrome. It is free to be interactive (an animated board example) and to read `Theme.of`. ## Two constraints from elsewhere ### Bots imply a timed game If a game can seat a bot, its creation UI must require a turn or budget clock. Bot dispatch is single-attempt, so the turn deadline firing the server's alarm is the only thing that resolves a bot which never moves. The engine enforces this at seating; declaring an untimed-only game that also allows bots just produces a rejection later. ### `rated` is a validated assertion, not a preference The client computes `rated` from the Dart `ratingPool` twin plus its own guest status, and sends a **concrete value**. The server recomputes it and **rejects a mismatch with a 422** rather than coercing. That is deliberate: coercion would silently paper over a drifted twin or a forged client, and the twin drifting is exactly the failure this design wants to be loud. Keep the two `ratingPool` implementations in agreement, and let [twin fixtures](./testing.md) prove it. --- ## Hidden information Fog costs less than you expect, in both halves. On the server it is what you *omit* from `computeObservation`. On the client it is a codec that accepts more than one shape, and an honest answer to "can I predict what happens next?" What it buys is more than secrecy: what you reveal also decides, silently, which concurrent submissions the engine accepts. ## The same-view rule Simultaneous moves are the classic source of turn-based race bugs. The engine resolves them with a rule that needs **zero game code**, driven entirely by what your `computeObservation` reveals: > A stale-version action (one computed against an older version) is accepted **if > and only if** the acting seat's projected observation is byte-identical between > the version it expected and the current version. Otherwise it is rejected with > `board_updated` and the client resyncs. Work through Rock–Paper–Scissors. Both players commit "simultaneously": - Player 0 commits. The state changes and the version bumps — but because `computeObservation` **hides player 1's commit and masks player 1's pending status**, *player 1's projected view is unchanged*. So player 1's in-flight commit, computed against the older version, still lands. Order does not matter. - When the *second* commit resolves the round, the reveal (`lastRound`, the new `wins`) changes *every* seat's view — so any submission still computed against the pre-resolution round is correctly rejected. You never wrote a lock, a "both players ready" check, or a retry. You chose what each seat sees, and the acceptance policy followed. A perfect-information game using `passthroughObservation` gets the *strict* policy automatically: any opponent move changes everyone's view, so no stale submission survives. Two invariants to rely on: versions stay strictly serial (the rule governs *acceptance*, never ordering — every accepted move is still the next version), and a seat's projection must stay truthful about itself, which the engine enforces. ## The client half ### The secret is not on the device There is no client-side masking to write, because the hidden data never arrives. `computeObservation` runs on the server; what it omits is absent from the bytes. The only client-side consequence is that the observation shape can differ by audience — RPS carries `yourMove` live and `commits` in replay — so the codec accepts both. That is covered in [Payload types](./schemas.md#modelling-the-observation). ### Masking pending changes what "my turn" means `pendingPlayers` is projected too. In RPS a seat sees at most **its own** seat in `frame.pendingPlayers`, never the opponent's. So: - `pendingPlayers.contains(mySeat)` still answers "may I act?" correctly. - Anything of the form "is the opponent still thinking?" is **unanswerable**, and a UI that implies otherwise is lying. Render "waiting" rather than "opponent is choosing". ### When you cannot predict, say so `previewAction` is the game's optimistic projection of its own next observation, and returning `null` means "this move is server-driven". Null is always a correct answer — never drift, never a gap in the implementation. RPS returns null unconditionally, and the reason is exactly the masking above. After you throw, you cannot tell which of two futures you are in: the opponent has not thrown yet, so your next frame just echoes `yourMove`; or they threw first, so your throw resolves the round and your next frame is a full reveal with a new score. Predicting either is wrong half the time, and a prediction that is wrong half the time is worse than none — it shows a reveal that never happened. ```dart /// Always null — RPS cannot predict its own next observation, and saying so /// is the correct answer rather than a gap. @override RpsV1Observation? previewAction({ /* … */ }) => null; ``` The board still feels instant. It holds the tapped move in widget state and resolves it against the `ActionSubmitResult` the submit returns — optimism about *your own action*, which you can always know, rather than about *the resulting position*, which here you cannot. See [Rendering](./rendering.md#optimistic-preview). That distinction is worth carrying into your own game: | You can always know | You can only sometimes predict | |---|---| | what you just tapped | what the position becomes | | that a submit is in flight | whether an opponent has acted | | whether the server accepted it | anything behind fog | A game whose every move resolves against hidden information implements `previewAction` as `=> null` and loses nothing. ## Where to look next [The RPS walkthrough](../getting-started/your-first-game.md) is the code this page describes, in both languages. --- ## The hooks The six hooks are the deciding half of a game, and they all live in TypeScript. Each section below ends with **what reaches the client** — because a hook's real output is not its return value, it is what a player ends up looking at. Everything returns an **`Envelope`**: the new `state`, the `pendingPlayers` who may act next (empty ⇒ game over), an optional `outcome` (present **only** when the game ends), and an optional `turnSeconds` override for this one action. See the [Envelope reference](../reference/envelope.md). ## `initialState({ config, rng, playerCount }) → Envelope` The starting position. Draw any setup randomness (shuffle, first player) from `rng`. Set `pendingPlayers` to whoever moves first. *On the client:* the first frame of the game, projected through `computeObservation` with `cause: null`. There is no predecessor, so a game should render it as a static opening position rather than animating into it. ## `applyAction({ state, pending, data, playerIndex, config, rng }) → Envelope` A player's move. **The engine has already confirmed it is this seat's turn at the expected version** — do not re-check turn order. Validate move *legality* only; if it fails, `throw new IllegalMoveError("…")` and the engine renders it as the caller's error. Any *other* throw is treated as a game bug (a server 500). Return the next envelope: advance the state, set the next `pendingPlayers`, and include `outcome` if this move ended the game. *On the client:* the legality check you write here is transcribed into the Dart `isValidAction`, which greys out the illegal tap before it is ever sent. The two are compared by [twin fixtures](./testing.md). The acting seat also gets an `ActionSubmitResult` telling it whether a confirming frame is coming — see [Rendering](./rendering.md). ## `applyLifecycle({ state, pending, type, data, rng }) → Envelope` \{#applylifecycle} Resolve an out-of-rules event. Unlike `applyAction` it can never be "illegal" — it always resolves. Three triggers: - **`timeout`** — the seats in `pending` ran out of time. Resolve the whole set in one envelope (you decide the consequence — often a loss for the idle seat, or a draw if everyone stalled). - **`forfeit`** — a voluntary resign; the seat is in `data.playerIndex`. - **`autoForfeit`** — the engine-driven variant (an account was deleted). Same shape as forfeit; you *may* choose a gentler consequence (a draw rather than a loss) since the seat did not choose to quit. *On the client:* an ordinary frame, arriving unprompted. Nothing in the game screen needs to know it came from a lifecycle event — `gameStatus` flips to `finished` and `outcomes` populates like any other ending. ## `computeObservation({ state, pending, playerIndex, cause, isReplay, … }) → ObservationSlice` Project the state into **one seat's view** — this is where hidden information lives, and it is the hook with the most leverage in the whole contract. Return `{ data, pendingPlayers }`: - `data` is exactly what this seat may see. Strip anything hidden (opponents' hands, face-down cards, un-revealed simultaneous commits). - `pendingPlayers` may be *narrowed* from the true set to avoid leaking information — for example hiding that an opponent has secretly moved — but it must stay truthful about the seat *itself*, and the engine enforces that. - `playerIndex` is `null` for a public viewer (only ever with `isReplay: true`, a finished public game), where you can reveal everything. - `cause` tells the seat *what just happened*; `isReplay` is true only for finished-game replay. For a **perfect-information game**, use the shipped `passthroughObservation` helper — every seat sees the full state and the true pending set. *On the client:* this hook's return value is the only game data that exists. `parseObservation` consumes it, `buildContent` draws it, and anything you did not project simply is not on the device. Note that the shape may differ between live play and replay — see [Payload types](./schemas.md#modelling-the-observation). :::warning[This hook silently sets your simultaneous-move policy] What you reveal here decides which concurrent submissions the engine accepts. See [Hidden information](./hidden-information.md). ::: ## `ratingPool({ access, turnSeconds, budgetSeconds, config, … }) → string | null` Decide whether — and in which pool — a game with these settings is rated. Return a pool name (`"standard"`, `"rapid"`, …) or `null` for unrated. The engine computes `canBeRated = pool !== null && !guest` and validates the client's concrete `rated` flag against it. *On the client:* the Dart twin of this function decides whether the create dialog shows a Rated toggle at all. It is display-only — but the client sends a concrete `rated` value and **the server rejects a mismatch with a 422** rather than coercing, so a drifted twin is a visible bug, not a silent one. See [Creation UI](./creation-ui.md). ## `botSeatable({ gameConfig, botConfig }) → boolean` Whether a bot's declared capabilities support this game config. Return `true` to allow the seating. *On the client:* the Dart twin filters the bot picker locally, with no network call. Also display-only; the server enforces the same rule before seating. See [Bots](./bots.md). --- ## Recipes — common game shapes The whole game is expressed through `pendingPlayers` and what `computeObservation` reveals. A few canonical shapes: ## Sequential (perfect information) Checkers, Connect Four. One seat pending at a time; each move hands the turn to the next seat. Use `passthroughObservation` (everyone sees everything). The same-view rule is automatically strict — no stale move survives an opponent's turn. ```ts applyAction({ state, playerIndex, data }) { const next = applyMove(state, playerIndex, data); return next.won ? { state: next, pendingPlayers: [], outcome: win(playerIndex) } : { state: next, pendingPlayers: [(playerIndex + 1) % playerCount] }; } computeObservation: passthroughObservation, ``` ## Simultaneous (hidden commitment) RPS, blind bidding. *All* actors pending each round; store each commit in the state and hide the opponents' commits in `computeObservation`, also masking their pending status so a hidden commit doesn't change anyone else's view (that's what lets both submissions land in either order — see [the same-view rule](./hidden-information.md)). Resolve when the last commit arrives. ## Team games Set `teamIndex` on outcome entries to the team, not the seat, so OpenSkill rates teammates together. `placement` is the team's finish. ## Elimination / multiplayer Shrink `pendingPlayers` as seats bust out; give an eliminated seat `result: "eliminated"` with its `placement`. The game ends when `pendingPlayers` empties; the final `outcome` ranks everyone by placement. ## Reveal for animation Carry a "what just happened" field (RPS's `lastRound`) in the projected `data` so clients can animate the transition. Decide per seat what that reveal shows using `cause` and `playerIndex` — see [Rendering](./rendering.md). ## Phased turns / variable clocks A phase that needs longer returns `turnSeconds: N` on its envelope to widen just that action's deadline, leaving every player's bank untouched. --- ## Rendering the game `buildContent` is the one widget a game must supply. Everything around it — sign-in, home, lobby, the countdown, the finished banner, replay controls — is the shell's. This page covers what it receives, how frames become animation, and how to hide a round trip honestly. ```dart @override Widget buildContent(GameContentContext context) => RpsBoard(context: context, rules: this); ``` The engine hands widgets no rules access of their own. Pass `this` (or just the members a widget needs) into the content widget you build, so the dependency stays explicit. ## `GameContentContext` One object rather than a long parameter list, so adding engine data later never breaks every game's signature. All JSON parsing is already done. | Member | Meaning | |---|---| | `config` | The parsed config, immutable for the whole game. Cast to your type. | | `frame` | The per-event snapshot: `observation`, `pendingPlayers`, `version`, `timing`. | | `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 — the engine has already surfaced any error. | | `onInvalidAction()` | Call when `isValidAction` rejects a tap. **The engine owns the haptic** — never import `flutter/services.dart` to pick one yourself. | | `playersContext` | Resolved identities, keyed by seat; `mySeat` delegates to it. | | `isReplay` | True when stepping a finished game frame by frame. | Identity is resolved **before** the screen renders, so `playersContext[seat]` is non-nullable — no loading states, no null checks. A participant whose account was deleted arrives as a synthetic identity with `isDeleted` set; guard on that flag, never on the synthetic id. During replay `gameStatus` is `finished` for every frame and `outcomes` is populated only on the final one, so a win banner appears at the end rather than part-way through. A game never *needs* `isReplay` to stay correct — the frame is a real observation and `onAction` is inert — it exists for replay-only presentation. ## The frame model Animation is the presentation of **frame transitions**, and three guarantees make that sound: 1. **You see every frame, in order.** Every move — yours, an opponent's, a bot's, a timeout resolution — arrives as its own frame, so "animate the change between the previous frame and this one" holds for *all* transitions. The one exception is a cold load, where the stream starts at the latest frame with no predecessor. 2. **The observation tells you what happened — do not diff frames.** Diffing 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. ### The server side of a cue Because diffing cannot recover causality, `computeObservation` receives a **`cause`** — the action that produced the state being projected (`{ kind: "game", data, playerIndex }`, a lifecycle event, or `null` for the opening frame). Embed whatever cues a seat is *permitted* to see into that seat's `data`: a `lastMove` field, an `events` list, RPS's `lastRound` reveal. Because the embedding happens inside the projection, **visibility stays game-controlled**, and replay frames carry the same cues — one animation pipeline serves live play and replay. ## Optimistic preview A turn-based round trip is usually well under a second, so latency hiding is **game-owned**: the transport never predicts game state, it only reports how a submit resolved. Two layers, and most games want only the first. ### Feedback that does not depend on the outcome Needs no bookkeeping at all. Lift the piece on tap, slide it, play the sound, show the throw you chose — all in local widget state, resolved when the server frame lands. `actionPending` already marks the in-flight window. This is what RPS does, because it is all RPS *can* do: ```dart setState(() => _submitting = move); final result = await ctx.onAction(action.toJson()); if (!mounted) return; // `committed` needs no handling: the confirming frame is guaranteed to be the // next one, and didUpdateWidget clears the guess when it lands. if (result != ActionSubmitResult.committed) { setState(() => _submitting = null); } ``` ### Predicting the resulting position Pairs the Dart twin's `previewAction` with the `ActionSubmitResult` that `onAction` returns. Compute the predicted observation locally, render it while the request is in flight, and let the result tell you what the stream will do: | Result | What it guarantees | What to do | |---|---|---| | `committed` | The confirming frame is the **next** frame this seat receives — versions are serial, so nothing commits in between. | Clear the prediction when it arrives. | | `rejected` | The move did not commit and **no frame is coming**. | Revert. The engine has already surfaced the error. | | `unconfirmed` | The request failed in transit; the server may or may not have committed it. | Revert. If it did commit, its frame arrives over the socket and re-applies. | Predict only the actor's own moves — opponents' moves always arrive as server frames. And `previewAction` returning null is always correct: see [Hidden information](./hidden-information.md#when-you-cannot-predict-say-so) for why RPS returns null unconditionally. ## Rendering seats Route every avatar through `PlayerAvatar` rather than building one yourself. Avatar URLs may be relative to the API host, and that resolution lives in one place; the widget also carries the bot badge and the deleted-account fallback. ```dart PlayerAvatar( avatarUrl: player.info.avatarUrl, isBot: player.type == SeatTypeEnum.bot, showBorder: isMe, ) ``` Show identity uniformly for humans and bots — do not branch on player type to decide whether to render a name. Use the seat's `type` only where the game's own rules must distinguish a bot seat. Per-game roles (host, team, dealer) are not an engine concept at all: they live in your observation JSON. ## Testing the screen `buildContent` receives a plain value object, so testing it needs no server, no socket and no auth — just a `GameContentContext` built by hand. The RPS example's `test/board_test.dart` is the worked version; the harness is about thirty lines and is the piece worth copying. The one framework wiring a widget test needs is a `ProviderScope` carrying an `AppConfig`, because shared widgets like `PlayerAvatar` resolve avatar URLs against the configured API host. --- ## Payload types One TypeScript declaration is authoritative for all four game payloads: | Payload | Worker uses it for | Flutter uses it for | |---|---|---| | `state` | persisted authoritative state | never received | | `observation` | the seat/public projection | rendering and preview | | `action` | validating submitted moves | constructing/submitting moves | | `config` | validating creation settings | creation and rendering | The schemas must implement both Standard Schema validation and Standard JSON Schema emission. Zod 4 does: ```ts import { z } from "zod"; const moveSchema = z.enum(["rock", "paper", "scissors"]).meta({ id: "Move" }); const stateSchema = z.object({ round: z.int(), commits: z.array(moveSchema.nullable()) }) .meta({ id: "State" }); const observationSchema = z.object({ round: z.int(), yourMove: moveSchema.nullable().optional(), }).meta({ id: "Observation" }); const actionSchema = z.object({ move: moveSchema }).meta({ id: "Action" }); const configSchema = z.object({ targetWins: z.int().min(1) }).meta({ id: "Config" }); export const rules: GameRules = { schemas: { state: stateSchema, observation: observationSchema, action: actionSchema, config: configSchema }, // hooks… }; ``` The engine requests the Standard JSON Schema `draft-2020-12` target. It is the current JSON Schema meta-schema and one of the two targets Standard JSON Schema strongly recommends implementors support. Using one explicit modern dialect keeps `$defs`, nullable unions, arrays, and references deterministic across schema libraries instead of accepting library-specific output. Give reusable/nested schemas stable `meta({ id: "…" })` names. These names become stable Dart type names; wire keys themselves are preserved exactly. The kernel validates state before commit and validates every observation after `computeObservation`, including public/replay views. A projection bug therefore fails at the source instead of becoming a Dart decoding mystery. ## Emit and consume the contract The Worker owns the deterministic artifact. Default-export its module from `src/module/index.ts` and declare its stable name: ```json { "eigen": { "game": "Rps" }, "scripts": { "contract": "eigen-contract" } } ``` Then run: ```bash pnpm contract ``` `@eigeninteractive/testkit` owns the executable and its `tsx` loader. By convention it imports `src/module/index.ts`, reads fixtures from `src/module/fixtures`, and writes `game-contract.json`. Optional `module`, `fixtures`, and `contract` keys under `eigen` override those paths. The artifact contains all versioned schemas and validated twin fixtures. The Flutter app consumes that file: ```bash dart run eigen_flutter:generate_payloads \ --contract game-contract.json \ --output lib/game/generated/payloads.dart \ --fixtures-output test/fixtures ``` The contract's top-level `game` value supplies the Dart type prefix. For example, `"game": "Example Game"` emits `ExampleGameV1Observation`, `ExampleGameV1Action`, `ExampleGameV1Config`, and `ExampleGameV1RulesBase`. The scaffolder derives that value from its one lowercase kebab-case game slug. A hand-created project controls it through `package.json`'s `eigen.game`. For each version the generator emits immutable classes/enums and a typed abstract rules base containing all payload parsing and serialization. Extend that base in the Dart rules unit: ```dart class RpsRulesV1 extends RpsV1RulesBase { // legality, optional preview, and UI remain handwritten } ``` Every version includes its number: version 1 uses `RpsV1RulesBase`, version 2 uses `RpsV2RulesBase`, and so on. The generated base is replaced whenever the contract is regenerated, while the subclass remains entirely game-owned. The generator and its `code_builder`/`dart_style` implementation dependencies ship inside `eigen_flutter`; the game app declares only `eigen_flutter`. Unknown fields are ignored while known fields are decoded strictly. That is the useful read-side balance: additive object fields survive an older app, while a game-payload enum or incompatible shape still selects a new game `schemaVersion`. The engine API's generated transport enums separately carry an `unknownDefaultOpenApi` read fallback so additive engine enum values do not crash an installed app. The fallback is read-only. Never serialize an unknown sentinel as an action; the generated action enum has no such member. ## Modelling the observation The client never receives `state`. `computeObservation` returns the exact audience-safe shape and the observation schema describes every allowed projection. For example, live RPS may include `yourMove`, while a finished public replay includes both commits. One observation schema can model that with optional audience-specific fields. Nothing confidential should be removed by Flutter UI logic. If a value must be hidden, it must not appear in the observation bytes. ## Drift policy Commit the contract and generated Dart. CI regenerates both and fails on a diff. This catches schema changes, fixture drift, wire-key mismatches, and stale generated payloads without requiring the Worker and app to share a repository. --- ## Testing Your rules exist twice. **Shared JSON fixtures record the expected behaviour once and run against both halves**, so a divergence fails a test in whichever language drifted. That is the load-bearing layer; everything else on this page is ordinary testing. Nothing here needs a Cloudflare account, a Firebase project or a network. ## Twin fixtures A fixture file is a list of cases, keyed to one `schemaVersion`: ```json { "schemaVersion": 1, "cases": [ { "kind": "action", "name": "first commit of a round is recorded and hidden", "config": { "targetWins": 1 }, "state": { "round": 1, "wins": [0,0], "commits": [null,null], "lastRound": null }, "obs": { "round": 1, "wins": [0,0], "lastRound": null, "yourMove": null }, "pending": [0, 1], "playerIndex": 0, "action": { "move": "rock" }, "expected": { "valid": true, "state": { "round": 1, "wins": [0,0], "commits": ["rock",null], "lastRound": null }, "pending": [1], "outcome": null, "observation": { "round": 1, "wins": [0,0], "lastRound": null, "yourMove": "rock" } } } ] } ``` `kind` is `action`, `ratingPool` or `botSeatable`. The two runners read the same file and check different things: | Field | TypeScript runner | Dart runner | |---|---|---| | `state` | input to `applyAction` | — | | **`obs`** | ignored | input to `isValidAction` / `previewAction` | | `action` | parsed by `schemas.action` | parsed and serialized by the generated rules base | | `expected.valid` | `applyAction` throws or not | `isValidAction` | | `expected.state` / `pending` / `outcome` | the returned envelope | — | | `expected.observation` | `computeObservation` output | `previewAction` output, **when non-null** | ### `obs` is the field hidden-information games need It defaults to `state`, which is correct only for a perfect-information game where the two coincide. **A game with fog must set it explicitly** — otherwise the Dart runner hands your codec a payload `computeObservation` would never produce, and the failure looks like a codec bug rather than a missing field. ### `expected.observation` is the shared anchor Both sides are compared through one recorded value: the TypeScript side must *project* to it, and a Dart `previewAction` that returns non-null must *predict* it. A `previewAction` returning null skips the check — that is a correct answer, not a gap, so a game like RPS simply has no preview coverage here. ## Wiring the two runners **TypeScript**, one line from the testkit, under plain-Node vitest: ```ts import { twinFixtureTests } from "@eigeninteractive/testkit"; import gameModule from "../../src/module/index.js"; twinFixtureTests(gameModule, new URL("../../src/module/fixtures/", import.meta.url)); ``` **Dart**, rides `flutter test`: ```dart import 'package:eigen_flutter/testing/twin_fixtures.dart'; void main() { const module = RpsModule(); for (final suite in loadTwinFixtureSuites('fixtures')) { final rules = module.versions[suite.schemaVersion]; group('twin fixtures v${suite.schemaVersion}', () { for (final fixtureCase in suite.cases) { test(fixtureCase.name, () { expect(runTwinFixtureCase(rules!, fixtureCase), isEmpty); }); } }); } } ``` Both expect a `v/` directory layout and read `schemaVersion` from inside each file. `eigen-contract` rejects a path such as `v2/case.json` whose document says `"schemaVersion": 1`, or any fixture targeting a version absent from `GameModule.versions`. ## What to cover Write fixtures for the interesting states — especially hidden-information reveals and `computeObservation` masking, because those are exactly where the two halves drift. At minimum: one legal move with its expected observation, one illegal move, one game-ending move, and one case per `ratingPool` / `botSeatable` branch. Grow the suite with the rules. ## The other layers **Widget tests for the screen.** `buildContent` takes a plain value object, so a hand-built `GameContentContext` is the whole harness — no server, no socket, no auth. See [Rendering](./rendering.md#testing-the-screen). **Integration tests against the real runtime.** Drive the actual Worker (routes + Durable Object + D1) with `@cloudflare/vitest-pool-workers`, using `@eigeninteractive/server/testing` to mint local tokens. The engine's own suites cover the plumbing — lobby, sockets, timing, finish, ratings, purge — so your job is *your game* end to end: a full match, a timeout resolution, a bot game. ## CI Both halves are plain commands with no secrets. The engine packages arrive as published dependencies, so an implementor does not build the engine workspace: ```bash # combined scaffold, from the repository root pnpm run contract:check # server/ pnpm install --frozen-lockfile pnpm run typecheck pnpm test # app/ flutter pub get flutter analyze flutter test ``` The root `contract:check` composes the server contract check and Dart generator check. In separate repositories, run those two underlying commands in their respective pipelines instead. The scaffold supplies the initial `test/twin.spec.ts`, `test/game/twin_fixtures_test.dart`, and v1 fixture. Grow those tests with the rules rather than replacing their wiring. :::danger[Do not deploy from CI] `wrangler d1 migrations apply --remote` mutates a real database, and a deploy is the one action in this system that re-running a job cannot reverse. Keep it a deliberate, credentialed `pnpm deploy` from a machine — or, if you want push-button deploys, connect the repo to Cloudflare **Workers Builds** so the deploy is owned by Cloudflare rather than by a long-lived API token sitting in GitHub secrets. ::: ## The cross-repository gate Fixtures have one authored home: `server/src/module/fixtures`. The contract CLI validates and embeds them in `game-contract.json`; the Dart generator copies those exact documents into the app. Do not hand-edit `app/test/fixtures`. In separate repositories, promote one exact contract artifact by checksum. The app's generator `--check` then proves that its payload types and fixture copies came from that artifact. This turns cross-repository drift into a normal generated-file failure rather than a manual directory comparison. --- ## The contract # What a game is A game is **two same-keyed registries**, one per language: - a **TypeScript `GameModule`** in your Worker — the rules that decide; - a **Dart `GameModule`** in your app — the codec and the screen that draws. Everything else is the engine's: persistence, serialization, timing, sockets, reconnection, ratings, bots, auth, history, the API, and the game's website. You never touch a database, a Durable Object, a migration, or a socket. Both halves come from a package with no engine dependencies — [`@eigeninteractive/rules`](../reference/typescript/rules.md) is pure types plus two tiny helpers, and you can read it top to bottom in ten minutes. ## Four facts that shape everything you write 1. **Your state is pure and opaque.** The engine stores and versions it but never looks inside. It holds *only* your game payload (board, deck, scores, fog) — never whose-turn or winner metadata, which are engine-owned. Your hooks are pure functions from `(state, input)` to a new state. 2. **The server decides; the client proposes.** A move is validated by *your* `applyAction` on the server. If it is illegal you throw and the engine rejects it. The Dart half also checks legality, but only to grey out a button — the server's answer is the truth. 3. **You never branch on version.** Rules are organised one unit per `schemaVersion`, on both sides. The engine resolves a game's version once and calls that unit; your hook bodies only ever see their own version's shapes. 4. **Determinism is required.** State must be a pure function of `(seed, ordered moves)`. Randomness comes from an engine-provided, replay-stable `rng`. This is what makes history, reconnection and preview work — so no `Date.now()`, no `Math.random()`, no external reads inside a hook. ## The two halves, member by member | Member | TypeScript `GameRules` | Dart `GameRules` | |---|---|---| | `schemas` — the payload contracts | ✅ Standard JSON Schema capable schemas | ✅ generated payload types and rules base | | `initialState`, `applyAction`, `applyLifecycle`, `computeObservation` | ✅ authoritative | — the client consumes observations, it does not produce them | | `isValidAction` | — `applyAction` *is* the check | ✅ UX-only transcription of its legality half | | `previewAction` | — `applyAction` is the truth | ✅ required; the game's own optimistic projection (null ⇒ server-driven) | | `buildContent` | — | ✅ the screen | | `ratingPool`, `botSeatable` | ✅ enforced | ✅ display-only twin | | `botActions` — bot brains | ✅ server-side | — client-side local bots do not exist | Every "keep in sync" above is enforceable rather than aspirational: shared JSON fixtures run against both units and fail a test on divergence. See [Testing](./testing.md). ## The TypeScript half A `GameModule` is a map from `schemaVersion` to a `GameRules` unit: ```ts import type { GameModule } from "@eigeninteractive/rules"; import { rulesV1 } from "./v1.js"; export default { versions: { 1: rulesV1 }, } satisfies GameModule; ``` A unit is one version's payload schemas plus six hooks (and an optional seventh for bots): ```ts interface GameRules { schemas: { state; observation; action; config }; // validation + JSON Schema initialState(args): Envelope; // seed a new game applyAction(args): Envelope; // a player's move applyLifecycle(args): Envelope; // timeout / forfeit computeObservation(args): ObservationSlice; // per-seat view (fog) ratingPool(args): string | null; // rated? which pool? botSeatable(args): boolean; // may this bot sit? botActions?: Record>; // in-engine bot brains } ``` Author each unit as a literal or class typed `GameRules` so you get full type-checking, then register it in the `versions` map. No base class, no lifecycle to manage. ## The Dart half The same keys, and the members from the right-hand column above: ```dart class RpsRulesV1 extends RpsV1RulesBase { const RpsRulesV1(); // Legality — the transcribed legality half of the TypeScript applyAction. @override bool isValidAction({ required RpsV1Observation obs, required List pending, required RpsV1Action data, required int playerIndex, required RpsV1Config config, }) => pending.contains(playerIndex) && !obs.committedBy(playerIndex); // Optimism — or null to stay server-driven. @override RpsV1Observation? previewAction({ /* same parameters */ }) => null; @override Widget buildContent(GameContentContext context) => RpsBoard(context: context, rules: this); // Display-only twins of the TypeScript predicates. @override String? ratingPool(RatingPoolArgs args) => args.access == GameAccess.public ? 'standard' : null; @override bool botSeatable(BotSeatableArgs args) => true; } ``` …registered in a Dart `GameModule`, which also carries the version-independent creation and About UI: ```dart class RpsModule extends GameModule { const RpsModule(); @override Map get versions => const {1: RpsRulesV1()}; // …creationSpec, buildCreationConfig, buildRules — see Creation UI. } ``` Four things that are easy to get wrong on this side: - **Do not re-check whose turn it is** in `isValidAction` for the sequential case — the caller has already gated on `pending`. Check *move* legality. Games with interrupt actions (a "Nope" window) use `pending` to tell a main-turn action from an interrupt. - **`playerIndex` is passed to every game** even when unused, so the contract stays uniform. Chess needs it (piece ownership); tic-tac-toe does not. - **Turn-gating, game-over and winner derivation are engine facts**, surfaced as `frame.pendingPlayers`, `gameStatus` and `outcomes`. Never re-derive them. - **The rules unit carries no player metadata.** Player counts are declared on `GameCreationSpec`; identities arrive via `PlayersContext`. ## One dependency, one import A game app depends on **`eigen_flutter` alone** and imports **only its barrel**: ```dart import 'package:eigen_flutter/eigen_flutter.dart'; ``` Never `package:eigen_api/…` — that is a generated build artifact, rewritten wholesale — and never a deep path into `lib/`. The barrel re-exports the wire vocabulary a game renders from (`GameStatus`, `Outcome`, `Player`, `Seat`, `Frame`, …) while keeping the generated `*Api` classes and their HTTP plumbing out of your namespace: > **Naming a type is part of the contract; calling the server is not.** ## What the engine owns, and you never reimplement - **Persistence & serialization** — the per-game Durable Object, its SQLite, the 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. - **Timing** — deadlines, the chess-clock bank, the grace window, the durable alarm. - **Ratings** — OpenSkill, the concurrency-safe CAS, pools, history. You only choose the pool via `ratingPool`. - **Identity & auth** — Firebase token verification, provisioning, guests, account deletion. - **History & replay** — the immutable transition log, compaction, and the replay path (your `computeObservation` is reused to project it). - **The whole app shell** — sign-in, home, lobby, friends, profile, settings, history, replay, offline UX, push registration, deep links, analytics. - **Bots infrastructure**, **avatars**, and the entire **HTTP/OpenAPI surface**. :::tip[A useful smell test] If you find yourself reaching for a database, a socket, a clock, or a lock inside a hook — stop. The engine already did it, and doing it in a hook would break determinism. ::: --- ## Timing You mostly get timing for free. A game is created in one of three modes — per-action window, chess-clock bank with optional increment, or untimed — the creation UI picks the values, and the engine enforces the deadline with a durable per-game alarm. **Expiry is entirely the server's.** There is no client-side nudge, no client-side timer that fires anything. That is the single largest simplification in the system, and it means your only real decisions are the two below. ## The server side: two touchpoints **`applyLifecycle` on `timeout`** decides what running out costs. The seats in `pending` are the ones that ran out; resolve the whole set in one envelope. ```ts if (type === "timeout") { // Both idle ⇒ a drawn match; one idle ⇒ the seat that did commit wins. if (pending.length === 2) return { state, pendingPlayers: [], outcome: drawOutcome() }; const winner = (1 - pending[0]) as 0 | 1; return { state, pendingPlayers: [], outcome: matchOutcome(winner) }; } ``` **The envelope's `turnSeconds`** widens the deadline for *one* action only — a longer window for a special phase — without touching any player's bank. Omit it to use the game's configured timing. That is the whole server surface. Deadlines, the bank, the grace window and the alarm are all engine-owned; see [Timing & the deadline alarm](../how-it-works/timing.md) for how they work. ## The client side: display only Each frame carries the true `deadline` (epoch ms, or null when untimed) and, in budget mode, the per-seat `playerTimes` banks. `TimingContext` on `GameContentContext.timing` exposes them as `clock`, `deadline`, `playerTimes` and `windowMillis`, plus `isTimed`, `deviceDeadline` and `remaining`. Four things to know: - **Measure against server time, not the device clock.** `ServerClock` tracks the offset from the `Date` header every response already carries, and `deviceTimeFor()` converts a server timestamp into device time so a countdown ticks correctly on a device whose clock is minutes out. Deadlines are absolute **server** timestamps — the same value the server's own alarm fires on — so display and expiry cannot diverge. - **Only one bank drains at a time.** Budget mode permits a single pending seat, so the turn deadline and the acting seat's bank are the same quantity. - **The soft margin nudges honest players to submit early.** `softDeadlineMarginFor(window)` returns `min(1s, 25% × window)` — capped as a fraction so a short window (a 3 s reaction phase) is not swallowed. `TurnCountdown` subtracts it so the displayed countdown reaches zero slightly early. `BudgetClock` uses it only to raise a "submit!" cue, because subtracting it would make a chess-style clock visibly snap back up on submit. - **The server's grace window is the server's.** `kServerDeadlineGrace` (750 ms) records the constant for reference; the client applies it to nothing. The soft margin is what keeps an on-time move from needing it. When the clock hits zero the client shows "time's up" and **waits for the timeout frame**. It never decides that time has expired. ### Rendering a clock Two shells handle the common cases, and the game screen picks by timing mode: `TurnCountdown` (per-action) and `BudgetClock` (a row of per-seat cells). Both pause automatically when the device goes offline. Two headless builders sit underneath, for a game that needs custom placement — chess clocks beside captured pieces, or an N-player game showing only the active seat: | Builder | What it owns | |---|---| | `TurnTimerBuilder` | A 1 s ticker toward a deadline, self-cancelling at zero. Hands `Duration remaining` to a `builder`. Pass `isPaused` to freeze the display without losing wall-clock position. | | `PlayerTimerBuilder` | One seat's bank — live drain for the acting seat, static for the rest. Hands `(int remainingMs, bool isActive)` to a `builder`. | :::note[Bots imply a timed game] If a game seats a bot it **must** be timed — the deadline is the backstop for a bot that never moves. The engine enforces this at seating, so `botSeatable` does not need to, but the creation UI should not offer an untimed option for a game that allows bots. See [Creation UI](./creation-ui.md). ::: --- ## Changing a shipped game Once players are using your game, the two halves **stop moving together**. A shipped app binary keeps calling a newer backend for weeks, and a daily-timed game can outlive several releases. So every change has to answer one question: > What does an old client — and an in-flight game started under the old rules — > do when it meets the new code? ## The mechanism: a new unit, not an edit When rules or payload shapes change **incompatibly**, never edit a shipped unit's semantics. That would break games and replays already running under it. Instead: 1. Copy `v1.ts` to `v2.ts`, importing whatever did not change. 2. Make the change in `v2`. 3. Register it: `versions: { 1: rulesV1, 2: rulesV2 }`. 4. **Do the same on the Dart side**, under the same key. Every game row is stamped with the `schemaVersion` it was created under, and that is honoured for its whole life. New games are created at the newest version your build ships; existing games keep running against their own unit until they drain. Neither side branches on version — the engine resolves it once and calls the right unit. Compatible tweaks — a bug fix that changes neither stored shapes nor recorded behaviour — can edit the unit in place. Update the fixtures alongside. Before the first release or persisted shared environment, v1 is not frozen: edit it directly, regenerate `game-contract.json` and the Dart payload library, and let both fixture suites expose the required client changes. Creating v2 for every development edit only preserves history that nobody consumes. ## Two gates, and one is longer than you think Retiring an old unit splits into two lifetimes, and conflating them is how replays break: - **The write path** — anything that advances state (`applyAction`, `applyLifecycle`) — can go once active games at that version have drained. - **The read path** — `computeObservation` on the server, `parseObservation` and rendering on the client — must survive **as long as you want to replay games created under that schema.** Replay re-projects historic transitions at the game's own version, so this is not bounded by draining at all. > **Draining gates the write path; replay gates the read path, and replay > outlives draining.** Only delete a `versions` entry once both are satisfied. ## How an old client is protected Two gates, deliberately redundant: - **The client** looks the game's version up in `GameModule.versions` and raises `UnsupportedGameSchemaException` rather than mis-parsing with old code. `supportsSchema` is key membership, not `<= latest`, so a retired old version is correctly unsupported. - **The server** refuses the join, so an unsupported game is rejected *before* a seat is created — not only when the screen later fails to render. The lobby additionally greys out the Join button as immediate feedback. ## What counts as breaking **Adding a member to a game payload enum is breaking**, even though it looks purely additive. The app cannot infer the legality or rendering of an unknown move, so put it in a new game `schemaVersion`. Engine API enums have a separate read-side `unknownDefaultOpenApi` fallback. That protects installed apps from additive transport vocabulary while nudging an update; it is never serialized back. Within a version, additive change is still fine: new fields must be nullable or carry a default, never `required`. Changing a field's type or meaning, or removing it, is breaking. ## The checklist | The change | What it needs | |---|---| | Alters the observation / action / config shape, or makes in-flight games inconsistent | **Breaking** — new `GameRules` unit on both sides, new fixtures, drain before retiring the write path | | Purely additive (a new optional field) | Nullable or defaulted, **no bump** | | Server-only rule logic, same shapes | Change `applyAction` only, **no bump** | | A new wire enum value | **Breaking** — bump, and ship both sides together | | A persisted client model's shape changed | Bump that provider's `destroyKey` — a stale cached row must be a cache *miss*, never a crash | Three version axes move independently, and it helps to name which one you are touching: | Axis | Granularity | Where it lives | |---|---|---| | Package version | per release | `pubspec.yaml` / `package.json`, git tag | | **Game schema version** | per game-type revision | `schemaVersion` on the game row — selects the unit on both sides | | Cache schema version | per persisted model | each provider's `destroyKey` | --- ## Working with an agent A game is a small, sharply-specified module — six pure functions and a widget — which is the kind of thing a coding agent does well, provided it knows the contract. Two things make that true rather than hopeful: a skill that states the contract, and a retrieval surface so the agent reads current documentation instead of recalling a version of the engine that no longer exists. ## The Claude Code skill The engine repository is a plugin marketplace. Install it once: ```text /plugin marketplace add eigeninteractive/eigen-server /plugin install eigen@eigeninteractive ``` 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 `computeObservation` projection rule, and a review checklist. It ships from the engine repository, so it moves with the engine rather than drifting behind it. ## 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*: | 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) | The generated HTTP reference is deliberately excluded from the `llms` bundles — those pages are component trees, and the raw spec is the better input. Worth putting in your project's `AGENTS.md` or `CLAUDE.md`: an instruction to retrieve rather than recall. Model training data will contain other turn-based engines and older shapes of this one, and the failure mode is confident, plausible code against an API that was never real. ## Where agents go wrong on this contract 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 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. - **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). - **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. ## Let the tests do the reviewing You do not have to catch all of that by reading. The [twin fixtures](./testing.md) are shared JSON run by both the TypeScript and Dart halves, so a rules twin an agent transcribed incorrectly fails a test rather than shipping as a UI that greys out the wrong button: ```bash pnpm run contract:check # from the repository root 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. --- ## Branding & the website Branding is app-owned: the engine ships none, because it has no app to ship. What it does do is make **one set of source images** serve everything — the app icon, the splash, the web assets, and the game's public website — so nothing is authored twice. Author the marks in any vector tool and export the PNG sources below; every platform-specific size is generated from them. ## The app icon Two 1024 × 1024 PNGs in `assets/icon/`. They are build-time inputs, so they are *not* declared under `flutter: assets:`. | File | Notes | |---|---| | `icon.png` | Full square icon, artwork edge-to-edge, opaque. Used for iOS, macOS, web and the legacy Android icon. iOS rejects alpha — set `remove_alpha_ios: true` if the source has any. | | `icon_foreground.png` | Adaptive-icon foreground: the mark alone on **transparent**, inside the inner ~66%. Android masks it to a circle or squircle and parallaxes it, so anything near the edge is cropped. Also reused as the splash image. | `dart run flutter_launcher_icons` writes the Android mipmaps and adaptive XML, the iOS/macOS appiconsets, and the web favicon and icons plus the `icons` array in `manifest.json`. It never touches `web/index.html`, and it does **not** generate the [notification icon](./push.md#the-android-notification-icon). ## The splash `flutter_native_splash:` is a **top-level** pubspec key, not nested under `flutter:`. Reusing `icon_foreground.png` as the splash image keeps the splash mark and the home-screen icon the same file. Regenerate with `dart run flutter_native_splash:create` after any config or asset change. Two things to know: - **On Android 12+ the `image:` key is ignored entirely.** The platform builds the splash from the adaptive launcher icon, so the `android_12:` block only sets colours. And `-v31` is a *minimum*-version qualifier: that block covers API 31 and everything after, not just Android 12. - **Colours cannot read Dart.** `color` / `color_dark` must be kept in sync by hand with the theme surfaces derived from `Branding.seedColor`; changing the seed means editing them and regenerating. For a splash mark that differs from the launcher icon, add `assets/splash/logo.png` (plus `logo_dark.png`) at 1152 × 1152 with artwork inside the inner 640 px — the outer ring is cropped by Android 12's circular mask. ## The app's web build A fresh Flutter app ships template values that fail silently: `` is the project name, the description is "A new Flutter project.", and `manifest.json` carries Flutter's default `#0175C2`. Replace all of them. Flutter's web template also has **no Open Graph tags**, so a pasted link renders as a bare URL. Add `og:*` and `twitter:*` to `<head>`, with `og:image` an **absolute** URL at 1200 × 630 (`web/og-image.png`) — a relative `og:image` is the usual reason a preview renders blank, since scrapers do not resolve them. Keep text centred; some clients crop to a square. Verify with the Facebook Sharing Debugger after deploying, and re-scrape after changes — both it and Slack cache hard. ## The game's website The Worker's `site` block generates the rest of the game's public web presence, and **it consumes exactly the files above** — no second icon set, no extra artwork: | Route | What it is | |---|---| | `GET /` | Landing page: name, tagline, screenshots, store buttons | | `GET /terms`, `/privacy`, `/delete-account` | The legal documents | | `GET /sitemap.xml`, `GET /robots.txt` | Crawler directives | | `GET /site.webmanifest` | Web app manifest | ```ts site: { tagline: "A hidden-information battle of wits for two players.", primaryColor: "#1a237e", screenshots: ["1.png", "2.png"], // under public/screenshots/ operator: { name: "Your Company Ltd", jurisdiction: "India", contactEmail: "hello@example.com", effectiveDate: "1 July 2026", }, }, ``` The point is that you get a complete, indexable, store-compliant site by configuration — the alternative is every game hand-rolling the same four pages and getting the store requirements subtly wrong. Absolute URLs in canonical links, OG tags and the sitemap are built from the **request origin**, so there is no domain to configure. To keep one canonical host, disable the `workers.dev` route in production. Store buttons come from your `deepLink` block, so store URLs are configured once. The `/download` page emits `SoftwareApplication` JSON-LD with `applicationCategory: "GameApplication"`. ### The web asset handoff The root scaffold's `build:web` command writes Flutter's complete release bundle directly into the Worker's `public/` directory. The engine's default paths match the filenames `flutter_launcher_icons` emits: | The app generates | Worker asset path | Used for | |---|---|---| | `web/favicon.png` | `favicon.png` | Browser tab | | `web/icons/Icon-192.png` | `icons/Icon-192.png` | Manifest, apple-touch-icon | | `web/icons/Icon-512.png` | `icons/Icon-512.png` | Manifest | | `web/icons/Icon-maskable-192.png` | `icons/Icon-maskable-192.png` | Manifest (maskable) | | `web/icons/Icon-maskable-512.png` | `icons/Icon-maskable-512.png` | Manifest (maskable) | | `web/og-image.png` | `og-image.png` | Landing-page share card | Screenshots go under `public/screenshots/`. `og-image.png` is the only hand-made file in the whole pipeline, and the app's own share card already asks for it. ### Legal documents All three default to templates the engine ships. They take your `operator` block as **typed props**, so there are no placeholders to fill in and nothing to keep in sync — a mistyped field is a compile error. They describe **only what the engine itself collects**: accounts, display names, optional avatars, game history, ratings, friend connections, push tokens and crash diagnostics. :::danger[Read them before you publish] They are a starting template, not legal advice, and you are the one on the hook for what they say. If you add analytics, advertising, payments, or any other processing, you must edit them. Two lines in particular assume things about your app: the privacy policy's "Diagnostics" bullet assumes crash reporting, and the delete-account steps describe the reference Flutter shell's Settings screen. ::: To supply your own prose, pass an HTML **fragment** — body content only, since the engine supplies the shell, styling and footer: ```jsonc // wrangler.jsonc — lets you import .html files as strings "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": true }] ``` ```ts import terms from "./legal/terms.html"; // … site: { /* … */ legal: { terms } }, ``` Your fragment is inserted as-is, so write your own values into it directly. :::info[Batteries included, batteries removable] The scaffold reserves legal and `/download` paths with `run_worker_first`, so Flutter's SPA fallback cannot shadow them. To replace generated legal prose, use the typed `site.legal` fragments above. ::: ## Checklist - [ ] `assets/icon/icon.png` + `icon_foreground.png` at 1024 × 1024, foreground inside the inner ~66% - [ ] `flutter_launcher_icons:` adaptive background matches the brand → regenerate - [ ] `flutter_native_splash:` colours match the theme → regenerate - [ ] *(optional)* `ic_notification.xml` declared to override `eigen_flutter`'s default silhouette — notifications work without it - [ ] `web/index.html`: real title, description and OG/Twitter tags, absolute `og:image`; `web/og-image.png` at 1200 × 630 - [ ] `web/manifest.json`: real `name`, `short_name`, `description`, `background_color` / `theme_color` - [ ] `pnpm run build:web` places the complete Flutter bundle in Worker assets - [ ] `site.operator` filled in, and the three legal documents actually read - [ ] App Links `<intent-filter>` carries an `android:pathPrefix` for both `/join` and `/game` — see [Deep links](./deep-links.md) --- ## Configuration A deployment has two configuration surfaces and one thing they share. The Worker reads bindings off its `Env`; the app injects an `AppConfig` at its composition root. Game-owned bindings are handed over through typed accessors, while the engine reserves a small set of environment names for cross-cutting credentials such as Firebase Admin. The app remains explicit and injects every runtime value, keeping the framework app-agnostic and the Worker free to name its D1, Durable Object and R2 bindings. The shared piece is the Firebase project: the app signs users in against it, and the Worker verifies the resulting tokens against the same project id. ## The Worker An implementor's entire runtime surface is one `createEngine` call plus a `BaseGameDO` subclass — see [Deploy the Worker](./deploy-the-worker.md) for the code. Optional blocks (`deepLink`, `avatars`, `site`, `lifecycle`) are simply absent when a feature is not wanted; the corresponding routes are then not mounted. | Kind | Name | Required | What it enables | |---|---|---|---| | Durable Object | `GameDO` (SQLite storage, via the `exports` field) | **yes** | The per-game session + history | | D1 database | any binding | **yes** | Identity, social, bots, ratings, summaries. `migrations_dir` points at `node_modules/@eigeninteractive/server/migrations` | | Cron trigger | daily | **yes** in practice | The guest purge + abandoned-game reap. Without it those two backstops never run | | Assets | `ASSETS` → `./public` | **yes for web** | Flutter bundle, served directly unless a path is in `run_worker_first` | | R2 bucket | any binding | optional | Avatar uploads (`avatars` config block) | | Var | `FIREBASE_PROJECT_ID` | **yes** | Token verification. Empty ⇒ every authed request 500s | | Var | `WEB_APP_ORIGIN` | **yes for web** | Canonical Flutter origin used for absolute notification click links and automatically trusted for cross-origin browser REST and WebSocket requests | | Secret | `FIREBASE_CLIENT_EMAIL` + `FIREBASE_PRIVATE_KEY` | **yes** | Push (FCM) **and** the Identity-Toolkit admin delete used by account deletion | | Secret | `BOT_SIGNING_SECRET` | optional | External bots (the per-bot HMAC is derived from it) | The entries under `wrangler.jsonc` → `vars` are Worker environment variables, not TypeScript constants. They are used locally and uploaded with every deployment, so keep `FIREBASE_PROJECT_ID` and `WEB_APP_ORIGIN` there as the single source of truth. `.dev.vars` is only for the Firebase credentials and other secrets that must not be committed. The Firebase service account belongs to the same project the app already uses for Auth; notifications do not introduce a second backend account. Production authenticated requests reject missing Admin credentials instead of silently running without push or leaving a Firebase identity behind during account deletion. Optional feature blocks still stay off when absent; for example, no `BOT_SIGNING_SECRET` means external bot webhooks are rejected. The full type is in the [`@eigeninteractive/server` reference](../reference/typescript/server.md). :::warning[The app-custom-data rule] If a game needs its own tables, they go in a **second D1 database** with its own `migrations_dir`. Never add tables to the engine's database — the engine owns that schema, and its migrations will not know about them. ::: ## The app One `AppConfig` passed to `runEngineApp`: `Branding` (name, theme seed) plus `EngineConfig` (the injected runtime values). The app reads Dart compilation environment declarations once at this composition root; the framework does not read hidden process or file state. ```dart const apiBaseUrl = String.fromEnvironment('API_BASE_URL'); const googleWebClientId = String.fromEnvironment('GOOGLE_WEB_CLIENT_ID'); const firebaseVapidKey = String.fromEnvironment('FIREBASE_VAPID_KEY'); const appHost = String.fromEnvironment('APP_HOST'); await runEngineApp( module: const RpsModule(), config: AppConfig( branding: const Branding(appName: 'Rock Paper Scissors', seedColor: Colors.teal), engine: EngineConfig( apiBaseUrl: apiBaseUrl, googleWebClientId: googleWebClientId, firebaseVapidKey: firebaseVapidKey, appHost: appHost.isEmpty ? null : appHost, ), ), firebaseOptions: DefaultFirebaseOptions.currentPlatform, onBackgroundMessage: _onBackgroundMessage, ); ``` The scaffold stores these public values in `app/app-config.json`. Pass that same file to every Flutter run or build; no generated environment class or configuration code-generation step is needed: ```bash flutter run --dart-define-from-file=app-config.json flutter build appbundle --release \ --dart-define-from-file=app-config.json ``` | Var | Required | Purpose | |---|---|---| | `API_BASE_URL` | **yes** | Origin of the Worker — scheme + host only, **no path, no trailing slash**. Routes carry their own `/api/engine` prefix; the socket is this origin with `ws`/`wss`. | | `GOOGLE_WEB_CLIENT_ID` | yes | Google Sign-In. | | `APP_HOST` | optional | This game's hostname, without scheme. In the default deployment it is the host part of `API_BASE_URL`; it enables invite/replay sharing and legal links. `/download` is the native install page. | | `FIREBASE_VAPID_KEY` | **yes for web** | Public FCM Web Push key from the same Firebase project. An empty key is a web startup configuration error. Android does not consume it. | These values are embedded in the Android binary or downloaded web bundle and must never be treated as secrets. Required entries start empty in a fresh scaffold. `runEngineApp` validates all of them before initializing Firebase and reports every missing or malformed value together. Worker service-account keys, bot signing keys, and other real credentials stay in Worker secrets. ## Firebase — once per deployment Firebase is mandatory on the client. A fresh scaffold contains a throwing `firebase_options.dart` seam so analysis works before project setup, but the app will not start until FlutterFire replaces it with real platform configuration. 1. **Create the project** at console.firebase.google.com with Analytics enabled. 2. Install and authenticate the official tooling: ```bash npm install --global firebase-tools firebase login dart pub global activate flutterfire_cli ``` From a scaffolded repository root, run: ```bash pnpm firebase:configure # or: npm run firebase:configure ``` The engine executable runs FlutterFire for Android and Web, reads the Web app ID FlutterFire records in `app/firebase.json`, asks the Firebase CLI for that app's SDK configuration, and writes `app/web/firebase-config.js`. This keeps `app/lib/firebase_options.dart` and the messaging worker on the same Firebase Web app without copying identifiers. In a standalone app repository, run `dart run eigen_flutter:configure_firebase` from the Flutter root. 3. **Add SHA fingerprints** to the Android app. `flutterfire` does *not* do this, and Google Sign-In validates the calling app's certificate at runtime: - **Now:** the debug key, so Sign-In works in dev builds. ```bash keytool -list -v -keystore ~/.android/debug.keystore \ -alias androiddebugkey -storepass android -keypass android ``` - **After the first Play upload:** the **Play App Signing** certificate (Play Console → Release → Setup → App signing). Play re-signs your bundle with *their* key, so the app on users' devices is not signed with yours — **omitting this is why Sign-In "works in dev and fails in production."** 4. Enable **Crashlytics** for Android and verify **Cloud Messaging** is on. Crashlytics has no Flutter web implementation; use your hosting/browser observability for uncaught web failures. 5. **Android FID registration:** `eigen_flutter` is an Android Flutter plugin. Its library manifest enables `firebase_messaging_installation_id_enabled`, and its exported Firebase BoM constraint selects a native Messaging SDK with FID registration. This works for scaffolded and hand-created apps that depend on `eigen_flutter`; do not edit the generated application manifest or `gradle.properties`. The engine's explicit BoM constraint can be removed once FlutterFire selects Messaging 25.1.0 or newer itself. See Firebase's [Android release notes](https://firebase.google.com/support/release-notes/android#messaging_v25-1-0). 6. **Android desugaring:** foreground notifications use `flutter_local_notifications`, which requires core-library desugaring in the application module. The scaffold adds the required compiler setting and `desugar_jdk_libs` dependency. Hand-created apps should copy the Gradle block from [Manual setup](../getting-started/manual-setup.md#create-the-flutter-app). 7. **Web Push key:** Project Settings → Cloud Messaging → Web configuration → generate a Web Push certificate. Pass its public VAPID key as `FIREBASE_VAPID_KEY`. 8. **Server-side Firebase credentials:** Project Settings → Service Accounts → Generate new private key. The Worker needs only `client_email` and `private_key` from that JSON — set them as Worker secrets and **delete the downloaded file**; it grants full Firebase Admin access. FCM is a no-cost Firebase product on both Spark and Blaze plans. Requiring it adds configuration to the Firebase project already needed by Auth, not another account or payment method. These are instance-specific Firebase configuration files. They contain public app identifiers, not service-account secrets; either commit the correct environment's files or reconstruct them in CI: | File | Platform | |---|---| | `lib/firebase_options.dart` | Dart, all platforms | | `android/app/google-services.json` | Android native | | `web/firebase-config.js` | Generated public Web config for the messaging worker | | `firebase.json` | FlutterFire CLI metadata and selected Firebase app IDs | Web Push also requires the app-owned `web/firebase-messaging-sw.js`. A service worker runs outside Dart and cannot `firebase-config.js` instead. The VAPID public key remains in `app-config.json`: Firebase's app SDK configuration does not include the Web Push certificate. See [Deploy the web app](./deploy-the-web-app.md). ## Avatars (optional) Avatars are opt-in R2, and uploads go **through the Worker** because R2 has no per-user access control: a raw-binary `PUT /api/engine/me/avatar` (type- and size-validated) stores the image under key = uid, and a public `GET /avatars/:uid` serves it with a long immutable cache. The stored `avatar_url` carries a `?v=<ts>` cache-buster, since the key is overwritten on re-upload — which is also what makes the client's cached images refresh with no manual invalidation. An optional `avatars.publicBaseUrl` points the URL straight at a bucket custom domain, bypassing the Worker for reads. The whole "serve from the bucket" flip is a config value, not a code change. The default worker-served path is the only one that works on a zoneless `workers.dev` deploy. On the client, every avatar routes through `PlayerAvatar`, which resolves a relative URL against the API origin — so both setups work with no app change. `cached_network_image` has no package-managed disk cache in a browser; the browser's HTTP cache honors the Worker's immutable response, and the versioned URL makes an upload a new cache entry. ## Generated artifacts Two, both engine-owned. You consume them; you never author them: - **D1 migrations** ship inside `@eigeninteractive/server` and are applied with `wrangler d1 migrations apply` — **never at runtime**. The **Durable Object SQLite schema self-applies** on activation (`blockConcurrencyWhile`), which is what lets a finished game woken years later migrate itself before serving anything. - **`openapi.json`** is emitted from the engine's route definitions. The typed Dart client is generated from it, committed, and published to pub.dev as `eigen_api` at the engine's version — so an app consumes it as an ordinary dependency rather than regenerating it from a copied spec. The wire loop is a **standing rule, not a suggestion**: a shape the generated client consumes badly gets fixed in the server's schemas and regenerated — never patched around in Dart. Re-emit `openapi.json` and rerun the client generator **in the same change**, because the two repos have no other coupling that would catch the drift. :::note[Unknown engine enum values] Generated Dart transport enums decode a new wire member as `unknownDefaultOpenApi`, allowing the app to show its update-required state instead of crashing during response decoding. The fallback is read-only; never send it back to a route. ::: ## Registering bots There is no provisioning route — a bot is a row an operator inserts into D1, one time, by hand: ```sql -- an engine bot: the brain ships in the game module as -- GameRules.botActions['easy_ai']; no webhook, no key material. INSERT INTO bots (id, username, display_name, type, schema_version, rated_eligible, config) VALUES (lower(hex(randomblob(16))), 'easy_ai', 'Easy AI', 'engine', 1, 0, '{}'); -- an external bot: hosted elsewhere, woken over HTTPS. INSERT INTO bots (id, username, display_name, type, schema_version, webhook_url, rated_eligible, config) VALUES (lower(hex(randomblob(16))), 'hard_ai', 'Hard AI', 'external', 1, 'https://my-bot.example/wake', 1, '{}'); ``` `type` is CHECK-enforced against the transport it implies — an `external` bot must carry a `webhook_url`, an `engine` bot must not. `schema_version` is the highest game schema the bot supports; seating refuses a bot below the game's version, mirroring the human join gate. `rated_eligible` is required for a rated game. `config` is **public read-only reference data** consumed by the `botSeatable` hook and the client's pickers — never put a secret in it. Then hand the bot's owner **one derived key** — `await deriveBotKey(BOT_SIGNING_SECRET, botId)` from `@eigeninteractive/server`, or the [`openssl` one-liner](../how-it-works/bots.md#external-bot-hmac) — and never the master secret. Adding a bot therefore needs no new secret and no redeploy. --- ## Deep links The game Worker **is** the deep-link host, so app-link verification and the API share one domain, one certificate, one deploy. Getting this right is mostly a matter of the same host being declared in every place that needs it — and the failure mode is silent, so the checklist at the end is worth actually running. ## What the Worker provides - **The verification files are generated** from the `deepLink` config, not hand-maintained: `/.well-known/assetlinks.json` (Android App Links) and `apple-app-site-association` (iOS Universal Links, served extensionless as `application/json` — the content-type a static file usually gets wrong). One source of truth, regenerated on deploy. - **`/join/:shortCode` and `/game/:gameId`** are native app links and Flutter web routes. They read the D1 summary for real Open Graph tags — host and open seats for an invite, roster and status for a game — so a shared link unfurls richly. They An installed app intercepts the HTTPS URL before it reaches the server. A browser receives the Flutter SPA at that same route, and a crawler reads the dynamic metadata from the same HTML response. There is no user-agent branch. `/game/:gameId` shows the roster for a **public** game only; a private game gets a generic card, because an unauthenticated page cannot authorise a viewer. The `deepLink` block must carry the **release** signing certificate's SHA-256 — not the upload key's, and not the debug key's. ## What the app must declare The app owns **two path prefixes** on this host: `/join/{code}` for invites and `/game/{id}` for replay links and push-notification taps. Everything else the Worker serves there — `/`, `/download`, `/terms`, `/privacy`, `/delete-account` — is deliberately *not* claimed, so it opens in the browser. The host is compiled into the binary, because the OS verifies domain ownership at install time. So it lives in **three places that must stay in sync**: 1. **`app/app-config.json`** — `"APP_HOST": "mygame.example.com"`. Pass the file to Android and web builds with `--dart-define-from-file=app-config.json`. 2. **`android/app/src/main/AndroidManifest.xml`** — `android:host` **and an `android:pathPrefix` for each of `/join` and `/game`** in the App Links `<intent-filter>`. Android fetches `https://<host>/.well-known/assetlinks.json` at install; a mismatch silently falls back to the browser. 3. **`ios/Runner/Runner.entitlements`** — `applinks:mygame.example.com`. **The entitlements file alone is not enough**: open Xcode → Runner target → Signing & Capabilities and confirm Associated Domains lists it. If it looks stale, remove and re-add it. iOS needs no separate path step — the Worker's generated AASA already scopes Universal Links to `paths: ["/join/*", "/game/*"]`. :::warning[The Android path prefixes are not optional] `assetlinks.json` declares `handle_all_urls`, so the **host** is verified as a whole and the `<intent-filter>` is the only thing deciding which paths the app claims. Without the prefixes the app claims **every** path on the host — including the Worker's `/terms`, `/privacy` and `/delete-account` — and hands them to a router that has no such route. Because the host is baked into the binary, fixing that needs a new app release. ::: ## Legal pages live on the same host They used to need a different domain, for exactly the reason above: App Links covered the whole of `APP_HOST`, so a `/terms` URL built on it was intercepted and handed to a router with no such route. Two things removed that constraint — the Worker's `site` config serves the legal pages on the game's own host, and the scoped intent-filter claims only `/join` and `/game`. Legal URLs therefore fall outside the claimed paths and open in the browser. If you would rather host legal pages elsewhere — one canonical policy shared across several games, say — just point the app's links there. Nothing in the engine requires them to be on `APP_HOST`. ## Coordinating a change **Android and iOS changes require a new app release** (the host is baked in); Worker changes take effect on deploy. Ship the app change first, or accept a window where links fall through to the browser. Verify before submitting: - [ ] The [Google Digital Asset Links validator](https://developers.google.com/digital-asset-links/tools/generator) resolves `https://<host>/.well-known/assetlinks.json`. - [ ] An AASA validator resolves `https://<host>/apple-app-site-association` and reports it as `application/json`, not redirected. - [ ] The SHA-256 in `deepLink` matches the **release** signing keystore. - [ ] The iOS Team ID matches. - [ ] The `<intent-filter>` carries both path prefixes. The usual failures are a fingerprint that does not match the signing keystore, an iOS Team ID mismatch, or the verification file being served through a redirect. --- ## Deploy the web app The scaffolded Flutter app is an Android **and web** app. The default production deployment uses **one canonical origin** such as `https://rps.example.com`: | Path | Served by | |---|---| | `/`, `/home`, and other app routes | Flutter web through Workers Static Assets | | `/join/:code`, `/game/:id` | Worker-enriched Flutter shell with dynamic share metadata | | `/api/*`, `/health`, `/avatars/*` | Worker | | `/.well-known/*` | Worker-generated native app-link verification | | `/terms`, `/privacy`, `/delete-account` | Worker-generated legal pages | | `/download` | Server-rendered native app download page | `eigeninteractive.com` remains the engine documentation/company site; it does not host an implementor's game. A custom game may use any domain. Web uses the same widgets, Riverpod state, generated API client and game module as Android; only the browser integration points differ: - Firebase Auth opens Google's Firebase-managed popup; - REST, avatars, and sockets are same-origin in production; - the game feed uses `wss://…?token=…`, because browser WebSocket upgrades cannot set an `Authorization` header; - Firebase Messaging runs background delivery in a service worker; - server responses stay in Riverpod memory for the browser session and are fetched again after a reload. Firebase Auth and small user preferences retain their own browser persistence. ## 1. Use a stable local origin OAuth and Worker origin policy both match origins, not arbitrary development ports. Run Flutter on the scaffold's fixed port: ```bash cd app flutter run -d chrome --web-hostname localhost --web-port 7357 \ --dart-define-from-file=app-config.json ``` Local development deliberately uses two origins, so Flutter can hot reload independently of Wrangler. The Worker template starts with: ```jsonc "vars": { "WEB_APP_ORIGIN": "http://localhost:7357" } ``` The engine automatically trusts that exact origin for browser REST and WebSocket requests. Set it to the game's canonical origin in production; it also supplies the absolute HTTPS target for background notification clicks. Same-origin browser requests are accepted automatically. Configure `clientOrigins` in `createEngine` only to replace this convention with multiple or otherwise non-standard origins. ## 2. Configure Firebase for web From the generated repository root, configure Android, Flutter Web, and the messaging worker together: ```bash pnpm firebase:configure # or: npm run firebase:configure ``` For an app maintained in its own repository, run `dart run eigen_flutter:configure_firebase` from the Flutter root. Both forms run FlutterFire for Android and Web, then derive the service worker configuration from the Web app FlutterFire selected. Use the generated options in `runEngineApp`: ```dart firebaseOptions: DefaultFirebaseOptions.currentPlatform, ``` Enable Google in Firebase Authentication, then add both `localhost` and the production app domain under Authentication → Settings → Authorized domains. The Google OAuth web client must list the same values as authorized JavaScript origins. `firebase_options.dart` contains public app identifiers, not a service-account secret. It may be committed, or generated per environment in CI. The Worker service-account private key remains a secret. ## 3. Finish Web Push The scaffold contains: - `web/firebase-messaging-sw.js`, which receives background messages; - `web/firebase-config.js`, generated from FlutterFire's selected Web app; - `web/flutter_bootstrap.js`, which registers that worker before Flutter starts; - an `app-config.json` containing the public `FIREBASE_VAPID_KEY` consumed by `EngineConfig`. Do not copy Firebase identifiers into the service worker. It imports the generated `firebase-config.js`; rerunning `firebase:configure` updates both it and `firebase_options.dart` from the same selected Firebase app. Both generated files contain public application identifiers, not Firebase Admin credentials. Generate or copy the public Web Push certificate key from Firebase Console → Project Settings → Cloud Messaging → Web configuration, then put it in `app-config.json` before running the command from step 1. The VAPID key is public but required for the engine's web target. An empty key stops web startup with an actionable configuration error; it is not treated as a player-facing “notifications unavailable” state. A browser that does not support Web Push still degrades gracefully. On a supported browser, the app requests permission only after the player taps **Enable notifications** in the contextual multiplayer waiting-room explanation (or the secondary action in Settings); initialization never opens the browser prompt. After a grant, the app creates the FCM subscription and registers its Firebase Installation ID with the Worker. Installation rotation, sign-in and tab resume all retry that reconciliation. If permission is later revoked, the stale Worker registration is removed on resume. The app uses Firebase's `register` API and never requests or stores the deprecated registration token. Background notifications display through Firebase's service worker integration. For web installations the Worker resolves the relative `deepLink` against its HTTPS `WEB_APP_ORIGIN` and sends the result as `webpush.fcm_options.link`. A `/game/:id` notification therefore opens that absolute app URL; the SPA fallback then serves Flutter and the router restores the route. Local HTTP development intentionally omits the click action because FCM requires a secure URL. `eigen_flutter` bundles the pinned Cropper.js JavaScript, stylesheet, and MIT license required by `image_cropper`, and loads the browser assets only when avatar editing starts. The app needs no Cropper.js files or `web/index.html` tags, has no runtime CDN dependency, and keeps `flutter_bootstrap.js` concerned only with registering FCM before Flutter starts. ## 4. Build and deploy one artifact Edit the scaffolded `app/app-config.json`. These are public build-time values shared by Android and web: ```json { "API_BASE_URL": "https://rps.example.com", "APP_HOST": "rps.example.com", "GOOGLE_WEB_CLIENT_ID": "…apps.googleusercontent.com", "FIREBASE_VAPID_KEY": "…" } ``` Attach the Worker itself to that hostname in `server/wrangler.jsonc`: ```jsonc "workers_dev": false, "preview_urls": false, "routes": [ { "pattern": "rps.example.com", "custom_domain": true } ] ``` A Custom Domain is the right Cloudflare routing mode here because the Worker is the origin for the whole hostname; Cloudflare creates the DNS record and certificate. Keep `workers.dev` during initial development if useful, then turn it off in committed production config so a later Wrangler deploy cannot silently re-enable a second public origin. ```bash pnpm run deploy ``` The root script runs `flutter build web --release` directly into `server/public/` with `--dart-define-from-file=app-config.json`, applies D1 migrations, then deploys the Worker and assets together. Wrangler's `single-page-application` fallback handles clean Flutter paths. Its selective `run_worker_first` list keeps exact static files on Cloudflare's asset path while reserving dynamic engine routes for Worker code. There is no second hosting product or second deployment URL to coordinate. For the simple reload update model: - serve `index.html`, `flutter_bootstrap.js`, `main.dart.js`, and `firebase-messaging-sw.js` plus `firebase-config.js` with revalidation or a short cache; - immutable-hash assets may use a long cache; - deploy the complete Worker + asset version atomically. That makes the engine's update-required button reload into the current bundle. The combined deploy removes the usual ordering race for web. Android still needs version ordering: publish a compatible Play build before server behavior that requires it. ### Deliberately splitting the origins A separate static host remains supported. Point `API_BASE_URL` at the Worker, set `APP_HOST` and `WEB_APP_ORIGIN` to the public web host, and configure that host's SPA fallback yourself. Use `clientOrigins` only if more browser origins must be trusted. You then own CORS, two deployments, cache policy, and ensuring notification links land on the web app. This is an advanced topology, not the scaffold default. ## 5. Verify the browser, not only the compiler Before release, test at the production origin: 1. Google sign-in and guest upgrade; 2. an authenticated REST request and avatar upload; 3. a live game through `wss`; 4. a worker-served relative avatar URL; 5. notification opt-in plus background receipt and display; 6. a background notification click opens the exact absolute `/game/:id` URL, including when no app tab is already open; 7. direct navigation and refresh at `/game/:id` or `/join/:code`; 8. the update-required reload after replacing the deployed bundle. The engine CI runs its browser socket and integration tests, then compiles the RPS reference entrypoint with `flutter build web --release`. Your app CI should do the same with `app-config.json`, `firebase_options.dart`, and the generated `firebase-config.js`, then add credentialed browser integration tests for the flows above. --- ## Deploy the Worker Your game's server is a single Cloudflare Worker. It owns its own domain, database and players, and it is about fifteen lines of glue around your `GameModule`. ## The glue Two pieces, both from `@eigeninteractive/server`: ```ts // src/index.ts import { BaseGameDO, createEngine } from "@eigeninteractive/server"; import gameModule from "./module/index.js"; // 1. Bind the game's Durable Object to your game module + D1. export class GameDO extends BaseGameDO<Env> { protected readonly gameModule = gameModule; protected d1(env: Env) { return env.MY_D1; } } // 2. Export the Worker. export default createEngine({ gameModule, appName: "Rock Paper Scissors", d1: (env: Env) => env.MY_D1, gameDO: (env: Env) => env.GAME_DO, // Optional feature blocks — omit to leave a feature off: // deepLink: { android: {…}, apple: {…} }, // avatars: { bucket: (env) => env.AVATARS }, // site: { tagline: "…", primaryColor: "#…", operator: {…} }, // lifecycle: { guestMaxAgeMs: … }, }); ``` You pass **accessors, not binding names** — the engine reads each binding off *your* `Env`, so you can call them whatever you like in `wrangler.jsonc`, and the config's type parameters infer from the accessors. ## What `wrangler.jsonc` declares The `GameDO` Durable Object (SQLite storage, via the `exports` field), your D1 database, a daily `cron` trigger (the lifecycle backstop), `nodejs_compat`, and — if you use them — an R2 bucket for avatars and a `public/` assets directory. Set the required Firebase trio (`FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, and `FIREBASE_PRIVATE_KEY`) from the same project used by the app. Set `WEB_APP_ORIGIN` for absolute web-notification links and as the automatically trusted browser origin for local or deliberately split hosting. Set `clientOrigins` only to replace that convention with multiple or non-standard browser origins. Add `BOT_SIGNING_SECRET` to enable external bots. You do **not** write D1 migrations. The engine owns its schema and ships them; you apply them at deploy. The per-game Durable Object schema self-applies. If you need your own app-specific tables, that is a *separate* D1 database with its own migrations — never the engine's. The full binding table is in [Configuration](./configure.md). ## Running it locally Pure engine tests need no Cloudflare account, Firebase project, or payment method. Running the complete Worker and Flutter app together uses the Firebase project required by Auth. ```bash pnpm install pnpm -r build # packages resolve through exports → dist pnpm -r test pnpm -r typecheck cd examples/rps # or your own worker pnpm dev # wrangler dev — local DO, D1, R2 and cron simulation ``` Three things make that true: - **Everything is simulated.** `wrangler dev` runs Durable Objects, their SQLite, D1, the cron trigger and R2 locally. Avatar upload and serving are developed entirely against the local R2 simulation; a real bucket enters only at a deploy with uploads enabled. - **Full-app development uses the real Firebase project.** Copy `.dev.vars.example` to the git-ignored `.dev.vars` and fill the service-account email and private key. The project ID and web origin remain in `wrangler.jsonc`; the credentials belong to that same Firebase project, not a second backend. - **Auth is testable without Firebase.** `@eigeninteractive/server/testing` mints local tokens the auth middleware accepts, so integration tests exercise the real middleware, the real Durable Object and the real D1 with no Firebase project or outbound FCM calls. :::tip[Tests run in the real runtime] Tests run under `@cloudflare/vitest-pool-workers`, inside the real `workerd` runtime — so a passing test has exercised the actual input gate, the actual SQLite and the actual alarm scheduler, not a mock of them. ::: ## Rate limiting (optional) The engine per-user rate-limits the write endpoints that are cheap to spam — game creation, friend requests, user search and avatar uploads — using the Workers [`ratelimit`](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) binding. It is **off until you bind it, and needs no code**: the engine resolves each limiter by a fixed binding name, so declaring the block below is the entire setup. A limiter you do not bind is simply unlimited. ```jsonc // wrangler.jsonc — recommended starting values "ratelimits": [ { "name": "EIGEN_RATE_LIMIT_AVATAR_UPLOAD", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }, { "name": "EIGEN_RATE_LIMIT_GAME_CREATE", "namespace_id": "1002", "simple": { "limit": 10, "period": 60 } }, { "name": "EIGEN_RATE_LIMIT_FRIEND_REQUEST", "namespace_id": "1003", "simple": { "limit": 20, "period": 60 } }, { "name": "EIGEN_RATE_LIMIT_USER_SEARCH", "namespace_id": "1004", "simple": { "limit": 20, "period": 10 } } ] ``` The **`name`** must match exactly — that is how the engine finds the binding. The **`limit`/`period`** are yours to tune; the engine never reads them, the platform enforces them, and `period` may only be `10` or `60`. Each **`namespace_id`** is a positive integer that **must be unique within your Cloudflare account**, since ids are account-scoped and reusing one across two Workers makes them share counters. A limited caller gets `429` with `code: "rateLimited"` and a `Retry-After` header. The binding is per-colo and eventually consistent — an abuse dampener, not a hard quota. ## Deploying ```bash pnpm exec wrangler secret put FIREBASE_CLIENT_EMAIL pnpm exec wrangler secret put FIREBASE_PRIVATE_KEY pnpm exec wrangler secret put BOT_SIGNING_SECRET # if external bots are wanted pnpm deploy # = wrangler d1 migrations apply --remote && wrangler deploy ``` Migrations apply **before** the code goes out, so new code never meets an old schema. Secrets persist across deploys and do not need re-setting. ### First-deploy checklist - [ ] `FIREBASE_PROJECT_ID` set to the real project — an empty value 500s every authed request and is the single most common misconfiguration. - [ ] `FIREBASE_CLIENT_EMAIL` and `FIREBASE_PRIVATE_KEY` stored as Worker secrets from that same project's service-account JSON. Missing values reject authenticated traffic. - [ ] `WEB_APP_ORIGIN` is the exact deployed Flutter origin, and the same domain is authorized in Firebase Auth. - [ ] D1 database created and its `database_id` written into `wrangler.jsonc`. - [ ] Cron trigger declared. Without it the guest purge and abandoned-game reap never run, and untimed abandoned games accumulate forever. - [ ] `deepLink` block filled with the **release** signing cert's SHA-256 and the real store URLs, matching the app's own declarations — see [Deep links](./deep-links.md). - [ ] Bots inserted for any game that offers solo play. - [ ] If avatars are enabled: `wrangler r2 bucket create`. **This is the point a payment method is first required.** - [ ] `openapi.json` re-emitted and the Dart client regenerated from it. ### What `/health` proves `GET /health` is public, unauthed and returns `{"status":"ok"}` — the thing to curl after a deploy, and the endpoint to point an uptime monitor at. Be clear about what it proves: **the Worker is deployed and routable, nothing more.** It performs no I/O by design — no D1 query, no Durable Object wake, no config disclosure — which is exactly what makes it safe to leave open. It costs one invocation, the same as the 404 any unknown path already returns, so it adds no amplification surface and needs no rate limiting. It answers 200 even with a garbage `Authorization` header, so a monitor never mistakes an auth problem for an outage, and it is served `no-store` so a cached 200 cannot keep reporting healthy after the Worker stops being able to serve. What it therefore does **not** catch is the most common misconfiguration — missing Firebase project or Admin values, which 500 every authed request while `/health` stays green. Verifying that needs a real authed call, which is why the checklist leads with it. A deeper readiness check that pinged D1 and asserted config would be both a cost multiplier and a config leak on an unauthed route; if you want one, put it behind a secret rather than opening it. It is deliberately absent from `openapi.json`: it is an operator endpoint, and including it would generate a Dart client method no app ever calls. ## Host story With a bought domain, configure the Worker as that hostname's origin: ```jsonc "workers_dev": false, "preview_urls": false, "routes": [{ "pattern": "rps.example.com", "custom_domain": true }] ``` This gives Flutter web, API, app links, legal pages, and `/download` one host; Cloudflare provisions its DNS record and certificate. The free `<name>.<account>.workers.dev` subdomain is useful before the custom domain is ready. Commit `workers_dev: false` for production: changing it only in the dashboard lets the next Wrangler deploy re-enable that second public origin. Avatars may require a paid Cloudflare plan at real scale; FCM itself is a no-cost Firebase product. --- ## Push notifications (FCM) Push is infra-owned; game code never registers anything. On startup the service: 1. Creates the Android notification channels, so users get per-category system-level control: | Channel | Importance | iOS level | Sent for | |---|---|---|---| | `your_turn` | High | `timeSensitive` | A seat newly becomes pending | | `game_updates` | Default | `active` | A game becomes ready or finishes | | `game_invites` | Default | `active` | A friends-access game is created | | `social_notifications` | Low | `active` | Friend request / accepted | | `general` | Default | `active` | Unknown or uncategorized messages | `your_turn` is also the manifest default channel, so a system-delivered background notification with no explicit channel lands somewhere sensible. 2. Enables foreground banners (iOS presentation options + `flutter_local_notifications`). 3. Never requests permission during initialization. After a successful multiplayer create or join, the waiting room shows a contextual, non-modal card explaining why alerts matter and offering **Enable notifications**. The OS/browser prompt opens only from that button. There is no separate first-visit state or automatic bottom sheet: the platform permission is the persisted state. On Android 13+, one local marker disambiguates Firebase's `denied` result before and after the app has actually requested permission. 4. Registers the installation with FCM, then reads the **Firebase Installation ID (FID)** and registers it with **`PUT /api/engine/me/devices { fid, platform }`**. A registration token is neither requested nor stored: Firebase's current APIs use `register` and target the FID directly. Web uploads the FID from `onRegistered` and removes it from `onUnregistered`; native also reconciles on `onIdChange`, sign-in and app resume. On web this step runs only after permission is granted and passes the required VAPID key. 5. On native platforms, a foreground message shows a local banner — **except** a `your_turn` push for the game currently on screen (it reads the router's current URI and suppresses a banner for a matching `/game/{id}`). Web does not synthesize an OS notification while the page is foregrounded; the open app catches up through its API/socket state. Background browser delivery and display belong to the service worker. 6. Routes taps via the deep link on the message (`/game/{id}`, `/social`). **Sign-out** calls `DELETE /api/engine/me/devices/{fid}` (scoped to the caller, so a device already reassigned to another account is left alone) and clears the local guard. It deliberately does **not** delete the Firebase installation — that would reset Crashlytics/Analytics identity — or unregister the installation from FCM, which would break same-session re-sign-in. Account deletion removes the device rows server-side. The **background handler** must be a top-level `@pragma('vm:entry-point')` function that re-initialises Firebase and does nothing else — the OS renders the notification from the payload. It is passed into `runEngineApp` by the app, because it needs the app's own `DefaultFirebaseOptions`. Web background delivery instead runs in `web/firebase-messaging-sw.js`; the Dart handler is not registered in a browser. :::note[Unknown categories degrade safely] An unknown or missing `category` falls back to the general notification channel and is logged. A newer server can therefore add a category without making an older app discard the notification. ::: The service worker, VAPID key and production-origin checklist are in [Deploy the web app](./deploy-the-web-app.md). Delivery is best-effort and there is no retry — the game state is the truth and the app catches up on open, so the client must never depend on a push arriving. The server half is [Push notifications](../how-it-works/notifications.md). The waiting-room prompt is rendered only for a **seated participant**. Failed joins, spectators and solo games do not trigger it. If permission is blocked, later waiting rooms show an inline **Settings** action on native platforms or browser site-settings guidance on web; the player is not expected to discover the recovery path unaided. :::note[FlutterFire compatibility seam] Firebase's FID registration APIs landed after the current FlutterFire messaging surface. The engine calls the official Web SDK's `register` through an isolated web adapter and enables Android's native FID auto-registration. Game code never calls `getToken`, handles `onTokenRefresh`, or stores a registration token. Remove the adapter when FlutterFire exposes the same APIs; the app/server contract remains `{ fid, platform }`. See Firebase's official [Web registration guide](https://firebase.google.com/docs/cloud-messaging/web/get-started#access_the_firebase_installation_id) and [Android FID setup](https://firebase.google.com/docs/cloud-messaging/android/get-started#enable-registration-via-fid). ::: ## The Android notification icon Android API 21+ ignores colour in notification icons — it composites the alpha channel against its own tint. Using the full-colour launcher icon renders a solid white box. The correct asset is a **monochrome silhouette vector drawable** named `ic_notification`, referenced in three places: the manifest's `default_notification_icon` meta-data (background and terminated delivery), `AndroidInitializationSettings` (foreground banners), and `AndroidNotificationDetails(icon:)` (per-notification, for consistency). **`eigen_flutter` ships a default, and the manifest meta-data that points at it.** Notifications work with no manifest to edit and no drawable to create — the engine names the resource, so the engine provides it. To use your own silhouette, add `android/app/src/main/res/drawable/ic_notification.xml` to the app. Android resolves resources in the application module's favour over a library's, so declaring that name *is* the override — nothing to delete, no `tools:replace`. It is a `<vector>`, so no per-density variants are needed, and `flutter_launcher_icons` does **not** generate it. --- ## Store release Store packaging is app-owned — this page is the Android path, which is the one that is wired. iOS submission is **not**: add an `ios` lane when you target it. ## Release hardening Two independent mechanisms; enable both. **R8** (`isMinifyEnabled` + `isShrinkResources` in `android/app/build.gradle.kts`) shrinks and obfuscates the Java/Kotlin layer. Only libraries that do not ship their own consumer rules need entries in `proguard-rules.pro` — the Flutter engine, Play Core (`in_app_update` / `in_app_review`), `google_sign_in` and `image_cropper` all bring their own, so the file stays nearly empty by design. Adding redundant `-keep` rules there is how it rots. **Dart obfuscation** is a Flutter tool flag, not a Gradle setting, so it belongs in the build command: ```bash flutter build appbundle --release \ --dart-define-from-file=app-config.json \ --obfuscate --split-debug-info=build/debug-info/android/ ``` Symbol upload splits accordingly. The **R8/ProGuard mapping** is uploaded automatically by the `firebase-crashlytics-gradle` plugin during the build, as long as `google-services.json` is present. **Dart deobfuscation symbols** are a separate artifact that CI must upload itself — without them a release stack trace is unreadable, so keep the retention long enough to outlive a release. ## The app's CI A scaffolded project has **no workflows until you ask for one**. The release path needs an upload keystore and a Play service account that a new game does not have, so generating it up front would only put a failing build on `main`. Add it when shipping becomes the goal: ```sh npx create-eigen-game add ci ``` That writes `.github/workflows/checks.yml` and `release.yml`; `--ci` at scaffold time does the same thing up front. The Android release path has three jobs: - **test** — checks out the app, supplies `app-config.json`, restores or generates `firebase_options.dart` and `web/firebase-config.js`, then format, analyze, test. - **build** (main pushes only) — decodes `google-services.json` and the keystore, writes `android/key.properties`, builds a signed obfuscated AAB with `--build-number=${{ github.run_number }}` and `--dart-define-from-file=app-config.json`, and uploads the AAB and the debug symbols as artifacts. - **deploy** — downloads the AAB and runs `bundle exec fastlane android internal`. Web has no store lane, but it is part of the same Worker release artifact. Add a web target to the verification matrix that runs the root `build:web` script with the public Firebase configuration. Deploy the combined Worker + asset version only after that target passes; the routing and cache requirements are in [Deploy the web app](./deploy-the-web-app.md). The four app declarations are public. Commit the production `app-config.json`, or construct it in CI from repository/environment variables when environments differ. Do not place these values in a secret store merely because they are injected at build time. | CI input | Used for | |---|---| | `API_BASE_URL`, `GOOGLE_WEB_CLIENT_ID`, `APP_HOST`, `FIREBASE_VAPID_KEY` | `app-config.json` | | `FIREBASE_OPTIONS_DART_BASE64` | `lib/firebase_options.dart` | | `FIREBASE_WEB_CONFIG_JS_BASE64` | `web/firebase-config.js` | | `GOOGLE_SERVICES_JSON_BASE64` | `android/app/google-services.json` (build only) | | `GOOGLE_SERVICE_INFO_PLIST_BASE64` | the iOS equivalent, when iOS CI is added | | `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` | signing | | `GOOGLE_PLAY_JSON_KEY` | fastlane `upload_to_play_store` | `firebase_options.dart`, `firebase-config.js`, `google-services.json`, and `firebase.json` contain client identifiers, not service-account credentials. They may be committed or reconstructed in CI according to the app's environment policy. Generate the first two together with `firebase:configure` so they name the same Web app. Signing keys, the Play service account, and Worker Admin credentials remain secrets. Encode files for CI with `base64 -i <file> | pbcopy`. ## fastlane - **`fastlane/`** — a `Fastfile` with `android internal` and `android production` lanes (`upload_to_play_store` with the built AAB), an `Appfile` with the `package_name`, and a `Gemfile` pinning the fastlane gem. - **Per-app setup** — create an upload keystore and add the four signing secrets; create a Google Play service account with the *Release* permission and add its JSON as `GOOGLE_PLAY_JSON_KEY`; set `applicationId` and bundle id as the app's own store identity. - **The first upload must be done by hand in the Play Console** to create the listing. Everything after that flows through fastlane. **The lanes upload the binary only.** Both pass `skip_upload_metadata`, `skip_upload_images` and `skip_upload_screenshots`, so the listing — icon, feature graphic, screenshots, description — is maintained by hand in the Console and CI will never overwrite it. That is deliberate: store copy changes on a different cadence than code. To flip it, drop assets into `fastlane/metadata/android/en-US/images/` and remove the matching `skip_upload_*` flags. From then on the repo is the source of truth and fastlane overwrites Console edits. ## Store assets Play's requirements (512 × 512 icon, 1024 × 500 feature graphic, at least two phone screenshots) tighten periodically — confirm against Google's current spec before a first submission rather than trusting a copy of it. There is no screenshot automation. Capture from an emulator at a qualifying resolution with `adb exec-out screencap -p > shot.png`, using a seeded account with realistic games in progress. The same shots feed the game's website via `site.screenshots` — see [Branding & the website](./branding.md). --- ## Account lifecycle & the cron ## Deletion & guest purge share one path `DELETE /api/engine/me` (self-service) and the cron's stale-guest sweep both run `purgeUser`, ordered **games → Firebase → D1**. The order is load-bearing: because the auth middleware re-provisions a `users` row on *any* valid token, deleting the D1 row while the Firebase account still lives would let the very next request resurrect the user. So: 1. Forfeit / cancel / leave every one of the user's live games (a rated forfeit applies its ratings while the user row still exists). 2. Delete the Firebase account (Identity Toolkit admin `accounts:delete`). On failure this throws **before** any D1 write, so nothing is half-deleted and a retry is clean — the route surfaces a 502 ("intact, retry"), never a partial deletion. 3. Purge D1 as one explicit `batch()`: anonymize the seats and `createdBy` (so finished-game history stays readable as "Deleted User"), delete ratings, history, relationships, and device rows, then the `users` row last. Delete the avatar object if present. ## The cron backstop The `scheduled` handler does only what has no per-entity timer of its own — notably **not** a timeout sweep (the [DO alarm](./timing.md) owns that): - **Stale-guest purge**: anonymous accounts past an age with no recent game activity, torn down through `purgeUser`. - **Abandoned-game reap**: never-started lobbies past a TTL, and untimed active games (which have no alarm) idle past a longer TTL — `abort`ed so they stop occupying the lobby and release their DO storage. Both jobs are best-effort, isolated (one failing never blocks the other), and batch-capped so a backlog drains over days. Every window and cap is a **default overridable via a `lifecycle` block on `createEngine`** (`guestMaxAgeMs`, `guestInactivityMs`, `lobbyTtlMs`, `untimedActiveTtlMs`, `guestBatch`, `reapBatch`). --- ## Bots(How-it-works) A bot is a registry row whose `type` selects how its moves are produced: - **`engine`** — the brain ships *in the game module*, as `GameRules.botActions[username]`. When a seated engine bot's turn starts, the DO resolves its row → username → move function, runs it **in-process post-commit**, and self-applies the returned move as that seat's action (a normal serialized command with a deterministic `commandId`, so it dedupes and chains through consecutive bot turns). A bot game needs no external service. - **`external`** — the bot is hosted elsewhere. On its turn the DO sends a single signed **wake** carrying the bot's freshly-committed observation; the bot later POSTs its move to `/api/bot/action`. Fire-and-forget, single attempt — a lost wake rides the turn deadline. - **`local`** — client-driven, reserved for the future offline-solo transcript import. A registry row for identity only; never dispatched server-side. A bot only ever sees its own seat's projection — the same fog-of-war a human at that seat gets — so a bot can never read hidden state. **Seating gates** (shared by add-bot and create-solo, checked at the Worker before minting): the game must be timed (bots ⇒ timed, so a broken brain is backstopped by the deadline), the bot must support the schema version, a rated game needs a rated-eligible bot, an engine bot needs a `botActions` entry for its username, an external bot needs a webhook, and the game's `botSeatable` hook must accept the pairing. To write a bot brain for your own game, see [Bots in a game module](../build-a-game/bots.md). ## External-bot HMAC Both directions (engine→bot wake, bot→engine action) are authenticated by an HMAC over the exact message body, using a **per-bot key derived from one engine secret**: ```text derivedKey = HMAC-SHA256(BOT_SIGNING_SECRET, botId) signature = "v1," + base64(HMAC-SHA256(derivedKey, "<domain>:<message>")) ``` The `domain` tag (`wake` vs `action`) is *inside* the signed bytes, so a signature captured in one direction can never verify in the other — no reflection. The signature travels in the `Eigen-Signature` header both ways. Registering a bot needs no new secret and no redeploy. **Onboarding an external bot** is therefore: insert the row, derive that bot's key, and hand it to whoever runs the bot — which may well be you. The bot's owner gets only the derived key and never sees `BOT_SIGNING_SECRET`. `@eigeninteractive/server` exports the derivation as an operator utility: ```ts import { deriveBotKey } from "@eigeninteractive/server"; const key = await deriveBotKey(BOT_SIGNING_SECRET, botId); // base64 ``` or, with no code at all: ```bash echo -n "<botId>" | openssl dgst -sha256 -hmac "<BOT_SIGNING_SECRET>" -binary | base64 ``` :::warning[Rotation is all-or-nothing] That key is a **credential** — it authenticates that bot to the engine for as long as it is registered. Because every key is derived from the one master secret, rotating a single bot's key means rotating the master, which rotates *every* bot's key. Issue a key only to an owner you would be willing to re-issue all of them for. ::: Verification is constant-time (`crypto.subtle.verify`). --- ## Failure model The engine's failure posture is uniform and blunt: **single attempt + error log, no retry machinery in v1.** This is a deliberate constraint, and it is safe because the architecture makes almost everything either idempotent or self-healing: - A lost **bot wake** or **push** is backstopped by the turn deadline / the app catching up on open — neither is a correctness dependency. - A failed **D1 finish-apply** leaves the DO's `outbox` row in place; a gated admin re-poke re-runs it, idempotent via `finish_id`. - A **duplicate command** replays its stored response (`commandId`); a **duplicate finish-apply** is a no-op (`finish_id`). - A **crashed deletion** never half-deletes (the games→Firebase→D1 order; see [Account lifecycle](./account-lifecycle.md)). - **D1 mirror staleness** is accepted by design — the DO is the truth, and a stale summary only ever costs a lobby a clean late rejection. Post-commit DO effects run as unawaited, self-catching promises (a Durable Object stays alive while a promise is pending, so `waitUntil` is redundant there). A genuine server fault — a game-hook bug, a storage failure — surfaces as a 500 and is logged; it never corrupts the append-only log, because it happens either before the commit (nothing written) or after it (the commit already stands). --- ## The game session # The game session — one Durable Object per game `BaseGameDO` is the abstract base an implementor subclasses. Each instance is one game, addressed deterministically by `idFromName(gameId)`. ## The per-game SQLite schema 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. | | `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). | | `commands` | live-only | `command_id → stored response` for idempotent retries. Drained by the finish compaction. | | `outbox` | transient | What the D1 finish-apply needs, written atomically with the finishing transition and cleared only *after* the apply succeeds. Its presence is the recovery signal. | The schema is engine-owned and self-applying: a drizzle `durable-sqlite` migration bundle is compiled into the Worker and runs inside `blockConcurrencyWhile` on first activation — so even a finished game woken years later migrates itself before serving anything. ## Lazy initialization A game's D1 row is written *before* its DO exists (creation is a direct Worker → D1 write; see [The game lifecycle](./lifecycle.md)). The DO is created lazily on first contact (first command or socket): it reads the game + participants from D1 once, inside `blockConcurrencyWhile`, and copies them into `meta` + `roster`. From then on the DO owns `status` and `rng_seed`; D1's copy becomes a display read-model updated from DO effects. If no game row exists in D1, first contact resolves to a clean `unknownGame`. ## The command pipeline & idempotency Every command that crosses the Worker → DO boundary is a **self-contained, pre-authenticated value** (`Command`): the kind, the game id, a `commandId`, the acting `Principal` (a user id *or* a bot id, never both), and the payload. The Worker has already verified the token and run every *policy* check before minting it; the DO enforces *integrity* (seat occupancy, status, versions) under its gate. This clean split — policy at the edge, integrity in the DO — means a command is loggable, replayable, and a CI fixture is just a JSON array of them. Two idempotency keys keep the pipeline exactly-once: - **`commandId`** (client → DO): the DO stores each accepted command's response and replays it verbatim for a duplicate, so a client retry never double-applies a move. (Rejections are recomputed fresh — re-evaluating one is always sound.) - **`finish_id`** (DO → D1): the finishing transition mints one; the D1 apply is a no-op if the games row already carries it, so a re-poked finish is safe. Serialization orders commands but cannot *identify* duplicates — that is what the ids are for. ## Versions are strictly serial Every accepted command commits as the next integer version, in arrival order, with **no gaps, ever**. The same-view rule governs *acceptance* only; it never reorders or skips versions. This invariant is what lets the client recover any gap by a simple version-range fetch and lets replay walk the log linearly. --- ## Identity & the social graph # Identity & authentication ## Firebase ID tokens, verified in-worker Every `/api/engine/*` request carries a Firebase ID token — as `Authorization: Bearer <token>`, or as `?token=` on WebSocket upgrades (browsers can't set headers on upgrades). The Worker verifies it with jose against Google's securetoken JWKS: RS256 pinned (no algorithm confusion), issuer and audience checked against the configured `FIREBASE_PROJECT_ID`, expiry enforced. A failure is a deliberately unspecific 401 — signature, expiry, issuer, and audience failures all read the same to a client ("re-authenticate"). The verified claims carry the uid, `isAnonymous` (the `anonymous` sign-in-provider claim, which drives every guest gate), and the profile fields (Google supplies name + picture, Apple usually only email, guests none). ## Provisioning & guests A `users` row appears on first sight of a valid token, so there is no signup call to make. Username is derived from the email local part (sanitized to a `[a-z0-9_.]{3,20}` charset) or a generated `player_NNNNN` handle for guests, with a collision-retry loop. Guests are first-class: anonymous sign-in gives a real uid and a real (ephemeral) account. Because `linkWithCredential` preserves the uid, guest→permanent conversion is an in-place backfill on the same row — the provider's display name and avatar overwrite the guest's, while the stable username handle survives. Guest capability is deliberately narrowed: guests may play (including vs bots, unrated) but cannot create friends-access games or join rated games. Inactive guests are swept by the cron; see [Account lifecycle](./account-lifecycle.md). The **username** is the stable, editable handle (distinct from the provider display name, which the engine never lets a user edit). `PUT /me/username` validates the same `[a-z0-9_.]{3,20}` charset and returns a clean 409 on a collision (the column is UNIQUE). The **display name** and **avatar** come from the auth provider (or an uploaded avatar); `PUT /me/avatar` is the only way a user changes their picture. ## The social graph Friendships, search, and blocking are **cross-game and D1-only** — they never touch a Durable Object. The `relationships` table stores one row per unordered pair in canonical order (`user_id_1 < user_id_2`) with a `status` (`pending` / `accepted` / `blocked`) and an `initiated_by` actor, so a single shared row encodes the relationship and the direction of a request or block is recovered from `initiated_by`. - **Requests.** `POST /friends/requests {targetUserId}` inserts a `pending` row — unless the target already has a pending request out to the caller, in which case it **auto-accepts** (sending back is accepting). `accept` transitions the request the *other* party initiated. `DELETE /friends/{id}` is the single idempotent unfriend / withdraw / decline. All writes require a **registered** caller, and a friend target must be registered too (a guest is a throwaway identity). - **Blocking.** `POST /friends/{id}/block` overwrites any pending/accepted row (or creates one) as `blocked`, recording the blocker in `initiated_by`. A block in *either* direction refuses new requests; only the blocker can `unblock`. - **Search.** `GET /users/search?q=` is a case-insensitive substring match on username or display name, excluding the caller, guests, and anyone in a blocked relationship with the caller, ranked exact → prefix → substring. `LIKE` today, D1 FTS5 later; the `%` wildcard is stripped so a caller can't force a scan. - **Discovery.** `GET /friends/games` lists joinable games created by the caller's accepted friends — the lobby that makes `friends`-access games reachable. Friend-event pushes (`friend_request`, `friend_accepted`) fire from the route through the shared, required FCM path. Because these run in a **stateless Worker** (not the always-alive DO), they ride `executionCtx.waitUntil` so a slow FCM call never delays the response — the one place the engine uses `waitUntil` deliberately. --- ## The kernel # The kernel — the pure decision core `@eigeninteractive/kernel` is where every decision about a game is made: a pure function from inputs to a commit plan. It touches no platform API, so it behaves identically in every environment — which is why your hooks can be tested without a Worker, a socket or a database anywhere in sight. ```text commit({ game, state, roster, intent, now, rules, staleViews }) → CommitPlan | Rejected ``` - **`intent`** is one of `start` (seed a new game), `action` (a player/bot move), or `lifecycle` (`timeout` / `forfeit` / `autoForfeit`). - **`rules`** is the game's `GameRules` unit for this game's `schemaVersion`. The kernel invokes the game's hooks but owns everything around them. - A **`CommitPlan`** carries: the next `StateRow` (version, opaque state, pending set, deadline, per-player clocks), the per-seat projected `frames`, the `action` to log, any `outcomes` (if the game ended), the `alarm` time to arm, and named **effects** (`wakeBot`, `notifyTurn`, `notifyFinished`) for the runtime to deliver post-commit. - A **`Rejected`** is a value, not an exception: a stable `code` (`illegalMove`, `notParticipant`, `board_updated`, …) plus a message. The DO returns it; the Worker maps it to an HTTP status. The kernel owns four things worth calling out: - **Timing & grace** — computing the next deadline, the per-player time bank, and whether a late submission is still inside the grace window. See [Timing & the deadline alarm](./timing.md). - **The same-view rule** — whether a stale-version action is still valid. See [The game lifecycle](./lifecycle.md). - **Observation fan-out** — calling `computeObservation` once per seat to build the frames, and enforcing that a seat's projection stays truthful about itself. - **Rating math** — OpenSkill posteriors, given priors and placements. (The *application* of ratings — reading priors, the CAS write — is in the DO/D1 layer; see [Data & storage](./storage.md). Only the math is here.) :::note[Version dispatch never happens inside game logic] The engine resolves the game's `schemaVersion` to a `GameRules` unit once, up front, and every hook it calls is already the right version. A game author never writes `if (version === …)`. See [Evolving your game](../build-a-game/versions.md). ::: The kernel's full API is in the [`@eigeninteractive/kernel` reference](../reference/typescript/kernel.md). --- ## The game lifecycle # The game lifecycle, end to end ## Creation — the one Worker-direct write `POST /api/engine/games` is the single place the Worker writes game state to D1 directly, because the DO does not exist yet. The Worker runs all creation policy (guest gates, config parse against the version schema, the `ratingPool` decision, and validation of the client's concrete `rated` assertion), generates a unique `shortCode` (a readable 6-char code with a retry loop on the UNIQUE index), and writes the games + participants rows with the creator in seat 0. The DO is not touched; it will lazy-init on first command or socket. :::info[`rated` is a validated assertion, never a coercion] The client computes it too (via the Dart twin of `ratingPool`), and a mismatch is rejected rather than silently "corrected" — that catches twin drift and forged clients. ::: ## The waiting room Before a game starts, the roster is mutable. Join / leave / cancel / add-bot / start are **Commands to the DO**, with policy checked at the Worker *before* minting (guest-vs-rated, friends-access, schema gate — no D1 reads inside the gate) and integrity enforced in the DO (status, seat occupancy, creator-only rules). Highlights: - **Join** by id or by shortCode. Creating with `minPlayers` already satisfied makes a game `ready`; otherwise `waiting`. - **Leave** compacts seat indexes (safe pre-start, since no transition references a seat yet). The creator cannot leave — they cancel. - **Add-bot** is creator-only and passes the [bot seating gates](./bots.md). - **Cancel** is creator-only, drops the DO's storage, and marks the D1 row `aborted` (the D1 write is *awaited* here, unlike other lobby effects, because the aborted row is the only survivor). - **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 — 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 opening v0 frame so the client can render immediately. Guests may play bots (unrated). ## Active play A move is `POST /api/engine/games/{id}/action` carrying the caller's own `seat`, the `expectedVersion` it computed against, and the game-defined `data`. The DO verifies the seat belongs to the caller against its authoritative roster (a seat you don't hold is a clean 403), runs the kernel, and — on accept — commits the next version and rides the caller's own projected frame back on the response. Every other seat's frame arrives over its socket. Forfeit is the same shape with a `lifecycle`/`forfeit` intent. Humans and bots submit a seat **uniformly**; the DO resolves the actor (user id from the token, bot id from the HMAC claim) against the roster the same way for both. There is no server-side "figure out my seat" fallback. ## Finish, and history compaction When a hook returns an `outcome`, the finishing transition commits `status = finished` and writes an `outbox` row *in the same SQLite transaction*. Then, post-commit and off the response path: 1. **The D1 finish-apply** writes the game summary + outcomes, and (for rated games) runs the [rating CAS](./storage.md). It is idempotent via `finish_id`. 2. On success, a final **ratings transition** (version N+1) is appended for rated games — carrying each seat's rating delta — and **the compaction rides the outbox clear**: one SQLite transaction empties the live-only `frames` and `commands` tables and deletes the `outbox` row. ~20–40 KB of permanent `transitions` + `meta` + `roster` remain. The outbox row is the recovery signal: if the D1 apply fails, it survives, and a gated admin re-poke re-runs the apply (idempotent). DO storage is **never** dropped at finish — only at cancel/abort. The finished DO *is* the game's history. ## Cancel & abort Cancel (creator, pre-start) and abort (the cron reap of abandoned games; see [Account lifecycle & the cron](./account-lifecycle.md)) mark the D1 row `aborted` and drop the DO's storage entirely — there is no history object for a game that never really happened. Abort is unconditional (no creator gate, works even on a never-initialized DO). --- ## Push notifications The engine sends best-effort "your turn" and "game over" pushes via FCM HTTP v1. Notification capability is part of every standard deployment: Auth and FCM use the same Firebase project, and authenticated Worker traffic requires its service-account credentials. The player still decides whether to grant notification permission. - **Auth**: a service-account JWT (signed with jose, RS256) is exchanged at Google's token endpoint for an OAuth bearer, cached per (account, scope) in isolate memory. The same token step serves FCM and the admin account-delete. - **Targets**: pushes are addressed to a user's **device installations** — one row per install, keyed by Firebase Installation ID (FID). Clients register via `PUT /api/engine/me/devices { fid, platform }` (upsert-on-FID, so signing in reassigns a device) and deregister on sign-out via `DELETE /api/engine/me/devices/{fid}` (scoped to the caller). Without a registration, a user has no targets and simply receives nothing. - **Delivery**: on a turn/finish transition the kernel emits `notify_turn` / `notify_finished` effects; the DO delivers them post-commit, single-attempt. A send that reports a permanently dead installation prunes that row; transient failures are left for the next send. There is no retry machinery — the game state is the truth and the app catches up on open. Required infrastructure does not make delivery authoritative. A denied permission, unsupported browser, offline device, expired installation or FCM failure all result in no push; sockets and ordinary state synchronization must still make the game correct. The client side of this — requesting permission, registering the FID, and handling a tapped notification — is covered in [Push notifications (client)](../ship-it/push.md). --- ## What the engine is EigenInteractive is a **whitelabel, server-authoritative, turn-based multiplayer game engine**. Your game deploys as a single Cloudflare Worker that owns its own domain, database, and players. An EigenInteractive game is a sequence of **versioned, server-authoritative transitions**. The server — never the client — decides what each move does, whose turn it is, what each player is allowed to see, when a clock expires, and how a finished game is rated. Clients render state and submit intents; they hold no authority. The design centre is a single principle: > **Each game is one serialized state machine with one owner.** That owner is a Cloudflare Durable Object (DO). One DO per game, addressed by the game's id, is the authoritative session *and* the game's permanent history. Everything else — the API, the global database, push, avatars — orbits that. ## The game model Four nouns carry the whole system. Three of them are yours to define; the fourth is the engine's. | | What it is | Who defines its shape | |---|---|---| | **State** | Everything true about the game right now — board, deck, scores, fog. One JSON payload | You | | **Action** | What a player proposes: "play the 7", "resign". A request, not a fact | You | | **Observation** | What *one seat* is allowed to see of the state. Derived, never stored | You | | **Transition** | One committed step: state at version *N* becomes version *N+1* | The engine | State is **opaque** to the engine. It stores and versions your payload but never looks inside it, and it holds *only* your game — whose turn it is, the deadline, the roster and the result are engine-owned and live outside it. That boundary is why you never write persistence code. The loop is one line long: > A player submits an **action**. The engine checks everything that is not about > your game — the right seat, the right version, inside the deadline — then calls > your `applyAction`, which returns the next **state**. The engine commits it as > a new **transition**, projects one **observation** per seat, and sends each > player only their own. Two consequences worth internalising early: - **Hidden information is a property of `computeObservation`, not of storage.** A face-down card is in the state; it is simply absent from the observation of every seat that may not see it. Nothing hidden is ever sent and then hidden in the UI. - **A version is a fact, not a suggestion.** A client submits against the version it last saw. If the game has moved on, the action is normally rejected — unless the acting seat's observation is unchanged between the two versions, which is precisely what makes simultaneous moves work. See [The game lifecycle](./lifecycle.md). Randomness comes from an engine-supplied `rng`, not from `Math.random()`, so a game is a pure function of its seed and its ordered actions. That is what makes replay, reconnection and optimistic preview all sound at once. ## Server and client They are one game written twice, with an unequal split of authority. | | Server (TypeScript, in the Worker) | Client (Dart, in the app) | |---|---|---| | Decides a move's legality | **Yes** — the only answer that counts | Guesses, to grey out a button | | Holds full state | Yes | Never — only this seat's observation | | Owns turn order, clocks, results | Yes | Displays them | | Draws the board | — | Yes | The client keeps a **rules twin**: a Dart transcription of just enough of the rules to answer "is this tappable?" and "what would this look like?" before the server replies. It exists for latency, not for truth — the board can move immediately, and reconciles when the real transition arrives. When the two disagree the server wins, silently and always. Keeping the twin honest is a test, not a discipline: shared JSON fixtures run against both halves and fail if they diverge. See [Testing](../build-a-game/testing.md). The transport is one WebSocket per game, held open for its whole lifetime, carrying one frame per transition — a single seat's observation at a single version. Frames are strictly serial, so a client always knows whether it is current: opening cold mid-game snaps straight to the present, while a client that missed a span fetches exactly that span and animates through it. It reconciles against a version the server states, never one it guesses. See [Transport](./transport.md). ## Three properties fall out of it - **Server authority.** The rules run on the server. A client's move is a *proposal*; the DO validates it against the true state and either commits it as the next version or rejects it. Hidden information never leaves the DO except as a per-seat projection. - **Strong per-game consistency.** A DO processes its commands one at a time under an input gate. There are no lost updates, no torn writes, no distributed-lock dance — the platform serializes access to each game. - **Determinism & replayability.** State is a pure function of `(base seed, ordered action log)`. The action log is append-only and immutable; replaying it reproduces the game exactly. This is what makes [history](./storage.md), reconnection, and the client's optimistic preview all sound. ## Non-goals The engine is not a real-time (sub-second, physics) engine, not a lobby matchmaker with skill-based queues (games are created and shared, or played vs bots), and not a general document store. It is tuned for **turn-based games where correctness and fair timing matter more than raw throughput.** ## The platform, and why each piece Everything runs on Cloudflare's developer platform. The inventory is deliberately small, and the required day-0 path uses **only free-tier services with no payment method**. | Concern | Service | Role | |---|---|---| | API + web host | **Workers** (hono + `@hono/zod-openapi`) | Stateless request handling, auth, policy, routing; also serves the app-link files and share pages | | Authoritative game session | **Durable Objects** (SQLite-backed) | One per game — live and finished. The serialized state machine and the permanent per-game history | | Global cross-game store | **D1** (SQLite) | Identity, social, bots, ratings, and game *summaries* — a read-model + registry, never an arbiter | | Avatars (opt-in) | **R2** | User-uploaded avatar objects; developed under local simulation, a real bucket only at deploy | | Auth | **Firebase Auth**, verified in-worker with **jose** | Google / Apple / Anonymous sign-in; the worker verifies ID tokens itself | | Push | **FCM HTTP v1** | Turn / finish notifications; permission remains player-controlled | Why these and not the obvious alternatives: - **Durable Objects, not a shared SQL row + locks.** The classic turn-based-game bug is two writers racing on one game row (two finishes, two moves at the same version). A DO makes that structurally impossible: the platform routes every request for a given game id to the same single-threaded object, and its input gate serializes them. The old lost-update bugs simply cannot be expressed. - **DO SQLite is also the history store.** A finished game's DO keeps its transition log forever. There is no separate "archive write at finish" — the DO *is* the archive. Replaying a game years later just wakes its DO. (A future cold tier can sweep very old games to R2; see [Data & storage](./storage.md).) - **D1 is a read-model, never the source of truth for live play.** Lobbies, "my games", leaderboards, and profiles read D1. It is updated *from* DO effects after a command commits, and it is allowed to be briefly stale (a lobby may show a game that just filled). It never arbitrates a move. - **KV is intentionally absent.** Its design centre is edge-cached hot reads — the opposite of authoritative serialized writes (that's the DO) and write-once cold history (that's DO SQLite / R2). - **jose, not a Firebase SDK.** Verifying a Firebase ID token is ~40 lines of standard JWT verification against Google's JWKS. jose is a maintained, platform-native library; the engine keeps the whole auth surface in view. ## Cost & scaling posture The free-tier binder is DO SQLite + D1. The first ceiling is DO storage writes (~100k rows/day ≈ ~1,400 games/day). Crossing it is a one-click plan upgrade with **zero code change** — no architecture in these documents assumes the paid tier. R2 remains an optional paid-service integration for avatars. FCM shares the Firebase project already required by Auth and is a no-cost Firebase product; the infrastructure is always configured even though players may decline notification permission. --- ## Security model Authorization is enforced explicitly in application code rather than delegated to the database, so every check is visible at the route that depends on it. - **Every `/api/engine/*` route is token-gated**; the socket upgrade included (its `?token=` is verified by the same middleware). - **Reads are uid-scoped**: "my games", ratings, rating history, and profile all filter to the caller. `getPlayers` returns only *public* identity (username, display name, avatar, anonymity) — never email. A user's own email is returned only by their own `/me`. - **Game visibility is a capability model.** A game id is an unguessable UUID; a private game is unlisted (never in the lobby) and joinable only by someone who holds its id or shortCode. Reading a game summary requires the id, and the sensitive part — the game *state* (frames) — is separately gated: only a participant, or anyone for a *finished public* game, may fetch frames. - **Seat ownership is enforced at the DO** against its authoritative roster, so a client (or a misbehaving external bot) can never act on a seat it doesn't hold — a clean 403, not a crash. - **Bot webhooks are HMAC-authenticated** with domain-bound, constant-time verification (see [Bots](./bots.md)); the client cannot forge a bot move and a bot cannot reflect a wake into an action. - **Tokens are RS256-pinned** and issuer/audience-checked; secrets (`BOT_SIGNING_SECRET`, the `FIREBASE_*` service account) are read from env by convention and absent by default (each feature is simply off when unconfigured). - **The Worker strips inbound `x-eigen-*` headers** before forwarding a socket upgrade to the DO and sets the principal itself — a client cannot spoof its identity to the DO. --- ## Data & storage ## Two stores, two jobs - **DO SQLite** (per game) is *integrity + history*: the authoritative roster and the immutable transition log. Never read to serve a list. - **D1** (global) is a *read-model + registry*: identity, social, bots, ratings, and game **summaries**. Never wake a DO to serve a read — lobbies, "my games", profiles, and leaderboards all read D1. A game's summary row is created Worker-direct, then updated from DO effects after each commit (accepted staleness). A summary carries dashboard hints (status, whose turn, the deadline, final outcomes) but **never game state** — raw state lives only in the DO. ## The D1 schema | Table | Purpose | |---|---| | `users` | Identity, keyed by Firebase uid (stable across guest→permanent upgrade). Merged users + profile. `avatar_url` defaults to the provider photo. | | `games` | The summary/read-model row (timing, rated, pool, status, outcomes, short_code, `finish_id`, `finished_at`, a nullable `archived_at` cold-tier seam). | | `participants` | The roster join table — one row per seat, the indexed access path for "games of user X". A display mirror of the DO roster. | | `relationships` | Friend edges in canonical pair order. | | `bots` | The [bot registry](./bots.md): `type` ∈ engine/external/local, `webhook_url` for external, capabilities `config`. CHECK-enforced. | | `player_ratings` | Per-identity per-pool OpenSkill rating + a `revision` CAS counter. | | `rating_history` | Immutable per-game rating log, unique per (game, identity), carrying `finish_id`. | | `device_installations` | FCM push targets keyed by Firebase Installation ID (FID). | D1 has **no foreign-key cascades**: relationships between tables are maintained explicitly (for example, account deletion is an explicit preserve-vs-delete 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. ## Ratings & the concurrency-safe CAS Ratings are OpenSkill, computed **at finish, in D1**, because they depend on global cross-game priors that any snapshot into the DO would render stale (games can run for days). The whole apply — summary row, rating rows, history log, and the `finish_id` marker — is **one D1 `batch()`**, so the dedupe marker and the rows it guards can never disagree. The write is a compare-and-swap on a per-rating `revision` counter, which fixes the classic concurrent-finish lost-update bug: 1. Read each identity's `(mu, sigma, revision)` and compute the posteriors in TS. 2. Write each history row with revision-guarded subselects for its before-values (`SELECT mu FROM player_ratings WHERE …revision = <the one just read>`), and UPDATE the rating `WHERE revision = <that>`, bumping it. 3. If a concurrent finish already moved the revision, the subselect returns NULL, the NOT-NULL column rejects the row, the **whole batch rolls back**, and the engine re-reads fresh priors and recomputes (bounded retry). The display rating shown on leaderboards is `max(0, round((mu − 3σ) · 40))` — computed in one place in the kernel. ### The purge guard A seat whose account was deleted mid-game still carries its user id in the DO roster (the purge nulls only D1's mirror — it never wakes every game). A later rated finish would therefore try to write a `player_ratings` row for a non-existent user. So the apply reads which identities still exist and skips the rating write (and its returned delta) for absent ones — while the purged seat still shapes the OpenSkill field. Bots are never purged. ## History & replay A finished game's DO holds its full transition log forever, so **replay is the live range-fetch path pointed at a finished DO.** The client asks for a version range; the DO projects each transition through `computeObservation(…, isReplay: true)` for the caller's seat (or `null` for a public viewer). Live gap-recovery and finished-game replay are literally the same endpoint — the only difference is that a finished game's frames were compacted away, so replay re-projects from the immutable `transitions` instead of reading the drained `frames` table. Replay reads go through a one-method **`HistoryStore` seam**. V1 ships exactly one implementation (the DO range-fetch) and no dispatch logic, but the seam is real: a future cold tier can add an R2-backed implementation and a "DO-if-present-else-R2" composition behind the same interface, and the replay route never changes. Three more seams are already in place for that cold tier: a store-agnostic replay contract, the field-for-field frozen-blob shape the compaction already leaves behind, and a nullable `archived_at` column on the games row that v1 never touches. History *lists* (as opposed to a single game's replay) always read D1 summaries. The free runway before any of that matters is ~125k–250k finished games in the account-wide 5 GB DO SQLite quota. --- ## Shape of the system ## Four packages The server ships as four npm packages. The split is by **trust and purity**, not by feature: ```text @eigeninteractive/rules The implementor contract: GameRules, GameModule, the six hooks, the JSON/Envelope/Observation types. Pure types + 2 helpers. Zero engine dependencies — a game author reads only this. @eigeninteractive/kernel The pure decision core. Given (game, state, roster, intent, now) it returns a commit plan or a rejection. No I/O, no platform APIs, fully unit-testable. Owns timing/grace, the same-view rule, observation fan-out, RNG derivation, and the rating math. @eigeninteractive/server Everything that deploys: the BaseGameDO class, the hono routes, the D1 schema + appliers, auth, bots, push, the createEngine factory. This is the only package an implementor's Worker imports at runtime (plus their own @eigeninteractive/rules game module). @eigeninteractive/testkit Shared conformance fixtures + kernel scenarios, run by both the TS tests and the Dart client's tests to catch twin drift. ``` An implementor authors a game against `@eigeninteractive/rules`, and ships a Worker that imports `@eigeninteractive/server`. They never see the DO internals, the D1 schema, or the migration machinery. ## One Worker, two authenticated API groups, one public web surface `createEngine(config)` returns a single Worker (`{ fetch, scheduled }`). Its request surface is three cleanly separated spaces on one host: ```text /api/engine/* Client API. Every route requires a verified Firebase ID token. Games, waiting room, actions, reads, profile, avatar upload, device registration, account deletion, the game socket. /api/bot/* External-bot webhook. Authenticated per-request by an HMAC signature (no user token). Just POST /api/bot/action today. / (public) Flutter web static assets plus unauthed Worker routes. /health is always on; configured routes include: /.well-known/assetlinks.json + apple-app-site-association (deep-link verification), /join/:shortCode and /game/:gameId (dynamic metadata + Flutter shell), /avatars/:uid (opt-in avatar serving), and the `site` group — /download, /terms, /privacy, /delete-account, /sitemap.xml, /robots.txt, /site.webmanifest. Plus static assets. ``` The two API groups are **separate hono sub-apps** so their auth never mixes: the engine group's Firebase middleware is scoped to `/api/engine/*` and never runs for a bot or a public request. Both groups emit into one OpenAPI document (each with its own security scheme) — the [HTTP API reference](../reference/http-api/eigeninteractive-engine-api.info.mdx) is generated from it, and the typed Dart client is generated from it in this same repository and published to pub.dev. Exact static assets are served directly by Cloudflare without a Worker invocation. The selective `run_worker_first` list reserves the API, app-link, legal, download, crawler, and avatar paths for Worker code; every other unknown browser route receives Flutter's `index.html` through the SPA fallback. ## The path of a move A single action shows how the pieces interact: ```text client ──POST /api/engine/games/{id}/action { seat, expectedVersion, data }──► Worker: verify Firebase token → provision/load user row → build a Command (a pre-authenticated value) → call the game's DO stub DO (input gate held): dedupe on commandId (replay stored response if seen) load meta + roster + latest transition from its SQLite verify the seat belongs to the caller (else a clean 403) run the KERNEL: validate move, apply the game hook, compute timing, project per-seat observations, decide finish if rejected → return the rejection as a value else → ONE SQLite transaction: append the transition (next version), write per-seat frames, store the command response, arm/clear alarm post-commit (gate released): fan out frames over sockets, mirror the summary to D1, run bot turns / pushes / finish apply ◄── the caller's own committed frame rides the HTTP response ◄── every other seat's frame arrives over its WebSocket ``` The critical discipline: between reading storage and writing it, the DO does **no non-storage `await`**. The read → pure-kernel-decision → single synchronous SQLite transaction runs entirely under the input gate, so no other command can interleave. Every network effect (socket fan-out, D1 writes, bot wakes) happens *after* the commit, where interleaving is harmless. --- ## The app shell # Package layout ```text eigen-flutter/ └── lib/ ├── eigen_flutter.dart # the public barrel ├── app_runner.dart # runEngineApp(...) — the entry point ├── src/api/ │ ├── generated/ # GENERATED REST client — never hand-edited │ └── generated_from.dart # which engine build produced it ├── core/ │ ├── api/ # Dio + auth interceptor, engineCall, the socket, │ │ # ServerClock, avatar-URL resolution │ ├── config/ # AppConfig (Branding + EngineConfig) │ ├── game/ # the game contract: GameModule, GameRules, │ │ # GameFrame, PlayersContext, TimingContext, │ │ # MySeat, GameCreationSpec, timing constants │ ├── analytics/ notifications/ updates/ review/ connectivity/ │ ├── storage/ theme/ navigation/ errors/ utils/ startup/ ├── features/ # about auth game home profile rating settings social │ └── <feature>/{data,providers,presentation} ├── shared/{data,providers,widgets} └── testing/ # the Dart half of the twin-fixture runner ``` The layering rule is enforced by a test, not convention: `test/core/architecture/api_isolation_test.dart` restricts `package:dio` and the six generated `*Api` classes to `core/api/`, the feature `data/` layers, and `shared/data/`. Generated *models* may be used anywhere — they are the domain vocabulary. What is confined is the **capability to make a request**, not the types that come back. That test is what made folding transport into this package safe after the separate pure-Dart package was dropped. A consuming app is a standard Flutter app with the game under `lib/game/`: ```text my_app/ ├── pubspec.yaml # depends on eigen_flutter (path, until published) ├── app-config.json # public Android + web build-time values ├── lib/ │ ├── main.dart # ~30-line entry: runEngineApp(module, config, …) │ ├── firebase_options.dart │ └── game/ │ ├── game_module.dart # versions map + creation/about UI │ └── v1/ # one folder per schemaVersion ├── test/game/twin_fixtures_test.dart ├── web/ │ ├── firebase-config.js # generated for the messaging service worker │ └── firebase-messaging-sw.js ├── android/ ios/ … ├── assets/icon/ # icon.png + icon_foreground.png └── fastlane/ # Fastfile + Appfile ``` `dart run eigen_flutter:configure_firebase` generates FlutterFire's platform files and `web/firebase-config.js` from the same selected Firebase app. The service worker remains app-owned because it runs outside the Dart isolate, but its identifiers are not hand-maintained. The `v1/` folder is a **convention, not enforced** — the contract is the `versions` map. But mirroring the layout across both languages is what makes a version bump mechanical: a new folder in each tree plus one map entry each. **Fonts need nothing per app.** The engine bundles Inter as a package font (all nine weights, declared under `fonts:` in its own pubspec), so Flutter includes it in every consuming app automatically and it renders offline from the first frame — no `google_fonts`, no runtime fetch. To change the typeface, add the new family's weights to the engine's `fonts/` and update the one constant in `AppTheme`. ## App startup `AppStartup` wires the singletons the shell depends on, in a fixed order so no initial event is missed: 1. Listen to auth state (`listenManual`, before anything can emit). 2. **Register the notification navigation listener *before* calling `initialize()`** — the terminated-state tap arrives on a broadcast stream, so a listener attached after init misses it. 3. Keep the native splash up until auth resolves; if authenticated, also await the profile warm-up, **capped at 2 s**. A native SQLite cache normally resolves immediately; web fetches the profile again after reload. If neither finishes within the cap, `FlutterNativeSplash.remove()` still runs in `finally` and the home screen opens with a loading profile. 4. An `AppLifecycleListener` reconciles OS/browser notification permission, FCM registration and the server's installation row, and polls for an Android in-app update on every resume. On **sign-in** the same handler does four things, all fire-and-forget so none of them delays first paint: identify the user to analytics, tag the account as guest or registered, register this install for push, and pre-warm the profile and bot catalog. Registration is driven by *auth state* rather than by the notification service's one-time init, because the row maps a **user** to a device — an in-session sign-in or account switch must re-register. Notification initialization **never requests permission**. The first time a player is successfully seated in a multiplayer waiting room, the shell explains the concrete value (game ready, turn and result alerts) and exposes an explicit **Enable notifications** action. That action owns the system/browser prompt. Choosing **Not now** is respected: future waiting rooms use a quiet inline action, while Settings remains a secondary fallback. Failed joins, spectators and solo games never trigger the education sheet. The shell resolves four permission states: - **unavailable** — Web Push is unsupported in this browser; - **promptable** — the player has not made a decision; - **enabled** — permission and FCM registration can be reconciled; - **blocked** — open Android system settings, or explain how to use browser site settings. This extra state is necessary on Android 13+, where Firebase reports `denied` both before the first request and after a denial. One install-local marker records only a user-initiated system request, so a fresh install still gets an **Enable** button; another ensures the explanatory modal appears only once. Blocked native users get an explicit system-Settings action and blocked web users get browser site-settings guidance. Granted permission calls Firebase's FID-based `register()` flow and then upserts the installation. FID rotation, sign-in and app resume all retry that reconciliation; revoking permission removes the stale server installation row without deleting the Firebase installation itself. The splash is **infra-owned**: a game never calls `FlutterNativeSplash.remove()`. ## Local persistence **Native goal:** eliminate cold-start spinners for data that is already known and rarely changes. Web deliberately starts with a fresh server read after each reload and relies on Riverpod's in-memory state for the browser session. Native apps currently store selected provider state as JSON with Riverpod's official SQLite adapter (`riverpod.db`, via `riverpod_sqflite`). Persisted providers **race** their restore against the network fetch rather than sequencing them: `persist()` is called *without* awaiting, and an internal `didChange` guard stops a slow cache read from overwriting a fresher network result. | Provider | Native across launches | Web after reload | |---|---|---| | current user profile | SQLite stale-while-revalidate | fetch | | player-info cache (per id) | SQLite, 30-day expiry | fetch through the batch endpoint | | friends | SQLite stale-while-revalidate | fetch | | bot catalog | SQLite, 7-day expiry | fetch | | ratings, active games | fetch | fetch | Two disciplines make this safe: - **`destroyKey` is per provider, not global.** Bump the individual provider's key when *its* model's persisted shape changes incompatibly; old entries are discarded and refetched. Sharing one key would mean a profile change wipes the friends cache. There is no incremental JSON migration — this is the only path. - **Clear native user caches on sign-out and account deletion.** `deleteUserData(uid)` wipes every user-scoped key (`profile_{uid}`, `friends_{uid}`, …) and must run **before** the auth session ends, since after deletion the credentials are gone. Cache entries also carry an expiry. The **player-info cache is deliberately not cleared** — player identity is public, and a second account on the same device benefits from it — but each entry expires after 30 days. The keys live in one place (`core/storage/`) rather than beside their providers, which also breaks a circular import between auth and profile. Theme choice and notification reconciliation markers are small preferences, not server-response caches, and continue to use `SharedPreferencesAsync` on web. Firebase owns authentication persistence independently. When native adopts Drift for queryable data such as game history, these JSON snapshots should move behind repositories as typed tables rather than turning Drift into another generic Riverpod key-value backend. ## Connectivity & offline UX Connectivity is infra-owned — game code never watches it. Two banners, both built on `StatusBanner`, both animating their height so the layout slides rather than jumps, and both pushing content down rather than overlaying it: - An **offline banner** on shell screens when the device reports no network. - A **reconnecting banner** on the game screen when offline *or* the game stream/observation is erroring *and* the game is non-terminal. It lives in its own leaf `ConsumerWidget` so a connection blip rebuilds the banner, not the whole game tree. Two subtleties worth keeping: - **Interface availability is not internet reachability.** `connectivity_plus` reports "online" on a captive Wi-Fi with no upstream. So the error arm matters as much as the offline arm, and the real recovery signal is the stream re-syncing, not the connectivity flag. - **Stale data beats an error screen.** The game screen renders from `asyncValue.value` whenever it is non-null — which covers `AsyncError` carrying a previous value — so the board stays visible while the banner communicates the reconnecting state. The hard error state only appears on a cold-start failure with no data ever received. On the offline → online transition the game screen invalidates its providers immediately, bypassing Riverpod's retry backoff. ## Navigation A shell with indexed-stack branches, and full-screen routes above it: ```text /home /lobby /history /social /about /settings — shell branches (drawer-switched) /game/:gameId /join/:code /profile — full-screen, above the shell ``` - Branch screens are top-level destinations; Back exits the app (branches switch via the drawer, not Back). There is no `PopScope` intercepting it. - `/game` is always reached by a push, so Back returns to the source screen (home/lobby/history) with the predictive-back peek. - `/join/:code` is a transient spinner that resolves the short code and `pushReplacement`s into the game, so Back from the game never lands on a stuck spinner. On error it `go`es home — safe for both in-app entry and a deep-link cold start where no shell is in the stack. - Deep links (`/join/{code}` from a share, or a push's deep link) route through the same join/game paths. Use `go` for auth redirects and branch roots (replaces the stack), `push` for anything Back should undo, `pushReplacement` for transient screens. :::warning[Three things that are easy to delete by accident] - **`android:enableOnBackInvokedCallback="true"`** in `AndroidManifest.xml` opts into the Android 14+ predictive back API. Its absence silently disables predictive back for every user on 14+. - **The `onException` handler** redirects any unmatched or malformed route to `/home`. Without it, an iOS Universal Link the OS hands to the app that matches no declared route (a `/terms` URL, say) throws a `GoException` that surfaces as a crash. - **`NotificationNavigation.navigateFromNotification`** pushes for overlay prefixes (`/game/`, `/join/`) and `go`es for shell branches — mirroring the route structure, so Back after a notification tap returns where the user was. A new overlay route must be added to its prefix list. ::: Terms/privacy links open with `LaunchMode.inAppBrowserView` (Safari View Controller / Custom Tabs) specifically to bypass Universal Links interception — see [Deep links & domain configuration](../ship-it/deep-links.md). ## Analytics & crash reporting Both are **infra-owned** — a game never imports a Firebase package or fires an event. Firebase itself is mandatory: `runEngineApp` initialises it before anything else, so every deployment runs it. `AnalyticsService` is an abstract interface over primitives (`String`, `int`, `bool`) that never imports `features/` types — call sites convert enums to strings. The Firebase implementation sits behind a keepAlive provider. The point of the interface is not swappability (there will only ever be Firebase); it is that call sites don't depend on Firebase and the service is trivially faked in tests. **Crashlytics** is wired before `runApp`, both arms, so no crash window exists at startup: ```dart FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; PlatformDispatcher.instance.onError = (error, stack) { FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); return true; }; ``` `FlutterError.onError` catches framework errors (build failures, assertions); `PlatformDispatcher.onError` catches isolate-level errors that escape the framework. **Screen tracking** is a `FirebaseAnalyticsObserver` registered on the GoRouter instance — one `screen_view` per route transition, no per-screen code. Events fired automatically: `game_created`, `game_started`, `game_finished`, `forfeit`, `join_by_code`, `friend_request_sent`, `friend_accepted`. Identity is `identify` on sign-in / `reset` on sign-out, plus an account-type tag so every metric segments by guest vs registered. Two implementation rules that keep these honest: - **Side effects use `listenManual` in `initState`, never `ref.listen` in `build`** — so they don't re-fire on widget rebuilds. - **Fire only on a *witnessed* transition.** `game_started` requires a previous status of `waiting`/`ready`, so opening an already-active game doesn't re-count. `game_finished` requires a previous **empty** outcomes list, which covers both re-fire paths: reopening a finished game from History (previous is null) and an app-resume reload (Riverpod's `AsyncLoading` carries the previous non-empty value). The **same guard** gates the win haptic and the in-app review counter, so revisiting an old win never inflates either. Note Firebase Analytics rejects raw `bool` parameters — booleans go as `int` 0/1. ## Guests Anonymous sign-in gives a real uid and a real (ephemeral) account, so a visitor can play immediately. Guest capability is deliberately narrowed **server-side** — the client's job is only to not offer what will be refused: - Guests **may** play, including solo vs bots (which comes out unrated). Solo is a guest's first-run experience and is *not* gated. - Guests **may not** create friends-access games, join rated games, or use social features at all. - The Social drawer destination stays **visible but disabled** rather than hidden, and `/social` is redirected home in the router as a deep-link backstop. Rated lobby games show with a disabled join button. Visible-but-disabled teaches what signing up buys; hiding teaches nothing. - Settings shows a "save your progress" upgrade card, because **inactive guests are swept server-side** after a period of inactivity. **Upgrade preserves the uid.** Native uses `linkWithCredential`; web uses `linkWithPopup`. Both convert in place, so games, ratings and friendships carry over with no migration; the provider's display name and avatar overwrite the guest's while the stable username handle survives. If the chosen account already belongs to a registered user the link fails, and the app explains that guest progress cannot be transferred and asks before switching. Only explicit confirmation signs into the existing account; after that succeeds, the abandoned guest's disposable local cache is cleared and the auth-state handler registers the device installation for the destination account. A long-dormant guest may have been purged server-side. The client treats "valid token, empty data" as automatic re-provisioning (the server creates a fresh guest row on the next request), not an error. ## Haptics, updates & review **Haptics** are infra-owned — a game never imports `flutter/services.dart` or picks a feedback style. Three moments fire from the game screen: `lightImpact` on a submitted action (optimistically, before the request), `heavyImpact` on a win outcome, and `selectionClick` via the `onInvalidAction` callback the game calls when `isValidAction` rejects a tap. Centralising the choice is what makes intensity a single future setting rather than a scattered one. **In-app updates (Android)** run on resume via Play Core. If an *immediate* update is allowed and no game is active, the full-screen update runs; if a game is active it is **skipped and retried next resume** — never silently downgraded to a flexible update, and never interrupting a game. A *flexible* update downloads in the background and surfaces a "new version ready — Restart" snackbar. The mid-game gate reads the current route (`/game/` sits outside the shell navigator, so a prefix check is reliable). The notifier exposes state rather than showing the snackbar itself, because it sits above `MaterialApp` and can't resolve a `ScaffoldMessenger` — the shell scaffold listens and shows it. iOS has no equivalent; the check returns early. **In-app review** requests the OS prompt every 5 lifetime wins (persisted in `SharedPreferences`), fire-and-forget so a slow store round-trip never delays the outcome UI. The OS enforces its own quota (~3×/year) silently, so no application-level gate beyond the counter is appropriate. The review dialog **never appears on simulators or debug builds** — test through TestFlight or an internal track. --- ## Timing & the deadline alarm Timing is server-authoritative and lives in the kernel. A game is created in exactly one timing mode: - **Turn**: a fixed budget per move (`turnSeconds`). - **Budget** (chess-clock): a per-player bank (`budgetSeconds`) with an optional Fischer `incrementSeconds` added after each move. - **Untimed**: no clock at all. (Turn and budget are mutually exclusive; increment requires budget.) A hook may also override the deadline for a single action via the envelope's `turnSeconds`, without touching any player's bank. ## The deadline computation After every transition the kernel computes the next `deadline` and `turnStartedAt` by a fixed precedence chain (all instants are injected epoch milliseconds — the kernel never reads a clock): 1. **Game over** → both `null` (no deadline). 2. **Hook per-action override** (`envelope.turnSeconds = N`) → `now + N·1000`, banks untouched. 3. **Budget mode** → `now + min(remaining bank over the new pending seats)`. A budget-timed game allows at most one pending seat (enforced upstream), so this min is normally just that seat's bank; the min is a safe degradation if a multi-pending state ever arrives. 4. **Per-turn mode** → `now + turnSeconds·1000`. 5. **Untimed** → both `null`. In budget mode the acting seat's bank is charged on each move: `bank[seat] = max(0, bank[seat] − (now − turnStartedAt)) + increment·1000`. The deduction floors at 0 (an overrun lands at 0, never negative), and the Fischer increment is added after. ## Grace, and why it's a single constant The enforcement mechanism is the **DO's durable alarm**, and this is a key simplification over a database-backed engine. Server time is measured when the request *arrives*, not when the player tapped, so a move made on time can land just past the deadline through pure network latency. One grace constant in the kernel (`DEADLINE_GRACE_MS = 750ms`) compensates, with exactly two call sites: the kernel accepts an action while `now ≤ deadline + grace`, and the DO arms its alarm at `deadline + grace`. Whichever arrives first — the latent action or the alarm — commits; the loser sees already-advanced state and no-ops. When the alarm fires it commits a `timeout` lifecycle with a deterministic `commandId` (so a double-fire dedupes, and a real move that arrived first simply wins). The grace forgives **acceptance, not time charged**: in budget mode the elapsed deduction still runs, so flag-fall is honoured — a player whose bank hits 0 can overrun by at most the grace and still have that final move counted (bounded and self-limiting). This replaced an older three-place race symmetry with one constant. :::tip[There is no timeout-sweep cron] Because the alarm is a durable, per-game, platform-retried timer, the periodic scan for overdue turns that a database-backed engine needs simply evaporates — the database has no per-row timer, but the DO alarm *is* that timer. The deadline alarm is the **only** code that sets an alarm on the DO. A stray `setAlarm` elsewhere would silently disarm a turn deadline. ::: Untimed games have no alarm at all; their only backstop is the abandoned-game reap, described in [Account lifecycle & the cron](./account-lifecycle.md). --- ## The client transport How the app talks to the Worker: the session, the request layer, the frame stream, and how a seat becomes a name and a face before the game screen renders. None of it is game code — a game never opens a socket or resolves an identity. ## Session, requests & errors - **Auth is Firebase.** Google and Anonymous sign-in are implemented. Native uses the Google ID token with `signInWithCredential`; web lets Firebase own the browser popup with `signInWithPopup`. `linkWithCredential` on native and `linkWithPopup` on web upgrade a guest in place, preserving the uid, so every game, rating and friendship carries over with no data migration. Every request sends the Firebase ID token as `Authorization: Bearer <token>`; WebSocket upgrades send it as `?token=` (browsers can't set headers on an upgrade). Tokens refresh on the Firebase SDK's schedule; the client attaches the current one per request. :::note[Apple Sign-In is scoped but not wired] There is no `sign_in_with_apple` dependency yet. ::: - **The API client is generated** from `openapi.json` — in the engine repo, and published to pub.dev as `eigen_api`, so the client repo depends on a version rather than holding a copy of the spec. Client routes live under `/api/engine/*`; the configured base URL is an **origin only** (scheme + host, no path, no trailing slash) because every generated route already carries its own prefix. The one non-generated piece is the frame stream, which is hand-written. - **Errors** are `{ error, code? }`, and `code` is a **generated enum** (`ErrorCode`), so `humanize` switches over it exhaustively — adding a code server-side fails the client build until copy exists. `engineCall` converts a server-reported failure into `EngineException`; a failure with *no* response propagates as the underlying `DioException`, because "the server said no" and "the outcome is unknown" mean different things to a state-changing command. - **Engine wire enums are forward-readable.** Generated enums map a member an older app does not know to `unknownDefaultOpenApi`, so decoding succeeds and the app can request an update. The fallback is read-side only and is never sent back. - **Lists page by keyset cursor**, not offset: the cursor is the previous page's last sort value. These lists change while they are being read, and an offset would show the same row twice after a single insert. - **Avatar URLs may be relative.** With the default worker-served setup the server returns `/avatars/{uid}?v=<ts>`; with a public bucket domain it returns an absolute URL. `resolveAvatarUrl` resolves either against the API origin, and every seat rendering routes through `PlayerAvatar` so that resolution lives in one place. The `?v=` cache-buster means `cached_network_image` refreshes on re-upload with no manual invalidation. ## The frame 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) — 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 — 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. ## Player identity The transport resolves every seat identity **before** the game screen renders, so game code gets non-nullable identity — no null checks, no loading states. - Identity comes from `GET /api/engine/players?ids=` (batch, public identity: username, display name, avatar, anonymity — never email), warmed by a client-side persisted cache. Game rows carry no denormalized identity, so a renamed user is correct everywhere on the next fetch. - For a **finished game whose participant was deleted**, the server anonymizes the seat (the roster keeps the seat, id nulled); the client renders a **synthetic identity** ("Deleted User", `player_{index}`) and sets `GamePlayer.isDeleted`. **`isDeleted` is the guard** — never inspect the synthetic `Player.id`, which exists only to give the seat a distinct widget key and is not a real user id. - **Game identity vs social identity.** Seat identity covers humans *and* bots and is the right tool in game screens and lobby cards. Social features (friend search, requests) are human-only and never surface bots. Don't branch on player type to decide whether to show identity — show it uniformly; use the seat's `type` only where game rules must distinguish a bot seat. - **The viewer case.** A non-participant replaying a public finished game has no seat — `MySeat` is a sealed `Seated(index) | Viewer`, so viewer checks simply never match "is it my turn". Read `mySeat.indexOrNull` where a null is the right answer for a viewer. - Per-game **roles** (host, team, dealer) are not a transport concept — they live in the game's observation JSON, shaped by `computeObservation`. **Shared identity widgets** (`lib/shared/widgets/`, exported from the barrel where a game needs them): | Widget | Use | |---|---| | `PlayerAvatar` | One seat's avatar — cached network image, initials/person fallback, optional active border, relative-URL resolution. `onTap` optional; leave it unset inside a `ListTile` (the tile's own ink covers the row). | | `OverlappingAvatars` | The overlapped row used on game/lobby cards. | | `PlayerProfileSheet` | Modal profile — identity, ratings across pools, friendship actions (humans only). Guard with `isDeleted` before opening. | | `EmptyStateView` | The illustrated empty state shared by all list screens (home, lobby, history, friends, requests). | | `StatusBanner` | The slim full-width banner primitive behind the offline / reconnecting banners. | --- ## Versions and compatibility Four artifacts ship from three repositories. This page says which ones pair, and what a version number is actually promising. :::info[Everything here is pre-1.0] Every package is still on the `0.x` line and the API is moving. Read the [breaking axis](#the-breaking-axis-is-the-minor-for-now) section before writing a version constraint — it is not where you expect. ::: ## What pairs with what {/* generated:compatibility-table — rewritten by scripts/sync-compatibility.mjs; do not edit between these markers */} | Docs | Engine — `@eigeninteractive/*` | Wire client — `eigen_api` | Flutter shell — `eigen_flutter` | | --- | --- | --- | --- | | **0.2.x** *(this version)* | `^0.2.0` | `^0.2.0` | `0.3.2`, `0.3.1`, `0.3.0`, `0.2.0` | | 0.1.x | `^0.1.0` | `^0.1.0` | `0.1.0` | {/* /generated:compatibility-table */} The first three columns are one number. The shell column is not, and is listed as exact versions rather than a range for that reason: `eigen_flutter` declares which engine it speaks through its own `eigen_api` constraint, so the releases pairing with a given line need not be contiguous. Retracted releases are omitted — they stay installable for anyone already locked to one, but the solver will not choose them for a new project. `eigen_api` is not versioned independently. It is generated from the engine's OpenAPI spec and **stamped with the engine's release version** — that is what the spec carries as `info.version` — so `0.2.4` there is `0.2.4` here, and they cannot drift. The engine bumps that version for any breaking change it ships, including ones with no wire consequence — a change to what `@eigeninteractive/rules` exports moves it just as a changed response body does. So a new `eigen_api` is not by itself evidence that the wire moved, and upgrading it may cost you nothing. Constrain on the version anyway: it is the number your package managers can actually enforce. `eigen_flutter` moves on its own clock. Its version describes its Dart API — the widgets, the providers, the `GameModule` contract — and it records which engines it works against through its own `eigen_api` constraint. So `eigen_flutter 0.4.0` depending on `eigen_api: ^0.2.0` means "this shell speaks the engine's 0.2.x wire". There is no lockstep release, and there deliberately isn't one: the engine breaks in ways that have no Dart-side consequence at all, and forcing a shell release for each would make its version number meaningless. ## What the scaffolder picks A new project does not choose from the table above — `create-eigen-game` has already chosen, and **the version of the scaffolder you run decides both halves**. It writes one engine range into `server/package.json` and one `eigen_flutter` range into `app/pubspec.yaml`, and it resolves nothing at run time. Use `@latest` rather than a cached copy: ```bash pnpm create eigen-game my-game npm create eigen-game@latest my-game ``` The two numbers reach it differently, and the difference is the point. **The engine range is derived, not maintained.** The scaffolder emits a caret on the `@eigeninteractive/server` version it was built against — the same engine its CI compiled the Worker template with. Nobody types that number, so the templates cannot ship paired with an engine no build ever saw. **The `eigen_flutter` range is a pin, deliberately.** It once resolved "the newest shell for this engine's wire line" from pub.dev, which was wrong: a shell declares which *wire* it speaks, and says nothing about whether its *Dart API* still matches the templates. `eigen_flutter 0.4.0` constraining `eigen_api: ^0.2.0` is a legal match that would emit code against an API that moved. The pin is raised by hand, and only after CI has scaffolded a project and run `flutter analyze` against that exact shell. So a scaffolder release trails an engine release, and that gap is real rather than an oversight. When the engine crosses a line, no shell can speak it yet: `eigen_flutter` records compatibility through its own `eigen_api` constraint, and `eigen_api` for the new line does not exist until the engine's release publishes it. The scaffolder keeps emitting the previous line — a pairing that works — until a shell for the new one ships and the pin is raised. If you need a combination the current scaffolder does not emit, take the manual path: [Set up without the scaffolder](../getting-started/manual-setup.md) uses the same public contracts, and the table above is what to write into it. ## The breaking axis is the minor, for now Semver treats `0.x` specially, and both halves of this project are in `0.x`: ``` ^0.1.0 resolves to >=0.1.0 <0.2.0 ^1.0.0 resolves to >=1.0.0 <2.0.0 ``` So while a package is pre-1.0, **breakage is announced in the MINOR position** and the major position is unused. `0.1.4` → `0.1.5` is additive; `0.1.4` → `0.2.0` is the break. Once a package reaches `1.0.0` this shifts to the usual major/minor split. This is worth stating plainly because release tooling generally does not translate it for you: asking for a "major" bump on a `0.1.0` package ships `1.0.0` and declares a stability guarantee by accident. While a package you publish is pre-1.0, choose `minor` for breaking and `patch` for everything else. ## Three different version numbers The word "version" means three unrelated things in this system. Keeping them apart is most of what compatibility reasoning is: | | What it versions | Who moves it | | --- | --- | --- | | **Package semver** | the developer-facing API of one package | whoever publishes that package | | **The engine's breaking axis** | the HTTP + socket wire contract | the engine; `eigen_api` mirrors it | | **Game `schemaVersion`** | one game's own state/action payloads | the game author | The third is the one people expect to find here and won't. A game's `schemaVersion` is internal to that game: the engine resolves each request against the version the game was created at, and old games keep running under old rules forever. It is not tied to the engine's version, and bumping one never implies bumping the other. See [Versions](../build-a-game/versions.md). **The docs are versioned on the engine's release line**, because that is what decides whether a page is still true. A page describes a task end to end — the TypeScript rules and the Dart client together — so either half going breaking invalidates it, and the engine's release line is the one number that moves for both. That is why it is the release line and not the wire specifically: `0.2.0` left the wire untouched and still rewrote what a `GameModule` imports. ## What a breaking bump means For the engine, it means **the wire changed in a way an existing client cannot absorb**. Two categories are less obvious than they look: **Widening a response enum is additive.** Every generated Dart enum has an `unknownDefaultOpenApi` member. When an installed client meets a value introduced by a newer server, decoding succeeds and the app can show generic or update-required UI rather than losing the whole response. That sentinel is deliberately **read-side only**. Serialising it produces `unknown_default_open_api`, which no route accepts. Adding a value that clients may optionally send is additive; changing a request so an old client must send the new value is breaking. Removing or renaming an enum member is also breaking. **Adding a field is not breaking.** The generated models are built with `disallowUnrecognizedKeys: false`, so an older client silently ignores keys it does not know. Removing a field, renaming one, or changing its type is breaking. ## Why this matters more here than in a normal library A library consumer upgrades when they choose to. **An installed app does not.** Once a release is in the stores, those binaries keep talking to your server for as long as people leave them installed — so an old client meeting a new server is the normal case, not the edge case, and it is a case you cannot fix by shipping a patch. That asymmetry is why the generated client tolerates both kinds of response widening: unknown fields are ignored, and unknown enum values become `unknownDefaultOpenApi`. The server can add either without making a current app fail response decoding. The sentinel adds a member to every generated enum, so exhaustive switches must handle it. Enum additions reuse that same member and do not change the Dart surface. Anything genuinely breaking needs a deprecation window: ship the additive half first, let installs turn over, and only then remove the old half. `eigen_flutter` checks for updates at cold start and on resume. Routine Android checks use Play's native update flow without interrupting an active game. When an unknown value makes one surface unsafe, that surface instead shows an explicit update action: Play in-app update on Android, or a reload of the current application in a browser. No store or download URL is configured by the client framework. For a new value that old clients cannot safely present, release in client-first order: 1. Publish the compatible Android build and deploy the compatible web client while the server still emits the old vocabulary. 2. Wait until the Play build is available to the full intended audience. 3. Only then enable the server behavior that emits the new value. Do not enable that behavior globally during a staged Play rollout unless the rollout already covers everyone who may receive it. The sentinel prevents a decode crash; it cannot make an unpublished or ineligible update available. ## Reading the docs at the right version The version selector in the navbar names the engine line these pages describe. Only `0.2.x` is served, and it is served at the root — so every `/docs/*` URL is a 0.2.x URL. There is no `/docs/0.1.x/`. This site's first public deploy already described `0.2.x`, so no 0.1.x page was ever published and there was nothing to freeze. Read the 0.1.x reference from the packages themselves: npm and pub.dev keep every published version, and `eigen_api`'s dartdoc is versioned alongside it. From here on a breaking engine release freezes this line: `0.2.x` moves to `/docs/0.2.x/*`, those links keep working, and the root becomes the new line. --- ## The cross-repo contract The Worker's schemas are authoritative; the app consumes one generated artifact: ```text TypeScript schemas + fixtures │ ▼ game-contract.json │ ▼ generated Dart payloads + fixture copies ``` A game's Worker owns its authoritative TypeScript rules and emits one deterministic `game-contract.json`. The artifact contains the four payload schemas for every `schemaVersion` plus validated behavioral fixtures. The Flutter repository consumes the exact artifact to generate immutable Dart payload types, the codec, and fixture copies. This works in a combined repository, separate Worker/app repositories, or a fully hand-created setup. The engine repositories remain ordinary npm/pub.dev dependencies in all three. ## Dependency identity Packages that exchange rule objects or inspect `IllegalMoveError` share `@eigeninteractive/rules` as a peer dependency. This makes the application select the compatible rules instance used in its dependency graph, preserving constructor/symbol identity across those package boundaries. pnpm and npm are the supported Node package managers. A normal transitive dependency may be hoisted and deduplicated, but that install layout is not its contract and nested copies remain valid. The game Worker therefore declares `@eigeninteractive/rules` directly as well as `@eigeninteractive/server`. This is intentional: - `rules` is the small, platform-free contract the game implements; - `server` is the Cloudflare deployment runtime that consumes that contract; - `kernel`, `server`, and `testkit` bind their rules peer to the implementor's one direct installation. `server` does not re-export the rules API. A re-export would create two canonical import paths for the same contract while leaving the peer requirement in place. Making rules a hidden transitive dependency instead would weaken the single-instance guarantee, especially when testkit participates in the same process. In the combined scaffold, run `pnpm run contract` or `npm run contract` at the repository root after changing schemas or fixtures. It emits the Worker artifact and regenerates the Dart payloads and fixture copies. The matching root `contract:check` checks both sides without writing; the underlying commands remain available for split repositories. ## Promotion order Treat the contract file as an immutable release input: 1. emit and test a new contract; 2. generate/test the Flutter app from that exact checksum; 3. release the compatible Android build to Play; 4. only then deploy Worker behavior that creates or returns the new `schemaVersion`. This order matters even when Google Play handles delivery: rollout is not instant and installed apps are not force-updated. The app already detects an unsupported game schema and presents the update path. On web, the equivalent action is a browser reload, which loads the current deployed bundle. Additive engine transport enums do not require this dance: generated Dart transport enums decode an unknown member as `unknownDefaultOpenApi`. Game payload enums remain schema-versioned because an unknown move cannot be acted on or safely serialized back. ## CI checks The Worker regenerates `game-contract.json` and fails on a diff. The app runs `eigen_flutter:generate_payloads --check` and its copied fixtures. Separate repositories can fetch the artifact from a release, registry, or object store; pin it by checksum instead of depending on a sibling checkout path. See [Payload types](../build-a-game/schemas.md) and [Versions and compatibility](compatibility.md). --- ## Dart API Game apps use one package and one import: ```dart import 'package:eigen_flutter/eigen_flutter.dart'; ``` **[Open the latest `eigen_flutter` API reference on pub.dev →](https://pub.dev/documentation/eigen_flutter/latest/)** The reference contains only the two supported library entry points: | Library | Use it for | |---|---| | `package:eigen_flutter/eigen_flutter.dart` | App startup, configuration, the Dart `GameModule` / `GameRules` contract, generated wire vocabulary, and game-facing widgets. | | `package:eigen_flutter/testing/twin_fixtures.dart` | Running the shared TypeScript/Dart contract fixtures from `flutter test`. | Everything under the package's `core/`, `features/`, and `shared/` directories is implementation detail. Do not deep-import it. If a task guide asks you to use a type that is missing from the barrel, that is an engine API bug. ## Guide versus API reference Use this site to complete a task; use pub.dev to look up an exact constructor, member, or type: - [The contract](../build-a-game/the-contract.md) explains what you implement on both the TypeScript and Dart sides. - [Rendering](../build-a-game/rendering.md) covers `GameContentContext`, actions, optimistic preview, seats, and widget tests. - [Creation UI](../build-a-game/creation-ui.md) covers `GameCreationSpec` and the version-independent module UI. - [Testing](../build-a-game/testing.md) covers the dedicated testing library. `eigen_api` is the generated transport package used inside `eigen_flutter`. Game apps do not depend on or import it directly. For wire-level lookup, use the [HTTP API reference](./http-api/eigeninteractive-engine-api.info.mdx) or [`openapi.json`](pathname:///openapi.json). ## Versioned docs The `latest` link follows the newest stable package. To inspect the API for a version pinned in an older app, open that release from the [`eigen_flutter` versions list](https://pub.dev/packages/eigen_flutter/versions); pub.dev keeps dartdoc for every published version. --- ## The Envelope, determinism & errors # The Envelope, determinism, and errors ## The Envelope Every hook returns `Envelope<State>`: | Field | Meaning | |---|---| | `state` | The new pure game payload — validated against your `state` schema before commit. Never carries whose-turn or winner metadata. | | `pendingPlayers` | 0-based seats that may act next. **Empty ⇒ the game is over** (with `outcome`). | | `outcome?` | Present **only** on the ending transition: one `OutcomeEntry` per seat (`result`, `placement`, `teamIndex`, optional `score`). | | `turnSeconds?` | Override the deadline for *this action only*; omit for the game's configured timing. | The generated types are in the [`@eigeninteractive/rules` reference](./typescript/rules.md). ## Determinism — the RNG contract State must be a pure function of `(base seed, ordered action log)`. The engine gives each transition a seeded `rng` (`rng.next()` → `[0, 1)`), derived from the game's stored seed and the committing version, so replaying a transition reproduces the identical sequence. The rules: - **Draw only from `args.rng`** — never `Math.random()`, `Date.now()`, `crypto`, or any external read inside a hook. - **Draw in deterministic code order** — the same number of draws in the same order every time, so a replay lines up. - **Bot brains may be impure** — a bot's `rng` is deterministic too, but the *chosen move* is what gets logged; replay reads the recorded action and never re-runs the brain, so a brain that peeks at the clock only affects live play. ## Errors — what to throw - `throw new IllegalMoveError("…")` from `applyAction` for a move that breaks the rules (a mis-tap, a buggy client). The engine renders it as the **caller's** error (a 400 `illegalMove`) — this is an *expected* outcome, not a fault. - **Any other throw** from a hook is treated as a **game bug** and surfaces as a server 500. Don't use exceptions for control flow; return the right envelope instead. - You never validate turn order, versions, seat ownership, or timing — the engine has already enforced all of it before your hook runs. Validate only move *legality*. For the HTTP-level error model, see [The HTTP surface](./http-surface.md#the-error-model). --- ## The HTTP surface at a glance The full request surface, grouped by the [three request spaces](../how-it-works/system-shape.md). Every `/api/engine/*` route requires a Firebase bearer; `/api/bot/action` is HMAC-authenticated; the web routes are public. This page is the map. For per-operation request/response schemas, see the generated [HTTP API reference](./http-api/eigeninteractive-engine-api.info.mdx), or read the [`openapi.json`](pathname:///openapi.json) spec directly. ## Client API — `/api/engine` **Reads** (D1-only, never wake a DO): | Method + path | Purpose | |---|---| | `GET /lobby` | Public joinable games, newest first | | `GET /games/mine?bucket=active\|finished` | The caller's games | | `GET /games/{id}` | One game's summary (capability read; never state) | | `GET /games/{id}/frames?from=&to=` | Version-range frames — live gap recovery **and** finished-game replay | | `GET /players?ids=` | Batch public identity (≤ 50), never email | | `GET /bots` | The bot catalog | | `GET /me` · `GET /me/ratings` · `GET /me/rating-history` | The caller's own profile / ratings | | `GET /friends` · `GET /friends/requests` · `GET /friends/games` | Social lists | | `GET /users/search?q=` | Friend-picker search (registered only) | **Game lifecycle** (Commands to the DO; policy at the edge, integrity in the DO): | Method + path | Purpose | |---|---| | `POST /games` · `POST /games/solo` | Create (Worker-direct D1) · create-and-start vs bots | | `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 | **Profile / account / devices / social writes:** | Method + path | Purpose | |---|---| | `PUT /me/username` · `PUT /me/avatar` · `DELETE /me` | Rename · upload avatar · delete account | | `PUT /me/devices` · `DELETE /me/devices/{fid}` | FCM device register / deregister | | `POST /friends/requests` · `/requests/{id}/accept` · `DELETE /friends/{id}` | Friend request / accept / remove | | `POST` + `DELETE /friends/{id}/block` | Block / unblock | ## Bot webhook — `/api/bot` `POST /api/bot/action` — an external bot submits a move, authenticated by the `Eigen-Signature` HMAC over the exact body. See [External-bot HMAC](../how-it-works/bots.md#external-bot-hmac). ## Public web `GET /.well-known/assetlinks.json` · `apple-app-site-association` · `GET /join/:shortCode` · `GET /game/:gameId` (share/landing) · `GET /avatars/:uid` (when avatars enabled). When `site` is configured: `GET /` (landing) · `GET /terms` · `GET /privacy` · `GET /delete-account` · `GET /sitemap.xml` · `GET /robots.txt` · `GET /site.webmanifest`. Each is overridden by a matching `public/` file. `GET /health` is always mounted and is deliberately absent from `openapi.json`; see [Deploying](../ship-it/deploy-the-worker.md#what-health-proves). ## The error model Every failure is one JSON shape — `{ error, code? }` — with the HTTP status carrying the coarse class and the optional stable `code` carrying the machine reason a client keys retry/resync UX off. Handlers only ever return their declared 200 shape; a failure is an `HttpError` throw (or a kernel/lobby rejection converted to one) rendered by the app-level error handler. | Status | Meaning | Representative `code`s | |---|---|---| | 400 | Client mistake | `invalidPayload`, `illegalMove` | | 401 | Missing/invalid token | — | | 403 | Ownership/permission refusal | `notCreator`, `notParticipant` | | 404 | No such game/user | `unknownGame` | | 409 | State conflict — resync and retry | `stateUpdated`, `notActive`, `notReady`, `expired`, `notPending`, `gameFull`, `alreadyJoined`, `notJoinable`, `creatorCannotLeave`, `schemaUnsupported` | | 413 / 415 | Avatar too big / wrong type | — | | 422 | Assertion mismatch (e.g. `rated`) | — | | 429 | Rate limited | `rateLimited` | | 500 | Server fault (game-hook bug, storage) | — | | 502 | Account deletion upstream failure (intact; retry) | — | Two reject codes are **not** errors and never reach the client as failures: `abstain` (a system `timeout` that lost its race — a clean no-op) and the accepted-lobby-staleness codes, which a client resolves by resyncing. Kernel rejections are *values*, not exceptions — recomputing one is always sound, so they are never cached the way accepted commands are. --- ## Project layout Your game owns two deployable applications: ```text server/ Cloudflare Worker + authoritative TypeScript rules app/ Flutter application + client-side rules and presentation ``` The engine is not source you copy into either application. The Worker consumes published `@eigeninteractive/*` packages from npm; the app consumes `eigen_flutter` from pub.dev. ## Combined repository Use one repository when the same team changes and releases both halves. This is the layout produced by `create-eigen-game`: ```text my-game/ ├── package.json # one contract / contract:check command ├── server/ │ ├── src/module/ │ └── game-contract.json └── app/ ├── lib/game/ └── test/fixtures/ ``` `pnpm run contract` emits the Worker contract and immediately regenerates the Dart payloads and fixture copies. It is the shortest development loop and the recommended starting point. ## Separate repositories Use independent repositories when the Worker and app have different ownership, permissions, or release cadence. No engine capability is lost. The only game-specific artifact crossing between them is `game-contract.json`, emitted from the Worker's authoritative schemas and fixtures. Treat it like an API artifact: 1. the Worker emits and tests it; 2. CI publishes that exact file with a checksum; 3. the app pins the artifact, generates Dart, and runs its fixture tests; 4. the compatible app ships before the Worker begins returning the new schema. The app never imports Worker source. The Worker never imports Dart output. ## Hand-created projects The scaffolder adds no private runtime contract and intentionally has no server-only or app-only modes. Existing projects can install the packages and create the required entry points themselves; see [Set up without the scaffolder](../getting-started/manual-setup.md). npm and pnpm are the supported Node package managers. Do not publish manifests with path dependencies or sibling-checkout overrides; those are local engine development tools, not part of a game. See [The cross-repository contract](./cross-repo.md) for artifact promotion and [Quickstart](../getting-started/quickstart.md) for the combined flow. --- ## TypeScript API This reference is generated from the published package barrels. Start with the package that owns the task you are doing: | Package | Open it when you need to… | |---|---| | [`@eigeninteractive/rules`](rules.md) | Implement a `GameModule`, payload schemas, hooks, observations, ratings, or bots. This is where most game code lives. | | [`@eigeninteractive/server`](server.md) | Compose the Cloudflare Worker with `createEngine`, `BaseGameDO`, bindings, deep links, avatars, or the public site. | | [`@eigeninteractive/testkit`](testkit.md) | Run twin fixtures, emit/check `game-contract.json`, or drive rules through the kernel in tests. | | [`@eigeninteractive/server/testing`](server-testing.md) | Mint local Firebase-compatible tokens and supply explicit no-op Firebase Admin effects for Worker integration tests. Never use it in production code. | Game Workers depend directly on `rules` and `server`; `testkit` and `server/testing` are test-only. The [task guides](../../build-a-game/the-contract.md) show how the TypeScript and Dart halves fit together. The kernel page is an engine internal. It remains available for debugging and contributors, but a game should not import it to implement rules or deploy a Worker. The D1 and Durable Object storage schemas are not documented here at all: they are private to the engine, and `readGameRow` returns a game row typed without them. --- ## @eigeninteractive/kernel `@eigeninteractive/kernel` — the pure decision core. Given the current row, a state snapshot and an intent, it returns a plan: the next state, the transition to append, the observations to fan out, and any effects to schedule. It touches no storage and no clock of its own, so every decision is reproducible from its inputs alone. ## Classes ### GameBugError Defined in: [eigen-server/packages/kernel/src/errors.ts:15](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L15) A broken game/engine invariant — a bug, not a rejection. #### Extends - `Error` #### Constructors ##### Constructor ```ts new GameBugError(message?): GameBugError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | ###### Returns [`GameBugError`](#gamebugerror) ###### Inherited from ```ts Error.constructor ``` ##### Constructor ```ts new GameBugError(message?, options?): GameBugError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | | `options?` | `ErrorOptions` | ###### Returns [`GameBugError`](#gamebugerror) ###### Inherited from ```ts Error.constructor ``` ## Interfaces ### CommitInput Defined in: [eigen-server/packages/kernel/src/commit.ts:96](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L96) #### Properties ##### game ```ts game: GameRow; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:97](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L97) ##### intent ```ts intent: Intent; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:102](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L102) ##### now ```ts now: number; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:105](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L105) The commit instant (epoch ms) — sampled once by the host, never read here. ##### roster ```ts roster: Seat[]; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:101](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L101) ##### rules ```ts rules: GameRules; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:108](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L108) The version unit for the game's `schemaVersion`, already resolved by the host from the `GameModule.versions` map. ##### staleViews? ```ts optional staleViews?: { current: SeatView | null; expected: SeatView | null; }; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:116](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L116) Same-view material for a stale game action: the acting seat's stored frames at `expectedVersion` and at the current version. Only consulted when `intent.expectedVersion < state.version`; if absent (or either frame is missing — e.g. compacted away), the stale action is rejected conservatively. ###### current ```ts current: SeatView | null; ``` ###### expected ```ts expected: SeatView | null; ``` ##### state ```ts state: StateRow | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:100](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L100) The latest transition, or null before v0 (only a `start` intent is meaningful then). *** ### CommitPlan Defined in: [eigen-server/packages/kernel/src/commit.ts:139](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L139) #### Properties ##### action ```ts action: TransitionAction | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:142](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L142) ##### alarm ```ts alarm: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:155](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L155) The instant the DO must arm its alarm at — the true deadline plus the grace window — or null to clear it. ##### effects ```ts effects: Effect[]; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:156](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L156) ##### frames ```ts frames: ObservationFrame[]; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:145](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L145) Per-seat projected frames (identified seats only) — persisted with the transition, fanned out over sockets. No raw state escapes the kernel. ##### nextState ```ts nextState: StateRow; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:141](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L141) The next transition row, already versioned (`v+1`, or 0 for start). ##### outcomes ```ts outcomes: OutcomeEntry[] | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:152](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L152) Per-seat results when this transition ends the game, else null. Rating deltas are deliberately NOT here: they depend on global cross-game priors (D1-domain data the kernel must never need). The D1 applier computes them inside the rating CAS via `computeRatings` (ratings.ts) and the host delivers them as a follow-up versioned ratings transition. *** ### GameRow Defined in: [eigen-server/packages/kernel/src/commit.ts:32](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L32) The game's standing configuration — the DO `meta` snapshot. #### Properties ##### budgetSeconds ```ts budgetSeconds: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:39](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L39) ##### config ```ts config: JsonObject; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:37](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L37) Stored creation config; parsed against the version unit's config schema before any hook sees it. ##### incrementSeconds ```ts incrementSeconds: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:40](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L40) ##### rated ```ts rated: boolean; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:41](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L41) ##### ratingPool ```ts ratingPool: string | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:42](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L42) ##### schemaVersion ```ts schemaVersion: number; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:34](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L34) ##### status ```ts status: GameStatus; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:33](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L33) ##### turnSeconds ```ts turnSeconds: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:38](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L38) *** ### NextDeadline Defined in: [eigen-server/packages/kernel/src/timing.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L48) #### Properties ##### deadline ```ts deadline: number | null; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:49](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L49) ##### turnStartedAt ```ts turnStartedAt: number | null; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:50](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L50) *** ### ObservationFrame Defined in: [eigen-server/packages/kernel/src/observe.ts:12](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/observe.ts#L12) One seat's projected frame, tagged with its seat. The host stamps version/timing when it persists and fans these out. #### Properties ##### data ```ts data: JsonObject; ``` Defined in: [eigen-server/packages/kernel/src/observe.ts:14](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/observe.ts#L14) ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: [eigen-server/packages/kernel/src/observe.ts:15](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/observe.ts#L15) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/kernel/src/observe.ts:13](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/observe.ts#L13) *** ### PlayerInput Defined in: [eigen-server/packages/kernel/src/ratings.ts:26](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L26) A player seat to be rated. Self-contained: each seat's current `mu`/`sigma` is bundled, so this module never reads a store. `displayRating` is intentionally NOT carried — it is derived from `mu`/`sigma` so the formula lives in one place per side of the wire. #### Extends - `Rating` #### Properties ##### botId ```ts botId: string | null; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:29](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L29) ##### placement ```ts placement: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:31](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L31) Ordinal finish rank (1 = best); ties share the same value. ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:27](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L27) ##### teamIndex ```ts teamIndex: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:34](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L34) Players sharing a teamIndex are rated as one team. For individual games this equals playerIndex. ##### userId ```ts userId: string | null; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:28](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L28) *** ### RatingDelta Defined in: [eigen-server/packages/kernel/src/ratings.ts:54](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L54) One rated identity's before → after, exactly the rating_history row minus store keys. Computed by the D1 applier inside the rating CAS and delivered on the post-finish ratings transition (the `kind: "ratings"` action). #### Properties ##### displayAfter ```ts displayAfter: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:62](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L62) ##### displayBefore ```ts displayBefore: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:59](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L59) ##### displayChange ```ts displayChange: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:63](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L63) ##### identity ```ts identity: RatingIdentity; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:55](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L55) ##### muAfter ```ts muAfter: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:60](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L60) ##### muBefore ```ts muBefore: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:57](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L57) ##### pool ```ts pool: string; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:56](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L56) ##### sigmaAfter ```ts sigmaAfter: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:61](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L61) ##### sigmaBefore ```ts sigmaBefore: number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:58](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L58) *** ### RatingResult Defined in: [eigen-server/packages/kernel/src/ratings.ts:47](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L47) One identity's newly computed rating — the pure OpenSkill posterior, before the store-owned CAS revision is attached by the applier. #### Extends - `Rating` #### Properties ##### identity ```ts identity: RatingIdentity; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L48) *** ### Rejected Defined in: [eigen-server/packages/kernel/src/errors.ts:42](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L42) An intent the kernel refused. A value, not a throw — rejections are part of the normal protocol. #### Properties ##### code ```ts code: RejectCode; ``` Defined in: [eigen-server/packages/kernel/src/errors.ts:44](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L44) ##### message ```ts message: string; ``` Defined in: [eigen-server/packages/kernel/src/errors.ts:45](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L45) ##### rejected ```ts rejected: true; ``` Defined in: [eigen-server/packages/kernel/src/errors.ts:43](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L43) *** ### Seat Defined in: [eigen-server/packages/kernel/src/commit.ts:47](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L47) One seat of the roster. Both ids null ⇒ the account was purged mid-game (the seat plays on as "Deleted User" for display, but can never act). #### Properties ##### botId ```ts botId: string | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:50](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L50) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L48) ##### type ```ts type: "bot" | "human"; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L51) ##### userId ```ts userId: string | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:49](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L49) *** ### SeatView Defined in: [eigen-server/packages/kernel/src/guards.ts:86](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L86) A seat's stored projection at one version — what the same-view compare runs on (and what the DO persists per transition as `frames[]`). #### Properties ##### data ```ts data: JsonObject; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:87](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L87) ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:88](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L88) *** ### StateRow Defined in: [eigen-server/packages/kernel/src/commit.ts:56](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L56) The latest committed transition — state plus the engine-owned clocks. All instants are epoch milliseconds. #### Properties ##### deadline ```ts deadline: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:63](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L63) The true turn deadline shown to clients; the alarm arms at `deadline + grace`. ##### pending ```ts pending: number[]; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:59](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L59) ##### playerTimes ```ts playerTimes: number[] | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:65](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L65) Per-seat budget banks (ms), budget mode only. ##### rngSeed ```ts rngSeed: string; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:60](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L60) ##### state ```ts state: JsonObject; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:58](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L58) ##### turnStartedAt ```ts turnStartedAt: number | null; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:66](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L66) ##### version ```ts version: number; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:57](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L57) ## Type Aliases ### Effect ```ts type Effect = | { botId: string; kind: "wakeBot"; seat: number; } | { kind: "notifyTurn"; seat: number; userId: string; } | { kind: "notifyFinished"; userIds: string[]; }; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:137](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L137) A push/wake the host should attempt post-commit (single attempt + error log — no retry machinery in v1). The kernel names seats; the host resolves delivery (FCM targets, bot webhook vs local bot). *** ### GameStatus ```ts type GameStatus = "waiting" | "ready" | "active" | "finished" | "aborted"; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:29](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L29) *** ### Intent ```ts type Intent = | { kind: "start"; seed: string; } | { actor: "user" | "bot"; data: unknown; expectedVersion: number; kind: "action"; seat: number; } | { kind: "lifecycle"; type: "timeout"; } | { kind: "lifecycle"; seat: number; type: "forfeit" | "autoForfeit"; }; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:73](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L73) What the host asks the kernel to do — the kernel-facing half of a `Command` (authorization already happened at the edge; dedupe at the DO). #### Union Members ##### Type Literal ```ts { kind: "start"; seed: string; } ``` ###### kind ```ts kind: "start"; ``` ###### seed ```ts seed: string; ``` The game's base RNG seed, freshly generated by the host (`randomSeed()`); stored on v0 and copied to every later row. *** ##### Type Literal ```ts { actor: "user" | "bot"; data: unknown; expectedVersion: number; kind: "action"; seat: number; } ``` ###### actor ```ts actor: "user" | "bot"; ``` ###### data ```ts data: unknown; ``` The raw move payload — parsed against the unit's action schema. ###### expectedVersion ```ts expectedVersion: number; ``` The version the client computed the move against. Equal to the current version in the common case; a *lower* value is arbitrated by the same-view rule. ###### kind ```ts kind: "action"; ``` ###### seat ```ts seat: number; ``` *** ##### Type Literal ```ts { kind: "lifecycle"; type: "timeout"; } ``` *** ##### Type Literal ```ts { kind: "lifecycle"; seat: number; type: "forfeit" | "autoForfeit"; } ``` `forfeit` = a voluntary resign (a user action); `autoForfeit` = the engine-driven variant (account purge; identity-less system action). *** ### ParseResult ```ts type ParseResult<T> = | { ok: true; value: T; } | { message: string; ok: false; }; ``` Defined in: [eigen-server/packages/kernel/src/schema.ts:13](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/schema.ts#L13) A client payload parse: refusal is the caller's fault, so failure comes back as a value for `commit()` to turn into a rejection. #### Type Parameters | Type Parameter | | ------ | | `T` | *** ### RejectCode ```ts type RejectCode = | "notActive" | "notReady" | "expired" | "notPending" | "stateUpdated" | "invalidPayload" | "illegalMove" | "abstain"; ``` Defined in: [eigen-server/packages/kernel/src/errors.ts:20](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L20) Why an intent was refused. Stable machine codes — the host's transport mapping and the client's retry policy key on these, so treat renames as breaking. *** ### TransitionAction ```ts type TransitionAction = | { data: JsonObject; kind: "game"; playerIndex: number; type: "user" | "bot"; } | { data: LifecycleAction; kind: "lifecycle"; playerIndex: number | null; type: ActionType; } | { data: { deltas: RatingDelta[]; }; kind: "ratings"; playerIndex: null; type: "system"; }; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:132](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L132) The action-log entry for a transition. Null only for the start transition (v0), which no action produced. `playerIndex` is the performer's seat — null for identity-less system actions (timeout, auto-forfeit). The `ratings` variant is engine-owned, never produced by `commit()`: the host appends it as the post-finish ratings transition (step 3) once the D1 apply returns the deltas. Game hooks never see it — its data is the engine's, not the game's opaque payload. ## Variables ### DEADLINE\_GRACE\_MS ```ts const DEADLINE_GRACE_MS: 750 = 750; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:25](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L25) Grace window (ms) added to every deadline comparison so a player who submits on time is not rejected because network latency carried the request past the deadline. Keep it small relative to per-action `turnSeconds` windows. The client's display-only `kServerDeadlineGrace` mirrors this. ## Functions ### assertBudgetPending() ```ts function assertBudgetPending( budgetSeconds, envelope, schemaVersion): void; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:26](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L26) Enforce budget mode's sequential-pending rule at the source: an accumulated clock only meters individual thinking time when at most one seat drains it, so a hook returning a multi-seat pending set in a budget-timed game is a game bug. `computeNextDeadline`'s MIN-over-pending remains the graceful-degradation safeguard should such a state ever be reached. No-op when the game has no budget clock. #### Parameters | Parameter | Type | | ------ | ------ | | `budgetSeconds` | `number` \| `null` | | `envelope` | `Envelope` | | `schemaVersion` | `number` | #### Returns `void` *** ### assertForfeitPending() ```ts function assertForfeitPending( targetSeat, envelope, schemaVersion): void; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:36](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L36) Enforce that a forfeit actually removes the forfeited seat: a hook that leaves `targetSeat` in the pending set is a game bug. Left uncaught, the account-deletion purge would turn that seat into a ghost — no identity, yet still holding a deadline the timeout alarm fires at forever. #### Parameters | Parameter | Type | | ------ | ------ | | `targetSeat` | `number` | | `envelope` | `Envelope` | | `schemaVersion` | `number` | #### Returns `void` *** ### assertHookPayload() ```ts function assertHookPayload<T>( schema, value, what): asserts value is T; ``` Defined in: [eigen-server/packages/kernel/src/schema.ts:63](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/schema.ts#L63) Validate a payload produced by a game hook. Unlike client parsing, a failure is always a game bug. Validate-only: callers retain the hook's original object so a schema library cannot silently normalize or strip a value on the game's behalf. #### Type Parameters | Type Parameter | | ------ | | `T` | #### Parameters | Parameter | Type | | ------ | ------ | | `schema` | `StandardSchemaV1`\<`unknown`, `T`\> | | `value` | `unknown` | | `what` | `string` | #### Returns `asserts value is T` *** ### assertHookState() ```ts function assertHookState( schemas, envelope, schemaVersion): void; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:16](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L16) Validate the state a hook returned against the game's version schema before it is committed — catching a hook that wrote a malformed or wrong-version shape at the source instead of on the next read. Validate-only: the original envelope object is what gets persisted. #### Parameters | Parameter | Type | | ------ | ------ | | `schemas` | `GameSchemas` | | `envelope` | `Envelope` | | `schemaVersion` | `number` | #### Returns `void` *** ### assertPendingIdentified() ```ts function assertPendingIdentified( roster, envelope, schemaVersion): void; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L48) Enforce that every pending seat has someone behind it: a seat whose account was purged mid-game (both ids null) can never act, so a hook that returns it as pending is a game bug — typically rules deriving pending from the participant count instead of from who is still in the game. Backstop to [assertForfeitPending](#assertforfeitpending): that one catches the forfeit itself; this one catches any later hook resurrecting the seat. #### Parameters | Parameter | Type | | ------ | ------ | | `roster` | readonly \{ `botId`: `string` \| `null`; `playerIndex`: `number`; `userId`: `string` \| `null`; \}[] | | `envelope` | `Envelope` | | `schemaVersion` | `number` | #### Returns `void` *** ### canonicalJson() ```ts function canonicalJson(value): string; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:69](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L69) Canonical JSON: deterministic serialization with object keys sorted and `undefined` object values treated as absent — so two structurally equal views compare byte-identical regardless of construction order. #### Parameters | Parameter | Type | | ------ | ------ | | `value` | `Json` \| `undefined` | #### Returns `string` *** ### commit() ```ts function commit(input): CommitPlan | Rejected; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:166](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L166) #### Parameters | Parameter | Type | | ------ | ------ | | `input` | [`CommitInput`](#commitinput) | #### Returns [`CommitPlan`](#commitplan) \| [`Rejected`](#rejected) *** ### computeNextDeadline() ```ts function computeNextDeadline(input): NextDeadline; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:67](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L67) Computes the deadline and `turnStartedAt` for the next action — the precedence chain used by start and every commit mode. Pass `gameOver = true` when the transition ends the game. 1. game over → both null 2. hook returned `turnSeconds` N → now + N s (banks untouched) 3. budget mode → now + MIN remaining bank over the new pending set 4. per-action mode → now + configured `turnSeconds` 5. untimed → both null Budget mode allows at most one pending seat — enforced at the source by `assertBudgetPending` before any envelope reaches this; the MIN remains the graceful-degradation safeguard should a multi-pending state arrive anyway. #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `input` | \{ `actionSeconds`: `number` \| `null`; `budgetSeconds`: `number` \| `null`; `gameOver`: `boolean`; `newPending`: readonly `number`[]; `newPlayerTimes`: readonly `number`[] \| `null`; `now`: `number`; `turnSeconds`: `number` \| `null`; \} | - | | `input.actionSeconds` | `number` \| `null` | The hook's per-action override (envelope `turnSeconds`), else null. | | `input.budgetSeconds` | `number` \| `null` | - | | `input.gameOver` | `boolean` | - | | `input.newPending` | readonly `number`[] | - | | `input.newPlayerTimes` | readonly `number`[] \| `null` | - | | `input.now` | `number` | - | | `input.turnSeconds` | `number` \| `null` | - | #### Returns [`NextDeadline`](#nextdeadline) *** ### computeRatings() ```ts function computeRatings(players): RatingResult[]; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:196](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L196) Compute every identity's new rating for one finished game. Exactly one result per identity — humans and bots alike — matching the one rating row per (game, identity) the store keeps. The field is rated once; single-seat identities read their posterior straight from that rating, while a multi-seat identity is re-rated seat-by-seat into a single net result (see `multiSeatUpdate`). The single full-field `rate()` is what every single-seat player is scored against, so a human who faced a two-seat bot is correctly rated against two distinct opponents. A seat with no identity — its account was purged mid-game — stays in the field (opponents' posteriors must account for everyone they actually faced, at that seat's supplied baseline) but yields no result: there is no rating row left to update. #### Parameters | Parameter | Type | | ------ | ------ | | `players` | [`PlayerInput`](#playerinput)[] | #### Returns [`RatingResult`](#ratingresult)[] *** ### deadlineExpired() ```ts function deadlineExpired(deadline, now): boolean; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:30](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L30) TRUE once a turn deadline (plus the grace window) has genuinely passed, measured against the injected `now`. A null deadline (untimed turn) is never expired. #### Parameters | Parameter | Type | | ------ | ------ | | `deadline` | `number` \| `null` | | `now` | `number` | #### Returns `boolean` *** ### deductBank() ```ts function deductBank( playerTimes, playerIndex, now, turnStartedAt, incrementSeconds): number[]; ``` Defined in: [eigen-server/packages/kernel/src/timing.ts:38](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/timing.ts#L38) Deducts the acting player's elapsed thinking time from their budget bank and applies the Fischer increment. Returns a new `playerTimes` array (ms banks, one per seat). Floored at 0: a player who overran their bank lands at 0, not negative. #### Parameters | Parameter | Type | | ------ | ------ | | `playerTimes` | readonly `number`[] | | `playerIndex` | `number` | | `now` | `number` | | `turnStartedAt` | `number` \| `null` | | `incrementSeconds` | `number` \| `null` | #### Returns `number`[] *** ### defaultRating() ```ts function defaultRating(): Rating; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:73](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L73) The OpenSkill prior for a never-rated identity. #### Returns `Rating` *** ### deriveRng() ```ts function deriveRng(seed, version): Rng; ``` Defined in: [eigen-server/packages/kernel/src/rng.ts:29](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/rng.ts#L29) The deterministic RNG for one transition: rand-seed's sfc32 keyed by the game's base seed and the state version the envelope will commit as. The same `(seed, version)` always yields the same draw sequence — a replay re-derives it — and every transition gets an independent stream, so hooks draw as many values as they need with no cross-invocation state. The derivation is fixed, so recorded games stay replayable. #### Parameters | Parameter | Type | | ------ | ------ | | `seed` | `string` | | `version` | `number` | #### Returns `Rng` *** ### displayRating() ```ts function displayRating(mu, sigma): number; ``` Defined in: [eigen-server/packages/kernel/src/ratings.ts:68](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/ratings.ts#L68) max(0, round((mu − 3σ) · 40)) — the one server-side home of the display formula (the client mirrors it for optimistic display only). #### Parameters | Parameter | Type | | ------ | ------ | | `mu` | `number` | | `sigma` | `number` | #### Returns `number` *** ### fanOutObservations() ```ts function fanOutObservations(rules, args): ObservationFrame[]; ``` Defined in: [eigen-server/packages/kernel/src/observe.ts:25](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/observe.ts#L25) Project the new state into one slice per seat — the eager fan-out the host persists per transition (frames serve live delivery and the same-view compare, so they stay eager). `rules` is the game's own version unit, already resolved by the caller. `args` is the hook's own contract minus the per-seat `playerIndex`, which the loop supplies; the body still forwards each field explicitly so a new hook arg forces a per-seat-or-shared decision here. #### Parameters | Parameter | Type | | ------ | ------ | | `rules` | `GameRules` | | `args` | `Omit`\<`ComputeObservationArgs`, `"playerIndex"`\> | #### Returns [`ObservationFrame`](#observationframe)[] *** ### isRejected() ```ts function isRejected(result): result is Rejected; ``` Defined in: [eigen-server/packages/kernel/src/commit.ts:160](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/commit.ts#L160) Type guard: did `commit()` refuse the intent? #### Parameters | Parameter | Type | | ------ | ------ | | `result` | [`CommitPlan`](#commitplan) \| [`Rejected`](#rejected) | #### Returns `result is Rejected` *** ### parseClientPayload() ```ts function parseClientPayload<T>( schema, value, what): ParseResult<T>; ``` Defined in: [eigen-server/packages/kernel/src/schema.ts:40](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/schema.ts#L40) Parse a client-submitted payload (an action's `data`, a create request's `config`) through its schema. Failure is the caller's fault. Returns the parsed value, so what flows onward — into hooks and the action log — is the sanitized shape (unknown keys stripped, defaults applied), never the raw submission. #### Type Parameters | Type Parameter | | ------ | | `T` | #### Parameters | Parameter | Type | | ------ | ------ | | `schema` | `StandardSchemaV1`\<`unknown`, `T`\> | | `value` | `unknown` | | `what` | `string` | #### Returns [`ParseResult`](#parseresult)\<`T`\> *** ### parseStoredPayload() ```ts function parseStoredPayload<T>( schema, value, what, schemaVersion): T; ``` Defined in: [eigen-server/packages/kernel/src/schema.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/schema.ts#L51) Parse a stored payload (a state row, the game's config) through its schema. Failure means corrupted data or a schema that no longer matches what this version historically wrote — an engine-side bug, thrown. #### Type Parameters | Type Parameter | | ------ | | `T` | #### Parameters | Parameter | Type | | ------ | ------ | | `schema` | `StandardSchemaV1`\<`unknown`, `T`\> | | `value` | `unknown` | | `what` | `string` | | `schemaVersion` | `number` | #### Returns `T` *** ### randomSeed() ```ts function randomSeed(): string; ``` Defined in: [eigen-server/packages/kernel/src/rng.ts:18](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/rng.ts#L18) A fresh base seed for a new game: 128 random bits, hex-encoded. Stored on the game's v0 state row and copied onto every later row (server-only — never expose it: the whole randomness of the game is derivable from it). #### Returns `string` *** ### reject() ```ts function reject(code, message): Rejected; ``` Defined in: [eigen-server/packages/kernel/src/errors.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/errors.ts#L48) #### Parameters | Parameter | Type | | ------ | ------ | | `code` | [`RejectCode`](#rejectcode) | | `message` | `string` | #### Returns [`Rejected`](#rejected) *** ### sameView() ```ts function sameView(a, b): boolean; ``` Defined in: [eigen-server/packages/kernel/src/guards.ts:100](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/kernel/src/guards.ts#L100) The same-view rule: a stale-`expectedVersion` action is accepted iff the acting seat's own projected observation — slice `data` plus the seat's *observed* pending set — is identical between the expected and current versions, ignoring version/timing bookkeeping. Identical view ⇒ the intent transfers soundly (and `applyAction` still validates legality against the true current state); changed view ⇒ the conflict is genuine and "state updated" is literally true. The implementor controls this policy implicitly through `computeObservation`: reveal an event and it invalidates pending stale submissions; hide it and they survive. #### Parameters | Parameter | Type | | ------ | ------ | | `a` | [`SeatView`](#seatview) | | `b` | [`SeatView`](#seatview) | #### Returns `boolean` --- ## @eigeninteractive/rules `@eigeninteractive/rules` — the contract a game implements. A `GameModule` bundles one `GameRules` unit per schema version; the engine calls its hooks and never inspects game state directly. This package is types plus a couple of helpers: it has no runtime dependencies and pulls in no engine code. ## Classes ### IllegalMoveError Defined in: [eigen-server/packages/rules/src/helpers.ts:14](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/helpers.ts#L14) Thrown by a game's `applyAction` to reject a move that breaks the rules — the *expected* failure of the hook (a mis-tap, a client bug), rendered to the caller as their error. Anything else a hook throws is treated as a game bug and surfaces as a server error. Domain-level on purpose: the game states "this move is illegal", the engine owns the transport mapping. #### Extends - `Error` #### Constructors ##### Constructor ```ts new IllegalMoveError(message?): IllegalMoveError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | ###### Returns [`IllegalMoveError`](#illegalmoveerror) ###### Inherited from ```ts Error.constructor ``` ##### Constructor ```ts new IllegalMoveError(message?, options?): IllegalMoveError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | | `options?` | `ErrorOptions` | ###### Returns [`IllegalMoveError`](#illegalmoveerror) ###### Inherited from ```ts Error.constructor ``` ## Interfaces ### ApplyActionArgs Defined in: [eigen-server/packages/rules/src/contract.ts:122](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L122) #### Extends - `HookContext`\<`TConfig`\> #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L113) ###### Inherited from ```ts HookContext.config ``` ##### data ```ts data: TAction; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:125](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L125) ##### pending ```ts pending: number[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:124](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L124) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:126](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L126) ##### rng ```ts rng: Rng; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:128](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L128) Deterministic per-transition RNG — see [Rng](#rng-4). ##### state ```ts state: TState; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:123](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L123) *** ### ApplyLifecycleArgs Defined in: [eigen-server/packages/rules/src/contract.ts:140](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L140) #### Extends - `HookContext`\<`TConfig`\> #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L113) ###### Inherited from ```ts HookContext.config ``` ##### data ```ts data: LifecycleAction; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:149](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L149) ##### pending ```ts pending: number[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:146](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L146) Seats awaiting an action. For `timeout` these are exactly the seats that ran out of time — resolve the whole set in one envelope (you may declare a draw). For `forfeit`/`autoForfeit`, the target seat is in `data.playerIndex`. ##### rng ```ts rng: Rng; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:151](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L151) Deterministic per-transition RNG — see [Rng](#rng-4). ##### state ```ts state: TState; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:141](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L141) ##### type ```ts type: LifecycleType; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:148](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L148) The trigger — always equal to `data.type`. *** ### BotActionArgs Defined in: [eigen-server/packages/rules/src/contract.ts:221](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L221) A seated engine bot's turn to move, passed to the matching entry in [GameRules.botActions](#botactions). The brain runs inside the game's Durable Object post-commit and sees exactly what a human at this seat would (`observation` — the same fog-of-war projection, so a bot cannot read hidden state its seat may not); `botConfig` is that bot registry row's declared knob (difficulty, personality). The engine self-applies the returned move as this seat's action, validated against `schemas.action` exactly like a human move. `rng` is deterministic per (game, version, seat) for reproducible tests — but the chosen move is what gets logged, so the brain need not be pure (replay uses the recorded action, never re-runs the brain). #### Extends - `HookContext`\<`TConfig`\> #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TObservation` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### botConfig ```ts botConfig: JsonObject; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:223](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L223) ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L113) ###### Inherited from ```ts HookContext.config ``` ##### observation ```ts observation: ObservationSlice<TObservation>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:222](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L222) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:224](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L224) ##### rng ```ts rng: Rng; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:225](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L225) *** ### BotSeatableArgs Defined in: [eigen-server/packages/rules/src/contract.ts:206](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L206) A candidate bot seating, passed to [GameRules.botSeatable](#botseatable). `gameConfig` is parsed against the game's version schema; `botConfig` is the bot's declared capabilities — game-owned but unversioned by the game schemas, so it stays opaque. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### botConfig ```ts botConfig: JsonObject; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:208](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L208) ##### gameConfig ```ts gameConfig: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:207](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L207) *** ### ComputeObservationArgs Defined in: [eigen-server/packages/rules/src/contract.ts:170](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L170) #### Extends - `HookContext`\<`TConfig`\> #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### cause ```ts cause: TransitionCause<TAction>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:182](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L182) What produced `state` — see [TransitionCause](#transitioncause). Shared across the per-seat fan-out; per-seat filtering of what it reveals is this hook's job. ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L113) ###### Inherited from ```ts HookContext.config ``` ##### isReplay ```ts isReplay: boolean; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:185](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L185) TRUE only when projecting a finished game for replay — hidden-info games may reveal opponent state. ##### participantCount ```ts participantCount: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:178](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L178) ##### pending ```ts pending: number[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:172](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L172) ##### playerIndex ```ts playerIndex: number | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:177](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L177) The seat this projection is for, or `null` for a viewer (a non-participant replaying a public game). A viewer projection only ever occurs with `isReplay` true (a public finished game), so a game may safely reveal the full post-game view for it. ##### state ```ts state: TState; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:171](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L171) *** ### Envelope Defined in: [eigen-server/packages/rules/src/contract.ts:81](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L81) The result of advancing the game by one transition — the return of `initialState`, `applyAction`, and `applyLifecycle`. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### outcome? ```ts optional outcome?: OutcomeEntry[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:89](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L89) Present **only** when the game ends. Absent/undefined means ongoing. ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:87](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L87) 0-based seats that may act next. Empty ⇒ game over. ##### state ```ts state: TState; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:85](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L85) New pure game payload (board, deck, fog…). Never carries whose-turn or winner info — those are engine-owned fields. Must match the game's `schemaVersion` schema — the engine validates it before committing. ##### turnSeconds? ```ts optional turnSeconds?: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:92](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L92) Optional per-action deadline override for *this action only* (does not touch any player's bank). Omit to use the game's configured timing. *** ### GameModule Defined in: [eigen-server/packages/rules/src/contract.ts:348](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L348) The complete game-specific surface — the same-named twin of the Dart `GameModule` (whose extras are client-only creation/about UI). Implement this once per app and pass it to `createEngine`; the engine owns all version dispatch — every request resolves the game's `schemaVersion` entry from [versions](#versions) and invokes that unit's hooks. Game code never branches on version. #### Properties ##### versions ```ts versions: Record<number, AnyGameRules>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:356](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L356) The [GameRules](#gamerules) units keyed by `schemaVersion` — exactly the versions this build ships. Sparse on purpose: game creation rejects a version not present here, loading a stored game requires its version's entry, and a drained old version is retired by deleting its entry. The value type is [AnyGameRules](#anygamerules) — each entry is authored against its concrete payload types and erased here; safe because the engine parses each payload with the same entry's schemas before invoking its hooks. *** ### GameRules Defined in: [eigen-server/packages/rules/src/contract.ts:264](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L264) Everything one `schemaVersion` of a game needs: the payload contracts plus all six hooks, narrowly typed to that version's shapes. The type parameters are the version's payload types, inferred from the schemas in [schemas](#schemas) (`z.infer<typeof stateSchema>` etc. — use `type` aliases, not `interface`s). The engine parses every payload with this unit's schemas before invoking its hooks, so hook bodies never see unvalidated JSON — and never another version's shape. When rules or shapes change incompatibly, ship a new `GameRules` under the next version key (reusing unchanged pieces by import) instead of branching inside hooks. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TObservation` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### botActions? ```ts optional botActions?: Record<string, BotAction<TAction, TObservation, TConfig>>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:319](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L319) Optional — the in-DO bot brains, **keyed by bot username**. When a seated `engine`-type bot's turn starts, the engine resolves its registry row's `username`, looks the move function up here, runs it post-commit, and self-applies the returned move — so a bot game needs no external service. Several bots that share behaviour point their usernames at the same function and differ by their per-row `botConfig`; distinct behaviour is a distinct entry. A seated engine bot whose username is absent here (or an `external` bot with no `webhook_url`) is rejected at seating. The returned move is validated against `schemas.action` and an illegal one is rejected exactly like a human's, so a buggy brain fails that seat's turn (the deadline backstops it) rather than corrupting the game. ##### schemas ```ts schemas: GameSchemas<TState, TObservation, TAction, TConfig>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:266](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L266) The payload contracts for this version. #### Methods ##### applyAction() ```ts applyAction(args): Envelope<TState>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:277](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L277) Apply a player's move. The engine has already confirmed it is this seat's turn at the expected version, so do not re-check turn order — only validate move legality and throw [IllegalMoveError](#illegalmoveerror) if it fails; the engine renders it as the caller's error. Any other throw is a game bug and surfaces as a server error. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`ApplyActionArgs`](#applyactionargs)\<`TState`, `TAction`, `TConfig`\> | ###### Returns [`Envelope`](#envelope)\<`TState`\> ##### applyLifecycle() ```ts applyLifecycle(args): Envelope<TState>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:284](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L284) Resolve a lifecycle action (`forfeit`/`timeout`) into an envelope. Lifecycle actions operate on the game from outside its rules — they may be player-triggered (a resign) or engine-triggered (timeout, purge); either way the consequence is the game's to decide. Unlike `applyAction` it cannot be "illegal" — it always resolves. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`ApplyLifecycleArgs`](#applylifecycleargs)\<`TState`, `TConfig`\> | ###### Returns [`Envelope`](#envelope)\<`TState`\> ##### botSeatable() ```ts botSeatable(args): boolean; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:306](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L306) Decide whether a bot's declared capabilities (`botConfig`) support a game with `gameConfig`. The engine gates seating on this before committing; the Dart `GameRules` twin filters the bot pickers locally. Return `true` to allow. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`BotSeatableArgs`](#botseatableargs)\<`TConfig`\> | ###### Returns `boolean` ##### computeObservation() ```ts computeObservation(args): ObservationSlice<TObservation>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:292](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L292) Project the state into one seat's view — including what that seat may see of the transition that produced it (`args.cause`), so the client can animate. Perfect-info games can use the `passthroughObservation` helper (which ignores the cause). What this hook reveals also implicitly sets the simultaneous-move policy: a stale submission survives exactly while the acting seat's projected view is unchanged (the same-view rule). ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`ComputeObservationArgs`](#computeobservationargs)\<`TState`, `TAction`, `TConfig`\> | ###### Returns [`ObservationSlice`](#observationslice)\<`TObservation`\> ##### initialState() ```ts initialState(args): Envelope<TState>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:270](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L270) Starting envelope. Draw any setup randomness (deck shuffle, first player…) from `args.rng`. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`InitialStateArgs`](#initialstateargs)\<`TConfig`\> | ###### Returns [`Envelope`](#envelope)\<`TState`\> ##### ratingPool() ```ts ratingPool(args): string | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:300](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L300) Decide whether — and in which pool — a game with these settings is rated. Return the pool name (e.g. `'rapid'`) or `null` for unrated. The engine computes `canBeRated = pool != null && !guest` and validates the client's concrete `rated` assertion against it (rejecting a mismatch). The Dart `GameRules` keeps a twin of this so the create dialog can gate the Rated/Casual toggle and send the same value. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`RatingPoolArgs`](#ratingpoolargs)\<`TConfig`\> | ###### Returns `string` \| `null` *** ### GameSchemas Defined in: [eigen-server/packages/rules/src/contract.ts:241](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L241) The declarative payload contracts for one `schemaVersion`: the Standard Schemas the engine uses to parse (and validate) every game payload crossing the JSON boundary. Keep them transform-free — what parses is what persists, and the engine re-validates hook-returned state against `state`. Schemas must validate **synchronously** (every mainstream library does unless you opt into async refinements) — the engine rejects an async schema as a game bug. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TObservation` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### action ```ts action: GamePayloadSchema<TAction>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:247](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L247) A player move's `data`, as submitted by clients and bots. ##### config ```ts config: GamePayloadSchema<TConfig>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:249](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L249) The per-instance creation config stored on the game. ##### observation ```ts observation: GamePayloadSchema<TObservation>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:245](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L245) One participant's projected view, as returned by `computeObservation`. ##### state ```ts state: GamePayloadSchema<TState>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:243](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L243) The pure game payload stored per transition. *** ### InitialStateArgs Defined in: [eigen-server/packages/rules/src/contract.ts:116](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L116) #### Extends - `HookContext`\<`TConfig`\> #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L113) ###### Inherited from ```ts HookContext.config ``` ##### playerCount ```ts playerCount: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:119](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L119) ##### rng ```ts rng: Rng; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:118](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L118) Deterministic RNG for this transition — see [Rng](#rng-4). *** ### JsonObject Defined in: [eigen-server/packages/rules/src/json.ts:23](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/json.ts#L23) A JSON object — the shape of `state`, `config`, `data`, and observation slices, and the constraint every game payload type must satisfy. An `interface` for the same lazy-resolution reason as `JsonArray`. Declare *game payload* types as `type` aliases (e.g. via your schema library's inference), not `interface`s — a payload `interface` lacks the implicit index signature this constraint relies on. #### Indexable ```ts [key: string]: Json | undefined ``` *** ### ObservationSlice Defined in: [eigen-server/packages/rules/src/contract.ts:96](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L96) One participant's view of the state, produced by `computeObservation`. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TObservation` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### data ```ts data: TObservation; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:98](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L98) What this seat is permitted to see. ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:103](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L103) Pending set as this seat sees it — may be narrowed from the true set for hidden-info games (e.g. a Nope window, or a simultaneous-commit round where revealing that the opponent moved would leak information). It must stay truthful about the seat *itself* — the engine enforces that. *** ### RatingPoolArgs Defined in: [eigen-server/packages/rules/src/contract.ts:192](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L192) The chosen game settings, passed to [GameRules.ratingPool](#ratingpool) at creation so the game can decide its rating pool (or that the game is unrated). `config` is already parsed against the requested version's config schema. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Properties ##### access ```ts access: GameAccess; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:193](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L193) ##### budgetSeconds ```ts budgetSeconds: number | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:195](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L195) ##### config ```ts config: TConfig; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:199](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L199) ##### incrementSeconds ```ts incrementSeconds: number | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:196](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L196) ##### maxPlayers ```ts maxPlayers: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:198](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L198) ##### minPlayers ```ts minPlayers: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:197](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L197) ##### turnSeconds ```ts turnSeconds: number | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:194](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L194) *** ### Rng Defined in: [eigen-server/packages/rules/src/contract.ts:53](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L53) Deterministic per-transition random source, derived by the engine from the game's stored base seed and the state version the envelope commits as. Draw freely (`next()` → float in `[0, 1)`, stateful within the invocation); replaying the transition re-derives the identical sequence, so the game stays a pure function of (base seed, action log) — provided the hook draws in deterministic code order. #### Methods ##### next() ```ts next(): number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:54](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L54) ###### Returns `number` ## Type Aliases ### ActionKind ```ts type ActionKind = "game" | "lifecycle"; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:43](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L43) Which species a logged action is. Everything that transitions state is an *action*; the two species differ by contract: a `game` action is rules-scoped (game-defined payload, validated by `applyAction`, rejectable as illegal), a `lifecycle` action is engine-scoped (a [LifecycleAction](#lifecycleaction) payload, resolved unconditionally by `applyLifecycle`). Stamped on every logged transition, so replay classifies the log structurally, never by payload shape. *** ### ActionType ```ts type ActionType = "user" | "bot" | "system"; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:34](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L34) Who performed a logged action. *** ### AnyGameRules ```ts type AnyGameRules = GameRules<any, any, any, any>; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:338](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L338) A [GameRules](#gamerules) unit with its payload types erased — the type of a rules entry once it is stored in a [GameModule.versions](#versions) registry that holds *many* games'/versions' rules whose concrete `TState`/`TAction`/`TConfig` genuinely differ. That container needs "a `GameRules` for *some* payload types", an existential TypeScript cannot spell; `any` is the one sanctioned escape for it (`unknown` cannot — the config/action params are contravariant input positions). It is **safe** here because the engine re-validates every payload against that entry's own `schemas` before invoking a hook, so the static type was only ever an authoring aid — redundant once the unit is registered. Authors keep full type-checking by writing `class X implements GameRules<State, Observation, Action, Config>` (or annotating a literal `: GameRules<…>`); assigning that into a `versions` map just works, with no `as`-cast, because `any` disables the variance check at this seam. *** ### BotAction ```ts type BotAction<TAction, TObservation, TConfig> = (args) => TAction; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:230](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L230) One engine bot's move function — the value type in [GameRules.botActions](#botactions). #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TObservation` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | #### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`BotActionArgs`](#botactionargs)\<`TObservation`, `TConfig`\> | #### Returns `TAction` *** ### GameAccess ```ts type GameAccess = "public" | "private" | "friends"; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:31](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L31) Game visibility. *** ### GamePayloadSchema ```ts type GamePayloadSchema<Payload> = StandardSchemaV1<Payload, Payload> & StandardJSONSchemaV1<Payload, Payload>; ``` Defined in: [eigen-server/packages/rules/src/standard-json-schema.ts:8](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/standard-json-schema.ts#L8) One game payload declaration: runtime validation plus portable schema emission from the same transform-free object. Input and output deliberately share one type: what parses is what persists and what Dart generates. #### Type Parameters | Type Parameter | | ------ | | `Payload` | *** ### GameResult ```ts type GameResult = "win" | "loss" | "draw" | "eliminated"; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:28](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L28) Per-player result of a finished game. *** ### Json ```ts type Json = string | number | boolean | null | JsonArray | JsonObject; ``` Defined in: [eigen-server/packages/rules/src/json.ts:10](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/json.ts#L10) Any JSON value. `undefined` is allowed inside objects (treated as an absent key, matching how schema libraries model optional fields); it never survives serialization. *** ### LifecycleAction ```ts type LifecycleAction = | { type: "timeout"; } | { playerIndex: number; type: "forfeit" | "autoForfeit"; }; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:138](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L138) The engine-constructed payload of a lifecycle action, recorded verbatim in the action log (with `kind = 'lifecycle'`). Engine-owned and version-independent: every game gets these transitions for free, without declaring them in its schemas. `forfeit` carries the forfeiting seat (a voluntary resign); `autoForfeit` is the engine-driven variant (account purge); `timeout` carries no seat — the affected seats are [ApplyLifecycleArgs.pending](#pending-1). *** ### LifecycleType ```ts type LifecycleType = "timeout" | "forfeit" | "autoForfeit"; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:25](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L25) The trigger of a lifecycle action, resolved by the game's `applyLifecycle` hook. `forfeit` is a voluntary resign; `autoForfeit` the engine-driven variant (account-deletion purge); `timeout` is the clock. The two forfeits share a shape (both target `data.playerIndex`) and most games resolve them identically — but the hook receives the real trigger, so a game may choose different consequences (e.g. a draw rather than a loss when the seat was purged). *** ### OutcomeEntry ```ts type OutcomeEntry = { placement: number; playerIndex: number; result: GameResult; score?: number | null; teamIndex: number; }; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:68](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L68) One participant's result, recorded when the game ends. `placement` (1 = best, ties share a value) feeds OpenSkill directly; `teamIndex` groups players rated together (use `playerIndex` for individual games). A `type` alias, not an `interface`, on purpose: outcomes are JSON payloads (persisted, compared by fixture runners), and only a type alias gets the implicit index signature that makes it assignable to `Json`. #### Properties ##### placement ```ts placement: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:71](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L71) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:69](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L69) ##### result ```ts result: GameResult; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:70](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L70) ##### score? ```ts optional score?: number | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:74](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L74) Optional raw game score, for display or score-based variants. ##### teamIndex ```ts teamIndex: number; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:72](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L72) *** ### TransitionCause ```ts type TransitionCause<TAction> = | { data: TAction; kind: "game"; playerIndex: number; } | { data: LifecycleAction; kind: "lifecycle"; } | null; ``` Defined in: [eigen-server/packages/rules/src/contract.ts:168](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/contract.ts#L168) The action that produced the state being projected — a `game` action (`applyAction`), a `lifecycle` action (`applyLifecycle`), or `null` for the initial frame (`initialState`), which no action produced. This is how a game tells each seat *what happened* — pure frame diffing can't recover causality (identical footprints, hidden-info moves, composite resolutions). Embed whatever animation/narration cues a seat is permitted to see into that seat's slice `data` (e.g. a `lastMove` field); visibility stays game-controlled because the embedding happens inside `computeObservation`. Cues describe a *transition*: a client should render them as animation only when it has the frame's predecessor, and as static "last move" info otherwise. #### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TAction` *extends* [`JsonObject`](#jsonobject) | [`JsonObject`](#jsonobject) | ## Functions ### passthroughObservation() ```ts function passthroughObservation<TState, TAction, TConfig>(args): ObservationSlice<TState>; ``` Defined in: [eigen-server/packages/rules/src/helpers.ts:24](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/rules/src/helpers.ts#L24) Default `computeObservation` for perfect-information games: every seat sees the full state and the true pending set. Ignores `args.cause` — a perfect-info client can usually infer the transition from consecutive frames; embed explicit cues in the slice instead when it can't. Note that under the same-view rule a passthrough game is automatically strict about simultaneous submissions: any opponent move changes every seat's view. #### Type Parameters | Type Parameter | | ------ | | `TState` *extends* [`JsonObject`](#jsonobject) | | `TAction` *extends* [`JsonObject`](#jsonobject) | | `TConfig` *extends* [`JsonObject`](#jsonobject) | #### Parameters | Parameter | Type | | ------ | ------ | | `args` | [`ComputeObservationArgs`](#computeobservationargs)\<`TState`, `TAction`, `TConfig`\> | #### Returns [`ObservationSlice`](#observationslice)\<`TState`\> --- ## @eigeninteractive/server/testing `@eigeninteractive/server/testing` — the test-auth recipe, for the engine's own suite and for implementor test workers alike: ```ts // test/worker.ts — your production entry with explicit Firebase fakes: export default createEngine({ ...sameConfig, testing: { auth: testVerifier(), firebaseAdmin: () => testFirebaseAdmin, }, }); // a spec: import { exports } from "cloudflare:workers"; await exports.default.fetch(url, { headers: await testBearer({ uid: "alice" }) }); ``` (`exports.default` is the loopback binding to the test worker's default export — the supported replacement for the deprecated `SELF` fetcher. It needs `Cloudflare.GlobalProps` to declare `mainModule`; see the engine's own `test/env.d.ts` for the hand-rolled version, or use `wrangler types`.) Tokens are verified through the SAME jose code path production uses — only the JWKS is local. The RS256 keypair below is a public fixture (checked in, shipped in the package); it protects nothing and must never reach a production config: pass `testing` ONLY in test workers. ## Interfaces ### TestTokenOptions Defined in: [eigen-server/packages/server/src/testing.ts:70](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L70) #### Properties ##### anonymous? ```ts optional anonymous?: boolean; ``` Defined in: [eigen-server/packages/server/src/testing.ts:72](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L72) ##### claims? ```ts optional claims?: Record<string, unknown>; ``` Defined in: [eigen-server/packages/server/src/testing.ts:77](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L77) Override any registered claim (e.g. an expired `exp`, a wrong `aud`). ##### email? ```ts optional email?: string; ``` Defined in: [eigen-server/packages/server/src/testing.ts:73](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L73) ##### name? ```ts optional name?: string; ``` Defined in: [eigen-server/packages/server/src/testing.ts:74](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L74) ##### picture? ```ts optional picture?: string; ``` Defined in: [eigen-server/packages/server/src/testing.ts:75](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L75) ##### uid ```ts uid: string; ``` Defined in: [eigen-server/packages/server/src/testing.ts:71](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L71) ## Variables ### TEST\_PROJECT\_ID ```ts const TEST_PROJECT_ID: "eigen-test" = "eigen-test"; ``` Defined in: [eigen-server/packages/server/src/testing.ts:36](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L36) *** ### testFirebaseAdmin ```ts const testFirebaseAdmin: FirebaseAdminEffects; ``` Defined in: [eigen-server/packages/server/src/testing.ts:60](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L60) No-op Firebase Admin effects for test workers and test Durable Objects. ## Functions ### mintTestToken() ```ts function mintTestToken(opts): Promise<string>; ``` Defined in: [eigen-server/packages/server/src/testing.ts:80](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L80) #### Parameters | Parameter | Type | | ------ | ------ | | `opts` | [`TestTokenOptions`](#testtokenoptions) | #### Returns `Promise`\<`string`\> *** ### testBearer() ```ts function testBearer(opts): Promise<Record<string, string>>; ``` Defined in: [eigen-server/packages/server/src/testing.ts:99](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L99) Authorization header for a minted token. #### Parameters | Parameter | Type | | ------ | ------ | | `opts` | [`TestTokenOptions`](#testtokenoptions) | #### Returns `Promise`\<`Record`\<`string`, `string`\>\> *** ### testVerifier() ```ts function testVerifier(): TokenVerifier; ``` Defined in: [eigen-server/packages/server/src/testing.ts:66](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/testing.ts#L66) The verifier a test worker passes under `createEngine({ testing })`. #### Returns [`TokenVerifier`](server.md#tokenverifier) --- ## @eigeninteractive/server `@eigeninteractive/server` — everything that deploys: the `createEngine` API factory, the GameDO base class, the D1 applier, and the protocol types. The D1 and Durable Object table definitions are deliberately NOT exported. They are engine-owned storage internals that migrate on their own schedule, and `readGameRow` already returns the whole game row typed. Exporting the drizzle tables would turn a private layout into a compatibility surface. ## Classes ### AuthError Defined in: [eigen-server/packages/server/src/auth/firebase.ts:12](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L12) Verification failure — always the caller's fault; the app maps it to 401. #### Extends - `Error` #### Constructors ##### Constructor ```ts new AuthError(message?): AuthError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | ###### Returns [`AuthError`](#autherror) ###### Inherited from ```ts Error.constructor ``` ##### Constructor ```ts new AuthError(message?, options?): AuthError; ``` Defined in: eigen-web/node\_modules/.pnpm/typescript@6.0.3/node\_modules/typescript/lib/lib.es5.d.ts:1080 ###### Parameters | Parameter | Type | | ------ | ------ | | `message?` | `string` | | `options?` | `ErrorOptions` | ###### Returns [`AuthError`](#autherror) ###### Inherited from ```ts Error.constructor ``` *** ### `abstract` BaseGameDO Defined in: [eigen-server/packages/server/src/do/game-do.ts:104](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L104) Durable Object base class that owns one authoritative game session. A game Worker subclasses this once to supply its [gameModule](#gamemodule) and D1 binding. Do not override command, socket, alarm, or persistence behavior: the base class owns the serialized game loop and applies engine migrations on activation. #### Example ```ts export class GameDO extends BaseGameDO<Env> { protected readonly gameModule = gameModule; protected d1(env: Env) { return env.GAME_DB; } } ``` #### Extends - `unknown`\<`TEnv`\> #### Type Parameters | Type Parameter | | ------ | | `TEnv` | #### Implements - `GameStub` #### Constructors ##### Constructor ```ts new BaseGameDO<TEnv>(ctx, env): BaseGameDO<TEnv>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:118](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L118) ###### Parameters | Parameter | Type | | ------ | ------ | | `ctx` | `DurableObjectState` | | `env` | `TEnv` | ###### Returns [`BaseGameDO`](#abstract-basegamedo)\<`TEnv`\> ###### Overrides ```ts DurableObject<TEnv>.constructor ``` #### Properties ##### gameModule ```ts abstract protected readonly gameModule: GameModule; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:106](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L106) The implementor's game — the `versions` map the engine dispatches on. #### Methods ##### abort() ```ts abort(gameId): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:262](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L262) Unconditional teardown (cron reap): mark the game aborted in D1 and drop the DO's storage — no creator gate, no init requirement. A never-touched lobby's DO has no `meta` row, so the caller passes the gameId. Idempotent: a re-run re-aborts a game whose storage is already gone. Used by the cron; `cancel` shares the teardown for its live path. ###### Parameters | Parameter | Type | | ------ | ------ | | `gameId` | `string` | ###### Returns `Promise`\<`void`\> ###### Implementation of ```ts GameStub.abort ``` ##### alarm() ```ts alarm(): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:708](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L708) ###### Returns `Promise`\<`void`\> ##### d1() ```ts abstract protected d1(env): D1Database; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:109](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L109) The EngineConfig seam: the engine never assumes binding names — the subclass picks the D1 database off its own Env. ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns `D1Database` ##### fetch() ```ts fetch(request): Promise<Response>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:732](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L732) The worker routes the upgrade here after authenticating; the principal header is worker-set (never client-supplied — the worker strips inbound headers when forwarding). One socket serves the game's whole lifetime: unversioned roster snapshots pre-game, versioned frames from v0. A not-yet-seated user's socket simply receives no frames until the roster contains them. ###### Parameters | Parameter | Type | | ------ | ------ | | `request` | `Request` | ###### Returns `Promise`\<`Response`\> ###### Implementation of ```ts GameStub.fetch ``` ##### firebaseAdmin() ```ts protected firebaseAdmin(env): FirebaseAdminEffects; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:112](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L112) Required Firebase Admin effects. Tests override this with the explicit fake exported by `@eigeninteractive/server/testing`. ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns [`FirebaseAdminEffects`](#firebaseadmineffects) ##### frames() ```ts frames(args): Promise<FrameMessage[]>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:832](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L832) Project a version range for one seat (null = public viewer, replay only). Live rows serve the stored frame; compacted/ratings rows re-project. Raw state never leaves the DO. ###### Parameters | Parameter | Type | | ------ | ------ | | `args` | \{ `from`: `number`; `isReplay?`: `boolean`; `seat`: `number` \| `null`; `to`: `number`; \} | | `args.from` | `number` | | `args.isReplay?` | `boolean` | | `args.seat` | `number` \| `null` | | `args.to` | `number` | ###### Returns `Promise`\<[`FrameMessage`](#framemessage)[]\> ###### Implementation of ```ts GameStub.frames ``` ##### handle() ```ts handle(cmd): Promise<CommandResult>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:131](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L131) ###### Parameters | Parameter | Type | | ------ | ------ | | `cmd` | [`Command`](#command) | ###### Returns `Promise`\<[`CommandResult`](#commandresult)\> ###### Implementation of ```ts GameStub.handle ``` ##### repokeFinish() ```ts repokeFinish(): Promise<boolean>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:696](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L696) The gated admin re-poke (step 4): re-runs the D1 apply for a finish whose effects never landed. Idempotent end to end — finish_id dedupes the apply, and the outbox row exists iff the ratings transition hasn't been committed. Returns false when there is nothing to do. ###### Returns `Promise`\<`boolean`\> ###### Implementation of ```ts GameStub.repokeFinish ``` ##### webSocketClose() ```ts webSocketClose(): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:766](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L766) ###### Returns `Promise`\<`void`\> ##### webSocketError() ```ts webSocketError(_ws, error): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:772](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L772) ###### Parameters | Parameter | Type | | ------ | ------ | | `_ws` | `WebSocket` | | `error` | `unknown` | ###### Returns `Promise`\<`void`\> ##### webSocketMessage() ```ts webSocketMessage(): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/do/game-do.ts:761](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/do/game-do.ts#L761) ###### Returns `Promise`\<`void`\> *** ### HttpError Defined in: [eigen-server/packages/server/src/http.ts:38](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/http.ts#L38) #### Extends - `Error` #### Constructors ##### Constructor ```ts new HttpError( status, message, code?, retryAfterSeconds?): HttpError; ``` Defined in: [eigen-server/packages/server/src/http.ts:46](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/http.ts#L46) ###### Parameters | Parameter | Type | | ------ | ------ | | `status` | `400` \| `401` \| `403` \| `404` \| `409` \| `413` \| `415` \| `422` \| `429` \| `500` \| `502` | | `message` | `string` | | `code?` | `ErrorCode` | | `retryAfterSeconds?` | `number` | ###### Returns [`HttpError`](#httperror) ###### Overrides ```ts Error.constructor ``` #### Properties ##### code ```ts readonly code: ErrorCode | undefined; ``` Defined in: [eigen-server/packages/server/src/http.ts:40](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/http.ts#L40) ##### retryAfterSeconds ```ts readonly retryAfterSeconds: number | undefined; ``` Defined in: [eigen-server/packages/server/src/http.ts:44](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/http.ts#L44) Seconds the caller should wait before retrying — rendered as the `Retry-After` header. Set only on a 429 (see `ErrorCode.rateLimited`); `undefined` everywhere else. ##### status ```ts readonly status: 400 | 401 | 403 | 404 | 409 | 413 | 415 | 422 | 429 | 500 | 502; ``` Defined in: [eigen-server/packages/server/src/http.ts:39](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/http.ts#L39) ## Interfaces ### AuthClaims Defined in: [eigen-server/packages/server/src/auth/firebase.ts:18](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L18) What a verified ID token asserts. `isAnonymous` (the `firebase.sign_in_provider === 'anonymous'` claim) drives every guest gate; the profile claims seed user provisioning (Google supplies name/picture, Apple usually only email, guests none). #### Properties ##### email ```ts email: string | null; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:21](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L21) ##### isAnonymous ```ts isAnonymous: boolean; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:20](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L20) ##### name ```ts name: string | null; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:22](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L22) ##### picture ```ts picture: string | null; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:23](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L23) ##### uid ```ts uid: string; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:19](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L19) *** ### CreateGameInput Defined in: [eigen-server/packages/server/src/d1/apply.ts:292](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L292) The worker-direct create, engine-owned so implementors never touch the D1 schema: seats already validated by worker policy. #### Properties ##### access ```ts access: GameAccess; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:296](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L296) ##### budgetSeconds ```ts budgetSeconds: number | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:300](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L300) ##### config ```ts config: JsonObject; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:298](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L298) ##### createdBy ```ts createdBy: string | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:294](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L294) ##### gameId ```ts gameId: string; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:293](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L293) ##### incrementSeconds ```ts incrementSeconds: number | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:301](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L301) ##### maxPlayers ```ts maxPlayers: number; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:305](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L305) ##### minPlayers ```ts minPlayers: number; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:304](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L304) ##### now ```ts now: number; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:308](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L308) ##### rated ```ts rated: boolean; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:302](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L302) ##### ratingPool ```ts ratingPool: string | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:303](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L303) ##### schemaVersion ```ts schemaVersion: number; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:297](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L297) ##### seats ```ts seats: Seat[]; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:307](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L307) ##### shortCode ```ts shortCode: string; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:306](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L306) ##### status ```ts status: "waiting" | "ready"; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:295](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L295) ##### turnSeconds ```ts turnSeconds: number | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:299](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L299) *** ### EngineConfig Defined in: [eigen-server/packages/server/src/engine.ts:99](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L99) The EngineConfig seam: the engine never assumes binding names — the implementor picks bindings off their own Env. Annotate the accessors' `env` parameter and both type arguments infer. #### Type Parameters | Type Parameter | | ------ | | `TEnv` | | `TDO` *extends* [`BaseGameDO`](#abstract-basegamedo)\<`TEnv`\> | #### Properties ##### appName ```ts appName: string; ``` Defined in: [eigen-server/packages/server/src/engine.ts:106](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L106) The whitelabel app's display name — the single source of truth for the engine's own identity (share metadata and public-page titles today; FCM titles and share copy later). Deliberately top-level, not nested under `deepLink`, so there is one place to set it regardless of which optional feature blocks are enabled. ##### avatars? ```ts optional avatars?: AvatarsConfig<TEnv>; ``` Defined in: [eigen-server/packages/server/src/engine.ts:128](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L128) Opt-in avatar uploads. Omit → not mounted. ##### clientOrigins? ```ts optional clientOrigins?: readonly string[] | ((env) => readonly string[]); ``` Defined in: [eigen-server/packages/server/src/engine.ts:124](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L124) Browser origins allowed to call the engine from a different origin. Same-origin requests always work. When omitted, the engine trusts the exact origin from the conventional `WEB_APP_ORIGIN` var when it is set. Supply this option to replace that default for multiple or otherwise non-standard browser origins. Paths and wildcards are intentionally unsupported. The list also protects browser WebSocket upgrades, whose `Origin` header is not governed by CORS. Set an empty list to disable the `WEB_APP_ORIGIN` default. ##### deepLink? ```ts optional deepLink?: DeepLinkConfig; ``` Defined in: [eigen-server/packages/server/src/engine.ts:126](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L126) Native deep-link verification and store links. Omit for web-only. ##### gameModule ```ts gameModule: GameModule; ``` Defined in: [eigen-server/packages/server/src/engine.ts:100](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L100) ##### lifecycle? ```ts optional lifecycle?: LifecycleOptions; ``` Defined in: [eigen-server/packages/server/src/engine.ts:135](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L135) Cron-backstop tuning — guest-purge/reap windows and batch caps. Omit for the defaults (`LIFECYCLE_DEFAULTS`); set any subset to override just those. ##### site? ```ts optional site?: SiteConfig; ``` Defined in: [eigen-server/packages/server/src/engine.ts:131](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L131) The public web surface — download page, legal documents, crawler files. Omit → not mounted (the worker is API-only). ##### testing? ```ts optional testing?: { auth: TokenVerifier; firebaseAdmin: FirebaseAdminEffects; }; ``` Defined in: [eigen-server/packages/server/src/engine.ts:140](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L140) Explicit test-only replacements for Firebase verification and Admin effects. Supplying them together prevents a fake verifier from accidentally turning missing production credentials into a nullable runtime path. Leave unset in production. ###### auth ```ts auth: TokenVerifier; ``` ###### firebaseAdmin() ```ts firebaseAdmin(env): FirebaseAdminEffects; ``` ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns [`FirebaseAdminEffects`](#firebaseadmineffects) #### Methods ##### d1() ```ts d1(env): D1Database; ``` Defined in: [eigen-server/packages/server/src/engine.ts:108](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L108) The engine's D1 database (engine-private). ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns `D1Database` ##### firebaseProjectId()? ```ts optional firebaseProjectId(env): string; ``` Defined in: [eigen-server/packages/server/src/engine.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L113) Firebase project id for token verification; defaults to the `FIREBASE_PROJECT_ID` var (the only secret verification needs). ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns `string` ##### gameDO() ```ts gameDO(env): DurableObjectNamespace<TDO>; ``` Defined in: [eigen-server/packages/server/src/engine.ts:110](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L110) The GameDO namespace binding. ###### Parameters | Parameter | Type | | ------ | ------ | | `env` | `TEnv` | ###### Returns `DurableObjectNamespace`\<`TDO`\> *** ### FinishApplyInput Defined in: [eigen-server/packages/server/src/d1/apply.ts:30](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L30) #### Properties ##### finishId ```ts finishId: string; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:34](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L34) The DO-minted idempotency key — the apply is a no-op replay when the games row already carries it. ##### gameId ```ts gameId: string; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:31](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L31) ##### now ```ts now: number; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:39](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L39) ##### outcomes ```ts outcomes: OutcomeEntry[]; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:35](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L35) ##### rated ```ts rated: boolean; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:37](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L37) ##### ratingPool ```ts ratingPool: string | null; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:38](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L38) ##### roster ```ts roster: Seat[]; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:36](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L36) *** ### FirebaseAdminEffects Defined in: [eigen-server/packages/server/src/firebase/admin-effects.ts:15](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/firebase/admin-effects.ts#L15) The Firebase Admin effects used by authenticated engine paths. #### Methods ##### deleteAccount() ```ts deleteAccount(userId): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/firebase/admin-effects.ts:19](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/firebase/admin-effects.ts#L19) Permanently delete one Firebase Authentication account. ###### Parameters | Parameter | Type | | ------ | ------ | | `userId` | `string` | ###### Returns `Promise`\<`void`\> ##### notifyUser() ```ts notifyUser( d1, userId, message): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/firebase/admin-effects.ts:17](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/firebase/admin-effects.ts#L17) Send one notification through the engine's registered-device store. ###### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `userId` | `string` | | `message` | `NotificationMessage` | ###### Returns `Promise`\<`void`\> *** ### FrameMessage Defined in: [eigen-server/packages/server/src/protocol.ts:91](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L91) One seat's versioned frame on the wire — the socket fan-out payload, and (for the acting seat) the command-response ride-along. `ratings` appears only on the post-finish ratings transition. #### Properties ##### data ```ts data: JsonObject; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:94](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L94) ##### deadline ```ts deadline: number | null; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:97](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L97) The true client-facing deadline (grace is display-only there). ##### outcomes? ```ts optional outcomes?: OutcomeEntry[]; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:99](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L99) ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:95](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L95) ##### playerTimes ```ts playerTimes: number[] | null; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:98](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L98) ##### ratings? ```ts optional ratings?: RatingDelta[]; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:100](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L100) ##### type ```ts type: "frame"; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:92](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L92) ##### version ```ts version: number; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:93](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L93) *** ### LegalConfig Defined in: [eigen-server/packages/server/src/site/config.ts:29](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L29) Legal document overrides. Each is an HTML **fragment** — body content only, no document wrapper; the engine supplies the shell, styling and footer. Omitted documents fall back to the engine's generic templates. A fragment is inserted as-is, so it is the implementor's own trusted markup with their own values already written in. There are no placeholders to fill: the engine's defaults take an [OperatorConfig](#operatorconfig) as typed props, which is what a template's tokens used to stand in for. #### Properties ##### deleteAccount? ```ts optional deleteAccount?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:32](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L32) ##### privacy? ```ts optional privacy?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:31](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L31) ##### terms? ```ts optional terms?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:30](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L30) *** ### OperatorConfig Defined in: [eigen-server/packages/server/src/site/config.ts:9](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L9) The legal entity publishing the game. Required whenever `site` is present: the default legal documents take it as a prop and cannot render without it. #### Properties ##### contactEmail ```ts contactEmail: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:15](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L15) Support and privacy contact address. ##### effectiveDate ```ts effectiveDate: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:18](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L18) Effective date of the legal documents, as displayed. A plain string, not a Date — it is prose, and its format is the operator's choice. ##### jurisdiction ```ts jurisdiction: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:13](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L13) Governing jurisdiction, e.g. `India`. ##### name ```ts name: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:11](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L11) Legal entity name. Also the page footers' copyright holder. *** ### Principal Defined in: [eigen-server/packages/server/src/protocol.ts:16](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L16) Who a command acts as, resolved at the edge. Exactly one id is set. #### Properties ##### botId ```ts botId: string | null; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:18](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L18) ##### userId ```ts userId: string | null; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:17](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L17) *** ### RatingDelta Defined in: eigen-server/packages/kernel/dist/index.d.ts:171 One rated identity's before → after, exactly the rating_history row minus store keys. Computed by the D1 applier inside the rating CAS and delivered on the post-finish ratings transition (the `kind: "ratings"` action). #### Properties ##### displayAfter ```ts displayAfter: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:179 ##### displayBefore ```ts displayBefore: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:176 ##### displayChange ```ts displayChange: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:180 ##### identity ```ts identity: RatingIdentity; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:172 ##### muAfter ```ts muAfter: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:177 ##### muBefore ```ts muBefore: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:174 ##### pool ```ts pool: string; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:173 ##### sigmaAfter ```ts sigmaAfter: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:178 ##### sigmaBefore ```ts sigmaBefore: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:175 *** ### RetryOptions Defined in: [eigen-server/packages/server/src/d1/retry.ts:59](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L59) #### Properties ##### attempts? ```ts optional attempts?: number; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:61](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L61) Total attempts including the first. Default 4. ##### baseDelayMs? ```ts optional baseDelayMs?: number; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:63](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L63) First backoff, doubling each retry. Default 50ms. ##### maxDelayMs? ```ts optional maxDelayMs?: number; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:65](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L65) Backoff ceiling. Default 2000ms. ##### onRetry? ```ts optional onRetry?: (error, attempt) => void; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:69](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L69) Observe each retry (logging); never throws into the loop. ###### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | | `attempt` | `number` | ###### Returns `void` ##### shouldRetry? ```ts optional shouldRetry?: (error) => boolean; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:67](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L67) Which failures are worth retrying. Default [isTransientD1Error](#istransientd1error). ###### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | ###### Returns `boolean` ##### sleep? ```ts optional sleep?: (ms) => Promise<void>; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:71](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L71) Delay primitive, injectable so tests run without real timers. ###### Parameters | Parameter | Type | | ------ | ------ | | `ms` | `number` | ###### Returns `Promise`\<`void`\> *** ### RosterSnapshot Defined in: [eigen-server/packages/server/src/protocol.ts:82](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L82) The unversioned pre-game snapshot: pushed to every socket on any roster change, idempotent — a reconnect just gets the current one. Also the response body of an accepted waiting-room command. #### Properties ##### players ```ts players: Seat[]; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:85](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L85) ##### status ```ts status: GameStatus; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:84](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L84) ##### type ```ts type: "roster"; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:83](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L83) *** ### SiteConfig Defined in: [eigen-server/packages/server/src/site/config.ts:41](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L41) The public web surface a deployed game serves on its own host: download page, legal documents, and the crawler files. Absent → none of it is mounted and the worker stays API-only. The scaffold reserves these paths for the Worker with Static Assets' `run_worker_first`; customize legal prose through this typed config. #### Properties ##### description? ```ts optional description?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:47](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L47) Longer download-page prose. Defaults to `tagline`. ##### legal? ```ts optional legal?: LegalConfig; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:59](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L59) ##### name? ```ts optional name?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:43](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L43) Public game name in titles and OG tags. Defaults to `appName`. ##### ogImage? ```ts optional ogImage?: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:57](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L57) Path under `public/` to the 1200x630 OG image. Defaults to `/og-image.png`, the name the [branding guide](https://eigeninteractive.com/docs/ship-it/branding) prescribes for the Flutter app's own share card — one image, both surfaces. The engine never generates images. ##### operator ```ts operator: OperatorConfig; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:58](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L58) ##### primaryColor ```ts primaryColor: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:49](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L49) Hex accent colour, e.g. `#1a237e`. Also the `theme-color`. ##### screenshots? ```ts optional screenshots?: string[]; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L51) Filenames under `public/screenshots/`, shown as a scrolling strip. ##### tagline ```ts tagline: string; ``` Defined in: [eigen-server/packages/server/src/site/config.ts:45](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/site/config.ts#L45) One-sentence hook. The meta description and OG description. *** ### TokenVerifier Defined in: [eigen-server/packages/server/src/auth/firebase.ts:29](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L29) The seam `createEngine` consumes. Production is [createFirebaseVerifier](#createfirebaseverifier) with the default remote JWKS; tests inject a local JWKS and mint their own RS256 tokens. #### Methods ##### verify() ```ts verify(token): Promise<AuthClaims>; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:31](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L31) Resolve a bearer token to claims, or throw [AuthError](#autherror). ###### Parameters | Parameter | Type | | ------ | ------ | | `token` | `string` | ###### Returns `Promise`\<[`AuthClaims`](#authclaims)\> ## Type Aliases ### Command ```ts type Command = | { actor: Principal; commandId: string; gameId: string; kind: "join" | "leave"; } | { actor: Principal; commandId: string; gameId: string; kind: "cancel"; } | { actor: Principal; commandId: string; gameId: string; kind: "start"; } | { actor: Principal; botId: string; commandId: string; gameId: string; kind: "add-bot"; } | { actor: Principal; commandId: string; data: unknown; expectedVersion: number; gameId: string; kind: "action"; seat: number; } | { actor: Principal | null; commandId: string; gameId: string; kind: "lifecycle"; seat?: number; type: LifecycleType; }; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:23](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L23) Everything that crosses the worker → DO boundary after creation ( create itself is a worker-direct D1 write; the DO does not exist yet). #### Union Members ##### Type Literal ```ts { actor: Principal; commandId: string; gameId: string; kind: "join" | "leave"; } ``` *** ##### Type Literal ```ts { actor: Principal; commandId: string; gameId: string; kind: "cancel"; } ``` *** ##### Type Literal ```ts { actor: Principal; commandId: string; gameId: string; kind: "start"; } ``` *** ##### Type Literal ```ts { actor: Principal; botId: string; commandId: string; gameId: string; kind: "add-bot"; } ``` *** ##### Type Literal ```ts { actor: Principal; commandId: string; data: unknown; expectedVersion: number; gameId: string; kind: "action"; seat: number; } ``` ###### actor ```ts actor: Principal; ``` ###### commandId ```ts commandId: string; ``` ###### data ```ts data: unknown; ``` ###### expectedVersion ```ts expectedVersion: number; ``` The version the client computed the move against — a lower value is arbitrated by the same-view rule. ###### gameId ```ts gameId: string; ``` ###### kind ```ts kind: "action"; ``` ###### seat ```ts seat: number; ``` The acting seat — carried uniformly by humans and bots. The DO verifies it belongs to the actor (user id from the token, bot id from the HMAC claim) against its own roster and rejects otherwise, so a client can never act on a seat it does not hold. Required because one bot id may hold several seats, and uniform for one code path. *** ##### Type Literal ```ts { actor: Principal | null; commandId: string; gameId: string; kind: "lifecycle"; seat?: number; type: LifecycleType; } ``` ###### actor ```ts actor: Principal | null; ``` Null for identity-less system lifecycles (timeout, autoForfeit). ###### commandId ```ts commandId: string; ``` ###### gameId ```ts gameId: string; ``` ###### kind ```ts kind: "lifecycle"; ``` ###### seat? ```ts optional seat?: number; ``` The affected seat: `forfeit` carries the resigning seat (verified against the actor, like an action); `autoForfeit` the purged seat; `timeout` carries none (it resolves all pending). ###### type ```ts type: LifecycleType; ``` *** ### CommandResult ```ts type CommandResult = | { frame: FrameMessage | null; ok: true; version: number; } | { ok: true; roster: RosterSnapshot; } | { code: | RejectCode | LobbyRejectCode; message: string; ok: false; }; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:124](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L124) What `GameDO.handle()` returns; accepted results are stored for commandId dedupe and replayed verbatim to a retry. Rejections are computed fresh each time — re-evaluating one is always sound. State-transitioning commands answer with a version (+ the acting seat's frame); waiting-room commands answer with the post-commit roster snapshot. *** ### LobbyRejectCode ```ts type LobbyRejectCode = | "unknownGame" | "notJoinable" | "gameFull" | "alreadyJoined" | "notParticipant" | "notCreator" | "creatorCannotLeave"; ``` Defined in: [eigen-server/packages/server/src/protocol.ts:62](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/protocol.ts#L62) Why the DO refused a waiting-room command — the integrity column. These are *expected* refusals (accepted lobby staleness: the lobby may show a game that just filled), returned as values exactly like kernel rejections; the worker maps them to HTTP. Genuine protocol violations (acting on a seat you don't own) still throw. *** ### UserRow ```ts type UserRow = typeof users.$inferSelect; ``` Defined in: [eigen-server/packages/server/src/auth/provision.ts:19](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/provision.ts#L19) ## Variables ### DEADLINE\_GRACE\_MS ```ts const DEADLINE_GRACE_MS: 750 = 750; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:443 Grace window (ms) added to every deadline comparison so a player who submits on time is not rejected because network latency carried the request past the deadline. Keep it small relative to per-action `turnSeconds` windows. The client's display-only `kServerDeadlineGrace` mirrors this. ## Functions ### applyFinish() ```ts function applyFinish(d1, input): Promise<RatingDelta[] | null>; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:48](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L48) Apply one finished game to D1. Returns the rated deltas (null for an unrated game) for the DO to deliver as the ratings transition. Throws on failure — the caller logs and keeps the outbox row (single attempt at the call site; the internal loop only absorbs CAS conflicts). #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `input` | [`FinishApplyInput`](#finishapplyinput) | #### Returns `Promise`\<[`RatingDelta`](#ratingdelta)[] \| `null`\> *** ### createEngine() ```ts function createEngine<TEnv, TDO>(cfg): ExportedHandler<TEnv>; ``` Defined in: [eigen-server/packages/server/src/engine.ts:414](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L414) Creates the complete Cloudflare Worker for one game deployment. Call this once from the default export of `src/index.ts`. The returned handler mounts the authenticated game API, WebSocket upgrades, scheduled lifecycle work, and any configured public/deep-link routes. Game implementors provide only [EngineConfig.gameModule](#gamemodule-1) and binding accessors; routes, persistence, migrations, authentication, and session dispatch stay engine-owned. #### Type Parameters | Type Parameter | | ------ | | `TEnv` *extends* `object` | | `TDO` *extends* [`BaseGameDO`](#abstract-basegamedo)\<`TEnv`\> | #### Parameters | Parameter | Type | | ------ | ------ | | `cfg` | [`EngineConfig`](#engineconfig)\<`TEnv`, `TDO`\> | #### Returns `ExportedHandler`\<`TEnv`\> #### Example ```ts export default createEngine({ gameModule, appName: "My Game", d1: (env: Env) => env.GAME_DB, gameDO: (env: Env) => env.GAME_DO, }); ``` *** ### createFirebaseVerifier() ```ts function createFirebaseVerifier(projectId, getKey?): TokenVerifier; ``` Defined in: [eigen-server/packages/server/src/auth/firebase.ts:46](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/firebase.ts#L46) #### Parameters | Parameter | Type | | ------ | ------ | | `projectId` | `string` | | `getKey?` | `JWTVerifyGetKey` | #### Returns [`TokenVerifier`](#tokenverifier) *** ### createGame() ```ts function createGame(d1, input): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:314](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L314) Write the games row + one participants row per seat, atomically. The DO lazy-inits from exactly these rows on first contact. Callers own the shortCode retry: a duplicate trips the UNIQUE index and throws. #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `input` | [`CreateGameInput`](#creategameinput) | #### Returns `Promise`\<`void`\> *** ### deriveBotKey() ```ts function deriveBotKey(masterSecret, botId): Promise<string>; ``` Defined in: [eigen-server/packages/server/src/bot/bot-auth.ts:60](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/bot/bot-auth.ts#L60) The per-bot signing key as base64 — **the operator utility**. This is the one value an external bot's owner is given, and the only one they need: it is what they HMAC their request bodies with. The master `BOT_SIGNING_SECRET` never leaves the operator, and because every bot's key is derived from it, registering a bot needs no new secret and no redeploy. Base64 to match the signature transport encoding. Equivalent to: ``` echo -n "<botId>" | openssl dgst -sha256 -hmac "<BOT_SIGNING_SECRET>" -binary | base64 ``` Treat the result as a credential: it authenticates that bot to the engine for as long as it is registered. Rotating one bot's key means rotating the master secret, which rotates every bot's key — so issue per-bot keys only to owners you would re-issue all of them for. #### Parameters | Parameter | Type | | ------ | ------ | | `masterSecret` | `string` | | `botId` | `string` | #### Returns `Promise`\<`string`\> *** ### displayRating() ```ts function displayRating(mu, sigma): number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:184 max(0, round((mu − 3σ) · 40)) — the one server-side home of the display formula (the client mirrors it for optimistic display only). #### Parameters | Parameter | Type | | ------ | ------ | | `mu` | `number` | | `sigma` | `number` | #### Returns `number` *** ### ensureUser() ```ts function ensureUser( d1, claims, now): Promise<{ avatarUrl: string | null; createdAt: number; displayName: string; email: string | null; id: string; isAnonymous: boolean; updatedAt: number; username: string; }>; ``` Defined in: [eigen-server/packages/server/src/auth/provision.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/auth/provision.ts#L51) Load the caller's row, creating or backfilling it as the token demands. One read on the hot path; writes only on first sight and on guest → permanent conversion. #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `claims` | [`AuthClaims`](#authclaims) | | `now` | `number` | #### Returns `Promise`\<\{ `avatarUrl`: `string` \| `null`; `createdAt`: `number`; `displayName`: `string`; `email`: `string` \| `null`; `id`: `string`; `isAnonymous`: `boolean`; `updatedAt`: `number`; `username`: `string`; \}\> *** ### isTransientD1Error() ```ts function isTransientD1Error(error): boolean; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:55](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L55) True for the D1 failures worth retrying — a network blip, a storage or Durable-Object reset, a code-update restart, or a transient routing failure. Deliberately narrow. Overload and resource-limit errors are excluded (the remedy is to shed load, not retry), as are deterministic failures such as a constraint or type error, where retrying only delays the report. The whole `cause` chain is examined, because drizzle rewraps failures in its own message that does not carry the underlying text. This is the default predicate for [withRetry](#withretry); pass `shouldRetry` to override it. #### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | #### Returns `boolean` *** ### mirrorRoster() ```ts function mirrorRoster(d1, args): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:280](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L280) The roster mirror after a committed waiting-room command — the DO's roster is the integrity copy; this rewrites the D1 display copy wholesale (delete + reinsert), which is idempotent and immune to per-row drift. Fire-and-forget post-commit (the DO leaves it unawaited; no `waitUntil`), single attempt. #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `args` | \{ `gameId`: `string`; `now`: `number`; `seats`: [`Seat`](testkit.md#seat)[]; `status`: `GameStatus`; \} | | `args.gameId` | `string` | | `args.now` | `number` | | `args.seats` | [`Seat`](testkit.md#seat)[] | | `args.status` | `GameStatus` | #### Returns `Promise`\<`void`\> *** ### openApiDocument() ```ts function openApiDocument(version): OpenAPIObject; ``` Defined in: [eigen-server/packages/server/src/engine.ts:490](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/engine.ts#L490) Build the API document from an inert app — route handlers never run, so the context can refuse everything. `appName` is an unused placeholder here: with `deepLink: null` the landing route (its only reader) is never mounted. `version` is an argument rather than a constant in here because it has exactly one correct value — `@eigeninteractive/server`'s own — and changesets owns that value. Baked in as a literal it silently disagrees with the package on the first release: nothing reads it back, and the CI drift check only compares this file against itself, so the lie survives every check. The Dart client's pubspec is stamped from the same source for the same reason. #### Parameters | Parameter | Type | | ------ | ------ | | `version` | `string` | #### Returns `OpenAPIObject` *** ### readGameRow() ```ts function readGameRow(d1, gameId): Promise< | { access: GameAccess; archivedAt: number | null; budgetSeconds: number | null; config: JsonObject; createdAt: number; createdBy: string | null; finishedAt: number | null; finishId: string | null; id: string; incrementSeconds: number | null; maxPlayers: number; minPlayers: number; outcomes: OutcomeEntry[] | null; participants: Seat[]; pendingPlayers: number[] | null; rated: boolean; ratingPool: string | null; schemaVersion: number; shortCode: string; status: GameStatus; turnDeadline: number | null; turnSeconds: number | null; updatedAt: number; } | undefined>; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:341](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L341) Lazy-init read: the D1 game + participants rows the DO copies into its `meta`/`roster` on first contact — one batched round trip. #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `gameId` | `string` | #### Returns `Promise`\< \| \{ `access`: `GameAccess`; `archivedAt`: `number` \| `null`; `budgetSeconds`: `number` \| `null`; `config`: `JsonObject`; `createdAt`: `number`; `createdBy`: `string` \| `null`; `finishedAt`: `number` \| `null`; `finishId`: `string` \| `null`; `id`: `string`; `incrementSeconds`: `number` \| `null`; `maxPlayers`: `number`; `minPlayers`: `number`; `outcomes`: `OutcomeEntry`[] \| `null`; `participants`: [`Seat`](testkit.md#seat)[]; `pendingPlayers`: `number`[] \| `null`; `rated`: `boolean`; `ratingPool`: `string` \| `null`; `schemaVersion`: `number`; `shortCode`: `string`; `status`: `GameStatus`; `turnDeadline`: `number` \| `null`; `turnSeconds`: `number` \| `null`; `updatedAt`: `number`; \} \| `undefined`\> *** ### updateSummary() ```ts function updateSummary(d1, args): Promise<void>; ``` Defined in: [eigen-server/packages/server/src/d1/apply.ts:262](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/apply.ts#L262) The display upsert after a non-finishing transition — fire-and-forget post-commit (the DO leaves it unawaited; no `waitUntil`), single attempt, re-derivable from the DO at any time. #### Parameters | Parameter | Type | | ------ | ------ | | `d1` | `D1Database` | | `args` | \{ `gameId`: `string`; `now`: `number`; `pendingPlayers`: `number`[]; `status?`: `"active"`; `turnDeadline`: `number` \| `null`; \} | | `args.gameId` | `string` | | `args.now` | `number` | | `args.pendingPlayers` | `number`[] | | `args.status?` | `"active"` | | `args.turnDeadline` | `number` \| `null` | #### Returns `Promise`\<`void`\> *** ### withRetry() ```ts function withRetry<T>(op, options?): Promise<T>; ``` Defined in: [eigen-server/packages/server/src/d1/retry.ts:88](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/server/src/d1/retry.ts#L88) Run `op`, retrying a *retryable* failure with jittered exponential backoff up to `attempts`. A non-retryable failure — or the last attempt — throws. Safe to leave unawaited inside a Durable Object: the DO stays alive while the returned promise (and its backoff timers) is pending, so the whole sequence runs to completion without `waitUntil`, exactly like the single-attempt writes it wraps. `op` MUST be idempotent — a retry can fire after a write that actually landed but whose acknowledgement was lost. #### Type Parameters | Type Parameter | | ------ | | `T` | #### Parameters | Parameter | Type | | ------ | ------ | | `op` | () => `Promise`\<`T`\> | | `options` | [`RetryOptions`](#retryoptions) | #### Returns `Promise`\<`T`\> --- ## @eigeninteractive/testkit `@eigeninteractive/testkit` — drive a game's rules through the real kernel without a Worker, a database or a network. Build a table, submit actions as seats, assert on the resulting transitions and per-seat observations. ## Interfaces ### ActionCase Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:82](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L82) A game-action case — exercises schemas, `applyAction`, and (through `expected.observation`) `computeObservation` for the acting seat. #### Properties ##### action ```ts action: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:93](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L93) ##### config ```ts config: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:85](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L85) ##### expected ```ts expected: { observation?: JsonObject; outcome?: OutcomeEntry[] | null; pending?: number[]; state?: JsonObject; valid: boolean; }; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:94](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L94) ###### observation? ```ts optional observation?: JsonObject; ``` ###### outcome? ```ts optional outcome?: OutcomeEntry[] | null; ``` ###### pending? ```ts optional pending?: number[]; ``` ###### state? ```ts optional state?: JsonObject; ``` ###### valid ```ts valid: boolean; ``` ##### kind ```ts kind: "action"; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:83](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L83) ##### name ```ts name: string; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:84](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L84) ##### obs? ```ts optional obs?: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:88](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L88) Dart-side observation payload; unused here (defaults to `state`). ##### participantCount? ```ts optional participantCount?: number; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:91](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L91) ##### pending ```ts pending: number[]; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:89](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L89) ##### playerIndex ```ts playerIndex: number; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:90](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L90) ##### rngSeed? ```ts optional rngSeed?: string; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:92](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L92) ##### state ```ts state: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:86](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L86) *** ### BotSeatableCase Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:118](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L118) A `botSeatable` predicate case. #### Properties ##### botConfig ```ts botConfig: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:122](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L122) ##### expected ```ts expected: boolean; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:123](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L123) ##### gameConfig ```ts gameConfig: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:121](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L121) ##### kind ```ts kind: "botSeatable"; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:119](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L119) ##### name ```ts name: string; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:120](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L120) *** ### BuildGameContractOptions Defined in: [eigen-server/packages/testkit/src/game-contract.ts:45](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L45) Inputs for building a [GameContract](#gamecontract) without writing it. #### Extended by - [`EmitGameContractOptions`](#emitgamecontractoptions) #### Properties ##### fixturesRoot? ```ts optional fixturesRoot?: any; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L51) Root containing `v<N>/*.json` twin fixtures. ##### game ```ts game: string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:47](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L47) Stable display name used as the generated Dart type prefix. ##### gameModule ```ts gameModule: GameModule; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:49](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L49) Authoritative TypeScript rules registry. *** ### CommitInput Defined in: eigen-server/packages/kernel/dist/index.d.ts:285 #### Properties ##### game ```ts game: GameRow; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:286 ##### intent ```ts intent: Intent; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:291 ##### now ```ts now: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:294 The commit instant (epoch ms) — sampled once by the host, never read here. ##### roster ```ts roster: Seat[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:290 ##### rules ```ts rules: GameRules; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:297 The version unit for the game's `schemaVersion`, already resolved by the host from the `GameModule.versions` map. ##### staleViews? ```ts optional staleViews?: { current: SeatView | null; expected: SeatView | null; }; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:305 Same-view material for a stale game action: the acting seat's stored frames at `expectedVersion` and at the current version. Only consulted when `intent.expectedVersion < state.version`; if absent (or either frame is missing — e.g. compacted away), the stale action is rejected conservatively. ###### current ```ts current: SeatView | null; ``` ###### expected ```ts expected: SeatView | null; ``` ##### state ```ts state: StateRow | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:289 The latest transition, or null before v0 (only a `start` intent is meaningful then). *** ### CommitPlan Defined in: eigen-server/packages/kernel/dist/index.d.ts:351 #### Properties ##### action ```ts action: TransitionAction | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:354 ##### alarm ```ts alarm: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:367 The instant the DO must arm its alarm at — the true deadline plus the grace window — or null to clear it. ##### effects ```ts effects: Effect[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:368 ##### frames ```ts frames: ObservationFrame[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:357 Per-seat projected frames (identified seats only) — persisted with the transition, fanned out over sockets. No raw state escapes the kernel. ##### nextState ```ts nextState: StateRow; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:353 The next transition row, already versioned (`v+1`, or 0 for start). ##### outcomes ```ts outcomes: OutcomeEntry[] | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:364 Per-seat results when this transition ends the game, else null. Rating deltas are deliberately NOT here: they depend on global cross-game priors (D1-domain data the kernel must never need). The D1 applier computes them inside the rating CAS via `computeRatings` (ratings.ts) and the host delivers them as a follow-up versioned ratings transition. *** ### EmitGameContractOptions Defined in: [eigen-server/packages/testkit/src/game-contract.ts:55](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L55) Inputs for emitting or checking a [GameContract](#gamecontract) file. #### Extends - [`BuildGameContractOptions`](#buildgamecontractoptions) #### Properties ##### fixturesRoot? ```ts optional fixturesRoot?: any; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:51](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L51) Root containing `v<N>/*.json` twin fixtures. ###### Inherited from [`BuildGameContractOptions`](#buildgamecontractoptions).[`fixturesRoot`](#fixturesroot) ##### game ```ts game: string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:47](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L47) Stable display name used as the generated Dart type prefix. ###### Inherited from [`BuildGameContractOptions`](#buildgamecontractoptions).[`game`](#game) ##### gameModule ```ts gameModule: GameModule; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:49](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L49) Authoritative TypeScript rules registry. ###### Inherited from [`BuildGameContractOptions`](#buildgamecontractoptions).[`gameModule`](#gamemodule) ##### output ```ts output: any; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:57](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L57) Destination `game-contract.json` path. *** ### GameContract Defined in: [eigen-server/packages/testkit/src/game-contract.ts:37](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L37) Language-neutral schemas and fixtures shared by a game's Worker and app. #### Properties ##### fixtures ```ts fixtures: GameContractFixture[]; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:41](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L41) ##### formatVersion ```ts formatVersion: 1; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:38](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L38) ##### game ```ts game: string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:39](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L39) ##### versions ```ts versions: Record<string, GameContractVersion>; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:40](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L40) *** ### GameContractFixture Defined in: [eigen-server/packages/testkit/src/game-contract.ts:19](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L19) One validated twin-fixture document embedded in a [GameContract](#gamecontract). #### Properties ##### document ```ts document: unknown; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:23](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L23) Validated fixture document, retained in its original JSON shape. ##### path ```ts path: string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:21](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L21) POSIX-style path relative to the supplied fixtures root. *** ### GameContractVersion Defined in: [eigen-server/packages/testkit/src/game-contract.ts:27](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L27) The four JSON Schemas emitted for one game `schemaVersion`. #### Properties ##### schemas ```ts schemas: { action: Record<string, unknown>; config: Record<string, unknown>; observation: Record<string, unknown>; state: Record<string, unknown>; }; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:28](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L28) ###### action ```ts action: Record<string, unknown>; ``` ###### config ```ts config: Record<string, unknown>; ``` ###### observation ```ts observation: Record<string, unknown>; ``` ###### state ```ts state: Record<string, unknown>; ``` *** ### GameRow Defined in: eigen-server/packages/kernel/dist/index.d.ts:223 The game's standing configuration — the DO `meta` snapshot. #### Properties ##### budgetSeconds ```ts budgetSeconds: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:230 ##### config ```ts config: JsonObject; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:228 Stored creation config; parsed against the version unit's config schema before any hook sees it. ##### incrementSeconds ```ts incrementSeconds: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:231 ##### rated ```ts rated: boolean; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:232 ##### ratingPool ```ts ratingPool: string | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:233 ##### schemaVersion ```ts schemaVersion: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:225 ##### status ```ts status: GameStatus; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:224 ##### turnSeconds ```ts turnSeconds: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:229 *** ### ObservationFrame Defined in: eigen-server/packages/kernel/dist/index.d.ts:115 One seat's projected frame, tagged with its seat. The host stamps version/timing when it persists and fans these out. #### Properties ##### data ```ts data: JsonObject; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:117 ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:118 ##### playerIndex ```ts playerIndex: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:116 *** ### RatingPoolCase Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:104](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L104) A `ratingPool` predicate case. Omitted timing fields mean null. #### Properties ##### access ```ts access: GameAccess; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:107](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L107) ##### budgetSeconds? ```ts optional budgetSeconds?: number | null; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:109](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L109) ##### config ```ts config: JsonObject; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:113](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L113) ##### expected ```ts expected: string | null; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:114](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L114) ##### incrementSeconds? ```ts optional incrementSeconds?: number | null; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:110](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L110) ##### kind ```ts kind: "ratingPool"; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:105](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L105) ##### maxPlayers ```ts maxPlayers: number; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:112](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L112) ##### minPlayers ```ts minPlayers: number; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:111](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L111) ##### name ```ts name: string; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:106](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L106) ##### turnSeconds? ```ts optional turnSeconds?: number | null; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:108](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L108) *** ### Rejected Defined in: eigen-server/packages/kernel/dist/index.d.ts:45 An intent the kernel refused. A value, not a throw — rejections are part of the normal protocol. #### Properties ##### code ```ts code: RejectCode; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:47 ##### message ```ts message: string; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:48 ##### rejected ```ts rejected: true; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:46 *** ### Seat Defined in: eigen-server/packages/kernel/dist/index.d.ts:237 One seat of the roster. Both ids null ⇒ the account was purged mid-game (the seat plays on as "Deleted User" for display, but can never act). #### Properties ##### botId ```ts botId: string | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:240 ##### playerIndex ```ts playerIndex: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:238 ##### type ```ts type: "bot" | "human"; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:241 ##### userId ```ts userId: string | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:239 *** ### SeatView Defined in: eigen-server/packages/kernel/dist/index.d.ts:93 A seat's stored projection at one version — what the same-view compare runs on (and what the DO persists per transition as `frames[]`). #### Properties ##### data ```ts data: JsonObject; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:94 ##### pendingPlayers ```ts pendingPlayers: number[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:95 *** ### StateRow Defined in: eigen-server/packages/kernel/dist/index.d.ts:245 The latest committed transition — state plus the engine-owned clocks. All instants are epoch milliseconds. #### Properties ##### deadline ```ts deadline: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:252 The true turn deadline shown to clients; the alarm arms at `deadline + grace`. ##### pending ```ts pending: number[]; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:248 ##### playerTimes ```ts playerTimes: number[] | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:254 Per-seat budget banks (ms), budget mode only. ##### rngSeed ```ts rngSeed: string; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:249 ##### state ```ts state: JsonObject; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:247 ##### turnStartedAt ```ts turnStartedAt: number | null; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:255 ##### version ```ts version: number; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:246 *** ### TwinFixtureFile Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:75](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L75) One fixture file: cases targeting one `schemaVersion` unit. #### Properties ##### cases ```ts cases: TwinFixtureCase[]; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:77](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L77) ##### schemaVersion ```ts schemaVersion: number; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:76](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L76) ## Type Aliases ### Effect ```ts type Effect = | { botId: string; kind: "wakeBot"; seat: number; } | { kind: "notifyTurn"; seat: number; userId: string; } | { kind: "notifyFinished"; userIds: string[]; }; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:339 A push/wake the host should attempt post-commit (single attempt + error log — no retry machinery in v1). The kernel names seats; the host resolves delivery (FCM targets, bot webhook vs local bot). *** ### Intent ```ts type Intent = | { kind: "start"; seed: string; } | { actor: "user" | "bot"; data: unknown; expectedVersion: number; kind: "action"; seat: number; } | { kind: "lifecycle"; type: "timeout"; } | { kind: "lifecycle"; seat: number; type: "forfeit" | "autoForfeit"; }; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:259 What the host asks the kernel to do — the kernel-facing half of a `Command` (authorization already happened at the edge; dedupe at the DO). #### Union Members ##### Type Literal ```ts { kind: "start"; seed: string; } ``` ###### kind ```ts kind: "start"; ``` ###### seed ```ts seed: string; ``` The game's base RNG seed, freshly generated by the host (`randomSeed()`); stored on v0 and copied to every later row. *** ##### Type Literal ```ts { actor: "user" | "bot"; data: unknown; expectedVersion: number; kind: "action"; seat: number; } ``` ###### actor ```ts actor: "user" | "bot"; ``` ###### data ```ts data: unknown; ``` The raw move payload — parsed against the unit's action schema. ###### expectedVersion ```ts expectedVersion: number; ``` The version the client computed the move against. Equal to the current version in the common case; a *lower* value is arbitrated by the same-view rule. ###### kind ```ts kind: "action"; ``` ###### seat ```ts seat: number; ``` *** ##### Type Literal ```ts { kind: "lifecycle"; type: "timeout"; } ``` *** ##### Type Literal ```ts { kind: "lifecycle"; seat: number; type: "forfeit" | "autoForfeit"; } ``` `forfeit` = a voluntary resign (a user action); `autoForfeit` = the engine-driven variant (account purge; identity-less system action). *** ### RejectCode ```ts type RejectCode = | "notActive" | "notReady" | "expired" | "notPending" | "stateUpdated" | "invalidPayload" | "illegalMove" | "abstain"; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:24 Why an intent was refused. Stable machine codes — the host's transport mapping and the client's retry policy key on these, so treat renames as breaking. *** ### TwinFixtureCase ```ts type TwinFixtureCase = | ActionCase | RatingPoolCase | BotSeatableCase; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:126](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L126) ## Variables ### GAME\_CONTRACT\_FORMAT\_VERSION ```ts const GAME_CONTRACT_FORMAT_VERSION: 1 = 1; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:16](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L16) Current format of the language-neutral contract consumed by EigenInteractive's Dart generator. ## Functions ### buildGameContract() ```ts function buildGameContract(options): GameContract; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:121](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L121) Build a deterministic in-memory contract without touching the filesystem. #### Parameters | Parameter | Type | | ------ | ------ | | `options` | [`BuildGameContractOptions`](#buildgamecontractoptions) | #### Returns [`GameContract`](#gamecontract) *** ### checkConfiguredGameContract() ```ts function checkConfiguredGameContract(root?): Promise<void>; ``` Defined in: [eigen-server/packages/testkit/src/contract-command.ts:76](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/contract-command.ts#L76) Fails when the conventionally configured contract is absent or stale. Use this in CI through `eigen-contract --check`. #### Parameters | Parameter | Type | | ------ | ------ | | `root` | `any` | #### Returns `Promise`\<`void`\> *** ### checkGameContract() ```ts function checkGameContract(options): void; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:165](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L165) Fail when an emitted contract is missing or differs from its inputs. #### Parameters | Parameter | Type | | ------ | ------ | | `options` | [`EmitGameContractOptions`](#emitgamecontractoptions) | #### Returns `void` *** ### commit() ```ts function commit(input): CommitPlan | Rejected; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:372 #### Parameters | Parameter | Type | | ------ | ------ | | `input` | [`CommitInput`](#commitinput) | #### Returns [`CommitPlan`](#commitplan) \| [`Rejected`](#rejected) *** ### deepEquals() ```ts function deepEquals(a, b): boolean; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:480](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L480) Structural JSON equality. Object keys with `undefined` values count as absent (matching how schema libraries model optional fields); array order matters. #### Parameters | Parameter | Type | | ------ | ------ | | `a` | `Json` \| `undefined` | | `b` | `Json` \| `undefined` | #### Returns `boolean` *** ### deriveRng() ```ts function deriveRng(seed, version): Rng; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:388 The deterministic RNG for one transition: rand-seed's sfc32 keyed by the game's base seed and the state version the envelope will commit as. The same `(seed, version)` always yields the same draw sequence — a replay re-derives it — and every transition gets an independent stream, so hooks draw as many values as they need with no cross-invocation state. The derivation is fixed, so recorded games stay replayable. #### Parameters | Parameter | Type | | ------ | ------ | | `seed` | `string` | | `version` | `number` | #### Returns `Rng` *** ### emitConfiguredGameContract() ```ts function emitConfiguredGameContract(root?): Promise<void>; ``` Defined in: [eigen-server/packages/testkit/src/contract-command.ts:67](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/contract-command.ts#L67) Emits `game-contract.json` from an EigenInteractive package's conventional layout. This is the programmatic form of the `eigen-contract` executable. Most games should invoke the executable through their package script. #### Parameters | Parameter | Type | | ------ | ------ | | `root` | `any` | #### Returns `Promise`\<`void`\> *** ### emitGameContract() ```ts function emitGameContract(options): void; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:159](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L159) Emit one deterministic, newline-terminated `game-contract.json`. #### Parameters | Parameter | Type | | ------ | ------ | | `options` | [`EmitGameContractOptions`](#emitgamecontractoptions) | #### Returns `void` *** ### evaluateTwinCase() ```ts function evaluateTwinCase(rules, kase): string[]; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:274](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L274) Run one fixture case against a rules unit, returning failure descriptions (empty ⇒ the case passes). Pure — the file-reading test registrar is [twinFixtureTests](#twinfixturetests). #### Parameters | Parameter | Type | | ------ | ------ | | `rules` | `GameRules` | | `kase` | [`TwinFixtureCase`](#twinfixturecase) | #### Returns `string`[] *** ### gameContractFilename() ```ts function gameContractFilename(game): string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:173](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L173) A useful default filename for scripts that accept an output directory. #### Parameters | Parameter | Type | | ------ | ------ | | `game` | `string` | #### Returns `string` *** ### gameContractJson() ```ts function gameContractJson(options): string; ``` Defined in: [eigen-server/packages/testkit/src/game-contract.ts:154](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/game-contract.ts#L154) Render one deterministic, newline-terminated contract document. #### Parameters | Parameter | Type | | ------ | ------ | | `options` | [`BuildGameContractOptions`](#buildgamecontractoptions) | #### Returns `string` *** ### isRejected() ```ts function isRejected(result): result is Rejected; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:371 Type guard: did `commit()` refuse the intent? #### Parameters | Parameter | Type | | ------ | ------ | | `result` | [`CommitPlan`](#commitplan) \| [`Rejected`](#rejected) | #### Returns `result is Rejected` *** ### parseTwinFixtureFile() ```ts function parseTwinFixtureFile(path, json): TwinFixtureFile; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:247](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L247) Validate one fixture file's parsed JSON, or throw naming the offending file, case, and field. Exported so a repo can lint its fixtures without running them. #### Parameters | Parameter | Type | | ------ | ------ | | `path` | `string` | | `json` | `unknown` | #### Returns [`TwinFixtureFile`](#twinfixturefile) *** ### projectView() ```ts function projectView(rules, args): SeatView; ``` Defined in: [eigen-server/packages/testkit/src/kernel-scenarios.ts:39](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/kernel-scenarios.ts#L39) Project one seat's view of a state — the stored-frame shape the same-view rule compares (`commit()`'s `staleViews` input). Convenience for scenario tests that replay a simultaneous-move race. #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `rules` | `GameRules` | - | | `args` | \{ `cause?`: `TransitionCause`; `config`: `JsonObject`; `isReplay?`: `boolean`; `participantCount?`: `number`; `pending`: `number`[]; `seat`: `number` \| `null`; `state`: `JsonObject`; \} | - | | `args.cause?` | `TransitionCause` | - | | `args.config` | `JsonObject` | - | | `args.isReplay?` | `boolean` | - | | `args.participantCount?` | `number` | - | | `args.pending` | `number`[] | - | | `args.seat` | `number` \| `null` | The seat to project for, or null for a viewer. | | `args.state` | `JsonObject` | - | #### Returns [`SeatView`](#seatview) *** ### randomSeed() ```ts function randomSeed(): string; ``` Defined in: eigen-server/packages/kernel/dist/index.d.ts:381 A fresh base seed for a new game: 128 random bits, hex-encoded. Stored on the game's v0 state row and copied onto every later row (server-only — never expose it: the whole randomness of the game is derivable from it). #### Returns `string` *** ### twinFixtureTests() ```ts function twinFixtureTests(gameModule, fixturesRoot): void; ``` Defined in: [eigen-server/packages/testkit/src/twin-fixtures.ts:290](https://github.com/eigeninteractive/eigen-server/blob/428ecf5cc528790767de6a95ce4b815765399e75/packages/testkit/src/twin-fixtures.ts#L290) Register one vitest test per fixture case found under `fixturesRoot` (layout: `<root>/v<N>/*.json`). Call at the top level of a test module running in a Node environment. #### Parameters | Parameter | Type | | ------ | ------ | | `gameModule` | `GameModule` | | `fixturesRoot` | `any` | #### Returns `void` ## References ### DEADLINE\_GRACE\_MS Re-exports [DEADLINE_GRACE_MS](server.md#deadline_grace_ms)