Skip to content

Latest commit

 

History

History
1601 lines (1350 loc) · 125 KB

File metadata and controls

1601 lines (1350 loc) · 125 KB

Frontend Architecture

This document describes the architecture of the frontend JavaScript/TypeScript code. It reflects the current implementation unless explicitly marked as a future direction.

For the backend/WASM architecture, see OVERVIEW.md. For the connectivity model (wallet, hub, peer, session interactions and rollover), see CONNECTIVITY.md.

System-Level View

The system consists of two separate deployable artifacts:

  1. Player App — A fully static HTML/JS/CSS application. This is the main application that players run. It contains the wallet connection, WASM cradle, game session logic, and all game UIs. It is served as static files with no server-side logic. No cookies, no server-side sessions.
  2. Hub — A separate dynamic service that provides two things: a hub UI for matchmaking (loaded as an iframe inside the player app), and a WebSocket relay that ferries game messages between peers. The hub is third-party code — anyone can run one, and players choose which hubs to connect to. The hub holds no cookies and requires no authentication.

The player app is static code and does not fetch a hub list from its own server. The user enters a hub URL in HubPicker (or uses the local dev shortcut), and Shell creates both the hub iframe and game relay connection from that origin. The selected hub URL may be remembered in localStorage so the app can reconnect on reload, but the current UI does not maintain or display a history/list of previously used hubs. A richer local hub list is future work.

Peer Messaging

All game messages between peers are relayed through the hub service. Both players connect to the same hub with a shared token, and the hub routes messages between them. This is simple and works well behind NATs, but it means the hub must stay connected for the duration of the session.

Per-session peer state is encapsulated in a PeerSession object (front-end/src/services/PeerSession.ts). Each game session gets one PeerSession; it owns the session ID, peer ID, liveness tracking, message buffering/routing, and outbound send methods. Shell holds a single peerSessionRef (PeerSession | null) rather than the five individual refs previously used for peer state. Destroying the PeerSession makes the object inert — all further calls are no-ops.

A future option is to upgrade to WebRTC for peer-to-peer messaging after the initial matchmaking. This would remove the hub as a runtime dependency once both peers are connected, but adds ICE/STUN/TURN complexity. Game messages are small and infrequent (a few per hand), so the relay approach is adequate for now.

Player App / Hub Integration

The player app connects to the hub through two parallel channels that share a session token:

  1. An iframe loaded from the hub's hub URL (e.g. https://hub.example.com/?session=TOKEN). This shows the matchmaking UX. The player app sends postMessage to the iframe only for theme syncing (CSS variables and dark-mode class), never for game data. The hub iframe may request a theme sync via postMessage as well.
  2. A game relay WebSocket to the hub (/ws/game). This carries match notifications and game messages. The protocol is defined in Hub Relay Protocol below.

The session token is a random value generated by the player app (persisted in localStorage as sessionId) and passed to both channels. The hub associates the iframe and game channel internally by matching the token, so it knows which visual hub session corresponds to which game connection.

The player app remains truly static files deployable on any web server with zero configuration.

Hub Relay Protocol

The hub has two communication channels per player:

  1. Hub/control channel — a bespoke hub protocol used by the hub iframe for alias, presence, challenge, and matchmaking messages. This channel remains JSON over /ws/hub.
  2. Game relay channel — the player app's HubConnection WebSocket. Its hub-control envelopes are bencodex binary dictionaries over /ws/game; addressed binary frames carry peer payloads through the hub relay.

Each channel uses a dedicated WebSocket endpoint: the hub channel connects to /ws/hub and the game channel connects to /ws/game. The player app provides a secret hub session nonce as session_id when joining/identifying. The hub treats that nonce as the bearer credential for reconnect/replacement and assigns a separate public hub id for discovery and challenges. Public hub updates never include the secret nonce.

Hub channel events

Hub iframe → Hub:

Event Payload Purpose
get_alias { session_id } Look up a previously saved alias for this hub session (sent on connect, before joining)
set_alias { session_id, alias } Save a new alias for this hub session
join { session_id, alias } Authenticate the hub socket by secret nonce and register/update the public hub player
leave {} Leave the hub for the current session-bound socket
challenge { target_id, challenger_amount, target_amount, channel_timeout?, unroll_timeout? } Challenge another player by public hub id with per-player channel buy-ins and optional channel/unroll timeouts. Amounts are decimal bigint strings; timeouts are decimal block counts accepted only in the hub range 3-30.
challenge_accept { challenge_id } Accept a pending challenge addressed to the current session-bound socket
challenge_decline { challenge_id } Decline a pending challenge
challenge_cancel {} Cancel outgoing challenges for the current session-bound socket
change_alias { newAlias } Update hub display alias mid-session

Hub → Hub iframe:

Event Payload Purpose
alias_result { alias } Response to get_alias or set_alias (alias is null if no alias is saved)
joined { id, alias } The public hub id and alias assigned to this session
hub_update Player[] Current list of public players in the hub (broadcast on changes). Each Player includes id, alias, status ('waiting', 'playing', or 'busy') and, when actively playing a paired session, opponent_alias; it never includes session_id.
challenge_received { challenge_id, from_id, from_alias, challenger_amount, target_amount, channel_timeout?, unroll_timeout? } Someone challenged you. Amounts are neutral hub labels: challenger_amount is the sender's buy-in, target_amount is yours. The hub auto-declines challenges with invalid amounts or out-of-range timeouts before showing them.
challenge_resolved { challenge_id, accepted } Your outgoing challenge was accepted or declined

When a challenge is accepted, the hub removes that challenge, cancels stale pending challenges involving either player, and sends an advisory_start message to the accepter's game channel only. That advisory is not authority to start a session by itself; it asks the accepter's player app whether to initiate. Two independent challenge accepts (while both players are still free) can each produce an advisory_start; the hub does not negotiate a single initiator. Local availability is authoritative for what happens next:

  • While mid-matchmaking or mid-session (isAvailableForNewSessionPrompt() is false): further advisory_start messages are ignored (no consent UI, no session_reject to the peer — advisory is hub-originated, not a peer request). Inbound session_proposal messages are rejected with session_reject.
  • Clients do not special-case same-peer dual-initiator races (no yield / steal / auto-join). Mutual rejects cancel both attempts cleanly.

The player app self-declares whether it is busy over the game channel. Busy means the app has an active session (the user explicitly accepted a session start). The HubConnection uses a getPresence callback — provided by Shell — to derive the authoritative busy+alias state at connect and reconnect time for the identify message. Presence alias comes from the active session aliases or peekAlias() (hub-synced prefs). It must never call getAlias(), which invents and persists a Player_* fallback that can overwrite the hub-side hub name. Explicit setBusy(true) is called only when the user accepts a session (not when a consent dialog is merely displayed), and setBusy(false) fires on the session controller's terminal event or when the user explicitly ends/cancels a session. Showing a session-consent dialog does not set busy; local availability still gates inbound advisories/proposals as above. When the app later reports that it is not busy, the hub sets the player back to 'waiting'.

Game channel events

The tables below describe logical dictionary fields. On /ws/game, hub control envelopes (identify, set_busy, registered, advisory_start, delivery_failure, hub_attention, keepalive, and error) are bencodex binary dictionary frames. The addressed peer-relay envelope is still a length-prefixed binary frame; peer app messages inside that envelope (session_proposal and session_reject) are bencodex dictionaries, while WASM protocol frames remain raw bytes with the existing reliability tags.

Player App → Hub:

Event Payload Purpose
identify { session_id, busy } Sent immediately after the game channel opens. Links this channel to the player's hub session and reports whether the player app currently considers itself unavailable.
binary frame [4-byte target_id_len BE][target_id UTF-8][payload] Send a peer payload addressed to a specific peer through the hub relay pipe.
set_busy { session_id, busy } Update hub availability from the client-authoritative state. busy: true maps to hub busy or playing and cancels pending challenges involving the player; busy: false maps to waiting.

Hub → Player App:

Event Payload Purpose
registered { player_id } Confirmation of identity. Sent in response to identify.
advisory_start { peer_id, peer_alias, my_amount, their_amount, channel_timeout?, unroll_timeout? } The hub suggests starting a session with this peer (triggered by challenge acceptance in the hub). One-sided: only sent to the challenge accepter, who may become the channel initiator after local consent. Amounts are from the accepter's perspective. The client ignores advisories with invalid amounts or out-of-range timeouts (no consent UI; advisory is hub-originated, so no session_reject).
binary frame [4-byte from_id_len BE][from_id UTF-8][4-byte alias_len BE][alias UTF-8][payload] A peer payload from another peer, relayed through the hub pipe with the sender's public id and alias.
delivery_failure { to } The target peer is not connected; the message could not be delivered.
hub_attention {} Signals that something happened in the hub that the user should look at.

