Skip to content

Latest commit

 

History

History
587 lines (462 loc) · 28.5 KB

File metadata and controls

587 lines (462 loc) · 28.5 KB

Connectivity Model

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.

Table of Contents


The Four Axes

The Blockchain

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.

Wallet

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.

Hub

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.

Peer

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.

Session

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.


State Space

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.

Resting States

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.


Cascade Rules

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 busy to 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.

User Actions

Wallet

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.

Hub

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.

Peer

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.

Session

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.

Session Lifecycle

Session States (as seen by Shell)

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.

What "on-chain" means for peer communication

When a session transitions to on-chain:

  • Game messages (OutboundMessage from WASM, deliverMessage inbound): 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.

Terminal detection

When the broader session phase becomes resolved after a terminal channel state (ResolvedClean, ResolvedUnrolled, ResolvedStale, or Failed) and any pending hand obligations have finished:

  1. The session is done.
  2. Shell is notified (via callback from useGameSession).
  3. Live protocol interaction stops: the WASM cradle is no longer used for new game actions, the PeerSession is destroyed (making it inert), keepalives stop, sessionStartedRef is reset, and the finished session is treated as a read-only display.
  4. 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.
  5. 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.
  6. Shell tells the hub/hub that the player is not busy.
  7. 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.
  8. Successful/terminal exit does not send session_reject.
  9. The old peer connection is no longer a live session route. New peer messages must come from a newly accepted session.

Hub Busy Protocol

The problem

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 solution

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 the broader session phase is none or resolved and there is no consent prompt, reserved peer id, buffered handshake, or live message handler (isAvailableForNewSessionPrompt()). 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_start messages are ignored (hub-originated; do not session_reject the peer);
  • inbound session_proposal messages are rejected with session_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.

Proposal Handoff

The hub does not create a session. It can only advise and relay:

  1. 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_received to the target hub iframe.
  2. If the target accepts in the hub, the hub removes that challenge, cancels stale challenge records involving either player, and sends advisory_start to 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 second advisory_start on the other side; the hub does not pick a single winner.)
  3. 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).
  4. If the target consents, it marks itself busy, generates a random hex game_session_id, sends a bencodex session_proposal app message (including the game_session_id) to the 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.
  5. The challenger app checks local availability before showing the proposal prompt. While the prompt is open it reserves that peer id so early HandshakeA bytes can buffer. If unavailable or declined, it sends session_reject and discards buffered handshake bytes. Receiving session_reject during 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.

Implementation Status

Currently implemented

  • Hub connection: HubConnection class 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 in PeerSession (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_start to the challenge accepter; peers exchange consent messages before starting WASM.
  • Session persistence: one salt-prefixed, masked Bencodex SessionSave byte 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 hardReset obliteration, 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 ChannelStatus notifications are normalized into SessionModel; selectSessionPhase and isWindingDownChannelStatus provide UI lifecycle gating.
  • Go on-chain and clean shutdown: Both implemented in WASM wrapper and exposed through useGameSession.

Recently implemented

  • Wallet disconnect preserving session: handleDisconnectWallet no longer calls clearSession() 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 reports busy (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 still none mid-resume). (Shell.tsx)

  • Session state surfaced to Shell: GameSession reports coarse session phase (off-chain | on-chain | resolved) and an error flag to Shell via the onSessionPhaseChange callback. Shell tracks this as sessionPhase and sessionError state. (GameSession.tsx, Shell.tsx)

  • Terminal session detection and resolved display preservation: When sessionPhase becomes 'resolved' (derived from terminal channel states plus the absence of pending hand obligations), Shell stops live protocol interaction, destroys the PeerSession, resets sessionStartedRef, 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 via sessionError.

  • Game message filtering on-chain: SessionController has an onChain flag. When set, deliverMessage() acks but does not deliver inbound game messages to the WASM cradle, and dispatchEvent() suppresses outbound OutboundMessage events.

  • Hub busy signaling: HubConnection.setBusy() sends a bencodex { type: "set_busy", busy } dictionary over the game WebSocket. The identify message includes the current busy bit. Shell calls setBusy(!(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_busy handler: The hub server accepts bencodex set_busy messages 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: HubConnection now has a MAX_RECONNECT_ATTEMPTS budget. 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, peerLiveness moves 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.


UX: Connectivity Indicators

Tab pipe marks

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.

Game dashboard banner rail

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.”

Game tab connectedness

selectGameTabConnected is true unless:

  1. sessionPhase === 'none' || 'resolved', or
  2. peerLiveness === '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.

Session error conditions

sessionError is derived from:

  • Failed channel state — the channel encountered an unrecoverable error
  • ResolvedStale channel state — the channel resolved but the outcome is suspect (e.g., opponent exploited a timeout)
  • game-error game terminal — a generic game-level error (GameStatus with EndedError, or an unknown GameSettled outcome)
  • adverse GameSettled outcomes — see isErrorSettlementOutcome in front-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.

Settlement labels

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.

Button placement

  • Disconnect Hub: In the hub tab header strip, right-aligned next to the "Connected to {hubOrigin}" text.

Not yet implemented

(No remaining items from the original design.)