Skip to main content

P2P Sync (Yjs)

WEFA uses Yjs CRDTs for realtime multiplayer sync. Documents are intentionally scoped to logical game/session state. AR scene state is not synced.

Transport Modes

ModeProviderUse Case
webrtcy-webrtcLocal network play (peer discovery)
websocketWebSocket relayGlobal play via relay server
hybridBothWorld mode: WebRTC with WebSocket fallback

Transport resolution:

// From src/hooks/games/useGameSession.ts
function getYjsConfigForMode(mode: GameMode | null): WEFAYjsConfig | undefined {
if (mode === 'device' || mode === null) return undefined
if (mode === 'local') return shouldUseLocalRelayFallback() ? 'hybrid' : 'webrtc'
if (mode === 'world') return config.yjs.transport === 'websocket' ? 'websocket' : 'hybrid'
}

Active Document Structure

Each game session creates a Y.Doc with:

Y.Doc
├── Y.Map('game')
│ ├── gameType: string
│ ├── phase: 'setup' | 'playing' | 'finished'
│ ├── matchId: string | null
│ ├── sessionEpoch: number
│ ├── board: JSON string (logical board only)
│ ├── turn: number
│ ├── winner: 0 | 1 | 'draw' | null
│ ├── rewards: { p0, p1 }
│ ├── moveCount: number
│ ├── lastMoveId: string | null
│ ├── startedAt: ISO string | null
│ └── finishedAt: ISO string | null
├── Y.Array('players')
│ └── [playerId] (debug only; gameplay logic does not depend on this)
├── Y.Array('moves')
│ └── [{ moveId, player, move, timestamp, baseMoveCount, resultingMoveCount }]
└── Y.Map('setup')
├── hostPlayerId: string | null
├── guestPlayerId: string | null
├── selectedCreatures: { p0, p1 }
├── creatureProfiles: { p0, p1 }
├── gameType: string
└── started: boolean

What Is Production-Relevant Today

  • Setup sync is the strongest part of the stack today:
    • seat claim and seat ownership
    • game type sync
    • per-seat creature selection
    • start signaling
  • Multiplayer authority is now snapshot-first for logical gameplay state:
    • reconnect and late join hydrate from the latest game snapshot
    • the move log remains the audit trail and fallback path
  • Room lifecycle is explicit:
    • same player can resume their existing seat
    • one player cannot own both seats
    • host reset advances sessionEpoch, clears active match state, preserves host seat/selection, and clears guest seat/selection
  • Reward attribution is keyed to the synced matchId for deterministic post-reconnect claims.
  • The deterministic sync-proof lanes now live in Playwright as separate world and local specs, and the current checkout is green on those lanes via bun run --cwd packages/app test:e2e.
  • No AR world data is shared:
    • no anchors
    • no camera/world transforms
    • no scene placement sync

Setup State Sync

  1. Host claims p0, guest claims p1
  2. Host sets the game type, peers converge on it
  3. Each player sets only their own seat creature
  4. Host writes the initial logical match snapshot and marks setup as started

Late join in this phase means the legitimate seat owner joining or rejoining an already-started match. Spectators and third-player observers remain out of scope.

Seat takeover protection prevents a second peer from claiming an occupied seat, and a single player id may not claim both seats.

Snapshot And Replay

  • Local gameplay still validates moves through the rules engine before accepting them.
  • Accepted local moves append a move-log entry and then write the resulting authoritative snapshot.
  • Remote peers hydrate from the snapshot and ignore historical move entries already covered by moveCount.
  • If move-log progress jumps ahead of the local snapshot, the client enters a visible desynced state instead of attempting silent conflict resolution.

Persistence

y-indexeddb provides offline persistence for Yjs documents, allowing interrupted sessions to resume logical gameplay state. The app also stores a small local resume descriptor (mode, joinCode, playerId, expectedSeat, matchId, sessionEpoch) so a refresh can reconnect to the correct room.

Validation Lanes

  • bun run --cwd packages/app test:e2e:smoke
    • current app-shell smoke only
    • live onboarding plus Garden camera add-plant path
  • bun run --cwd packages/app test:e2e:sync:world
    • two-context deterministic world sync proof
    • relay-backed and blocking in CI
  • bun run --cwd packages/app test:e2e:sync:local
    • two-context deterministic local sync proof
    • uses the webdriver relay fallback by design in CI
  • bun run --cwd packages/app test:e2e
    • smoke + both sync lanes
    • current blocking release gate for logical multiplayer behavior

These lanes must not use skip conditions to treat transport unavailability as success.

True WebRTC Local Validation

The raw peer-to-peer local path still needs separate operational validation because browser automation runs with navigator.webdriver === true, which intentionally enables the relay fallback path in CI.

Manual or nightly checklist:

  1. Launch two real browsers outside Playwright/WebDriver on the same network with VITE_LOCAL_RELAY_FALLBACK=false.
  2. Create a local room on one device and join it from the other.
  3. Verify both peers report WEBRTC • Connected before match start.
  4. Start Tic-Tac-Toe, make alternating moves, and confirm both boards stay in sync.
  5. Refresh the guest mid-match and confirm seat, board, turn, and matchId recover.
  6. Finish the match, claim the reward once, reopen the finished room as the same seat owner, and confirm no duplicate reward is applied.
  7. Use host Play Again, confirm sessionEpoch advances, guest state is cleared, and the guest must explicitly rejoin.

Pass criteria:

  • no transport skips or manual state edits are needed
  • both peers converge on the same logical board after refresh/rejoin
  • duplicate reward claim does not change local energy or create a second result
  • host reset clears stale guest state before the next match

Signaling

The Fly.io API server (packages/api) includes a WebSocket signaling relay for y-webrtc peer discovery. The endpoint is configurable via VITE_SIGNALING_URLS.

Key Files

  • src/modules/yjs.ts - Session creation, transport resolution, setup sync
  • src/modules/yjs.test.ts - Transport and sync tests
  • src/hooks/yjs/useYjsSession.ts - React hook for session management
  • src/hooks/games/useGameSession.ts - active gameplay integration
  • src/machines/gameSessionMachine.ts - authoritative local game rules and hydration targets
  • src/config.ts - config.yjs transport configuration