Connection lifecycle:

  1. Player opens a WebSocket connection to the hub game channel.
  2. Player sends identify with the session token (the same token passed to the hub iframe via URL parameter) and current availability.
  3. The hub associates this game channel with the hub session. If a previous game channel exists for this player, the hub closes it and replaces it.
  4. The hub responds with registered, confirming the player's public ID.
  5. When a challenge is accepted in the hub, the hub sends advisory_start to the accepter's game channel only. The app first checks its local availability. If it is already in a session, restoring, handshaking, or showing another consent prompt, it ignores the advisory (no session_reject).
  6. If the accepter consents, that app becomes the channel initiator. It marks itself busy, generates a random hex game_session_id, sends a bencodex session_proposal app message (including the game_session_id) to the peer, starts the WASM session as initiator, and then sends the binary handshake frames through the same addressed pipe. Starting persists session state asynchronously; a later session_reject (or local cancel) must abort that in-flight start so it cannot resurrect an orphan handshake.
  7. The peer receives the session_proposal, checks local availability and validates amounts/timeouts at the trust boundary, stores the game_session_id in its PeerSession, reserves that peer id so early handshake bytes can buffer, and shows its own consent prompt. Invalid amounts or out-of-range timeouts are rejected with session_reject only — they must not clear a finished freeze or IndexedDB checkpoint. If unavailable or declined, it sends session_reject and discards any buffered handshake bytes. Receiving session_reject or delivery_failure during an Accept transition aborts that attempt with the same freeze-safe disposition as dashboard Cancel (peer-only abandon before the checkpoint write lands; full teardown after). Outside Accept, session_reject during pre-active matchmaking cancels the attempt (including any in-flight async start) and surfaces cancelled/error — it must not leave an orphan handshake; resolved finished sessions without an Accept in flight keep their freeze. If accepted, the peer marks itself busy, starts the WASM session as receiver, and drains the buffered handshake bytes. A start failure or dashboard Cancel before the live checkpoint write lands ends the peer attempt only (reject + clear provisional relay) and must preserve any finished freeze / terminal IndexedDB save; full attempt teardown is reserved for failures or Cancel after that persist succeeds. A start failure past the intake wall may also surface a session error warning. While an aborted Accept may still be draining its persist callback, the client stays unavailable for new session prompts so a second Accept cannot race checkpoint restore/cleanup.
  8. Both players exchange binary game frames through the addressed hub pipe. The reliability layer (msgno/ack/keepalive) is encoded inside the binary payload, peer-to-peer — the hub never interprets it.

What the hub holds per game channel: just the mapping from session ID to player ID and the WebSocket reference. The hub has no authoritative session pairing, message log, delivery receipts, or game state. Message routing is purely addressed. When a channel drops, messages to that peer are silently discarded. When a new game channel claims the slot (step 3), the hub resumes routing. Hub channel disconnects do NOT immediately remove the player from the hub — the hub service applies a short TTL sweep to clean up truly departed players. The hub iframe re-joins on reconnect, refreshing lastActive.

Hub Liveness

TCP closes are not always reliable (half-open connections, NAT timeouts, proxy buffering). The hub and clients maintain bidirectional application-level keepalives at two separate layers:

  1. Hub-level keepalives{ type: 'keepalive' } logical envelope frames sent directly between the hub server and each client, every 15 seconds in both directions. /ws/hub serializes these as JSON; /ws/game serializes them as bencodex dictionaries. These prove the WebSocket connection itself is alive.
  2. Peer-level keepalives — relay payloads with the peer reliability keepalive tag, relayed through the hub to the paired peer. These prove the peer is alive end-to-end (see Peer Liveness).

Server side (index.ts):

  • Maintains WebSocket clients for hub and game channels.
  • Starts a 15-second keepalive interval per connection. Hub keepalives are JSON; game keepalives are bencodex. The interval is cleared on ws.close.
  • Handles inbound keepalive envelopes as no-ops (the frame arriving is sufficient proof of life).

Game channel client (HubConnection):

  • Uses a WebSocket connection to /ws/game and re-sends identify on reconnect.
  • Starts a 15-second keepalive interval on ws.onopen that sends a bencodex { type: 'keepalive' } dictionary to the hub. Cleared on close/error/disconnect.
  • Fires onHubActivity() on every incoming ws.onmessage (any message type proves the hub is alive).

Hub channel client (useHubSocket):

  • Uses a WebSocket connection to /ws/hub.
  • Connects immediately and sends get_alias to retrieve a saved alias. Once the alias is confirmed (either from a saved alias or after the user picks one), sends join to register in the hub.
  • On reconnect, re-emits get_alias followed by join to re-register.
  • When the user confirms, auto-joins, or edits an alias, the hub iframe posts { type: 'hub-alias', alias } to the parent Shell so local prefs stay in sync. Shell stores that via setAlias / peekAlias and must not invent a Player_* fallback for game-channel identify / set_busy — those generated names previously overwrote the hub-side hub alias. The hub therefore treats hub set_alias / join / change_alias as authoritative and ignores game-channel alias updates once a name is already known for the session.

Hub Reconnection

Both channels use WebSocket reconnect behavior managed by client code. On reconnect, clients re-send identify (game channel) or join (hub channel). For game channels, this triggers a connection_status response from the hub, which feeds into the reconciliation logic described in Reconnect Reconciliation.

HubConnection exposes onHubDisconnected and onHubReconnected callbacks for logging/diagnostics around game channel stream health.

WebSocket Connection Discipline

All three WebSocket clients — FakeBlockchainInterface (simulator), HubConnection (game channel), and useHubSocket (hub iframe) — follow the same connection discipline:

  1. Exponential backoff with jitter on reconnect. HubConnection and useHubSocket use [1s, 2s, 4s, 8s, 15s, 30s]; the simulator blockchain client extends the same shape to 60s. Each attempt picks a random jitter factor (0.75-1.25x the base delay). The attempt counter resets to zero on a successful onopen.

  2. Connection timeout. Each new WebSocket() is given 10 seconds to reach OPEN. If readyState is still CONNECTING after 10 seconds, the socket is closed, which triggers onclose and feeds into the backoff reconnect.

  3. Avoid using unopened sockets as the active connection. The hub and game clients track in-flight sockets separately from active sockets, and the simulator blockchain client only assigns its active socket after onopen. This lets cleanup abort a pending connection attempt without treating it as usable.

These properties are critical for local development, where all three clients target the same host (127.0.0.1). Without backoff and timeouts, aggressive reconnect attempts (especially after connection-refused RSTs) can trigger browser-level per-host connection throttling, causing multi-second freezes across all connections to that host — even connections from different browser contexts (e.g. the hub iframe vs. the main app).

Hub scope: The hub negotiates only session setup terms: each player's channel buy-in contribution and the channel/unroll timeout block counts. The hub UX defaults to equal buy-ins and 15-block timeouts, but accepts asymmetric buy-ins and timeout values in the 3-30 block range. The hub service rejects invalid challenge terms before forwarding them to the target hub, and the target hub auto-declines invalid terms if they are ever received. Game type and per-hand terms are negotiated inside the state-channel session via game proposals, which allows players to switch between supported games from hand to hand without rematching in the hub.

Future direction: connection identifiers. The MVP supports one paired session per game channel. A future extension adds a connection_id field to all events, allowing multiple simultaneous sessions through one hub.

Session Persistence

Design principle: A page reload must be invisible to the user. The entire UX state — active tab, wallet connection, game session, form inputs — is continuously persisted so that after a reload the app returns to exactly where it was. The user should not be able to tell that a reload happened. Network connections (wallet backend, hub) treat a reload the same as a remote drop and silently reconnect in the background.

The one exception is the restore / start over dialog: when a saved game session exists, the app asks the user whether to resume or discard it before proceeding. This is intentional — silently resuming a stale or unwanted session could be worse than asking.

This is always-on — not a feature the user opts into.

WASM–JS trust model

The WASM module and its host JavaScript execute in the same trust domain — they are served from the same origin, run in the same process, and share the same memory. The WASM-to-JS boundary is not a security boundary.

Private keys (channel, unroll, referee) are intentionally included in the serialized cradle state. Without them, a deserialized session cannot resume signing and the game would be unrecoverable after a page reload. Any JavaScript that can call serialize_cradle() can equally call every other exported WASM function (make_move, go_on_chain, etc.), so withholding keys from the serialized form would not meaningfully limit an attacker who already has script execution in the same origin.

The actual security boundaries are:

  • The browser origin — isolates the player app from other web content.
  • The WebSocket connection to peers — all peer messages are untrusted and validated by the WASM engine before acting on them.
  • The blockchain — on-chain spends require valid aggregate signatures that only the two channel participants can produce.

RNG non-serialization

The game cradle's ChaCha8Rng (used for move entropy and identity generation) is not serialized. The ChaCha8SerializationWrapper emits nothing for the RNG field (#[serde(skip)]) and deserializes to a zeroed placeholder via Default. On restore, restore_session always takes a fresh new_seed parameter from JavaScript, hashes it, and creates a brand new ChaCha8Rng — the deserialized placeholder is immediately overwritten. This avoids persisting seed material and guarantees fresh entropy after every save/restore cycle. The RNG is used only for commit-reveal preimages and initial key generation, not for cryptographic nonces or signatures (BLS signatures are deterministic).

What is saved (SessionSave)

IndexedDB holds one complete SessionSave record as one salt-prefixed, obfuscated binary value. The record is encoded with bencodex, then XOR-masked with a stream derived from the fresh salt and a key compiled into the client. This deters casual inspection but is not a security boundary: the client has everything needed to reverse it. The serialized WASM cradle and unacknowledged protocol messages remain raw Uint8Array values within that binary encoding; they are not base64-expanded. localStorage holds only small preferences, the resumable-session marker, and tab/reset coordination keys, inside the same-origin trust model described above.

