Launching a game
sequenceDiagram
autonumber
actor P as Player
participant H as Hub (host)
participant G as Game iframe
participant L as IndexedDB
participant A as Platform API
P->>H: tap game card
H->>H: check entryUrl is on the games origin
H->>H: createHost() starts listening
H->>G: iframe.src = entryUrl
G->>H: ready {gameId, sdkVersion}
H->>L: load save (profile, game)
H->>G: init {profile, locale, settings, save, seed}
G->>H: menu {open} — a shell game's first screen is up (ADR 0019)
H->>H: lift the loading screen (icon and name of the game)
Note over G: game plays
G->>H: save {id, data}
H->>L: put save (synced = 0)
H-->>G: result {replyTo: id, ok}
G->>H: score {id, value, board}
H->>L: add score (synced = 0)
H-->>G: result {ok}
Note over H,A: M2: background sync pushes unsynced rows to the API
P->>H: switches tab
H->>G: pause
P->>G: ☰ → Về Tò He (shell games, ADR 0018)
G->>H: exit
H->>H: back to the catalog in the history, dispose host, iframe → about:blank
Rules
- The hub starts listening before it sets
iframe.src, soreadycan never be missed. - While the game loads, the hub shows the game’s icon and name. For a
shellgame it keeps them up until the game’s firstmenumessage — its title screen, or play at a table — so a half-drawn game never shows; at most 8 s after the handshake (ADR 0019). - The hub only accepts messages whose
sourceis that iframe’s window and whoseoriginis the games origin; everything else is ignored. - Every incoming message is validated with the Zod schemas from
@tohe/contract. Invalid messages are dropped and reported throughonProtocolError(logged to the console in the hub). saveandscoreare only accepted if the game declared thesave/scorescapability ingame.json.- Saves are limited to 64 KB of JSON. Larger saves are rejected with
ok: false. - The seed is the local date (
YYYYMMDD), so everybody gets the same randomness on the same day — the basis for daily challenges.
Leaving and phone Back
The hub’s history is the way the player walked: catalog → game → lobby → table → replay. Leaving a screen (its ←, a game’s “Về Tò He”, leaving a table, a profile chosen) goes back to its target when the player came from there, and replaces the screen otherwise (a room link opened from a chat, a reload); it never pushes the target on top. So phone Back on the catalog leaves Tò He instead of reopening the game just left, and the lobby’s ← returns to the game’s main menu without stacking another copy of it. Steps of signing in (welcome → profiles → catalog) replace each other for the same reason.
apps/hub/src/navigation/trail.tsremembers the path of every history entry of the visit;useLeaveTo()and<BackLink>(navigation/leave.tsx) go back to the target or replace with it. Going back skips a game’s Back handling (useBlocker), which only catches the player’s own Back.- After a reload the older entries are unknown, so leaving replaces rather than guessing.
- Phone Back in a shell game is blocked (the browser steps back, the router steps forward again) and sent to the game; when the game answers by leaving (Back on its title screen), the hub waits for that forward step to land (at most 500 ms) before going back to the catalog — two history traversals at once and the browser drops one.
- Going deeper (a game card, Chơi online, a room, a replay) pushes as usual, so Back returns.
Offline
- The hub’s service worker precaches the hub;
GET /api/gamesandGET /api/configare cached network-first, and the hub additionally keeps the last good copy in IndexedDB. - Saves and scores are written to IndexedDB first. Nothing waits for the network.
- Game builds are immutable and served with long cache lifetimes, so a game played once is in the browser cache. A dedicated games-origin service worker is a planned spike (walking skeleton).