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.
The system consists of two separate deployable artifacts:
- 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.
- 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.
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.
The player app connects to the hub through two parallel channels that share a session token:
- 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 sendspostMessageto the iframe only for theme syncing (CSS variables and dark-mode class), never for game data. The hub iframe may request a theme sync viapostMessageas well. - 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.
The hub has two communication channels per player:
- 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. - Game relay channel — the player app's
HubConnectionWebSocket. 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 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): furtheradvisory_startmessages are ignored (no consent UI, nosession_rejectto the peer — advisory is hub-originated, not a peer request). Inboundsession_proposalmessages are rejected withsession_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'.
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:
- Player opens a WebSocket connection to the hub game channel.
- Player sends
identifywith the session token (the same token passed to the hub iframe via URL parameter) and current availability. - 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.
- The hub responds with
registered, confirming the player's public ID. - When a challenge is accepted in the hub, the hub sends
advisory_startto 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 (nosession_reject). - If the accepter consents, that app becomes the channel initiator. It marks
itself busy, generates a random hex
game_session_id, sends a bencodexsession_proposalapp message (including thegame_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 latersession_reject(or local cancel) must abort that in-flight start so it cannot resurrect an orphan handshake. - The peer receives the
session_proposal, checks local availability and validates amounts/timeouts at the trust boundary, stores thegame_session_idin 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 withsession_rejectonly — they must not clear a finished freeze or IndexedDB checkpoint. If unavailable or declined, it sendssession_rejectand discards any buffered handshake bytes. Receivingsession_rejectordelivery_failureduring 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_rejectduring 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. - 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.
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:
- Hub-level keepalives —
{ type: 'keepalive' }logical envelope frames sent directly between the hub server and each client, every 15 seconds in both directions./ws/hubserializes these as JSON;/ws/gameserializes them as bencodex dictionaries. These prove the WebSocket connection itself is alive. - 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/gameand re-sendsidentifyon reconnect. - Starts a 15-second keepalive interval on
ws.onopenthat sends a bencodex{ type: 'keepalive' }dictionary to the hub. Cleared on close/error/disconnect. - Fires
onHubActivity()on every incomingws.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_aliasto retrieve a saved alias. Once the alias is confirmed (either from a saved alias or after the user picks one), sendsjointo register in the hub. - On reconnect, re-emits
get_aliasfollowed byjointo 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 viasetAlias/peekAliasand must not invent aPlayer_*fallback for game-channelidentify/set_busy— those generated names previously overwrote the hub-side hub alias. The hub therefore treats hubset_alias/join/change_aliasas authoritative and ignores game-channel alias updates once a name is already known for the session.
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.
All three WebSocket clients — FakeBlockchainInterface (simulator),
HubConnection (game channel), and useHubSocket (hub iframe) —
follow the same connection discipline:
-
Exponential backoff with jitter on reconnect.
HubConnectionanduseHubSocketuse[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 successfulonopen. -
Connection timeout. Each
new WebSocket()is given 10 seconds to reachOPEN. IfreadyStateis stillCONNECTINGafter 10 seconds, the socket is closed, which triggersoncloseand feeds into the backoff reconnect. -
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.
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.
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.
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).
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. |
Session persistence is executed by the session-machine runtime. A save combines two authoritative sources:
- WASM-native state —
SessionController.getWasmFields()returns the cradle serialization, message counters, protocol state, history, aliases, and other fields that originate inside the WASM bridge. - 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.
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:
- Cancel any pending debounced save.
- Call
onSaveNeeded, which serializes the cradle and merges the current WASM/JS fields intoSessionSave. - Await
flushSessionSave()to commit the record through an IndexedDB read/write transaction. - 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.
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 bencodexunexpected 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.
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.
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():
- Signals sibling tabs to stop persisting.
- Clears
localStorage/sessionStoragefirst (ordering only — the boot marker and prefs must not outlive a later IndexedDB hang). - Deletes every known app / WalletConnect IndexedDB database, then enumerates
and deletes any remaining origin databases. Deletion waits through
onblockeduntilonsuccess/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.
When the user chooses to resume a full save, performResume fires:
- Hydrate local UI state (game params, human history, WASM notification history, and diagnostic log) from the save.
- Connect to the wallet backend (
beginConnect+finalize). - Connect to the hub. On
connection_status, reconcile the hub's pairing state against the save (see Reconnect Reconciliation). sessionController.restoreSessionloads WASM and deserializes the cradle viaWasmStateInit.deserializeGame(), restores WASM/transport counters and logs, and callsmarkRestored().sessionModelFromSaveinitializes the machine's game-ownedhandStatedirectly from the decoded save.- When
qualifyingEventsreaches 7 (bitmask: wasm loaded + cradle set + auto-flush), re-send all un-acked messages and re-submit all pending transactions.
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:
localStorageholds small preferences, the resumable-session marker, tab lease, and reset coordination keys.sessionStorageholds 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.
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.
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-endianmsgno, followed by the opaque WASM peer message bytes. - Ack payload: tag
0x02, 4-byte big-endianmsgno. - 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.
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.
SessionController.deliverMessage enforces strict ordering:
msgno <= remoteNumber: duplicate, dropped. An ack is re-sent in case the original ack was lost. The receiver also callsresendUnacked()(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 areorderQueuemap.msgno == remoteNumber + 1: delivered to the WASM cradle,remoteNumberincremented, ack queued, then contiguous messages are flushed from the reorder queue. Acks are sent only after the updatedremoteNumberand serialized cradle have been written to the session save.
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 boolean — false
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.
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.
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.
The player app is composed of three independently deployable static asset layers, ordered from most stable to least:
- 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.
- 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.clspto.hexand replacing the file on the static server. No Rust rebuild required. - 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.
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.
┌─────────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────────┘
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
HubConnectionclient 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 presentation —
useShellSessionStatefields plususeAcceptLifecycle/acceptLifecyclefor 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.
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.
Shell manages wallet connections through two abstractions defined in
ChiaGaming.ts:
InternalBlockchainInterface— the backend-specific implementation (RealBlockchainInterfacefor WalletConnect,FakeBlockchainInterfacefor the simulator). Each exposesbeginConnect(),disconnect(),isConnected(),spend(), etc.ConnectionSetup— returned bybeginConnect(). Contains aurifor the QR code and afinalize()promise that resolves when the wallet is paired. Optionally containsfields(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:
- User picks "Simulator" or "Link Wallet" →
handleConnect(bcType). handleConnectcallsiface.beginConnect(uniqueId), which returns aConnectionSetup.- If
setup.fieldsis present, Shell shows theSimulatorSetupModaloverlay so the user can provide the required values, thenhandleFinalize()callssetup.finalize(). - If
setup.fieldsis absent (WalletConnect), Shell renders the QR code and immediately awaitssetup.finalize(), which resolves when the wallet scans. - 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.
After a wallet backend is active, the player app uses BlockchainPoller as the
host-side coordinator for chain observations. It separates three concerns:
- Polling interest —
TransactionManageris 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 aswatchCoinsdeltas 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. - Scheduling —
BlockchainPollerowns oneAsyncJobQueueper active backend. That queue serializes both background polling and foreground wallet actions exposed throughblockchain.rpc, applying the backend's requested inter-request gap.AsyncPollingSchedulerruns the repeating height, balance, and coin-sweep jobs by enqueueing them onto that same lane. - Connection adapters —
FakeBlockchainInterfaceandRealBlockchainInterfaceperform 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
livetoterminalwithout changing the feature component type orhandKey; 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 finalizedSessionModel. Protocol access through a terminal source fails immediately. FinishedSessionGameViewis 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'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.patchandscripts/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 inparseMojosduringchia_createOfferForIds. -
JSON.stringifymonkey-patch (packages/gui/src/index.tsx): Early in the renderer entry point,JSON.stringifyis 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 usesJSON.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 aJSON.parsereviver that converts all integers to BigInt (matching the behavior of thelossless-jsonlibrary previously used). This ensures values from the simulator backend arrive as BigInts.jsonStringify— hand-rolled serializer that emits BigInts as bare numeric literals (viatoString()directly into the JSON string), avoiding both theJSON.stringifyBigInt crash and the precision loss ofNumber()conversion.jsonParseLossless/jsonStringifyLossless— JSON-only helpers used where lossless JSON is explicitly required. They are not theSessionSavepersistence format.
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.
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
postMessagewith CSS variables; the iframe can request a sync viapostMessagewith{ 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.
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.tsis the pure root reducer.sessionMachineNotifications.tsreduces normalized WASM notifications.sessionMachineCommands.tsmaps UI events to typed commands.sessionMachineEffects.tsenforces authority → commands/save → React ordering; saves combine WASM cradle bytes with machine-ownedhandState.sessionMachineInterpreter.tsperforms controller calls, timers, persistence, gameplay emission, and async enrichment.sessionMachinePersist.tsassembles and writes snapshots at effect time.gameSessionEvents.tsnormalizes 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.
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
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:
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 |
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.
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 orderedgroup_ids(singleton ⇒[id]), and triggers group auto-accept
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 staleproposal-rejectedentries from the game queue)OpponentMoved— the opponent made a move (with readable data andmoverShare, 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 actionSettled—{ gameId, outcome, ourShare }fromGameSettled; dual-delivered to the session banner and the active game hook viagameplayEvent$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.
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.
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.
Two proposal constraints live in WASM because they arise from the potato protocol's asynchronous nature and cannot be deferred to JS:
-
SupersededByIncoming— When a batch arrives containing aProposeGroupfrom the peer, any locally queuedQueuedProposalGroupactions are removed from thegame_action_queue. The queued groups were built against a now-stale state (the incoming batch carries the potato and the definitive state). WASM emits oneProposalCancelled { reason: SupersededByIncoming }for each removed group, keyed by its first ID. -
PeerProposalPending— When JS callspropose_gamewhile an unresolved peer proposal exists inproposed_games, WASM rejects immediately withProposalCancelled { 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.
| 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 |