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
| Mode | Provider | Use Case |
|---|---|---|
webrtc | y-webrtc | Local network play (peer discovery) |
websocket | WebSocket relay | Global play via relay server |
hybrid | Both | World 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
gamesnapshot - the move log remains the audit trail and fallback path
- reconnect and late join hydrate from the latest
- 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
matchIdfor deterministic post-reconnect claims. - The deterministic sync-proof lanes now live in Playwright as separate
worldandlocalspecs, and the current checkout is green on those lanes viabun 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
- Host claims
p0, guest claimsp1 - Host sets the game type, peers converge on it
- Each player sets only their own seat creature
- 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
desyncedstate 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
worldsync proof - relay-backed and blocking in CI
- two-context deterministic
bun run --cwd packages/app test:e2e:sync:local- two-context deterministic
localsync proof - uses the webdriver relay fallback by design in CI
- two-context deterministic
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:
- Launch two real browsers outside Playwright/WebDriver on the same network with
VITE_LOCAL_RELAY_FALLBACK=false. - Create a
localroom on one device and join it from the other. - Verify both peers report
WEBRTC • Connectedbefore match start. - Start Tic-Tac-Toe, make alternating moves, and confirm both boards stay in sync.
- Refresh the guest mid-match and confirm seat, board, turn, and
matchIdrecover. - Finish the match, claim the reward once, reopen the finished room as the same seat owner, and confirm no duplicate reward is applied.
- Use host
Play Again, confirmsessionEpochadvances, 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 syncsrc/modules/yjs.test.ts- Transport and sync testssrc/hooks/yjs/useYjsSession.ts- React hook for session managementsrc/hooks/games/useGameSession.ts- active gameplay integrationsrc/machines/gameSessionMachine.ts- authoritative local game rules and hydration targetssrc/config.ts-config.yjstransport configuration