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 withsignInWithPopup.linkWithCredentialon native andlinkWithPopupon 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 asAuthorization: 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.Apple Sign-In is scoped but not wiredThere is no
sign_in_with_appledependency yet. -
The API client is generated from
openapi.json— in the engine repo, and published to pub.dev aseigen_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? }, andcodeis a generated enum (ErrorCode), sohumanizeswitches over it exhaustively — adding a code server-side fails the client build until copy exists.engineCallconverts a server-reported failure intoEngineException; a failure with no response propagates as the underlyingDioException, 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.resolveAvatarUrlresolves either against the API origin, and every seat rendering routes throughPlayerAvatarso that resolution lives in one place. The?v=cache-buster meanscached_network_imagerefreshes 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
syncon 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 setsGamePlayer.isDeleted.isDeletedis the guard — never inspect the syntheticPlayer.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
typeonly where game rules must distinguish a bot seat. - The viewer case. A non-participant replaying a public finished game has no
seat —
MySeatis a sealedSeated(index) | Viewer, so viewer checks simply never match "is it my turn". ReadmySeat.indexOrNullwhere 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. |