The current and only legal envelope schema is chia-gaming-session version 12. Because the project is still alpha, every other version is deleted wholesale without decoding or migration. A decoded v12 record must also satisfy the complete phase-owned envelope contract (keyed game membership, game-owned payload/type agreement, terminal data, and frozen terminal coin list); malformed v12 records are deleted rather than partially restored. The boot marker is retained after an incompatible or malformed resumable record is discarded so the failure remains visible at the Resume / Start Over boundary. The version field is kept as a future migration hook for when there is an installed base to preserve. decodeSessionSaveEnvelope is the one envelope decoder used by both the pre-write check and the IndexedDB read check. Acceptance always constructs the normalized SessionModel; validation is not maintained as a second, shape-only parser. Game-owned handState likewise goes through one registered codec decode that returns its canonical envelope, referenced game IDs, and finished-remount capability together. The envelope is a discriminated union. Every variant has schema, version, phase, identity, preferences, and history; pre-handshake adds pairing; live adds complete pairing, live, and presentation payloads; and terminal adds terminal plus frozen presentation. A preferences record cannot carry resumable state, a pre-handshake record cannot carry game state, and a terminal record cannot carry pairing, cradle, or transport state. The live and terminal presentation payload is wire-complete: empty collections, nullable identities, false flags, zero balances, and timer absence are encoded explicitly rather than reconstructed by decoder defaults. The following fields are grouped under those phase-owned payloads:

Field Type Purpose
version bigint Save schema version; currently 12.
playerId string Stable local hub/player identity for this browser state.
sessionId string? Stable token linking the hub iframe and game-channel WebSocket.
alias string? Local hub display alias preference.
theme 'dark' | 'light'? Persisted player app theme.
defaultFee bigint? Default transaction fee preference.
feeUnit 'mojo' | 'xch'? Display/editing unit for the transaction fee preference.
hubUrl string? Last selected hub origin for reconnect on reload.
activeTab string? Last selected top-level tab.
unreadGame boolean? Whether the Game tab has unread activity.
walletAlert boolean? Whether the Wallet tab should show an alert dot.
hubAlert boolean? Whether the Hub tab should show an alert dot.
blockchainType 'simulator' | 'walletconnect'? Which wallet backend is active or should be reconnected.
serializedGameSession Uint8Array? Raw binary WASM game-session state via serialize().
gameSessionSchemaVersion bigint? Rust-owned schema ID for serializedGameSession; currently 4. Missing or mismatched IDs are unsupported and cleared before deserialization.
pairingToken string? Locally generated identity for the current peer-session/controller instance. It is persisted so pre-cradle setup or a full session resumes into the same instance, and it correlates Shell transition completion with that instance; it is not protocol authority.
sessionPeerId string? Public hub peer id of the current opponent, used to rebind PeerSession on restore.
myHubPlayerId string? Last public player id assigned by the hub, used only to detect remapping during resume.
gameSessionId string? Per-pairing game session id exchanged in session_proposal.
messageNumber bigint? Next outbound game-message sequence number.
remoteNumber bigint? Last delivered inbound game-message sequence number.
iStarted boolean? Whether this player was the channel/session initiator.
terminalIStarted boolean? Display-only initiator role retained after terminal protocol fields are cleared.
myContribution string? This player's channel buy-in contribution as a decimal bigint string.
theirContribution string? Opponent's channel buy-in contribution as a decimal bigint string.
perGameAmount string? Default per-hand amount as a decimal bigint string.
channelTimeout string? Channel timeout retained for pre-cradle handshake resume.
unrollTimeout string? Unroll timeout retained for pre-cradle handshake resume.
rewardPuzzleHash string | null Immutable reward/change address for the active session, or null when none is active.
unackedMessages Array<{ msgno, msg }>? Outbound binary game messages, with raw Uint8Array payloads, that have not been acknowledged by the peer.
humanHistory string[]? Recent user-facing transcript entries (capped at 1,000).
wasmNotificationHistory string[]? Recent serialized WASM notifications (capped at 1,000).
diagnosticLog string[]? Recent diagnostic entries (capped at 2,000).
durabilityWarning string? Last delivery-boundary storage failure warning.
activeGameIds string[] IDs of currently live games in an atomic group; empty when none are active.
currentHandGameIds string[] IDs belonging to the current hand group; empty when there is no retained hand.
lastDisplayedGameId string | null Key of the game instance selected for display when no active game supersedes it.
gameInstances Record<string, …> Keyed-only per-game protocol snapshots: amount, coin, canonical GameProtocolPresentation, and terminal data.
currentHandOrigin 'local' | 'peer' | null Canonical origin of the current hand, owned by the game slice and retained through independent member settlement and terminal display.
activeGameType string Current registered game type (calpoker, spacepoker, or krunk).
handState PersistedGameState | null Opaque game-owned state envelope (gameType, codec version, payload) for live restore or supported finished remounts.
channelStatus ChannelStatusPayload | null Last Rust-owned canonical snapshot for UI restore: actual channel lifecycle plus optional local session_disposition, advisory, coin identity/amount, balances, allocation, potato ownership, and zero_payout. It is normalized once into ChannelStatusModel before any view or lifecycle policy reads it.
myAlias string? Local player display name for the active pairing/session.
opponentAlias string? Opponent display name for the active pairing/session.
lastOutcomeWin 'win' | 'lose' | 'tie' | null Last hand result classification.
coinsOfInterest Array<{ label, id }>? Actual live coin list frozen for terminal display.
myRunningBalance string Running balance delta from the initial amount, including explicit "0".
channelNotifQueue QueuedNotification[] Persisted channel-scope notification queue, without non-serializable payloads.
gameNotifQueue QueuedNotification[] Persisted game-scope notification queue, without non-serializable payloads.
dismissedChannelStatus string | null Last dismissed channel-status notification value.
cleanShutdownStarted boolean Whether clean shutdown has been requested.
betweenHandMode string Between-hand overlay state.
betweenHandCompose { selected_game, game_timeout, proposal_sent, calpoker: { amount }, krunk: { amount }, spacepoker: { unit_size, stack_size } } Complete session-owned compose draft. Every registered game draft is present and all amounts are decimal bigint strings. Space Poker persists the exact editable unit and stack independently; the stake is derived as unit_size * stack_size.
betweenHandLastTerms SavedHandTerms | null Last agreed hand terms.
betweenHandRejectedOnceTerms SavedHandTerms | null Terms already rejected once, used to avoid repeated automatic retries.
betweenHandPendingRetryTerms SavedHandTerms | null Local proposal terms waiting for retry after a proposal collision.
proposalGroups Array<{ primary_id, member_ids, terms, origin, disposition }> Normalized proposal projection. Each group owns its canonical first ID, ordered factory members, one terms object, local/peer origin, and outgoing/incoming-cached/incoming-review/accepted disposition. Member lookup is derived rather than persisted.
waitingStateEnteredAt bigint | null Epoch ms when the channel entered an abandon-eligible waiting state.
cleanShutdownGraceStartedAt bigint | null Epoch ms when the clean-shutdown grace timer started.

Save architecture

Session persistence is executed by the session-machine runtime. A save combines two authoritative sources:

  1. WASM-native stateSessionController.getWasmFields() returns the cradle serialization, message counters, protocol state, history, aliases, and other fields that originate inside the WASM bridge.
  2. JS session state — the current SessionMachineState: keyed game protocol presentation, game-owned durable payload envelope, notification queues, complete compose draft, between-hand mode, running balance, and dismissed notifications.

The pure root reducer returns the next authority and ordered effects. SessionMachineRuntime publishes that authority, runs commands (including persist-session), and only then schedules React. Local game commands use the shared commitLocalGameAction boundary: Rust/WASM accepts the command first, then one machine transition commits the game-owned candidate state and local turn together. A synchronous rejection therefore cannot enter authority or a save. assembleSessionSave reads game-owned handState only from current machine authority and combines it with the controller's WASM-origin snapshot at effect execution time. Live game mounts receive a discriminated hand source containing the real controller; terminal mounts receive only readonly persisted hand state. There is no controller-owned feature-state mirror, render-driven save effect, or React/model mirror ref. Every game reads that hand source through useInitialGameHandState exactly once per keyed mount. The captured value is initialization/restore input only; subsequent canonical state transitions do not re-decode the source. A new handKey or a cold terminal mount creates the next lifetime and therefore the next snapshot. SessionController.onSaveNeeded invokes the same runtime persistence path for ordinary debounced WASM changes. Transaction submission and resubmission remain owned by Rust's TransactionManager, not by a frontend transaction field. Likewise, move redo after an unroll is serialized Rust protocol state. The frontend submits a semantic move once and does not persist a move journal, receive replay instructions, or reissue the move through WASM.

GameSlice atomically owns activeIds, currentHandIds, currentHandOrigin, keyed instances, lastDisplayedId, hand key, and active game type. Its reducer updates a game instance's coin and protocol presentation together, so there are no separately mutable aggregate current-game fields that can drift across game IDs. A game instance's initial turn comes from Rust's per-game ProposalAccepted.our_turn fact; the frontend does not reconstruct it from channel role or factory order. A game hook computes a candidate state and submits it through commitLocalGameAction; after Rust accepts, the root reducer applies the game-owned state and local-turn projection atomically. Feature hooks never write controller persistence state or call persistence directly. GameSettled retires only its own game ID from the slice's active set. This allows separate members of an atomic factory group to settle independently without removing the still-live member from persistence or presentation.

Proposal state is one normalized proposalGroups collection. Each entry owns its canonical first ID, ordered members, one terms object, origin, and explicit UI/lifecycle disposition. Member-ID lookup scans this collection as a pure derivation; there are no per-ID terms/group maps or parallel outgoing/accepted ledgers to rebuild on restore. Product policy permits at most one outgoing local group while one incoming collision may coexist. During an acceptance wave the same entry changes to accepted, preserving terms and ordered Krunk membership across both ProposalAccepted notifications. An InsufficientBalance removes the affected group atomically; successful Krunk members still settle independently, and the accepted entry is removed only after the hand is fully settled. Schema version 12 also makes gameInstances plus lastDisplayedGameId the only persisted game protocol presentation and stores the canonical GameProtocolPresentation discriminant. Under the alpha no-migration policy, version 11 and all other incompatible records are deleted rather than translated from aggregate current-game fields.

