This document describes the semantics of the four types of connections in the player app and how they interact. It captures the intended design for session rollover support — the ability to disconnect from and reconnect to wallets, hubs, and peers without losing game sessions unnecessarily.
For background on the system architecture, see FRONTEND_ARCHITECTURE.md.
For game lifecycle details, see GAME_LIFECYCLE.md.
- The Four Axes
- State Space
- Cascade Rules
- User Actions
- Session Lifecycle
- Hub Availability Protocol
- Implementation Status
The blockchain is not a connection. It is the ground truth — always present, immutable, the same chain regardless of how you reach it. All game state ultimately lives on-chain (coins, channel state, resolution transactions). No connectivity decision affects the blockchain itself.
The wallet is a replaceable interface to the blockchain. WalletConnect and the simulator are different lenses into the same chain. Connecting a different wallet (or reconnecting the same one) gives you the same view of the same coins. Switching between simulator and real chain is a user error the app doesn't guard against — coins simply won't exist.
The wallet is orthogonal to the other three axes. It can be connected or disconnected in any combination with hub, peer, and session state. No other connection depends on the wallet being up. The wallet affects only whether blockchain operations (signing transactions, reading balances) can make progress.
WalletConnect is an external protocol. The player app can adapt around its quirks at the edges (for example BigInt serialization handling), but it does not control WalletConnect's wire format. This is different from the peer/hub protocol, which is project-owned: the game WebSocket control envelopes and peer app messages use bencodex, while the hub iframe WebSocket remains JSON.
A hub is a specific server. The hub is its matchmaking HTML UX (player list and challenges); the game relay is a separate channel on the same server. Hub A and Hub B are distinct entities — different lobbies, different pairings, different relay channels. The player connects to zero or one hub at a time.
The hub connection auto-reconnects with backoff on transient failures. A hub is considered permanently dead only after the retry budget is exhausted or the user explicitly disconnects.
A peer connection is mediated by a hub. There is no direct peer-to-peer transport (WebRTC is a future option). The peer relay rides on the hub's WebSocket — structurally, peer requires hub. If the hub is down, the peer is down by definition.
Peer sessions are established through addressed messaging: after the hub
sends an advisory_start to the challenge accepter, the peers exchange consent
messages (session_proposal / session_reject) as bencodex peer app
dictionaries and then binary handshake frames through the hub's relay pipe.
The session_proposal includes a game_session_id (random hex generated by the
initiator); the acceptor stores it in its PeerSession. This ID uniquely
identifies the peer session for both sides. The hub acts as a dumb pipe — it
delivers messages to the target peer if connected and reports delivery_failure
if not.
Per-session peer state is encapsulated in PeerSession
(front-end/src/services/PeerSession.ts). Shell holds a single peerSessionRef
instead of multiple individual refs. The PeerSession owns the peer ID, session
ID, liveness tracking, message buffering/routing, and outbound send methods.
Destroying it makes the object inert — all further method calls are no-ops.
Once the peer connection is considered lost (delivery failures without reconnect within the liveness window), it is gone. There is no "reconnect to the same peer" — both sides would have to re-match on a hub.
A session is an obligation, not a connection. Once started, it runs to completion. Funds are locked in the channel coin and must be distributed through the protocol — either cooperatively (clean shutdown) or unilaterally (on-chain resolution). The session does not care whether you have a peer or a hub — it will grind to completion on the blockchain if it has to.
The wallet is orthogonal and does not participate in the state machine. The core state space is:
hub: up | down
peer: up | down (peer up requires hub up)
session: none | off-chain | on-chain
- off-chain: the game is being played through the peer relay. The relay is the authority for game moves.
- on-chain: the blockchain is the authority. This transition is one-way —
once you initiate
goOnChain(), you are on-chain from the perspective of all connectivity rules.cleanShutdown()does not immediately transition to on-chain — it stays in the cooperative off-chain flow until the peer countersigns and the shutdown transaction is formed.
2 × 2 × 3 = 12 combinations, minus 3 impossible states (hub down, peer up) = 9 reachable states.
Four of those are ephemeral — they exist for one tick and auto-transition:
| Ephemeral state | Rule | Transitions to |
|---|---|---|
| hub up, peer down, session off-chain | off-chain + no peer → on-chain | hub up, peer down, session on-chain |
| hub down, peer down, session off-chain | off-chain + no peer → on-chain | hub down, peer down, session on-chain |
(Each of those can occur with or without wallet, but since wallet is orthogonal, it doesn't affect the transition.)
The rule is: off-chain session without a peer must immediately transition to on-chain. No dialog, no prompt, no user decision. This is automatic.
After collapsing ephemeral states, the system has 7 resting states:
| # | Hub | Peer | Session | Description |
|---|---|---|---|---|
| 1 | up | up | none | Idle on a hub. Can accept challenges. |
| 2 | up | up | off-chain | Playing a game through the relay. |
| 3 | up | up | on-chain | Resolving on-chain, peer still connected. |
| 4 | up | down | none | On a hub, no match. Waiting in hub. |
| 5 | up | down | on-chain | Resolving on-chain, peer gone. Hub available for future matchmaking. |
| 6 | down | down | none | Disconnected from everything. Can reconnect. |
| 7 | down | down | on-chain | Resolving on-chain, no hub. Grinding through blockchain. |
The wallet can be up or down in any of these states. When the wallet is down, blockchain operations stall but the logical state is unchanged.
Forced cascades flow downward through the dependency chain:
hub dies → peer degraded → user decides whether to go on-chain
Specific rules:
- Hub goes down temporarily → peer relay interrupted → liveness degrades to yellow. Hub auto-reconnects; peer messages resume when the pipe is back.
- Hub goes down permanently → peer is gone (rides the same socket) → liveness degrades to yellow. User must manually go on-chain to resolve.
- Peer silent 30+ seconds or delivery failure → liveness degrades to yellow. No automatic escalation — silence alone is not terminal.
- Explicit terminal signal (local go-on-chain or received FOAD) → peer marked dead (red). This is the only path to the dead state.
- Session transitions off-chain → on-chain — one-way. No going back.
- Wallet loss — no cascade to the hub or peer. The session is logically
unchanged and stays mounted; blockchain operations just can't make progress
until a wallet is reconnected. While walletless the app reports
busyto the hub (it cannot fund or resolve a channel, so the lobby must not offer matches), and any pending pre-Active matchmaking attempt is cancelled.
| Action | Allowed? | Warning | Consequence |
|---|---|---|---|
| Disconnect | Always | "You are in a session. Blockchain operations will stall until you reconnect, and you will appear busy to the hub." (only if session exists) | Wallet interface torn down. Hub connection kept. Pending pre-Active matchmaking cancelled. App reports busy. Session save preserved. |
| Reconnect (same or different) | Always | None | Stalled operations resume. busy recomputed from session phase and any in-progress non-terminal restore cradle. Session continues. |
| Action | Allowed? | Warning | Consequence |
|---|---|---|---|
| Disconnect | Always | If peer up: "This will end your peer connection." If session off-chain: "This will force your game on-chain." | Peer dies (cascade). |
| Reconnect (same hub) | Always | None | Hub re-identifies. Resends un-acked messages if session active. |
| Connect to new hub | Always | None | New hub. If session is active, join as unavailable. |
| Action | Allowed? | Warning | Consequence |
|---|---|---|---|
| End session | Always | None currently. | Player marks available (setBusy(false)). Off-chain session transitions to on-chain via the peer-loss cascade. |
| Reconnect | Not a user action | — | Handled by hub auto-reconnect; player resends un-acked messages on registered. |
| Action | Allowed? | Warning | Consequence |
|---|---|---|---|
| Go on-chain | When session = off-chain | None currently. | Session transitions to on-chain. Game messages stop. |
| Clean shutdown | Between hands only, requires peer cooperation | None (it's the graceful path) | Cooperative close. Channel resolves cleanly. |
| State | Derived from | Meaning |
|---|---|---|
none |
sessionConfig === null |
No session. Not busy and available for matchmaking. |
off-chain |
Session exists, not yet resolving on-chain | Playing through the peer relay. cleanShutdown() stays off-chain until the shutdown transaction is formed. |
on-chain |
goOnChain() initiated, clean shutdown transaction submitted, or channel resolved while game outcomes are still pending |
Resolving on the blockchain or waiting for remaining hand outcomes. May or may not have peer. |
resolved |
Terminal channel state and no pending hand obligations | No live protocol obligation. Not busy and available for matchmaking, while the finished session may remain visible as display state. |
The on-chain state persists until the broader session phase is terminal. A raw
channel status of ResolvedClean, ResolvedUnrolled, ResolvedStale, or
Failed is terminal for the channel itself, but ResolvedUnrolled and
ResolvedStale can still be followed by per-game outcomes. If any hand remains
unresolved, the broader session phase stays on-chain. Once the last hand is
finished, the broader phase becomes resolved: the save can be wiped and the
player is not busy for new matches. Failed is also terminal; it maps to
broader resolved plus the separate sessionError advisory bit.
When a session transitions to on-chain:
- Game messages (
OutboundMessagefrom WASM,deliverMessageinbound): stop. Don't send new game messages to the peer. Ignore incoming game messages from the peer (ack them to prevent retransmit, but don't deliver to the WASM cradle). - Acks for already-delivered messages: still processed (they concern the past).
- Keepalives: harmless but no longer meaningful for game liveness.
When the broader session phase becomes resolved after a terminal channel
state (ResolvedClean, ResolvedUnrolled, ResolvedStale, or Failed) and
any pending hand obligations have finished:
- The session is done.
- Shell is notified (via callback from
useGameSession). - Live protocol interaction stops: the WASM cradle is no longer used for new
game actions, the
PeerSessionis destroyed (making it inert), keepalives stop,sessionStartedRefis reset, and the finished session is treated as a read-only display. - The resolved dashboard is intentionally preserved (channel label such as Resolved Clean, final Me/Opp balances, and related status detritus). Shell must not wipe to "No Session" on clean/terminal finish. A new match replaces this display; there is no manual "Close Session" button.
- That finished snapshot is persisted with the boot Resume/Start Over marker (live cradle/pairing cleared). Reload must show Resume/Start Over — not silently auto-connect the hub with an empty game tab.
- Shell tells the hub/hub that the player is not busy.
- The player can accept new challenges. This is intentional: terminal sessions no longer impose a protocol obligation, so the UI should encourage continued play instead of making the user manually clear the finished game.
- Successful/terminal exit does not send
session_reject. - The old peer connection is no longer a live session route. New peer messages must come from a newly accepted session.
A hub only knows about pairings it created. If a player connects to a new hub while mid-session (or while on-chain resolution is in progress), that hub has no idea the player is busy. Other players will see them as waiting and can send challenges.
The player app tells the hub whether it is busy over the
game channel WebSocket (HubConnection → /ws/game). The hub
is not trusted either (it's third-party code anyone can run), but the
WebSocket is a TCP connection with known coherent semantics — clear ordering,
connection state, and a single stream. The hub iframe's postMessage
boundary is a broadcast mechanism with no delivery guarantees, no ordering,
and a much harder surface to guard against. Busy signaling goes over
the WebSocket because it's the more defensible transport, not because the
hub is trusted.
Busy is client-authoritative session-obligation state, not just hub pairing state. A player can be busy because an old session is still unresolved even if the hub no longer has a pairing for that player (for example, after disconnecting from the hub while resolving on-chain). Conversely, after a session finishes, the player can become not busy and available for new matches while a previous result remains visible until a new session replaces it.
The app is available for a new session when hub presence is not busy
(shouldReportHubBusy — session obligation, walletless, or the active blockchain
backend not yet ready for play) and there is no consent prompt, reserved peer id,
buffered handshake, or live message handler (isAvailableForNewSessionPrompt()).
Backend readiness is owned by InternalBlockchainInterface.isReadyForPlay()
(simulator and Cloud Wallet: connected; WalletConnect: a verified full-node
peer, checked privately). The app still connects to the hub normally while a backend is not
ready; it simply advertises busy, and inbound advisory_start /
session_proposal must still be declined even if the game WebSocket is live. A
consent prompt is a temporary unavailable state for inbound matchmaking even
though it does not by itself set hub busy. While unavailable:
- further
advisory_startmessages are ignored (hub-originated; do notsession_rejectthe peer); - inbound
session_proposalmessages are rejected withsession_reject.
Clients do not negotiate dual-initiator races (no same-peer yield / steal).
Raw ChannelStatus is more detailed than this
(Handshaking, funding/offer states, Active, shutdown states, on-chain
transition states, resolved channel states, Failed, etc.) and must not be
treated as the hub availability state directly. The broader session phase
folds in pending hand state: a raw channel status can be resolved while the
session remains on-chain because a hand is still being settled.
Player app → Hub (game channel):
Logical bencodex dictionary: { type: "set_busy", session_id: "...", busy: true }.
Sent when the user accepts a session start, when restore reconnection reports
an unresolved session, and when the broader session phase ends or the user
cancels.
The same busy bit is also included in the initial identify message so the
hub has correct status immediately after a game channel opens, reconnects,
or restores. This avoids a brief waiting flicker for unresolved restored
sessions.
The hub updates the player's hub status to 'busy' or 'playing' while
busy, or 'waiting' when not busy, and broadcasts a hub update. When a player
becomes busy, the hub cancels all pending challenges involving that player.
Challenges to/from non-waiting players are rejected.
When the session ends (broader session phase becomes resolved, including
any pending hands having finished), the player app sends set_busy with
busy: false. The hub sets the player back to 'waiting' and broadcasts
the update.
The hub iframe receives the updated Player.status via the normal
hub_update broadcast and renders busy players as unavailable. No
iframe-side protocol changes are needed — it is read-only for this signal.
The hub does not create a session. It can only advise and relay:
- A hub user creates a challenge with per-player buy-in contributions and
optional channel/unroll timeouts. The hub validates the challenge at the
hub boundary: amounts must be positive integers and timeout block counts
must be in the 3-30 range. There is no upper limit on amounts. Invalid
challenges are rejected before the target hub sees them; valid challenges
are stored and sent as
challenge_receivedto the target hub iframe. - If the target accepts in the hub, the hub removes that challenge,
cancels stale challenge records involving either player, and sends
advisory_startto the target player's game channel only. The target is now the proposed channel initiator for that challenge. (A separate reverse challenge accepted while both players are still free can produce a secondadvisory_starton the other side; the hub does not pick a single winner.) - The target player's app checks local availability. If it is in a live
session, on-chain resolution, restore/handshake, or another consent prompt,
it ignores the advisory (no
session_reject). - If the target consents, it marks itself busy, generates a random hex
game_session_id, sends a bencodexsession_proposalapp message (including thegame_session_idand its selectednetwork) to the challenger through the addressed relay, starts WASM as initiator, and sends binary handshake frames. Persist of that start is async;session_reject/ local cancel must abort any in-flight start so it cannot resurrect an orphan handshake. - The challenger app checks local availability before showing the proposal
prompt. The hub is network-blind, so cross-network challenges can reach this
point; the challenger rejects a proposal whose
networkis missing, malformed, or not equal to its own network preference withsession_rejectand no consent UI (a mainnet and a testnet client bind different genesis challenges, so a session between them could never verify signatures). While the prompt is open it reserves that peer id so earlyHandshakeAbytes can buffer. If unavailable or declined, it sendssession_rejectand discards buffered handshake bytes. Receivingsession_rejectduring pre-active matchmaking cancels the attempt (including in-flight async start) and surfaces cancelled/error. If accepted, it marks itself busy, starts WASM as receiver, and drains the buffer.
- Hub connection:
HubConnectionclass with auto-reconnect, backoff, and keepalive (front-end/src/services/HubConnection.ts). - Peer relay: Addressed message relay through hub WebSocket pipe with
numbered ack protocol, reorder queue, and keepalive
(
front-end/src/hooks/SessionController.ts). Per-session peer state (peer ID, liveness, message buffering/routing) is encapsulated inPeerSession(front-end/src/services/PeerSession.ts). - Peer liveness: 30-second degradation threshold (no dead-from-timeout)
with 5-second polling interval managed by
PeerSession. Dead state only from explicit go-on-chain or FOAD signals. Hub liveness with 45-second timeout. - Advisory matchmaking: Challenge acceptance sends
advisory_startto the challenge accepter; peers exchange consent messages before starting WASM. - Session persistence: one salt-prefixed, masked Bencodex
SessionSavebyte value in IndexedDB (including raw cradle/unacked byte strings), plus small preferences and the resumable-session boot marker in localStorage (front-end/src/hooks/save.ts). - Resume on reload: Marker-first boot state machine with Resume /
Start Over dialog, full
hardResetobliteration, and lease system for tab conflict detection (Shell.tsx). - Game dashboard banner: Selector-driven channel / lifecycle /
balance strip from
SessionModel(selectGameDashboardView,selectStatusBarBalances). - Channel state tracking: Rust-owned
ChannelStatusnotifications are normalized intoSessionModel;selectSessionPhaseandisWindingDownChannelStatusprovide UI lifecycle gating. - Go on-chain and clean shutdown: Both implemented in WASM wrapper and
exposed through
useGameSession.
-
Wallet disconnect preserving session:
handleDisconnectWalletno longer callsclearSession()and does not tear down the hub. The session save is preserved across wallet disconnects; blockchain operations stall until a wallet is reconnected. While walletless the app reportsbusy(shouldReportHubBusy(phase, false)forces busy regardless of phase) and cancels any pending pre-Active matchmaking attempt; wallet reconnect recomputes presence from the session phase and any in-progress non-terminal restore cradle (phase alone is often stillnonemid-resume). The Choose Connection network toggle is locked while a session binds a genesis challenge (sessionLocksNetwork) so reconnect cannot pair a different chain id than the existing WASM cradle. (Shell.tsx) -
Session state surfaced to Shell:
GameSessionreports coarse session phase (off-chain | on-chain | resolved) and an error flag to Shell via theonSessionPhaseChangecallback. Shell tracks this assessionPhaseandsessionErrorstate. (GameSession.tsx,Shell.tsx) -
Terminal session detection and resolved display preservation: When
sessionPhasebecomes'resolved'(derived from terminal channel states plus the absence of pending hand obligations), Shell stops live protocol interaction, destroys thePeerSession, resetssessionStartedRef, and marks the player as not busy. The game UI stays visible as a read-only resolved display until a new match replaces it, and a reload may restore that finished view. Terminal error or suspect outcomes are carried separately viasessionError. -
Game message filtering on-chain:
SessionControllerhas anonChainflag. When set,deliverMessage()acks but does not deliver inbound game messages to the WASM cradle, anddispatchEvent()suppresses outboundOutboundMessageevents. -
Hub busy signaling:
HubConnection.setBusy()sends a bencodex{ type: "set_busy", busy }dictionary over the game WebSocket. Theidentifymessage includes the current busy bit. Shell callssetBusy(!(sessionPhase === 'none' || sessionPhase === 'resolved'))whenever the broader session phase changes, while restore blocking keeps unresolved restores busy until reconciliation completes. A resolved session no longer has an active game obligation, so the player can be available for a new match even if the existing relay is still visible. -
Hub-side
set_busyhandler: The hub server accepts bencodexset_busymessages on the game channel. It updates the player's hub status to'playing','busy', or'waiting'and broadcasts a hub update. When busy becomes true, pending challenges involving that player are cancelled; challenges to/from non-waiting players are rejected. (hub-service/src/index.ts) -
Hub retry budget:
HubConnectionnow has aMAX_RECONNECT_ATTEMPTSbudget. After the budget is exhausted, the hub is declared permanently dead. -
User-initiated hub disconnect: A "Disconnect" button in the hub tab header allows explicit hub disconnect. Gated by a cascade warning if peer/session would be affected.
-
User-initiated peer disconnect: Ending a peer session means marking oneself as available again (
setBusy(false)). The session remains off-chain until the user explicitly goes on-chain. -
Peer degradation (no auto-cascade): When the peer becomes unreachable (delivery failures, 30-second silence, or hub disconnect) while the session is off-chain,
peerLivenessmoves to'degraded'(yellow banner rail; tab stays a link). There is no automatic go-on-chain — the user must decide to escalate. Only explicit terminal signals (user clicks "Go On-Chain" or receives a FOAD) mark the peer as dead. -
Cascade warning dialogs: Confirmation dialogs currently warn before disconnecting or switching hubs when a peer/session would be affected. Peer disconnect and the explicit "Go On-Chain" button do not currently prompt.
Wallet, Hub, and Game tabs show an uncolored link (connected) or broken-chain (disconnected) emoji to the left of the label. History and Log have no pipe mark. The existing upper-right notification dots indicate unread activity and are unchanged.
Pipe marks answer only “is this pipe up?” Session mode lives on the game dashboard banner rail, not on the tabs.
| Tab | Link | Broken chain |
|---|---|---|
| Wallet | Connected. | Disconnected. The Wallet label is also red. |
| Hub | hubLiveness === 'connected' |
Reconnecting, inactive, disconnected, or never connected |
| Game | Live session and peer is not dead |
sessionPhase none/resolved, or peerLiveness === 'dead' |
Handshake with peerLiveness === null counts as connected. degraded pings
stay a link; that warning is banner-only. sessionError does not affect the
tab mark.
The session dashboard has a full-height left-edge color rail:
| Tone | Color | When |
|---|---|---|
idle |
Gray | No session / never set up |
playing |
Green | Setup, handshake, off-chain play, cooperative shutdown |
pings-bad |
Yellow | Same as playing, but peerLiveness === 'degraded' |
on-chain |
Red | Going on-chain, unrolling, or a resolved unroll that still has games |
ended |
Blue | Terminal dashboard still showing (clean resolve, failed, abandoned) |
On-chain beats yellow. Failed/stale outcomes that are actually over stay
ended; the Channel label still names the outcome. Yellow also shows
“Peer pings look stuck.”
selectGameTabConnected is true unless:
sessionPhase === 'none' || 'resolved', orpeerLiveness === 'dead'
Clean shutdown does not mark the peer dead on its own. Keepalives and the
small allowlist of shutdown-related peer messages continue until local
shutdown completes. Successful/terminal session exit does not send
session_reject (that signal means decline/abort). If a session_reject
does arrive during pre-active matchmaking, it is honored as an abort: cancel
the attempt (including any in-flight async session start), surface
cancelled/error, and do not leave an orphan handshake. When the channel
reaches a terminal state the session exits and the game tab shows a broken
chain.
sessionError is derived from:
Failedchannel state — the channel encountered an unrecoverable errorResolvedStalechannel state — the channel resolved but the outcome is suspect (e.g., opponent exploited a timeout)game-errorgame terminal — a generic game-level error (GameStatuswithEndedError, or an unknownGameSettledoutcome)- adverse
GameSettledoutcomes — seeisErrorSettlementOutcomeinfront-end/src/lib/settlement.ts(forfeits,timed_out_waiting_for_our_move,attempt_to_move_failed,opponent_slashed_us,opponent_cheated)
Normal settlements such as accept_settlement, settled_cleanly,
opponent_timed_out, we_accepted, and slashed_opponent are not session
errors. These conditions do not change the tab pipe mark; terminal outcomes
use the ended banner rail.
The session banner and dashboard derive display text from
SETTLEMENT_OUTCOME_LABELS in front-end/src/lib/settlement.ts (sourced from
the settlement glossary). Examples:
outcome (wire) |
Display label |
|---|---|
accept_settlement / we_accepted |
Accepted |
settled_cleanly |
Settled cleanly |
opponent_timed_out |
Opponent timed out |
forfeited_* |
Forfeited |
attempt_to_move_failed |
Attempt to move failed |
timed_out_waiting_for_our_move |
Timed out waiting for our move |
slashed_opponent |
Slashed opponent |
opponent_slashed_us |
Opponent slashed us |
opponent_cheated |
Opponent cheated |
There is no session-level Folded label. Space Poker may still show Fold
as a game-local button that calls accept_settlement.
- Disconnect Hub: In the hub tab header strip, right-aligned next to the "Connected to {hubOrigin}" text.
(No remaining items from the original design.)