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.
| 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.
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
| 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.
-
Create the project at console.firebase.google.com with Analytics enabled.
-
Install and authenticate the official tooling:
npm install --global firebase-toolsfirebase logindart pub global activate flutterfire_cliFrom a scaffolded repository root, run:
pnpm firebase:configure# or: npm run firebase:configureThe 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 writesapp/web/firebase-config.js. This keepsapp/lib/firebase_options.dartand the messaging worker on the same Firebase Web app without copying identifiers. In a standalone app repository, rundart run eigen_flutter:configure_firebasefrom the Flutter root. -
Add SHA fingerprints to the Android app.
flutterfiredoes 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."
-
-
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.
-
Android FID registration:
eigen_flutteris an Android Flutter plugin. Its library manifest enablesfirebase_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 oneigen_flutter; do not edit the generated application manifest orgradle.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. -
Android desugaring: foreground notifications use
flutter_local_notifications, which requires core-library desugaring in the application module. The scaffold adds the required compiler setting anddesugar_jdk_libsdependency. Hand-created apps should copy the Gradle block from Manual setup. -
Web Push key: Project Settings → Cloud Messaging → Web configuration → generate a Web Push certificate. Pass its public VAPID key as
FIREBASE_VAPID_KEY. -
Server-side Firebase credentials: Project Settings → Service Accounts → Generate new private key. The Worker needs only
client_emailandprivate_keyfrom 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
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/serverand are applied withwrangler 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.jsonis emitted from the engine's route definitions. The typed Dart client is generated from it, committed, and published to pub.dev aseigen_apiat 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.
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 key —
await 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.