Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d200cce
feat: add @webgamekit/multiplayer package (#61)
Apr 3, 2026
263ca6e
chore: update pnpm lockfile for @webgamekit/multiplayer
Apr 3, 2026
1e9becc
feat: add @webgamekit/multiplayer-client, multiplayer-server, and mul…
Apr 4, 2026
85cc37e
fix: show peers immediately on join in P2P room
Apr 4, 2026
4c7178f
fix: enable vertical scroll in navigation panel on mobile
Apr 4, 2026
db256ee
fix: improve p2p tests to verify callbacks fire, exclude trystero fro…
Apr 4, 2026
90ba16c
test(p2p): add two-session integration test with room-aware mock
Apr 4, 2026
6c1573c
debug(p2p): add console.warn logs to surface runtime connection issues
Apr 4, 2026
3af58e3
fix: switch p2p from trystero/nostr to @trystero-p2p/torrent
Apr 4, 2026
4e37c1e
fix(p2p): detect missing crypto.subtle and surface clear error in UI
Apr 5, 2026
16610f0
fix(p2p): switch back to trystero/nostr — torrent trackers unreliable
Apr 5, 2026
32bd1bf
fix(p2p): add p2pGetPeerIds to catch peers who joined before you
Apr 5, 2026
72ab5b0
docs: add multiplayer P2P journey page and fix markdown lint
Apr 5, 2026
6fd1502
feat(p2p): stickboy.glb players with animation sync over P2P
Apr 5, 2026
f44d66e
fix(p2p): increase stickboy player scale to 2x
Apr 5, 2026
7f555ff
fix(p2p): fix ground alignment, peer spawn overlap, and scene reinit …
Apr 5, 2026
b9a5957
fix(p2p): cache makeAction calls and add HUD with controls + player c…
Apr 5, 2026
fda6c23
fix(p2p): fix movement lag — use leading+trailing throttle for positi…
Apr 5, 2026
0c51a5b
feat: add all 7 actions to mobile faux-pad button mapping
Apr 5, 2026
95193cd
fix(p2p): fix movement frequency, Euler serialization, ground contact…
Apr 5, 2026
667f535
Update config and model
cnotv Apr 5, 2026
3c2e5e1
feat(p2p): add remote walk animation, idle speed, movable actions, an…
cnotv Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions documentation/docs/journey/multiplayer-p2p.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
---
sidebar_position: 12
---

# Multiplayer P2P

Adding real-time P2P multiplayer using WebRTC via [Trystero](https://github.com/dmotz/trystero) (NOSTR signaling — no dedicated server required).

## Package split: client, server, P2P

The initial `@webgamekit/multiplayer` was a Socket.IO client only. It was replaced by three focused packages:

| Package | Role |
| -------------------------------- | -------------------------------------------------------- |
| `@webgamekit/multiplayer-client` | Socket.IO client — connects to a dedicated server |
| `@webgamekit/multiplayer-server` | Socket.IO server — Node.js only, manages player registry |
| `@webgamekit/multiplayer-p2p` | WebRTC via Trystero — fully serverless |

## Why Trystero over PeerJS

PeerJS requires a signaling server you own and is no longer actively maintained. Trystero uses public NOSTR relays as a signaling layer — no account, no server, no configuration. The tradeoff is that NOSTR relay availability is out of your control, but multiple redundant relays are used by default.

## Bug: DataPayload type constraint

**Symptom:** TypeScript error `Type 'PlayerPosition' does not satisfy constraint 'DataPayload'`.

**Cause:** Trystero's `makeAction<T>` requires `T extends DataPayload`. `DataPayload` includes `JsonValue`, which requires `{ [key: string]: JsonValue }` index signatures on all objects. A plain `interface { x: number; y: number; z: number }` doesn't satisfy this.

**Fix:** Use intersection types to add the index signature without changing the named fields:

```ts
// Does not satisfy DataPayload — missing index signature
interface PlayerPosition {
x: number
y: number
z: number
}

// Satisfies DataPayload
type PlayerPosition = { x: number; y: number; z: number } & Record<string, number>
```

For generic data channels constrain the type parameter:

```ts
export const p2pSendData = <T extends DataPayload>(
session: P2PSession,
channel: string,
payload: T
): void => { ... }
```

## Bug: TypeScript can't resolve Trystero types

**Symptom:** `Cannot find module '@trystero-p2p/core' or its corresponding type declarations`.

**Cause:** Two sub-issues:

1. `@trystero-p2p/core` is a transitive dependency — not directly importable.
2. Trystero ships `.d.mts` declaration files alongside `.mjs` entry points. TypeScript's `Node` `moduleResolution` cannot resolve `.d.mts` files.

**Fix:** Import types from `trystero/nostr` (which re-exports everything via `export *`), and set `"moduleResolution": "Bundler"` in the package `tsconfig.json`:

```ts
import type { Room, DataPayload } from 'trystero/nostr'
```

```json
{ "compilerOptions": { "moduleResolution": "Bundler" } }
```

## Bug: crypto.subtle crash in Vite pre-bundling

**Symptom:** `TypeError: Cannot read properties of undefined (reading 'digest')` thrown at startup.

**Cause:** Vite's dependency pre-optimizer runs in Node.js before the browser loads. Trystero calls `crypto.subtle.digest` at module initialization (to hash room IDs). Node.js doesn't expose `crypto.subtle` globally during Vite's pre-bundling phase.

**Fix:** Exclude Trystero from Vite's optimizer so it is served as native ESM directly to the browser:

```ts
// vite.config.ts
optimizeDeps: {
exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr']
}
```

## Bug: crypto.subtle unavailable on plain HTTP origins

**Symptom:** `TypeError: Cannot read properties of undefined (reading 'importKey')` at runtime when accessing the app over HTTP on a custom hostname.

**Cause:** `crypto.subtle` (the Web Crypto API) is only available in secure contexts — HTTPS or `localhost`. Browsers deliberately block it on plain HTTP to prevent key extraction. All Trystero strategies use `crypto.subtle` for room ID hashing, so none will work on plain HTTP.

**Fix:** Add a pre-flight check before calling `joinRoom`, and surface a clear error in the UI:

```ts
export const p2pIsSupported = (): boolean => {
return typeof window !== 'undefined' && typeof window.crypto?.subtle !== 'undefined'
}
```

The view calls this on mount and shows a banner if the app isn't on HTTPS/localhost instead of silently failing.

## Bug: torrent WebSocket trackers fail to connect

**Symptom:** Switched to `@trystero-p2p/torrent` to avoid `crypto.subtle`, but `WebSocket connection to 'wss://tracker.webtorrent.dev/' failed`.

**Cause:** The public BitTorrent WebSocket trackers used by the torrent strategy (`tracker.webtorrent.dev`, `tracker.btorrent.xyz`) are unreliable. Peers can never discover each other when trackers are down.

**Fix:** Switch back to `trystero/nostr`. The torrent strategy was only chosen to avoid `crypto.subtle`, but that issue is better solved with `p2pIsSupported()` + HTTPS. NOSTR relays are more reliably available.

## Bug: peers only appear after broadcasting a position

**Symptom:** After joining a room, the peer list shows 0 peers even though another tab is connected.

**Cause:** The peer list was populated only via `p2pOnPlayers` (position data). There was no join/leave tracking — a peer only appeared once they sent their first position.

**Fix:** Use `room.onPeerJoin` and `room.onPeerLeave` to track peer presence independently of position data:

```ts
export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => {
session.room.onPeerJoin(callback)
}
```

Peers now appear immediately on join with "waiting for position…" until their first broadcast arrives.

## Bug: first tab doesn't see second tab until a third tab joins

**Symptom:** Open tab A (already in room), open tab B — B doesn't appear in A's peer list. Open tab C, and suddenly all three tabs see each other.

**Cause:** `onPeerJoin` only fires for peers who join _after_ you register the callback. If tab B was already in the room when tab A joined, A's `onPeerJoin` never fired for B. Tab C joining triggered a new round of peer handshakes, which is what made B visible to A.

**Fix:** Call `room.getPeers()` immediately after joining to get the list of already-connected peers:

```ts
export const p2pGetPeerIds = (session: P2PSession): string[] => Object.keys(session.room.getPeers())
```

Call this right after registering `onPeerJoin` and add any returned IDs to the peer list:

```ts
p2pOnPeerJoin(newSession, (peerId) => {
/* add to list */
})

// Catch peers already in the room
p2pGetPeerIds(newSession).forEach((peerId) => {
/* add to list */
})
```

## Testing strategy

Unit tests mock `trystero/nostr` but only verify individual functions in isolation. They don't catch cross-peer bugs — a mock that registers a callback without ever invoking it will pass even if the whole peer communication chain is broken.

The integration test uses a room-aware mock that routes sends between sessions and fires `onPeerJoin`/`onPeerLeave` across all sessions, matching real Trystero behavior. It also implements `getPeers()` returning the actual connected peer map to test the late-joiner fix:

```ts
getPeers: () =>
Object.fromEntries([...room.entries()].filter(([id]) => id !== peerId).map(([id]) => [id, {}]))
```

Key scenarios tested:

- Peer A sees Peer B join via `onPeerJoin`
- Peer A receives Peer B's position broadcast
- Peer A is notified when Peer B leaves
- Peer A sees Peer B via `p2pGetPeerIds` when A joins after B (late-joiner fix)
- Sender does not receive their own broadcast
- Three-peer scenario: both B and C appear in A's list

## Bug: player movement fires once per second

**Symptom:** The local player barely moves when pressing WASD, crawling at ~0.5 units/sec with visible stutter.

**Cause:** Both `local-player` and `remote-players` timeline actions used `frequency: 60`. The timeline engine skips action execution when `frame % frequency !== 0`, so at 60 FPS the movement callback ran only on frames 0, 60, 120 — once per second. The `MOVEMENT_SPEED = 0.5` constant was applied once per second instead of 30 times per second.

**Fix:** Changed `frequency: 60` to `frequency: 2` on both actions, matching the convention used by MixamoPlayground and MazeGame. At `frequency: 2` the action fires every other frame (30x/sec at 60 FPS), giving an effective movement speed of ~15 units/sec — matching MixamoPlayground's `distance: 0.5` at `frequency: 2`.

The value 60 likely came from confusing "60 FPS target" with the frequency parameter's meaning. `frequency` is a frame-skip divisor, not a rate — `frequency: N` means "run every Nth frame."

## Design note: direct position update vs controllerForward

MixamoPlayground uses `controllerForward()` which handles obstacle checks, ground checks, and calls `moveCharacter()` internally. MultiplayerP2P uses direct `position.x += direction.x * speed` instead. This was deliberate:

1. The P2P arena has no obstacles or complex terrain — just a flat ground plane
2. `controllerForward` requires `obstacles` and `groundBodies` arrays, adding unnecessary complexity
3. The position is authoritative on each client — simpler math is easier to reason about for netcode

The tradeoff is no built-in obstacle collision. If the arena gains obstacles later, switching to `controllerForward` would be appropriate.

## Design note: no delta-time scaling on movement

Movement uses fixed `MOVEMENT_SPEED` without multiplying by `getDelta()`. At a fixed `frequency: 2`, the action fires at a consistent rate regardless of actual frame time. This matches MixamoPlayground which also uses fixed `distance` without delta scaling.

Delta scaling would be needed if `frequency` were set to 1 (every frame) and frame rate varied. At `frequency: 2`, the movement rate is effectively decoupled from frame-rate variance — if a frame takes longer, the frequency skip still ensures roughly consistent execution counts per second.

## Known limitation: remote player position snapping

Remote player positions are set directly via `model.position.set()` without interpolation. With the 30ms send throttle in `p2pSendPosition`, remote players visually "snap" between positions. Linear interpolation (lerping toward the last received position each frame) would smooth this, but adds complexity and latency. Acceptable for the current prototype scope.
5 changes: 4 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,10 @@ export default [
'packages/controls/**/*.ts',
'packages/audio/**/*.ts',
'packages/game/**/*.ts',
'packages/recording/**/*.ts'
'packages/recording/**/*.ts',
'packages/multiplayer-client/**/*.ts',
'packages/multiplayer-server/**/*.ts',
'packages/multiplayer-p2p/**/*.ts'
],
rules: {
'functional/immutable-data': 'warn',
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
"dependencies": {
"@webgamekit/controls": "workspace:^",
"@webgamekit/logic": "workspace:^",
"@webgamekit/multiplayer-client": "workspace:^",
"@webgamekit/multiplayer-p2p": "workspace:^",
"@webgamekit/threejs": "workspace:^",
"trystero": "^0.23.0",
"socket.io-client": "^4.0.0",
"dat.gui": "^0.7.9",
"p5": "^1.9.0",
"pinia": "^2.3.0",
Expand Down
35 changes: 35 additions & 0 deletions packages/multiplayer-p2p/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@webgamekit/multiplayer-p2p",
"version": "0.0.1",
"type": "module",
"files": [
"dist"
],
"main": "./dist/index.umd.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.umd.cjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "vite build && tsc",
"prepare": "pnpm run build",
"prepublishOnly": "pnpm run build"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"trystero": "^0.23.0"
},
"devDependencies": {
"trystero": "^0.23.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vitest": "^4.0.18"
}
}
34 changes: 34 additions & 0 deletions packages/multiplayer-p2p/src/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { DataPayload } from 'trystero/nostr'
import type { P2PSession } from './types'

/**
* Send typed data to all peers on a named channel.
* @param session - The active P2P session
* @param channel - Channel name (must match receiver's channel name)
* @param payload - Serializable data to broadcast
*/
export const p2pSendData = <T extends DataPayload>(
session: P2PSession,
channel: string,
payload: T
): void => {
const [send] = session.room.makeAction<T>(channel)
send(payload)
}

/**
* Subscribe to typed data arriving on a named channel from any peer.
* @param session - The active P2P session
* @param channel - Channel name to listen on
* @param callback - Called with the payload and the sender's peer ID
* @returns Unsubscribe function (stops the listener)
*/
export const p2pOnData = <T extends DataPayload>(
session: P2PSession,
channel: string,
callback: (payload: T, peerId: string) => void
): (() => void) => {
const [, onData] = session.room.makeAction<T>(channel)
onData((data: T, peerId: string) => callback(data, peerId))
return () => {}
}
Loading
Loading