Delivery-critical saves

Peer message counters and queues are part of the reliable transport protocol, so they are not allowed to wait for the normal debounce. When an outbound WASM message is produced, SessionController increments messageNumber, appends the message to unackedMessages, and queues the actual WebSocket send. When an inbound message is delivered, it advances remoteNumber and queues the ack. The queued sends/acks are held until the current WASM event drain is empty.

At that point SessionController performs one immediate durability flush:

  1. Cancel any pending debounced save.
  2. Call onSaveNeeded, which serializes the cradle and merges the current WASM/JS fields into SessionSave.
  3. Await flushSessionSave() to commit the record through an IndexedDB read/write transaction.
  4. Send all queued outbound messages and acks.

This preserves the transport invariant across reloads: the peer only observes a message or ack after the local save contains the corresponding messageNumber/unackedMessages or remoteNumber/cradle state. A burst of events in one drain still causes only one full cradle serialization and one IndexedDB transaction instead of one write per message. If the transaction fails, the app shows a persistent session-storage warning and leaves the messages/acks queued; none cross the protocol boundary until a later durability retry succeeds.

Development builds log the raw cradle byte count, an estimated total IndexedDB record size, the compact historical-unroll count when available, and all three history counts. The record-size walk is skipped in production.

Prop-safe session values

SessionSave contains raw Uint8Array cradles and message payloads plus bigint fields. React props cannot safely deep-enumerate those values:

  • Expanding a typed array into {0:n,1:n,...} destroys the cradle and makes WASM restore fail with bencodex unexpected end of input.
  • Deep-cloning a degraded numeric-keyed byte object (or cloning the full session every render) can OOM the tab.

reactPropSafeValue / sessionSaveForReactProps leave ArrayBuffer views and dense byte-objects alone, hide bigints as non-enumerable properties, and Shell keeps a stable sessionSavePropRef so GameSession does not re-walk the save on every parent render. Persistence never routes cradle bytes through this React-prop path: it bencodex-encodes the complete SessionSave, masks the salt-prefixed bytes, and stores that single Uint8Array as the IndexedDB value.

This helper is an opaque persistence bridge, not a numeric conversion API. Game codecs, reducers, refs, and hook-local state retain canonical bigint values, including through game-component props. React 19.2.8 or newer is required together with the checked-in react-dom@19.2.8 pnpm patch. The patch applies React's upstream fix for development Performance Tracks incorrectly passing primitive BigInt arrays to native JSON.stringify, which crashed otherwise valid renders.

Synchronous local-action failures are emitted as scoped session errors and then rethrow unchanged, preserving fail-fast invariants. Asynchronous runtime failures enter the same session notification stream. A Shell-level browser error/unhandledrejection reporter covers failures outside React boundaries with a dismissible/reloadable dialog over the still-mounted game. It never prevents the browser event and ignores error objects already reported through the session path.

Session model ownership

Restore-sensitive UX state lives behind an MVC-style frontend session model in front-end/src/lib/session/. The model records generic session facts from WASM, hub, wallet/blockchain, restore snapshots, and user intents. Selectors then derive the props consumed by Shell, GameSession, and game-specific views.

The migration is intentionally incremental: existing screens should continue to look and behave the same while individual state slices move from scattered React state into selector-derived view models. Local React state should remain for ephemeral display-only details such as input drafts, copied flags, hover state, and drag positions. Restorable protocol/session facts should flow through the model so normal play and restore use the same projection path.

The motivation is reliability, not architectural ceremony: normal display and restore should be two ways of projecting the same session model. If a value needs to survive reload or affect protocol/availability decisions, prefer putting it in the model and deriving the view from selectors instead of maintaining a separate React-only copy that restore has to reconstruct by hand.

SessionModel is the generic shell boundary. It owns the canonical keyed protocol presentation and carries handState only as an opaque PersistedGameState { gameType, version, state } envelope. The shell does not interpret the payload. Calpoker, Space Poker, and Krunk each expose exactly one feature-owned pure registration. That registration owns the state codec, proposal encoding/decoding, term validation/equality, compose defaults, persisted term extras, lifecycle defaults, and durable-state reduction. GAME_REGISTRATIONS is the single pure keyed source and derives display metadata; its mapped type is exhaustive over RegisteredGameType. React mounts live in the separate exhaustive GAME_MOUNTS registry so the pure registration graph does not import React. Rendering indexes that registry directly—there are no duplicate game arrays or switch dispatchers—and the dependency direction does not cycle. All three codecs support live restore. The codec's explicit canRemountFinished capability is true for Cal Poker, Space Poker, and Krunk, so cold finished-session rendering validates the game-owned payload before remounting instead of inferring support from payload presence alone.

Game dashboard (status banner): The compact strip above the Game tab content (GameDashboard in Shell.tsx) is selector-driven. selectGameDashboardView projects channel / lifecycle labels and the primary action button (clean shutdown, go on-chain, abandon, etc.). selectStatusBarBalances projects the balance segments under those labels. Both read from the shared SessionModel; they are not a separate React-owned copy of channel state.

During the short interval after the user accepts a session — before GameSession has reported its first live model, and also while a prior finished freeze model is still mounted until retireTerminalDisplay runs after async replaceSession — Shell passes an explicit setupPending input to the same dashboard selector. This makes the existing primary action show Cancel immediately without introducing a second setup button or a parallel cancellation path. Once a live (non-resolved) model exists, labels and actions are entirely core-derived even if the session-pane transition is still pending: handshake and wallet-signing statuses remain Cancel, while OfferSent / TransactionPending cross the commitment boundary and project Waiting (or the later timer-gated Abandon action).

The dashboard never derives whether a shutdown has value remaining from its displayed balances or game state. Rust provides channelStatus.zero_payout when shutdown begins. A ShuttingDown status with that flag set offers immediate Abandon as a user-controlled escape hatch, but Rust continues the cooperative close until it has supplied the peer with the completed close spend. That zero-payout responder does not submit the transaction itself. Its drain reports one typed terminal-handoff command; SessionController durably persists, sends, and replays its complete-close message until the peer ACKs it, while Rust reports session_disposition: AwaitOutboundTerminal so React keeps the controller alive even if the channel snapshot becomes resolved. After the ACK, Rust sets session_disposition: Abandoned while retaining the actual channel status. It does not wait for the peer’s on-chain publication or confirmation. A shutdown without the flag observes the normal cooperative grace period before offering Go On-Chain. The same Rust predicate makes a direct or stale go_on_chain call abandon before creating a new spend, so the UI label is a projection of protocol authority rather than the enforcement point. A failed inbound deliver_message is also deliberately routed through that Go On-Chain entry point: Rust abandons a zero-payout session there, while a session with value remaining starts normal on-chain resolution. SessionController.goOnChain() returns whether Rust actually began on-chain resolution; Shell applies the peer-disconnect, phase, and dashboard on-chain effects only for that successful result. Timer-gated abandon actions in other waiting states remain separate stalled-flow escapes. See Abandonment and Zero-Payout Shutdown for the full state and terminal-effect rules.

The potato marker is likewise a projection of that one status snapshot: the banner shows 🥔 only when havePotato is true. It is protocol-token context, not a claim about which game turn is currently playable.

Unroll hand projection: GoingOnChain and Unrolling do not yet make per-game turn, replay, or slash classifications authoritative: the unroll can still be preempted. The dashboard therefore keeps each hand Active and hides per-hand lifecycle rows until Rust reports ResolvedUnrolled or ResolvedStale. At that boundary, the reported game classification is shown immediately even if asynchronous enrichment has not yet derived the game coin’s hex ID. A stale resolution preserves its reported channel change balances and continues to show any remaining classified hands.

Pre-game saves and the boot marker: A durable game session is anything with serializedGameSession or pairingToken (isResumable). Those writes set the localStorage boot marker (appState_savedSession) automatically.

Pre-game wallet connection is different: Shell calls markSavedSession() when the wallet finishes connecting, then saveSession({ blockchainType }). The marker is what forces Resume / Start Over on reload even before a game session exists. Preference-only / non-resumable IndexedDB writes must not clear that marker — otherwise a wallet reconnect would restore blockchainType with no dialog. peekSession() treats marker + blockchainType (or leftover WalletConnect storage) as resumable pre-game state. blockchainType alone, without a marker, is not enough (it is preserved across normal clearSession()).

Unsupported IndexedDB schema versions are deleted, but the marker is kept so the next boot still shows Resume / Start Over instead of silently booting into leftover preferences.

Boot state machine

On page load, Shell.tsx runs a boot sequence that determines which dialog (if any) to show before the app becomes interactive. The initializer never claims the tab lease (that would fence other tabs) and never blocks the dialog on IndexedDB:

hasSavedSessionMarker()?
                 │
                 ├─ yes → show Resume / Start Over (hydrate IndexedDB into the
                 │         in-memory cache in the background so incidental
                 │         preference patches cannot clobber a durable cradle)
                 │       │
                 │       ├─ Start Over → hardReset(), reload
                 │       │                (separate "Starting over…" UI state;
                 │       │                 does not share the Resume spinner)
                 │       │
                 │       └─ Resume → peekSession() / load IndexedDB
                 │           │
                 │           ├─ load failure / unsupported → keep dialog open
                 │           │   with loadError; re-arm the marker
                 │           │
                 │           └─ save loaded → is there a lease conflict?
                 │               │
                 │               ├─ Yes → show Take Over dialog
                 │               │   ├─ Take Over → claimLease(), restore
                 │               │   └─ Close Tab → dead
                 │               │
                 │               └─ No → claimLease(), restore
                 │
                 ├─ no marker, lease conflict (another tab is active)
                 │   → show Take Over dialog (save: null)
                 │
                 └─ no marker, no conflict
                     → claimLease(), ready (fresh start)

