Skip to main content

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 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.

KindNameRequiredWhat it enables
Durable ObjectGameDO (SQLite storage, via the exports field)yesThe per-game session + history
D1 databaseany bindingyesIdentity, social, bots, ratings, summaries. migrations_dir points at node_modules/@eigeninteractive/server/migrations
Cron triggerdailyyes in practiceThe guest purge + abandoned-game reap. Without it those two backstops never run
AssetsASSETS./publicyes for webFlutter bundle, served directly unless a path is in run_worker_first
R2 bucketany bindingoptionalAvatar uploads (avatars config block)
VarFIREBASE_PROJECT_IDyesToken verification. Empty ⇒ every authed request 500s
VarWEB_APP_ORIGINyes for webCanonical Flutter origin used for absolute notification click links and automatically trusted for cross-origin browser REST and WebSocket requests
SecretFIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEYyesPush (FCM) and the Identity-Toolkit admin delete used by account deletion
SecretBOT_SIGNING_SECREToptionalExternal bots (the per-bot HMAC is derived from it)

The entries under wrangler.jsoncvars 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.

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.

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:

flutter run --dart-define-from-file=app-config.json
flutter build appbundle --release \
--dart-define-from-file=app-config.json
VarRequiredPurpose
API_BASE_URLyesOrigin 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_IDyesGoogle Sign-In.
APP_HOSToptionalThis 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_KEYyes for webPublic 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:

    npm install --global firebase-tools
    firebase login
    dart pub global activate flutterfire_cli

    From a scaffolded repository root, run:

    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.

      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.

  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.

  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:

FilePlatform
lib/firebase_options.dartDart, all platforms
android/app/google-services.jsonAndroid native
web/firebase-config.jsGenerated public Web config for the messaging worker
firebase.jsonFlutterFire 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 import firebase_options.dart, so it imports the generated 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.

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 applynever 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.

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:

-- 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 keyawait deriveBotKey(BOT_SIGNING_SECRET, botId) from @eigeninteractive/server, or the openssl one-liner — and never the master secret. Adding a bot therefore needs no new secret and no redeploy.