Start over hard reset: Start over is deliberately not graceful cleanup. It is the escape hatch for garbled local state, so it must not deserialize saved state, reconnect to services, preserve preferences, or otherwise interpret the current session. The handler tears down live hub/wallet sockets (so IndexedDB deletes are not blocked), awaits hardReset(), and reloads the page. hardReset():

  1. Signals sibling tabs to stop persisting.
  2. Clears localStorage / sessionStorage first (ordering only — the boot marker and prefs must not outlive a later IndexedDB hang).
  3. Deletes every known app / WalletConnect IndexedDB database, then enumerates and deletes any remaining origin databases. Deletion waits through onblocked until onsuccess/onerror; hardReset does not time out and abandon the wipe.

Full vs pre-game saves: The resume/takeover handlers check save.serializedGameSession to distinguish full game saves from pre-game saves. A full save triggers performResume (WASM restore + hub reconnect). A pre-game save triggers handleConnect(save.blockchainType) to re-establish the wallet connection without attempting WASM deserialization.

Lease claiming: The lease is never claimed during the boot initializer's read phase. It is only claimed inside resume/takeover handlers after the user has made a choice. Start over does not claim a lease; it wipes local browser state and reloads.

Restore path

When the user chooses to resume a full save, performResume fires:

  1. Hydrate local UI state (game params, human history, WASM notification history, and diagnostic log) from the save.
  2. Connect to the wallet backend (beginConnect + finalize).
  3. Connect to the hub. On connection_status, reconcile the hub's pairing state against the save (see Reconnect Reconciliation).
  4. sessionController.restoreSession loads WASM and deserializes the cradle via WasmStateInit.deserializeGame(), restores WASM/transport counters and logs, and calls markRestored(). sessionModelFromSave initializes the machine's game-owned handState directly from the decoded save.
  5. When qualifyingEvents reaches 7 (bitmask: wasm loaded + cradle set + auto-flush), re-send all un-acked messages and re-submit all pending transactions.

Cleanup

There are two different reset paths:

  • clearSession() is normal lifecycle cleanup. It clears game/session fields while preserving identity, preferences, saved games, and other non-session UI state.
  • hardReset() is destructive app-origin storage reset. It is used by Start over and intentionally wipes all local browser state without attempting graceful wallet, hub, or session cleanup. Sync storage is cleared before IndexedDB so markers/prefs cannot outlive the wipe; IndexedDB deletion is awaited to completion (no give-up timeout).

The browser storage involved is split across three APIs:

  • localStorage holds small preferences, the resumable-session marker, tab lease, and reset coordination keys.
  • sessionStorage holds per-tab identity such as the tab id.
  • IndexedDB holds the raw binary SessionSave; WalletConnect may also maintain its own IndexedDB state after localStorage has been cleared.

Because tabs and windows for the same origin can share localStorage, a hard reset also signals sibling tabs to stop persisting their in-memory cached state. This is only a reset broadcast, not a graceful coordination protocol.

Peer Message Reliability

Authoritative game messages use a numbered ack protocol to guarantee exactly-once ordered delivery across reconnects. The hub relays frames and relay-control messages verbatim — it does not understand message numbers or acks.

Wire format

Authoritative WASM game messages remain raw bytes inside addressed peer-relay payloads, not JSON and not bencodex. The player app wraps them in a tiny peer-to-peer reliability tag before handing the payload to HubConnection:

  • Data payload: tag 0x01, 4-byte big-endian msgno, followed by the opaque WASM peer message bytes.
  • Ack payload: tag 0x02, 4-byte big-endian msgno.
  • Keepalive payload: tag 0x03.

Peer app messages that are not WASM protocol bytes (session_proposal and session_reject) are bencodex dictionaries inside the same addressed peer-relay envelope. The hub does not interpret either form; it only forwards the addressed payload.

Outbound

Every outbound game message (including handshake messages) is assigned a monotonically increasing messageNumber and stored in unackedMessages. The binary frame is not sent immediately; it is queued until the current WASM event drain is empty and the updated session state has been durably flushed. On receiving an ack with number N, all entries with msgno <= N are pruned from the log and persisted through the normal debounced save path.

Inbound

SessionController.deliverMessage enforces strict ordering:

  • msgno <= remoteNumber: duplicate, dropped. An ack is re-sent in case the original ack was lost. The receiver also calls resendUnacked() (throttled) so a peer that retransmitted after reload still receives any unacked outbound we hold (for example an OfferSent handshake payload). If another message boundary is already waiting for a durability flush, the duplicate ack is queued behind that flush too.
  • msgno > remoteNumber + 1: out-of-order, buffered in a reorderQueue map.
  • msgno == remoteNumber + 1: delivered to the WASM cradle, remoteNumber incremented, ack queued, then contiguous messages are flushed from the reorder queue. Acks are sent only after the updated remoteNumber and serialized cradle have been written to the session save.

Peer Liveness

Both peers independently send periodic { keepalive: true } relay payloads through the hub (same message channel as data and acks). Keepalives are fire-and-forget — no response is needed. Receiving any peer traffic (data, ack, or keepalive) counts as proof of life.

  • Send interval: 15 seconds (KEEPALIVE_INTERVAL_MS)

The keepalive timer starts when ChannelCreated fires. On restore, SessionController derives its internal readiness flag from the persisted canonical channelStatus; readiness is not a separate save field. SessionController.notePeerActivity() is called on every inbound message delivery, ack reception, and keepalive reception.

Peer liveness is measured passively from relay traffic. The PeerSession object derives liveness indicators using a 5-second polling interval. These feed into the tab pipe marks — uncolored link / broken-chain emojis to the left of Wallet, Hub, and Game tab labels — and into the game dashboard banner rail (session mode: idle / playing / pings-bad / on-chain / ended). They are also passed to GameSession for in-game display. Separately, Shell has a cascade rule: if the peer is marked lost while the session is still off-chain, it calls goOnChain() on the WASM cradle.

Inbound peer frames are validated before counting as activity: only tags 0x01 (msg, len ≥ 5), 0x02 (ack, len ≥ 5), and 0x03 (keepalive) update liveness; unknown or short frames return false and do not throw into Shell. Outbound hub sends (sendToPeer / presence sendWs) return booleanfalse when the WebSocket is not OPEN — so SessionController can leave durable outbound queued rather than treating a dropped send as success. There is no offline send queue beyond that re-queue-on-failure behavior.

Hub indicator (HubLiveness) combines WebSocket connectivity with keepalive freshness into four states:

State Meaning
Connected WebSocket is open AND hub activity within the last 45 seconds
Reconnecting WebSocket dropped, auto-reconnect in progress
Inactive WebSocket appears open but no hub activity for 45+ seconds
Disconnected Permanently closed (session ended)

Transitions: onHubDisconnected → Reconnecting, onHubReconnected → Connected, keepalive timeout while WS is up → Inactive.

Peer indicator (PeerLiveness) has four states:

State Meaning Tab mark
connected Peer traffic received within the last 30 seconds Link
degraded Delivery failure reported by hub, or no peer traffic for 30+ seconds Link (banner rail yellow)
dead Local go-on-chain or session rejection (FOAD) — terminal for this peer relationship Broken chain
null No keepalive yet, or no active peer session Link if a session is live (handshake); broken chain if none/resolved

dead is sticky: incoming messages from that peer are ignored. Only a new session start resets to null.

Action buttons: Controls live with the state axis they affect. The hub disconnect button lives in the Hub tab header strip (right-aligned next to "Connected to {origin}"). Go On-Chain lives in the Game tab session header. Disruptive hub actions are gated by cascade confirmation dialogs. See CONNECTIVITY.md for the full connectivity model.

Reconnect

When a player reconnects (receives registered from the hub), and has an active session peer, it calls resendUnacked() to replay un-acked messages. The ordering and deduplication logic on the receiving side handles any duplicates caused by the replay.

Reconnect Reconciliation

The onRegistered handler fires on every identify response from the hub — both on initial page load and on mid-session game channel reconnects. On reconnect with an active session peer, the player app resends un-acked messages. The hub has no concept of pairings or session state — reconnect reconciliation is purely a client-side concern based on local session saves.

Static Asset Layers

The player app is composed of three independently deployable static asset layers, ordered from most stable to least:

  1. WASM binary — The Rust game engine compiled to WebAssembly. This is the core of the system: state channel management, move validation, blockchain interaction. It should change rarely once solid. Rebuilding it is the most expensive operation.
  2. Chialisp (.hex files) — Compiled chialisp programs (referee, unroll, game-specific validation). These are fetched over HTTP at runtime and injected into the WASM via cache_file() — they are not compiled into the WASM binary. Changing a chialisp program means recompiling the .clsp to .hex and replacing the file on the static server. No Rust rebuild required.
  3. Frontend (JS/TS/CSS) — The UI layer: React components, hooks, styling. Changes here are the most frequent (UX tweaks, new game UIs, layout fixes). Rebuilt with the JS bundler, no Rust or chialisp rebuild required.

This layering means most day-to-day development only touches layer 3 (frontend), which has the fastest rebuild cycle. Chialisp changes (layer 2) require only a chialisp compile. The Rust/WASM layer (1) is rebuilt only when the engine itself changes — which should be rare once the protocol is stable.

Player App Internal Architecture

The player app is a single-page React application with one real iframe (the hub). Game session and game UI are React components within the same window, separated by hook boundaries rather than iframe boundaries. The design supports future extension to multiple game types and multiple simultaneous games, but the MVP is limited to one game at a time.

Component Hierarchy

┌─────────────────────────────────────────────────────────────────┐
│  Shell (top-level React component)                              │
│  Wallet, blockchain, hub connection, tabs, logs             │
│                                                                 │
│  Wallet tab (initial landing — QR code / simulator setup)       │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  Hub iframe (UNTRUSTED — third-party hub code)     │   │
│  │  Matchmaking only; shown as the "Hub" tab            │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  GameSession component (TRUSTED)                         │   │
│  │  useGameSession hook: WASM cradle, notifications, state  │   │
│  │  Shown as the "Game" tab                                 │   │
│  │                                                          │   │
│  │  ┌────────────────────────────────────────────────────┐  │   │
│  │  │  Game-specific component (CalpokerHand/SpacePoker) │  │   │
│  │  │  Game hook: parsing, display, move logic           │  │   │
│  │  │  Remounted per hand via React key                  │  │   │
│  │  └────────────────────────────────────────────────────┘  │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                 │
│  History tab (append-only text area)                            │
│  Log tab (append-only text area)                                │
└─────────────────────────────────────────────────────────────────┘

Shell (Shell.tsx)

The Shell is the top-level React component. It owns:

  • Wallet connection (WalletConnect or simulator) — the Wallet tab presents a QR code for WalletConnect and a simulator option via SimulatorSetupModal
  • Hub connection — accepts the selected hub URL, creates the HubConnection client for the game channel, and sets up the hub iframe
  • Theme sync — pushes CSS variables and dark-mode class into the hub iframe (useThemeSyncToIframe)
  • Tab navigation — five tabs: Wallet, Hub, Game, History, Log
  • Unique ID and session ID — persisted in localStorage, stable across reloads
  • Session lifecycle and Accept presentationuseShellSessionState fields plus useAcceptLifecycle / acceptLifecycle for Accept abort, persist, and session-pane setup covers

The Shell does not know about game protocol details. When the hub challenge flow completes, Shell creates GameSessionParams with the total channel amount and the perspective-correct myContribution / theirContribution, then renders the GameSession component. Specific game types and per-hand terms are chosen later inside the session through game proposals.

Session-end side effects (hub busy state, balance polling, peer relay teardown, and clearing session refs) are driven by Shell's handleSessionPhaseChange('resolved'). SessionModel reaches that phase only after Rust's final ChannelStatus snapshot has been projected into React, so Shell preserves the final dashboard snapshot before tearing down the live controller.

Session transition ownership

Accept ownership lives in front-end/src/lib/session/acceptLifecycle.ts and useAcceptLifecycle, composed by Shell. Session fields and the Accept session-pane transition bookkeeping live in useShellSessionState / shellSessionState.ts.

Accept session setup is always session-pane scope: tabs and GameDashboard stay mounted while SessionTransitionSurface covers only the game-content pane. Shell renders that cover only before GameSession is kept; once mounted, GameSession hosts the same surface under its notification z-index so overlays remain clickable.

beginAccept clears consent prompts atomically with entering the pending transition, keyed by the same pairingToken that identifies the new controller instance. Completion for a different instance is ignored.

Shell releases the transition via shouldCompleteAcceptTransition: true once the projected ChannelStatus leaves Cancel-only setup states. The core remains authoritative for that commitment boundary.

abortAccept is the single Accept abort API (Cancel, session_reject, delivery_failure, remap, disconnect-during-Accept). It owns session_reject when a peer id is supplied and chooses freeze-safe disposition: pre-replaceSession → peer-only abandon via atomic acceptAborted (finished freeze + terminal IndexedDB stay); after the write lands → full attempt teardown via cancelAttemptedSession. persistFreshStartCheckpoint marks the write committed as soon as replaceSession succeeds, so a Cancel-race restore failure still takes full teardown rather than leaving an orphan live cradle. While Accept is pending or its persist callback is still draining, getPresence / wallet-reconnect busy and local prompt availability all stay blocked so hub re-identify cannot advertise available mid-Accept.

Blockchain Connection Flow

Shell manages wallet connections through two abstractions defined in ChiaGaming.ts:

  • InternalBlockchainInterface — the backend-specific implementation (RealBlockchainInterface for WalletConnect, FakeBlockchainInterface for the simulator). Each exposes beginConnect(), disconnect(), isConnected(), spend(), etc.
  • ConnectionSetup — returned by beginConnect(). Contains a uri for the QR code and a finalize() promise that resolves when the wallet is paired. Optionally contains fields (a map of input descriptors) indicating the backend needs extra user input before connecting (e.g. the simulator's initial balance).

Design principle: Shell must not branch on blockchainType for connection logic. All differences between backends live behind the interface. A single getInterface(bcType) helper maps the type string to the concrete instance and poll interval; the rest of the flow is generic.

Connection lifecycle:

  1. User picks "Simulator" or "Link Wallet" → handleConnect(bcType).
  2. handleConnect calls iface.beginConnect(uniqueId), which returns a ConnectionSetup.
  3. If setup.fields is present, Shell shows the SimulatorSetupModal overlay so the user can provide the required values, then handleFinalize() calls setup.finalize().
  4. If setup.fields is absent (WalletConnect), Shell renders the QR code and immediately awaits setup.finalize(), which resolves when the wallet scans.
  5. After finalize resolves, completeConnection() activates polling and switches to the Hub tab.

Auto-reconnect: Both backends implement their own WebSocket reconnect following the shared connection discipline described in WebSocket Connection Discipline. Shell's onConnectionChange callback handles UI state transitions (connected ↔ disconnected) generically. On page load, if the user chooses to resume a pre-game save (one with blockchainType but no serializedGameSession), Shell calls handleConnect(bcType, true) (silent mode) to re-establish the connection automatically — no modals or QR codes are shown, consistent with the principle that a reload should be invisible to the user.

Session persistence: blockchainType is written via saveSession({ blockchainType }) as soon as the wallet connection completes, together with an explicit markSavedSession() so reload shows Resume / Start Over even before a WASM game session exists. Preference-only writes must not clear that marker. Once the full game session is running, useGameSession takes over persistence and includes blockchainType in every subsequent save alongside the WASM and JS state. clearSession() preserves blockchainType as part of normal session lifecycle cleanup; hardReset() is the destructive path that wipes it.

Intentional deviation: The simulator returns ConnectionSetup.fields because there is no external wallet to scan the QR code. This triggers the SimulatorSetupModal overlay — the only place where Shell's UI differs between backends. All other connection logic is shared.

Blockchain Polling Architecture

After a wallet backend is active, the player app uses BlockchainPoller as the host-side coordinator for chain observations. It separates three concerns:

  1. Polling interestTransactionManager is the sole owner of watched coin meaning and lifetime. The frontend poller only receives the transport projection: coin name plus full coin string. Runtime additions arrive as watchCoins deltas from WASM drain results. snapshot_watched_coins() is only the restore/attach snapshot of the durable WASM interest set, not the per-sweep source of truth.
  2. SchedulingBlockchainPoller owns one AsyncJobQueue per active backend. That queue serializes both background polling and foreground wallet actions exposed through blockchain.rpc, applying the backend's requested inter-request gap. AsyncPollingScheduler runs the repeating height, balance, and coin-sweep jobs by enqueueing them onto that same lane.
  3. Connection adaptersFakeBlockchainInterface and RealBlockchainInterface perform the backend-specific RPCs. WalletConnect still handles fingerprint injection, relayer readiness, and remote-wallet registration shape, but it does not own scheduling or coin lifecycle semantics.

Coin polling reports raw height and coin-state observations upward every successful sweep. The transaction manager computes ordered semantic create/spend/reorg transitions and confirmation-depth retention from those observations. The browser never decides that a watch has become terminal.

When WASM processing registers new watched coins, SessionController applies the watchCoins deltas to BlockchainPoller. On restore, the deserialized TransactionManager already contains the semantic watch set, so BlockchainPoller.attachGameSession() seeds itself once from snapshot_watched_coins() without replaying old events. When manager-owned confirmation-depth eviction ends an interest, WASM emits an unwatchCoins delta and the poller removes only that transport registration.

Polling Termination. ManagerDrainDisposition is the sole generic host lifecycle boundary: active, await-outbound-terminal(command), or terminal. WASM exposes that one discriminated disposition; no game-specific settlement outcome decides host lifetime. SessionController durably sends and replays its Rust-issued command until the peer ACKs it, then asks Rust to finalize. Only terminal discards queued protocol work and watch-coin updates and stops the BlockchainPoller and keepalive timer. Its retained ChannelStatus presentation event updates the SessionModel. Shell then stages one terminal snapshot, awaits the controller's pending durability work and the IndexedDB write, updates the resume marker, and only then destroys the controller and releases the peer relay/hub busy state. If either durability step fails, the staged terminal candidate is discarded while the live cache and controller remain owned and retryable; teardown is not attempted. Timer/effect cleanup that can finish after this atomic replacement uses patchLiveSessionPresentation; it updates only a still-live owner and becomes a no-op once terminal persistence owns the record. Ordinary presentation writes continue to fail fast outside the live phase.

Presentation, protocol, and restoration lifetimes. These are three separate boundaries:

  • The current hand's React feature tree is a visual lifetime. It remains mounted through individual game settlements and successful channel finalization. Finalization changes its generic interaction mode from live to terminal without changing the feature component type or handKey; only acceptance of a new hand changes that key.
  • The real SessionController, peer relay, callbacks, subscriptions, and blockchain attachment are a protocol lifetime. After the terminal reduction queue and atomic terminal save have flushed, they are detached and destroyed. The retained feature receives a readonly terminal hand source, an empty gameplay stream, and one selector projection built entirely from the finalized SessionModel. Protocol access through a terminal source fails immediately.
  • FinishedSessionGameView is cold-restoration infrastructure. It mounts a validated persisted hand only when no live React tree survived, such as after a page reload. Its game controls remain disabled by terminal interaction mode, while scrolling, text selection, and copying remain available. Its error/fallback handling is isolated from the in-place terminal path.

The terminal save retains only presentation payloads needed by supported game-specific rehydrators; an absent, unsupported, or stale payload renders the terminal summary instead. selectFinishedSessionDisplay consults the owning codec's explicit finished-remount capability. Cal Poker, Space Poker, and Krunk all provide validated cold terminal remounts.

WalletConnect BigInt Serialization

WalletConnect's internal JSON handling (@walletconnect/safe-json) uses a custom convention for BigInts: safeJsonStringify serializes BigInt(123) as the string "123n", and safeJsonParse converts strings matching /^\d+n$/ back to BigInts. This means BigInt values survive a WC round-trip, but as string-encoded values rather than native JSON numbers.

This convention has two bugs that we patch around:

Bug 1: Negative BigInts. The parse regex ^\d+n$ doesn't match negative values like "-100n". These pass through as plain strings, which downstream code (e.g. the Chia daemon) can't parse. We fix this with a pnpm patch on @walletconnect/safe-json@1.0.2 (patches/@walletconnect__safe-json@1.0.2.patch) that changes the regex to ^-?\d+n$. This patch applies to all WC packages in the frontend that depend on safe-json.

Bug 2: Verify API hashing. WC's sign-client computes SHA-256 hashes of payloads for its Verify API using bare JSON.stringify, which throws on BigInt values. We fix this with a pnpm patch on @walletconnect/sign-client@2.23.9 that injects a __wcSafe helper using the same "n"-suffix convention and replaces the 5 internal hashMessage(JSON.stringify(...)) call sites with hashMessage(__wcSafe(...)). This patch is large (280KB) because the sign-client ships as a single minified line — the actual change is one helper definition and 5 call-site substitutions.

Wallet GUI side. The Chia wallet GUI (chia-blockchain-gui) has its own mitigations since its WC packages are installed via npm (no pnpm patchedDependencies):

  • patch-package + postinstall rewrite (patches/@walletconnect+safe-json+1.0.2.patch and scripts/fix-walletconnect-bigint-regex.js): Same negative-BigInt regex fix as the player app (/^-?\d+n$/). The patch covers @walletconnect/safe-json; the script also rewrites WC UMD bundles that inline a copy of the parser. Without this, offer amounts like "-100n" stay strings and fail in parseMojos during chia_createOfferForIds.

  • JSON.stringify monkey-patch (packages/gui/src/index.tsx): Early in the renderer entry point, JSON.stringify is replaced with a BigInt-safe version using the "n" convention. This covers WC's internal hash computation paths in the renderer process.

  • Confirm dialog replacer (packages/gui/src/electron/dialogs/Confirm/Confirm.tsx): The "Raw data" display uses JSON.stringify(data, (_, v) => typeof v === 'bigint' ? String(v) : v, 2) to avoid crashing when rendered data contains BigInts.

Frontend (jsonSafe.ts). The player app has its own BigInt-safe JSON utilities in front-end/src/util/jsonSafe.ts:

  • jsonParse — uses a JSON.parse reviver that converts all integers to BigInt (matching the behavior of the lossless-json library previously used). This ensures values from the simulator backend arrive as BigInts.
  • jsonStringify — hand-rolled serializer that emits BigInts as bare numeric literals (via toString() directly into the JSON string), avoiding both the JSON.stringify BigInt crash and the precision loss of Number() conversion.
  • jsonParseLossless / jsonStringifyLossless — JSON-only helpers used where lossless JSON is explicitly required. They are not the SessionSave persistence format.

UX BigInt policy

All integer values in the player app are bigint. This applies universally to protocol counters, money amounts, card values, move data, timestamps, version numbers, message sequence numbers — everything. JavaScript's number type is IEEE 754 double-precision floating-point and silently loses precision for values beyond 2^53. Rather than auditing each field individually, the rule is simple: if it's an integer, it's a bigint.

The only exceptions are values consumed directly by APIs that require number: array indices, DataView get/set methods (which take 32-bit number arguments), CSS pixel values, setTimeout delays, and similar DOM/browser APIs. These conversions happen at the call site with an explicit Number() cast — the bigint remains the source of truth.

Persistence. SessionSave fields including version, messageNumber, remoteNumber, timestamp, and all game-specific state use bigint. Bencodex represents those integers and raw byte strings directly. IndexedDB stores one salt-prefixed, masked Uint8Array containing the bencodex record; there is no tagged-JSON save envelope and no structured-clone object graph.

View layer boundary. React components that render or edit a value receive view-safe props: decimal strings for money and CLVM integers, or small numbers only for genuinely UI-local quantities such as input step counts, array indices, CSS/layout values, and enum-like controls. Game-specific wrappers such as GameSession build these view models explicitly, and convert back to bigint only when calling hook actions that construct protocol moves.

This boundary is also defensive. Native JSON.stringify throws on BigInts, and React development diagnostics may enumerate props or error payloads in ways that hit JSON serialization. Avoid passing BigInt-rich domain objects directly into deep component trees. Prefer explicit string/number view props; if a domain object must cross a React boundary, keep BigInt-heavy implementation details out of ordinary enumerable props.

Wire protocol. Peer-to-peer message sequence numbers (msgno) are bigint internally but are serialized as 32-bit unsigned integers in binary WebSocket frames (via DataView.setUint32). The Number() conversion happens at the HubConnection send boundary; incoming values are converted to BigInt() immediately upon receipt. The hub itself never interprets these values — it relays binary frames opaquely.

Hub Iframe (Hub)

The hub iframe is untrusted. It is served by a hub and provides matchmaking UX. The only interaction between the player app and the iframe is:

  • Theme sync — the player app sends postMessage with CSS variables; the iframe can request a sync via postMessage with { type: 'theme-request' }
  • Session token — passed via URL parameter so the hub can link the iframe to the game channel

The hub iframe uses the hub WebSocket challenge protocol (see above) to trigger matches. The player app never reads from or writes to the iframe's DOM.

GameSession Component (GameSession.tsx + useGameSession)

The GameSession component manages one game session (a channel with a series of individual hands). useGameSession is a thin React interpreter boundary: it obtains the SessionController, creates one SessionMachineRuntime, subscribes to host events, dispatches typed machine events, attaches/detaches the blockchain poller, and returns selector-derived view data plus dispatch callbacks. It does not contain notification policy, command interpretation, durable game reduction, or persistence assembly.

When Shell supplies a finalized terminal presentation, useGameSession atomically projects every model-derived field from that model, replaces the live hand source with a readonly terminal source, and exposes an empty gameplay stream. The existing feature mount remains in place under the same hand key; effects that subscribe, attach blockchain services, autoplay, or install command-producing keyboard handlers are disabled by the generic interaction mode rather than by inspecting protocol phases.

The cohesive session modules own those responsibilities:

  • sessionMachine.ts is the pure root reducer.
  • sessionMachineNotifications.ts reduces normalized WASM notifications.
  • sessionMachineCommands.ts maps UI events to typed commands.
  • sessionMachineEffects.ts enforces authority → commands/save → React ordering; saves combine WASM cradle bytes with machine-owned handState.
  • sessionMachineInterpreter.ts performs controller calls, timers, persistence, gameplay emission, and async enrichment.
  • sessionMachinePersist.ts assembles and writes snapshots at effect time.
  • gameSessionEvents.ts normalizes raw notification payloads.

The controller still waits for its normal macrotask boundary, then drains one active FIFO to quiescence so synchronously re-entrant WASM effects enter the same machine transaction. A self-replenishing source yields after 100 events. Terminal manager dispositions retain their separate queue-clearing and awaited finalization path. Compose/review overlays retain the completed hand beneath an inert subtree; the complete compose draft remains machine-owned and durable across unmounts and reloads.

Game Components

The active game UI is rendered inside GameSession based on the current game type. front-end/src/lib/gameRegistry.ts holds the pure feature registrations for California Poker (calpoker), Space Poker (spacepoker), and Krunk (krunk). front-end/src/lib/gameMountRegistry.tsx separately and exhaustively registers their lazy live/frozen React mounts.

CalpokerHand receives gameplay events via an RxJS observable and submits moves through the shared Rust-first local-action boundary.

Space Poker keeps its hand history and terminal presentation inside useSpacepokerHand. A betting-round fold, a showdown no-reveal concession, and a revealed showdown remain distinct displays. The hook attributes a terminal opponent action only when the current readable handler proves it; a GameSettled notification alone does not imply that either player folded. Its terminal reveal, concession, and fold entries are committed only after Rust accepts the local command. They are removed and the playable hand restored only when a later matching game-scoped MoveRejected, game-action-error, or context-bearing Rust ActionFailed event reports that makeMove or acceptSettlement failed. Rust preserves that context when a potato-gated queued move or settlement fails during a later flush; unscoped failures are never attributed to a hand. A failed automatic reveal or concession enters an explicit recovery state and waits for a user retry or authoritative update; it never resubmits on a React effect rerun. Generic terminal errors and non-voluntary settlements replace optimistic terminal state with the authoritative generic presentation. A revealed presentation survives only its voluntary settlement acknowledgement, never a timeout, slash, or other settlement outcome. This is UI state only: the session controller and Rust GameSettled outcome remain the authority, and the game component never observes the chain itself.

The useCalpokerHand hook manages the five-step protocol:

  • Move 0 (auto) — nil move to initiate commit-reveal
  • Move 1 (interactive) — card selection and discard submission
  • Move 2 (auto) — final reveal
  • Outcome — parsed from the opponent's final move into a CalpokerOutcome

Game components are remounted from scratch for every hand via React key (key={session.handKey}). This ensures no stale state accumulates between hands.

What the game UI does not know about:

  • Blockchain, channels, wallets, unrolling, on-chain resolution
  • Channel-scope events
  • Other games (in the future when multiple games are supported)
  • What happens when things go wrong at the channel level — the session component handles all of that

Notification Routing

useGameSession normalizes each WASM notification into a typed machine event. sessionMachineNotifications.ts then reduces it and emits ordered effects into the scoped queues, gameplay stream, controller, persistence path, or async enrichment boundary:

Channel notification queue

Infrastructure-level events pushed to the channel-scoped FIFO queue (pushChannel). These appear as dismissable, non-modal overlays at z-50 over the full session area. See Dashboard Status Labels and Additional Design Rules for details.

Kind Source
channel-state ChannelStatus in ATTENTION_STATES (replaceable slot)
session-over Balance exhausted → cooperative shutdown
action-failed ActionFailed (WASM Err) — also logged
infra-error ReceiveError, tx failures, general error events

Game notification queue

In-game and between-hand events pushed to the game-scoped FIFO queue (pushGame). Overlays appear at z-40 within the game area.

Kind Source
game-terminal Adverse GameSettled outcomes during on-chain flow (via isErrorSettlementOutcome), except bar-only forfeits
proposal-rejected ProposalCancelled with CancelledByPeer (peer-side cancellation notice)
insufficient-bal InsufficientBalance notification

Settlement banner labels come from SETTLEMENT_OUTCOME_LABELS in front-end/src/lib/settlement.ts (see settlement glossary and CONNECTIVITY.md "Settlement labels"). Adverse outcomes are flagged via isErrorSettlementOutcome on GameTerminalInfo.outcome.

Game lifecycle (handled internally by session)

These drive game proposal and acceptance flow. They are consumed by the notification reducer and never forwarded raw to the game UI:

  • ProposalMade — one notification per factory group; carries the first ID and always-non-empty ordered group_ids (singleton ⇒ [id]), and triggers group auto-accept

Gameplay events (forwarded to game UI via observable)

These are the normal flow of play, forwarded to the active game UI component via the gameplayEventSubject RxJS stream:

  • ProposalAccepted — a new game is starting (also clears stale proposal-rejected entries from the game queue)
  • OpponentMoved — the opponent made a move (with readable data and moverShare, our share after that move / on timeout from it)
  • GameMessage — advisory data (e.g. Alice revealing cards to Bob early)
  • MoveRejected — a recoverable delayed rejection with game id, tag, and message; game hooks roll back only the matching Rust-accepted local action
  • Settled{ gameId, outcome, ourShare } from GameSettled; dual-delivered to the session banner and the active game hook via gameplayEvent$
  • GameError — non-settlement terminals (EndedCancelled, EndedError, InsufficientBalance) and unknown settlement outcomes

Legacy GameStatus slash/timeout Ended* kinds are no longer forwarded to gameplay hooks; settlements use GameSettled only.

Single-Hand Enforcement

The WASM layer supports multiple simultaneous games (games are tracked by GameID), but the frontend currently enforces one game at a time. This is a deliberate architectural choice: single-hand enforcement lives almost entirely in JavaScript, keeping the WASM/Rust layer multi-hand-ready for future use. The game UI component contract does not change — each game instance behaves as if it is the only game. When multi-handing is added, the session component gains a multiplexer (game ID → component mapping) and the JS-side guards are relaxed.

JS-side guards

Send guard — the command interpreter checks the current machine authority and does not call SessionController.proposeGame while model.game.activeIds is non-empty. This prevents the user from proposing a new hand while one is in progress without a mirror ref.

Atomic factory proposals — the proposal command constructs one request with game_type, game-specific CLVM parameters, and a shared game timeout. SessionController.proposeGame sends that single request to WASM and stores all returned IDs. The registered deterministic factory decides cardinality: Calpoker and Space Poker return one ID; Krunk returns two ordered IDs. On the receive side there is exactly one ProposalMade for the group, so the frontend presents one logical proposal without deduplicating per-member notifications. Accepting or cancelling via the first ID expands to the full group in WASM.

Receive guard — When a ProposalMade notification arrives while a game is active, the notification reducer emits controller-cancel-proposal rather than caching it.

First-game proposal — The initiator proposes the first game exactly once, triggered by ChannelStatus { state: Active } while the machine's firstGameAccepted coordination flag is false. The reducer advances that flag in the same transition that emits the command. The receiver auto-accepts the first ProposalMade through the symmetric reducer branch.

WASM-side constraints

Two proposal constraints live in WASM because they arise from the potato protocol's asynchronous nature and cannot be deferred to JS:

  1. SupersededByIncoming — When a batch arrives containing a ProposeGroup from the peer, any locally queued QueuedProposalGroup actions are removed from the game_action_queue. The queued groups were built against a now-stale state (the incoming batch carries the potato and the definitive state). WASM emits one ProposalCancelled { reason: SupersededByIncoming } for each removed group, keyed by its first ID.

  2. PeerProposalPending — When JS calls propose_game while an unresolved peer proposal exists in proposed_games, WASM rejects immediately with ProposalCancelled { reason: PeerProposalPending }. This prevents silently cancelling the peer's proposal as a side effect of proposing our own.

Both represent the same fundamental situation — a collision between our proposal intent and the peer's — hitting at different points in the potato cycle. In case 1, our proposal was queued but unsent when the peer's batch arrived. In case 2, the peer's proposal was already recorded when JS tried to propose. The frontend handles both identically: stash the cancelled terms in the machine-owned durable betweenHand.pendingRetryTerms field and wait for the incoming peer proposal to surface before deciding what to do (see Proposal Collision Handling).

Everything else in WASM — MAX_PROPOSALS (100), nonce parity/monotonicity, factory/member consistency, positive shared timeout validation, aggregate balance preflight, and all-or-none group acceptance — are validation/safety checks, not single-hand enforcement. They exist to prevent protocol violations, not to limit concurrency.

Key Files

File Purpose
front-end/src/components/Shell.tsx Top-level component: boot dialogs, wallet, hub, GameDashboard banner, tabs, logs
front-end/src/components/GameSession.tsx Game session UI: header, coin status, game area, overlays
front-end/src/hooks/useGameSession.ts Thin React boundary: controller/runtime setup, host subscription, typed dispatch, selector projection
front-end/src/lib/session/sessionMachine*.ts Root dispatcher plus cohesive channel, between-hand, proposal, durable-game, notification, command, effect, runtime, and persistence modules
front-end/src/lib/session/persistence*.ts Canonical strict-v12 phase decoder plus primitive, between-hand/proposal, and phase-payload codecs; accepted records always produce a normalized SessionModel
front-end/src/lib/session/sessionSnapshot.ts Canonical SessionModel → v12 presentation snapshot encoder
front-end/src/lib/gameRegistry.ts Exhaustive pure feature registration and game-owned codec/terms/compose dispatch
front-end/src/lib/gameMountRegistry.tsx Exhaustive React live/frozen mount registration
front-end/src/features/calPoker/useCalpokerHand.ts Calpoker hook: five-step protocol, card parsing, move submission
front-end/src/hooks/SessionController.ts WASM bridge (SessionController class): message delivery, block data, event queue, getWasmFields() for persistence
front-end/src/hooks/WasmStateInit.ts WASM initialization: load binary, deposit .hex files, create cradle
front-end/src/hooks/blobSingleton.ts Singleton management: getOrCreateSessionController / destroySessionController; restore path for session persistence
front-end/src/services/PeerSession.ts Per-session peer state: session ID, peer ID, liveness, message buffering/routing, send methods
front-end/src/hooks/save.ts v12 cache/write and live/terminal lifecycle facade
front-end/src/hooks/saveCoordination.ts Resume markers, active-tab lease, and cross-tab persistence fencing
front-end/src/hooks/saveHardReset.ts Hard-reset and WalletConnect browser-storage cleanup
front-end/src/hooks/savePreferences.ts Local preference encoding and decoding
front-end/src/lib/session/indexedDb.ts IndexedDB session record read/write/delete
front-end/src/lib/session/model.ts Session model + selectGameDashboardView / selectStatusBarBalances
front-end/src/lib/reactPropSafe.ts Prop-safe cloning that preserves typed arrays / dense byte objects
front-end/src/hooks/BlockchainPoller.ts Chain polling coordinator: height ticks, coin-state reports, watch deltas, restore snapshots
front-end/src/lib/AsyncScheduler.ts Generic serialized async queue and repeating polling loop
front-end/src/hooks/FakeBlockchainInterface.ts Simulator blockchain backend: WebSocket to local sim, auto-reconnect
front-end/src/hooks/RealBlockchainInterface.ts WalletConnect blockchain backend: RPC via WalletConnect sessions
front-end/src/hooks/WalletConnectRpc.ts WalletConnect RPC formatting/normalization helpers
front-end/src/services/HubConnection.ts Game relay WebSocket client (/ws/game)
front-end/src/types/ChiaGaming.ts TypeScript types for WASM interface and game data
hub/hub-frontend/src/hub.tsx Hub UI; syncs chosen alias to parent via hub-alias postMessage
hub/hub-frontend/src/useHubSocket.ts Hub channel hook (useHubSocket): hub WebSocket join/challenge/alias messaging
hub/hub-service/src/index.ts Hub server: hub, challenges, addressed message relay, liveness sweep
hub/hub-service/src/hubState.ts Hub state: players, challenges