From d200cce21de850b4eef09a1fd6e5f3b9d4f30f4d Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Fri, 3 Apr 2026 23:35:41 +0200 Subject: [PATCH 01/22] feat: add @webgamekit/multiplayer package (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side Socket.IO wrapper for sharing 3D scene state between players. Based on the cnotv/multi-game reference implementation. Public API: - multiplayerCreate(url, config?) — connect to Socket.IO server - multiplayerDestroy(session) — disconnect - multiplayerSendPosition(session, position, rotation) — throttled (30 ms default) - multiplayerOnPlayers(session, callback) — subscribe to player list (user:list) - multiplayerCollectCoin(session, coinId) — emit coin:collected - multiplayerOnCoinCollected(session, callback) — receive coin:collected events - multiplayerOnCoinsSync(session, callback) — receive initial coin:list Socket events match cnotv/multi-game server: user:updated, user:list, coin:collected, coin:list. socket.io-client is a peer dependency. Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer/package.json | 35 ++++ packages/multiplayer/src/coins.ts | 38 ++++ packages/multiplayer/src/connection.ts | 32 ++++ packages/multiplayer/src/index.test.ts | 250 +++++++++++++++++++++++++ packages/multiplayer/src/index.ts | 13 ++ packages/multiplayer/src/players.ts | 46 +++++ packages/multiplayer/src/types.ts | 41 ++++ packages/multiplayer/tsconfig.json | 24 +++ packages/multiplayer/vite.config.ts | 20 ++ 9 files changed, 499 insertions(+) create mode 100644 packages/multiplayer/package.json create mode 100644 packages/multiplayer/src/coins.ts create mode 100644 packages/multiplayer/src/connection.ts create mode 100644 packages/multiplayer/src/index.test.ts create mode 100644 packages/multiplayer/src/index.ts create mode 100644 packages/multiplayer/src/players.ts create mode 100644 packages/multiplayer/src/types.ts create mode 100644 packages/multiplayer/tsconfig.json create mode 100644 packages/multiplayer/vite.config.ts diff --git a/packages/multiplayer/package.json b/packages/multiplayer/package.json new file mode 100644 index 00000000..bd4bf87a --- /dev/null +++ b/packages/multiplayer/package.json @@ -0,0 +1,35 @@ +{ + "name": "@webgamekit/multiplayer", + "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": { + "socket.io-client": "^4.0.0" + }, + "devDependencies": { + "socket.io-client": "^4.8.1", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/multiplayer/src/coins.ts b/packages/multiplayer/src/coins.ts new file mode 100644 index 00000000..36e39f29 --- /dev/null +++ b/packages/multiplayer/src/coins.ts @@ -0,0 +1,38 @@ +import type { CoinState, CoinCollectedEvent, MultiplayerSession } from './types' + +/** + * Notify the server that the local player collected a coin. + * @param session - The active multiplayer session + * @param coinId - Unique identifier of the collected coin + */ +export const multiplayerCollectCoin = (session: MultiplayerSession, coinId: string): void => { + session.socket.emit('coin:collected', { coinId }) +} + +/** + * Subscribe to coin-collected events broadcast by the server (from any player). + * @param session - The active multiplayer session + * @param callback - Called with the event payload when a coin is collected + * @returns Unsubscribe function + */ +export const multiplayerOnCoinCollected = ( + session: MultiplayerSession, + callback: (event: CoinCollectedEvent) => void +): (() => void) => { + session.socket.on('coin:collected', callback) + return () => session.socket.off('coin:collected', callback) +} + +/** + * Subscribe to the initial coin state list sent by the server on connect. + * @param session - The active multiplayer session + * @param callback - Called with the full coin list from the server + * @returns Unsubscribe function + */ +export const multiplayerOnCoinsSync = ( + session: MultiplayerSession, + callback: (coins: CoinState[]) => void +): (() => void) => { + session.socket.on('coin:list', callback) + return () => session.socket.off('coin:list', callback) +} diff --git a/packages/multiplayer/src/connection.ts b/packages/multiplayer/src/connection.ts new file mode 100644 index 00000000..222c427c --- /dev/null +++ b/packages/multiplayer/src/connection.ts @@ -0,0 +1,32 @@ +import { io } from 'socket.io-client' +import type { MultiplayerConfig, MultiplayerSession } from './types' + +/** + * Connect to a Socket.IO multiplayer server. + * @param url - WebSocket server URL (e.g. "http://localhost:3000") + * @param config - Optional configuration overrides + * @returns A MultiplayerSession with the socket and a destroy function + */ +export const multiplayerCreate = ( + url: string, + config?: MultiplayerConfig +): MultiplayerSession & { _config: Required } => { + const socket = io(url) + const resolvedConfig: Required = { + throttleMs: config?.throttleMs ?? 30 + } + + const destroy = () => { + socket.disconnect() + } + + return { socket, destroy, _config: resolvedConfig } +} + +/** + * Disconnect and clean up a multiplayer session. + * @param session - The session returned by multiplayerCreate + */ +export const multiplayerDestroy = (session: MultiplayerSession): void => { + session.destroy() +} diff --git a/packages/multiplayer/src/index.test.ts b/packages/multiplayer/src/index.test.ts new file mode 100644 index 00000000..ebd08338 --- /dev/null +++ b/packages/multiplayer/src/index.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Socket } from 'socket.io-client' +import type { PlayerState, CoinState, CoinCollectedEvent } from './types' + +// ── Mock socket.io-client ──────────────────────────────────────────────────── +const mockListeners = new Map void)[]>() +const mockEmitted: Array<[string, unknown]> = [] + +const mockSocket = { + connected: false, + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (!mockListeners.has(event)) mockListeners.set(event, []) + mockListeners.get(event)!.push(callback) + }), + off: vi.fn(), + emit: vi.fn((event: string, data: unknown) => { + mockEmitted.push([event, data]) + }), + disconnect: vi.fn() +} + +const emit = (event: string, ...args: unknown[]) => { + mockListeners.get(event)?.forEach((callback) => callback(...args)) +} + +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => mockSocket) +})) + +// ── Import after mock ──────────────────────────────────────────────────────── +import { + multiplayerCreate, + multiplayerDestroy, + multiplayerSendPosition, + multiplayerOnPlayers, + multiplayerCollectCoin, + multiplayerOnCoinCollected, + multiplayerOnCoinsSync +} from './index' + +describe('multiplayerCreate', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('returns a session with a socket and destroy function', () => { + const session = multiplayerCreate('http://localhost:3000') + expect(session).toHaveProperty('socket') + expect(session).toHaveProperty('destroy') + expect(typeof session.destroy).toBe('function') + }) + + it('connects to the provided URL', async () => { + const { io } = await import('socket.io-client') + multiplayerCreate('http://example.com:4000') + expect(io).toHaveBeenCalledWith('http://example.com:4000') + }) +}) + +describe('multiplayerDestroy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls disconnect on the socket', () => { + const session = multiplayerCreate('http://localhost:3000') + multiplayerDestroy(session) + expect(mockSocket.disconnect).toHaveBeenCalled() + }) + + it('destroy method on session also disconnects', () => { + const session = multiplayerCreate('http://localhost:3000') + session.destroy() + expect(mockSocket.disconnect).toHaveBeenCalled() + }) +}) + +describe('multiplayerSendPosition', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('emits user:updated event with position and rotation', () => { + const session = multiplayerCreate('http://localhost:3000') + const position = { x: 1, y: 2, z: 3 } + const rotation = { x: 0, y: 0.5, z: 0 } + + multiplayerSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) + + expect(mockSocket.emit).toHaveBeenCalledWith('user:updated', { position, rotation }) + }) + + it('throttles rapid calls to one emission per interval', () => { + const session = multiplayerCreate('http://localhost:3000') + const position = { x: 1, y: 0, z: 0 } + const rotation = { x: 0, y: 0, z: 0 } + + multiplayerSendPosition(session, position, rotation) + multiplayerSendPosition(session, { x: 2, y: 0, z: 0 }, rotation) + multiplayerSendPosition(session, { x: 3, y: 0, z: 0 }, rotation) + vi.advanceTimersByTime(30) + + const userUpdatedCalls = mockEmitted.filter(([event]) => event === 'user:updated') + expect(userUpdatedCalls.length).toBe(1) + }) + + it('respects custom throttleMs from config', () => { + const session = multiplayerCreate('http://localhost:3000', { throttleMs: 100 }) + const position = { x: 1, y: 0, z: 0 } + const rotation = { x: 0, y: 0, z: 0 } + + multiplayerSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) // Not enough + expect(mockSocket.emit).not.toHaveBeenCalledWith('user:updated', expect.anything()) + + vi.advanceTimersByTime(70) // Now 100ms total + expect(mockSocket.emit).toHaveBeenCalledWith( + 'user:updated', + expect.objectContaining({ position }) + ) + }) +}) + +describe('multiplayerOnPlayers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls the callback when user:list is received', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + const players: PlayerState[] = [ + { id: 'abc', name: 'Alice', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } } + ] + + multiplayerOnPlayers(session, callback) + emit('user:list', players) + + expect(callback).toHaveBeenCalledWith(players) + }) + + it('returns an unsubscribe function', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + + const unsubscribe = multiplayerOnPlayers(session, callback) + unsubscribe() + + expect(mockSocket.off).toHaveBeenCalledWith('user:list', expect.any(Function)) + }) +}) + +describe('multiplayerCollectCoin', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('emits coin:collected with the coinId', () => { + const session = multiplayerCreate('http://localhost:3000') + multiplayerCollectCoin(session, 'coin-42') + expect(mockSocket.emit).toHaveBeenCalledWith('coin:collected', { coinId: 'coin-42' }) + }) +}) + +describe('multiplayerOnCoinCollected', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls callback with CoinCollectedEvent when server broadcasts', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + const event: CoinCollectedEvent = { coinId: 'coin-42', playerId: 'player-1' } + + multiplayerOnCoinCollected(session, callback) + emit('coin:collected', event) + + expect(callback).toHaveBeenCalledWith(event) + }) + + it('returns an unsubscribe function', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + + const unsubscribe = multiplayerOnCoinCollected(session, callback) + unsubscribe() + + expect(mockSocket.off).toHaveBeenCalledWith('coin:collected', expect.any(Function)) + }) +}) + +describe('multiplayerOnCoinsSync', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls callback with initial coin list from server', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + const coins: CoinState[] = [ + { id: 'coin-1', position: { x: 0, y: 1, z: 0 }, collected: false }, + { id: 'coin-2', position: { x: 5, y: 1, z: 5 }, collected: true } + ] + + multiplayerOnCoinsSync(session, callback) + emit('coin:list', coins) + + expect(callback).toHaveBeenCalledWith(coins) + }) + + it('returns an unsubscribe function', () => { + const session = multiplayerCreate('http://localhost:3000') + const callback = vi.fn() + + const unsubscribe = multiplayerOnCoinsSync(session, callback) + unsubscribe() + + expect(mockSocket.off).toHaveBeenCalledWith('coin:list', expect.any(Function)) + }) +}) + +// ── Integration: socket typed as Socket for type safety ────────────────────── +describe('session.socket type', () => { + it('exposes the underlying socket.io Socket', () => { + const session = multiplayerCreate('http://localhost:3000') + // TypeScript ensures this compiles — cast for runtime check + const socket = session.socket as unknown as Socket + expect(socket).toBeDefined() + }) +}) diff --git a/packages/multiplayer/src/index.ts b/packages/multiplayer/src/index.ts new file mode 100644 index 00000000..fced8113 --- /dev/null +++ b/packages/multiplayer/src/index.ts @@ -0,0 +1,13 @@ +export type { + PlayerPosition, + PlayerRotation, + PlayerState, + CoinState, + CoinCollectedEvent, + MultiplayerConfig, + MultiplayerSession +} from './types' + +export { multiplayerCreate, multiplayerDestroy } from './connection' +export { multiplayerSendPosition, multiplayerOnPlayers } from './players' +export { multiplayerCollectCoin, multiplayerOnCoinCollected, multiplayerOnCoinsSync } from './coins' diff --git a/packages/multiplayer/src/players.ts b/packages/multiplayer/src/players.ts new file mode 100644 index 00000000..98ad5637 --- /dev/null +++ b/packages/multiplayer/src/players.ts @@ -0,0 +1,46 @@ +import type { PlayerPosition, PlayerRotation, PlayerState, MultiplayerSession } from './types' + +type InternalSession = MultiplayerSession & { _config: { throttleMs: number } } + +const pendingTimers = new WeakMap>() + +/** + * Broadcast the local player's position and rotation to the server, throttled. + * Only one emission is sent per throttle window regardless of how many times + * this function is called — always using the most recent values. + * @param session - The active multiplayer session + * @param position - Current player position {x, y, z} + * @param rotation - Current player rotation {x, y, z} + */ +export const multiplayerSendPosition = ( + session: MultiplayerSession, + position: PlayerPosition, + rotation: PlayerRotation +): void => { + const internal = session as InternalSession + const throttleMs = internal._config?.throttleMs ?? 30 + const existing = pendingTimers.get(session) + if (existing !== undefined) clearTimeout(existing) + + pendingTimers.set( + session, + setTimeout(() => { + pendingTimers.delete(session) + session.socket.emit('user:updated', { position, rotation }) + }, throttleMs) + ) +} + +/** + * Subscribe to player list updates from the server. + * @param session - The active multiplayer session + * @param callback - Called with the full list of connected players on each update + * @returns Unsubscribe function + */ +export const multiplayerOnPlayers = ( + session: MultiplayerSession, + callback: (players: PlayerState[]) => void +): (() => void) => { + session.socket.on('user:list', callback) + return () => session.socket.off('user:list', callback) +} diff --git a/packages/multiplayer/src/types.ts b/packages/multiplayer/src/types.ts new file mode 100644 index 00000000..5f137100 --- /dev/null +++ b/packages/multiplayer/src/types.ts @@ -0,0 +1,41 @@ +export interface PlayerPosition { + x: number + y: number + z: number +} + +export interface PlayerRotation { + x: number + y: number + z: number +} + +export interface PlayerState { + id: string + name: string + position: PlayerPosition + rotation: PlayerRotation +} + +export interface CoinState { + id: string + position: PlayerPosition + collected: boolean +} + +export interface CoinCollectedEvent { + coinId: string + playerId: string +} + +export interface MultiplayerConfig { + /** Minimum ms between position broadcasts (default: 30) */ + throttleMs?: number +} + +export interface MultiplayerSession { + /** The underlying socket instance */ + socket: import('socket.io-client').Socket + /** Cancel any pending position broadcast and disconnect */ + destroy: () => void +} diff --git a/packages/multiplayer/tsconfig.json b/packages/multiplayer/tsconfig.json new file mode 100644 index 00000000..f3de4d67 --- /dev/null +++ b/packages/multiplayer/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/multiplayer/vite.config.ts b/packages/multiplayer/vite.config.ts new file mode 100644 index 00000000..3b80293c --- /dev/null +++ b/packages/multiplayer/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(import.meta.dirname, 'src/index.ts'), + name: 'WebGameToolkitMultiplayer', + fileName: 'index' + }, + rollupOptions: { + external: ['socket.io-client'], + output: { + globals: { + 'socket.io-client': 'io' + } + } + } + } +}) From 263ca6e308f95173eb619af3c5631bdf5fe6e538 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Fri, 3 Apr 2026 23:36:02 +0200 Subject: [PATCH 02/22] chore: update pnpm lockfile for @webgamekit/multiplayer Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a96f85..793ecefe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,21 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + packages/multiplayer: + devDependencies: + socket.io-client: + specifier: ^4.8.1 + version: 4.8.3 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + packages/recording: devDependencies: typescript: @@ -1172,6 +1187,9 @@ packages: resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1718,6 +1736,13 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2789,6 +2814,14 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3220,6 +3253,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -3851,6 +3888,8 @@ snapshots: '@sindresorhus/base62@1.0.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.18': @@ -4481,6 +4520,20 @@ snapshots: emoji-regex@9.2.2: {} + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -5619,6 +5672,24 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6024,6 +6095,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + yaml@1.10.2: {} yaml@2.8.2: {} From 1e9beccfb261ac004a8161c17cd05b57b5436654 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 18:19:29 +0200 Subject: [PATCH 03/22] feat: add @webgamekit/multiplayer-client, multiplayer-server, and multiplayer-p2p packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #61 - Rename `@webgamekit/multiplayer` → `@webgamekit/multiplayer-client` (Socket.IO client) - Add `@webgamekit/multiplayer-server` (Socket.IO server — Node.js only) - Add `@webgamekit/multiplayer-p2p` (WebRTC via Trystero + NOSTR signaling, fully serverless) - All three packages expose generic typed data channels (multiplayerClient*/p2p* prefix) - Add `MultiplayerClient` and `MultiplayerP2P` experiment views - Register multiplayer packages in vite aliases and root deps - Add multiplayer packages to ESLint utility-files override Co-Authored-By: Claude Sonnet 4.6 --- eslint.config.js | 5 +- package.json | 4 + .../package.json | 2 +- packages/multiplayer-client/src/connection.ts | 32 ++ packages/multiplayer-client/src/data.ts | 31 ++ packages/multiplayer-client/src/index.test.ts | 203 +++++++++++ packages/multiplayer-client/src/index.ts | 11 + .../src/players.ts | 21 +- .../src/types.ts | 19 +- .../tsconfig.json | 0 .../vite.config.ts | 0 packages/multiplayer-p2p/package.json | 35 ++ packages/multiplayer-p2p/src/data.ts | 34 ++ packages/multiplayer-p2p/src/index.test.ts | 193 ++++++++++ packages/multiplayer-p2p/src/index.ts | 4 + packages/multiplayer-p2p/src/players.ts | 54 +++ packages/multiplayer-p2p/src/room.ts | 39 ++ packages/multiplayer-p2p/src/types.ts | 31 ++ packages/multiplayer-p2p/tsconfig.json | 24 ++ packages/multiplayer-p2p/vite.config.ts | 16 + packages/multiplayer-server/package.json | 36 ++ packages/multiplayer-server/src/index.test.ts | 111 ++++++ packages/multiplayer-server/src/index.ts | 2 + packages/multiplayer-server/src/server.ts | 75 ++++ packages/multiplayer-server/src/types.ts | 23 ++ packages/multiplayer-server/tsconfig.json | 24 ++ packages/multiplayer-server/vite.config.ts | 16 + packages/multiplayer/src/coins.ts | 38 -- packages/multiplayer/src/connection.ts | 32 -- packages/multiplayer/src/index.test.ts | 250 ------------- packages/multiplayer/src/index.ts | 13 - pnpm-lock.yaml | 338 +++++++++++++++++- .../MultiplayerClient/MultiplayerClient.vue | 302 ++++++++++++++++ .../MultiplayerP2P/MultiplayerP2P.vue | 229 ++++++++++++ vite.config.ts | 12 +- 35 files changed, 1884 insertions(+), 375 deletions(-) rename packages/{multiplayer => multiplayer-client}/package.json (94%) create mode 100644 packages/multiplayer-client/src/connection.ts create mode 100644 packages/multiplayer-client/src/data.ts create mode 100644 packages/multiplayer-client/src/index.test.ts create mode 100644 packages/multiplayer-client/src/index.ts rename packages/{multiplayer => multiplayer-client}/src/players.ts (64%) rename packages/{multiplayer => multiplayer-client}/src/types.ts (54%) rename packages/{multiplayer => multiplayer-client}/tsconfig.json (100%) rename packages/{multiplayer => multiplayer-client}/vite.config.ts (100%) create mode 100644 packages/multiplayer-p2p/package.json create mode 100644 packages/multiplayer-p2p/src/data.ts create mode 100644 packages/multiplayer-p2p/src/index.test.ts create mode 100644 packages/multiplayer-p2p/src/index.ts create mode 100644 packages/multiplayer-p2p/src/players.ts create mode 100644 packages/multiplayer-p2p/src/room.ts create mode 100644 packages/multiplayer-p2p/src/types.ts create mode 100644 packages/multiplayer-p2p/tsconfig.json create mode 100644 packages/multiplayer-p2p/vite.config.ts create mode 100644 packages/multiplayer-server/package.json create mode 100644 packages/multiplayer-server/src/index.test.ts create mode 100644 packages/multiplayer-server/src/index.ts create mode 100644 packages/multiplayer-server/src/server.ts create mode 100644 packages/multiplayer-server/src/types.ts create mode 100644 packages/multiplayer-server/tsconfig.json create mode 100644 packages/multiplayer-server/vite.config.ts delete mode 100644 packages/multiplayer/src/coins.ts delete mode 100644 packages/multiplayer/src/connection.ts delete mode 100644 packages/multiplayer/src/index.test.ts delete mode 100644 packages/multiplayer/src/index.ts create mode 100644 src/views/Experiments/MultiplayerClient/MultiplayerClient.vue create mode 100644 src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue diff --git a/eslint.config.js b/eslint.config.js index a8448f33..0fa31064 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/package.json b/package.json index 037b6c7d..47f557a6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,11 @@ "dependencies": { "@webgamekit/controls": "workspace:^", "@webgamekit/logic": "workspace:^", + "@webgamekit/multiplayer-client": "workspace:^", + "@webgamekit/multiplayer-p2p": "workspace:^", "@webgamekit/threejs": "workspace:^", + "socket.io-client": "^4.0.0", + "trystero": "^0.23.0", "dat.gui": "^0.7.9", "p5": "^1.9.0", "pinia": "^2.3.0", diff --git a/packages/multiplayer/package.json b/packages/multiplayer-client/package.json similarity index 94% rename from packages/multiplayer/package.json rename to packages/multiplayer-client/package.json index bd4bf87a..f98108f1 100644 --- a/packages/multiplayer/package.json +++ b/packages/multiplayer-client/package.json @@ -1,5 +1,5 @@ { - "name": "@webgamekit/multiplayer", + "name": "@webgamekit/multiplayer-client", "version": "0.0.1", "type": "module", "files": [ diff --git a/packages/multiplayer-client/src/connection.ts b/packages/multiplayer-client/src/connection.ts new file mode 100644 index 00000000..729f0ee2 --- /dev/null +++ b/packages/multiplayer-client/src/connection.ts @@ -0,0 +1,32 @@ +import { io } from 'socket.io-client' +import type { MultiplayerClientConfig, MultiplayerClientSession } from './types' + +/** + * Connect to a Socket.IO multiplayer server. + * @param url - WebSocket server URL (e.g. "http://localhost:3000") + * @param config - Optional configuration overrides + * @returns A MultiplayerClientSession with the socket and a destroy function + */ +export const multiplayerClientCreate = ( + url: string, + config?: MultiplayerClientConfig +): MultiplayerClientSession & { _config: Required } => { + const socket = io(url) + const resolvedConfig: Required = { + throttleMs: config?.throttleMs ?? 30 + } + + const destroy = () => { + socket.disconnect() + } + + return { socket, destroy, _config: resolvedConfig } +} + +/** + * Disconnect and clean up a multiplayer client session. + * @param session - The session returned by multiplayerClientCreate + */ +export const multiplayerClientDestroy = (session: MultiplayerClientSession): void => { + session.destroy() +} diff --git a/packages/multiplayer-client/src/data.ts b/packages/multiplayer-client/src/data.ts new file mode 100644 index 00000000..179043d3 --- /dev/null +++ b/packages/multiplayer-client/src/data.ts @@ -0,0 +1,31 @@ +import type { MultiplayerClientSession } from './types' + +/** + * Send typed data to all connected players via a named channel. + * @param session - The active multiplayer client session + * @param channel - Event name identifying the data channel (e.g. "coin:collected") + * @param payload - Serializable data to broadcast + */ +export const multiplayerClientSendData = ( + session: MultiplayerClientSession, + channel: string, + payload: T +): void => { + session.socket.emit(channel, payload) +} + +/** + * Subscribe to typed data arriving on a named channel from the server. + * @param session - The active multiplayer client session + * @param channel - Event name to listen on + * @param callback - Called with the received payload + * @returns Unsubscribe function + */ +export const multiplayerClientOnData = ( + session: MultiplayerClientSession, + channel: string, + callback: (payload: T) => void +): (() => void) => { + session.socket.on(channel, callback) + return () => session.socket.off(channel, callback) +} diff --git a/packages/multiplayer-client/src/index.test.ts b/packages/multiplayer-client/src/index.test.ts new file mode 100644 index 00000000..a2fde9e1 --- /dev/null +++ b/packages/multiplayer-client/src/index.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { PlayerState } from './types' + +// ── Mock socket.io-client ────────────────────────────────────────────────── +const mockListeners = new Map void)[]>() +const mockEmitted: Array<[string, unknown]> = [] + +const mockSocket = { + connected: false, + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (!mockListeners.has(event)) mockListeners.set(event, []) + mockListeners.get(event)!.push(callback) + }), + off: vi.fn(), + emit: vi.fn((event: string, data: unknown) => { + mockEmitted.push([event, data]) + }), + disconnect: vi.fn() +} + +const emit = (event: string, ...args: unknown[]) => + mockListeners.get(event)?.forEach((callback) => callback(...args)) + +vi.mock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })) + +// ── Import after mock ────────────────────────────────────────────────────── +import { + multiplayerClientCreate, + multiplayerClientDestroy, + multiplayerClientSendPosition, + multiplayerClientOnPlayers, + multiplayerClientSendData, + multiplayerClientOnData +} from './index' + +describe('multiplayerClientCreate', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('returns a session with a socket and destroy function', () => { + const session = multiplayerClientCreate('http://localhost:3000') + expect(session).toHaveProperty('socket') + expect(session).toHaveProperty('destroy') + expect(typeof session.destroy).toBe('function') + }) + + it('connects to the provided URL', async () => { + const { io } = await import('socket.io-client') + multiplayerClientCreate('http://example.com:4000') + expect(io).toHaveBeenCalledWith('http://example.com:4000') + }) +}) + +describe('multiplayerClientDestroy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls disconnect on the socket', () => { + const session = multiplayerClientCreate('http://localhost:3000') + multiplayerClientDestroy(session) + expect(mockSocket.disconnect).toHaveBeenCalled() + }) + + it('session.destroy() also disconnects', () => { + const session = multiplayerClientCreate('http://localhost:3000') + session.destroy() + expect(mockSocket.disconnect).toHaveBeenCalled() + }) +}) + +describe('multiplayerClientSendPosition', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('emits user:updated with position and rotation', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const position = { x: 1, y: 2, z: 3 } + const rotation = { x: 0, y: 0.5, z: 0 } + multiplayerClientSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) + expect(mockSocket.emit).toHaveBeenCalledWith('user:updated', { position, rotation }) + }) + + it('throttles rapid calls — only one emission per interval', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const rotation = { x: 0, y: 0, z: 0 } + multiplayerClientSendPosition(session, { x: 1, y: 0, z: 0 }, rotation) + multiplayerClientSendPosition(session, { x: 2, y: 0, z: 0 }, rotation) + multiplayerClientSendPosition(session, { x: 3, y: 0, z: 0 }, rotation) + vi.advanceTimersByTime(30) + const calls = mockEmitted.filter(([e]) => e === 'user:updated') + expect(calls.length).toBe(1) + }) + + it('respects custom throttleMs from config', () => { + const session = multiplayerClientCreate('http://localhost:3000', { throttleMs: 100 }) + const position = { x: 1, y: 0, z: 0 } + const rotation = { x: 0, y: 0, z: 0 } + multiplayerClientSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) + expect(mockSocket.emit).not.toHaveBeenCalledWith('user:updated', expect.anything()) + vi.advanceTimersByTime(70) + expect(mockSocket.emit).toHaveBeenCalledWith( + 'user:updated', + expect.objectContaining({ position }) + ) + }) +}) + +describe('multiplayerClientOnPlayers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls callback when user:list is received', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const callback = vi.fn() + const players: PlayerState[] = [ + { id: 'abc', name: 'Alice', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } } + ] + multiplayerClientOnPlayers(session, callback) + emit('user:list', players) + expect(callback).toHaveBeenCalledWith(players) + }) + + it('returns an unsubscribe function', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const unsubscribe = multiplayerClientOnPlayers(session, vi.fn()) + unsubscribe() + expect(mockSocket.off).toHaveBeenCalledWith('user:list', expect.any(Function)) + }) +}) + +describe('multiplayerClientSendData', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('emits typed data on the given channel', () => { + const session = multiplayerClientCreate('http://localhost:3000') + multiplayerClientSendData(session, 'coin:collected', { coinId: 'c1', playerId: 'p1' }) + expect(mockSocket.emit).toHaveBeenCalledWith('coin:collected', { coinId: 'c1', playerId: 'p1' }) + }) + + it('works with any serializable payload type', () => { + const session = multiplayerClientCreate('http://localhost:3000') + multiplayerClientSendData(session, 'game:event', { type: 'levelUp', level: 5 }) + expect(mockSocket.emit).toHaveBeenCalledWith('game:event', { type: 'levelUp', level: 5 }) + }) +}) + +describe('multiplayerClientOnData', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListeners.clear() + mockEmitted.length = 0 + }) + + it('calls callback with typed payload when server sends data', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const callback = vi.fn() + multiplayerClientOnData<{ coinId: string }>(session, 'coin:collected', callback) + emit('coin:collected', { coinId: 'c1' }) + expect(callback).toHaveBeenCalledWith({ coinId: 'c1' }) + }) + + it('returns an unsubscribe function', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const unsubscribe = multiplayerClientOnData(session, 'coin:collected', vi.fn()) + unsubscribe() + expect(mockSocket.off).toHaveBeenCalledWith('coin:collected', expect.any(Function)) + }) + + it('supports multiple independent channels', () => { + const session = multiplayerClientCreate('http://localhost:3000') + const onCoin = vi.fn() + const onChat = vi.fn() + multiplayerClientOnData(session, 'coin:collected', onCoin) + multiplayerClientOnData(session, 'chat:message', onChat) + emit('coin:collected', { coinId: 'c2' }) + emit('chat:message', { text: 'hello' }) + expect(onCoin).toHaveBeenCalledWith({ coinId: 'c2' }) + expect(onChat).toHaveBeenCalledWith({ text: 'hello' }) + }) +}) diff --git a/packages/multiplayer-client/src/index.ts b/packages/multiplayer-client/src/index.ts new file mode 100644 index 00000000..9b692cb5 --- /dev/null +++ b/packages/multiplayer-client/src/index.ts @@ -0,0 +1,11 @@ +export type { + PlayerPosition, + PlayerRotation, + PlayerState, + MultiplayerClientConfig, + MultiplayerClientSession +} from './types' + +export { multiplayerClientCreate, multiplayerClientDestroy } from './connection' +export { multiplayerClientSendPosition, multiplayerClientOnPlayers } from './players' +export { multiplayerClientSendData, multiplayerClientOnData } from './data' diff --git a/packages/multiplayer/src/players.ts b/packages/multiplayer-client/src/players.ts similarity index 64% rename from packages/multiplayer/src/players.ts rename to packages/multiplayer-client/src/players.ts index 98ad5637..c57020a0 100644 --- a/packages/multiplayer/src/players.ts +++ b/packages/multiplayer-client/src/players.ts @@ -1,19 +1,18 @@ -import type { PlayerPosition, PlayerRotation, PlayerState, MultiplayerSession } from './types' +import type { PlayerPosition, PlayerRotation, PlayerState, MultiplayerClientSession } from './types' -type InternalSession = MultiplayerSession & { _config: { throttleMs: number } } +type InternalSession = MultiplayerClientSession & { _config: { throttleMs: number } } -const pendingTimers = new WeakMap>() +const pendingTimers = new WeakMap>() /** * Broadcast the local player's position and rotation to the server, throttled. - * Only one emission is sent per throttle window regardless of how many times - * this function is called — always using the most recent values. - * @param session - The active multiplayer session + * Only one emission is sent per throttle window — always the most recent values. + * @param session - The active multiplayer client session * @param position - Current player position {x, y, z} * @param rotation - Current player rotation {x, y, z} */ -export const multiplayerSendPosition = ( - session: MultiplayerSession, +export const multiplayerClientSendPosition = ( + session: MultiplayerClientSession, position: PlayerPosition, rotation: PlayerRotation ): void => { @@ -33,12 +32,12 @@ export const multiplayerSendPosition = ( /** * Subscribe to player list updates from the server. - * @param session - The active multiplayer session + * @param session - The active multiplayer client session * @param callback - Called with the full list of connected players on each update * @returns Unsubscribe function */ -export const multiplayerOnPlayers = ( - session: MultiplayerSession, +export const multiplayerClientOnPlayers = ( + session: MultiplayerClientSession, callback: (players: PlayerState[]) => void ): (() => void) => { session.socket.on('user:list', callback) diff --git a/packages/multiplayer/src/types.ts b/packages/multiplayer-client/src/types.ts similarity index 54% rename from packages/multiplayer/src/types.ts rename to packages/multiplayer-client/src/types.ts index 5f137100..41e952b3 100644 --- a/packages/multiplayer/src/types.ts +++ b/packages/multiplayer-client/src/types.ts @@ -17,25 +17,14 @@ export interface PlayerState { rotation: PlayerRotation } -export interface CoinState { - id: string - position: PlayerPosition - collected: boolean -} - -export interface CoinCollectedEvent { - coinId: string - playerId: string -} - -export interface MultiplayerConfig { +export interface MultiplayerClientConfig { /** Minimum ms between position broadcasts (default: 30) */ throttleMs?: number } -export interface MultiplayerSession { - /** The underlying socket instance */ +export interface MultiplayerClientSession { + /** The underlying socket.io Socket */ socket: import('socket.io-client').Socket - /** Cancel any pending position broadcast and disconnect */ + /** Disconnect and cancel pending broadcasts */ destroy: () => void } diff --git a/packages/multiplayer/tsconfig.json b/packages/multiplayer-client/tsconfig.json similarity index 100% rename from packages/multiplayer/tsconfig.json rename to packages/multiplayer-client/tsconfig.json diff --git a/packages/multiplayer/vite.config.ts b/packages/multiplayer-client/vite.config.ts similarity index 100% rename from packages/multiplayer/vite.config.ts rename to packages/multiplayer-client/vite.config.ts diff --git a/packages/multiplayer-p2p/package.json b/packages/multiplayer-p2p/package.json new file mode 100644 index 00000000..76537bef --- /dev/null +++ b/packages/multiplayer-p2p/package.json @@ -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" + } +} diff --git a/packages/multiplayer-p2p/src/data.ts b/packages/multiplayer-p2p/src/data.ts new file mode 100644 index 00000000..84d18809 --- /dev/null +++ b/packages/multiplayer-p2p/src/data.ts @@ -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 = ( + session: P2PSession, + channel: string, + payload: T +): void => { + const [send] = session.room.makeAction(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 = ( + session: P2PSession, + channel: string, + callback: (payload: T, peerId: string) => void +): (() => void) => { + const [, onData] = session.room.makeAction(channel) + onData((data: T, peerId: string) => callback(data, peerId)) + return () => {} +} diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts new file mode 100644 index 00000000..71c9456b --- /dev/null +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { PlayerState } from './types' + +// ── Mock trystero/nostr ──────────────────────────────────────────────────── +type ActionCallback = (data: T, peerId: string) => void + +const mockSend = vi.fn() +const mockLeave = vi.fn() +const mockOnPeerJoin = vi.fn() +const mockOnPeerLeave = vi.fn() +const mockGetPeers = vi.fn(() => [] as string[]) + +const actionCallbacks = new Map>() + +const mockRoom = { + makeAction: vi.fn((channel: string): [typeof mockSend, ActionCallback] => { + return [ + mockSend, + (callback: ActionCallback) => { + actionCallbacks.set(channel, callback as ActionCallback) + } + ] + }), + leave: mockLeave, + onPeerJoin: mockOnPeerJoin, + onPeerLeave: mockOnPeerLeave, + getPeers: mockGetPeers +} + +vi.mock('trystero/nostr', () => ({ + joinRoom: vi.fn(() => mockRoom) +})) + +import { p2pJoin, p2pLeave, p2pSendPosition, p2pOnPlayers, p2pSendData, p2pOnData } from './index' + +describe('p2pJoin', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('returns a session with room and destroy function', async () => { + const session = p2pJoin('room-1') + expect(session).toHaveProperty('room') + expect(session).toHaveProperty('destroy') + expect(typeof session.destroy).toBe('function') + }) + + it('calls joinRoom with the provided roomId and default appId', async () => { + const { joinRoom } = await import('trystero/nostr') + p2pJoin('level-abc') + expect(joinRoom).toHaveBeenCalledWith({ appId: 'webgamekit' }, 'level-abc') + }) + + it('uses custom appId from config', async () => { + const { joinRoom } = await import('trystero/nostr') + p2pJoin('room-2', { appId: 'my-game' }) + expect(joinRoom).toHaveBeenCalledWith({ appId: 'my-game' }, 'room-2') + }) +}) + +describe('p2pLeave', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('calls room.leave()', () => { + const session = p2pJoin('room-1') + p2pLeave(session) + expect(mockLeave).toHaveBeenCalled() + }) + + it('session.destroy() also calls room.leave()', () => { + const session = p2pJoin('room-1') + session.destroy() + expect(mockLeave).toHaveBeenCalled() + }) +}) + +describe('p2pSendPosition', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + actionCallbacks.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends position and rotation via the pos action', () => { + const session = p2pJoin('room-1') + const position = { x: 1, y: 2, z: 3 } + const rotation = { x: 0, y: 0.5, z: 0 } + p2pSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) + expect(mockSend).toHaveBeenCalledWith({ position, rotation }) + }) + + it('throttles rapid calls — only one send per interval', () => { + const session = p2pJoin('room-1') + const rotation = { x: 0, y: 0, z: 0 } + p2pSendPosition(session, { x: 1, y: 0, z: 0 }, rotation) + p2pSendPosition(session, { x: 2, y: 0, z: 0 }, rotation) + p2pSendPosition(session, { x: 3, y: 0, z: 0 }, rotation) + vi.advanceTimersByTime(30) + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + it('respects custom throttleMs', () => { + const session = p2pJoin('room-1') + const position = { x: 5, y: 0, z: 0 } + p2pSendPosition(session, position, { x: 0, y: 0, z: 0 }, { throttleMs: 100 }) + vi.advanceTimersByTime(50) + expect(mockSend).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ position })) + }) +}) + +describe('p2pOnPlayers', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('calls callback when position data arrives from a peer', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPlayers(session, callback) + + const onPos = actionCallbacks.get('pos') as ActionCallback<{ + position: PlayerState['position'] + rotation: PlayerState['rotation'] + }> + onPos?.({ position: { x: 5, y: 0, z: 3 }, rotation: { x: 0, y: 1, z: 0 } }, 'peer-42') + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ id: 'peer-42', position: { x: 5, y: 0, z: 3 } }) + ) + }) + + it('returns an unsubscribe function', () => { + const session = p2pJoin('room-1') + const unsubscribe = p2pOnPlayers(session, vi.fn()) + expect(typeof unsubscribe).toBe('function') + unsubscribe() + }) +}) + +describe('p2pSendData', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('sends typed payload on the given channel', () => { + const session = p2pJoin('room-1') + p2pSendData(session, 'coin:collected', { coinId: 'c1' }) + expect(mockSend).toHaveBeenCalledWith({ coinId: 'c1' }) + }) + + it('supports any serializable payload', () => { + const session = p2pJoin('room-1') + p2pSendData(session, 'game:event', { type: 'levelUp', score: 100 }) + expect(mockSend).toHaveBeenCalledWith({ type: 'levelUp', score: 100 }) + }) +}) + +describe('p2pOnData', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('calls callback with typed payload and sender peerId', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnData<{ coinId: string }>(session, 'coin:collected', callback) + + const onChannel = actionCallbacks.get('coin:collected') as ActionCallback<{ coinId: string }> + onChannel?.({ coinId: 'c1' }, 'peer-7') + + expect(callback).toHaveBeenCalledWith({ coinId: 'c1' }, 'peer-7') + }) + + it('returns an unsubscribe function', () => { + const session = p2pJoin('room-1') + const unsubscribe = p2pOnData(session, 'game:event', vi.fn()) + expect(typeof unsubscribe).toBe('function') + }) +}) diff --git a/packages/multiplayer-p2p/src/index.ts b/packages/multiplayer-p2p/src/index.ts new file mode 100644 index 00000000..ae23550a --- /dev/null +++ b/packages/multiplayer-p2p/src/index.ts @@ -0,0 +1,4 @@ +export type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' +export { p2pJoin, p2pLeave } from './room' +export { p2pSendPosition, p2pOnPlayers } from './players' +export { p2pSendData, p2pOnData } from './data' diff --git a/packages/multiplayer-p2p/src/players.ts b/packages/multiplayer-p2p/src/players.ts new file mode 100644 index 00000000..0caaf553 --- /dev/null +++ b/packages/multiplayer-p2p/src/players.ts @@ -0,0 +1,54 @@ +import type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' + +type PositionRecord = Record> + +const pendingTimers = new WeakMap>() + +/** + * Broadcast the local player's position and rotation to all peers, throttled. + * @param session - The active P2P session + * @param position - Current player position {x, y, z} + * @param rotation - Current player rotation {x, y, z} + * @param config - Optional config carrying throttleMs + */ +export const p2pSendPosition = ( + session: P2PSession, + position: PlayerPosition, + rotation: PlayerRotation, + config?: P2PConfig +): void => { + const throttleMs = config?.throttleMs ?? 30 + const existing = pendingTimers.get(session) + if (existing !== undefined) clearTimeout(existing) + + const [sendPos] = session.room.makeAction('pos') + + pendingTimers.set( + session, + setTimeout(() => { + pendingTimers.delete(session) + sendPos({ position, rotation }) + }, throttleMs) + ) +} + +/** + * Subscribe to position updates from other peers. + * @param session - The active P2P session + * @param callback - Called with the sender's PlayerState on each update + * @returns Unsubscribe function + */ +export const p2pOnPlayers = ( + session: P2PSession, + callback: (player: PlayerState) => void +): (() => void) => { + const [, onPos] = session.room.makeAction('pos') + + onPos((data: PositionRecord, peerId: string) => { + const pos = data['position'] as PlayerPosition + const rot = data['rotation'] as PlayerRotation + callback({ id: peerId, position: pos, rotation: rot }) + }) + + return () => {} +} diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts new file mode 100644 index 00000000..f4f4d713 --- /dev/null +++ b/packages/multiplayer-p2p/src/room.ts @@ -0,0 +1,39 @@ +import { joinRoom } from 'trystero/nostr' +import type { P2PConfig, P2PSession } from './types' + +/** + * Join a P2P room using WebRTC via Trystero (NOSTR signaling — no server required). + * All peers in the same roomId can communicate directly. + * @param roomId - Shared identifier for the game room (e.g. "level-1-abc123") + * @param config - Optional configuration overrides + * @returns A P2PSession with the room handle and destroy function + */ +export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { + const appId = config?.appId ?? 'webgamekit' + const room = joinRoom({ appId }, roomId) + + let peerId = '' + room.onPeerJoin((id: string) => { + if (!peerId) peerId = id + }) + + const destroy = () => { + room.leave() + } + + return { + get peerId() { + return peerId + }, + room, + destroy + } +} + +/** + * Leave a P2P room and clean up all connections. + * @param session - The session returned by p2pJoin + */ +export const p2pLeave = (session: P2PSession): void => { + session.destroy() +} diff --git a/packages/multiplayer-p2p/src/types.ts b/packages/multiplayer-p2p/src/types.ts new file mode 100644 index 00000000..6d118c04 --- /dev/null +++ b/packages/multiplayer-p2p/src/types.ts @@ -0,0 +1,31 @@ +import type { Room, DataPayload } from 'trystero/nostr' + +export type { DataPayload } + +export interface P2PConfig { + /** App identifier — must be unique to your game (default: "webgamekit") */ + appId?: string + /** Minimum ms between position broadcasts (default: 30) */ + throttleMs?: number +} + +export interface P2PSession { + /** Local peer ID assigned by Trystero */ + peerId: string + /** The underlying Trystero Room */ + room: Room + /** Leave the room and clean up */ + destroy: () => void +} + +/** Serializable position — compatible with Trystero DataPayload */ +export type PlayerPosition = { x: number; y: number; z: number } & Record + +/** Serializable rotation — compatible with Trystero DataPayload */ +export type PlayerRotation = { x: number; y: number; z: number } & Record + +export interface PlayerState { + id: string + position: PlayerPosition + rotation: PlayerRotation +} diff --git a/packages/multiplayer-p2p/tsconfig.json b/packages/multiplayer-p2p/tsconfig.json new file mode 100644 index 00000000..db2a21b8 --- /dev/null +++ b/packages/multiplayer-p2p/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Bundler", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/packages/multiplayer-p2p/vite.config.ts b/packages/multiplayer-p2p/vite.config.ts new file mode 100644 index 00000000..78db90e5 --- /dev/null +++ b/packages/multiplayer-p2p/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(import.meta.dirname, 'src/index.ts'), + name: 'WebGameToolkitMultiplayerP2P', + fileName: 'index' + }, + rollupOptions: { + external: ['trystero', 'trystero/nostr'], + output: { globals: { trystero: 'trystero', 'trystero/nostr': 'trysteroNostr' } } + } + } +}) diff --git a/packages/multiplayer-server/package.json b/packages/multiplayer-server/package.json new file mode 100644 index 00000000..744551b0 --- /dev/null +++ b/packages/multiplayer-server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@webgamekit/multiplayer-server", + "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": { + "socket.io": "^4.0.0" + }, + "devDependencies": { + "socket.io": "^4.8.0", + "@types/node": "^22.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/multiplayer-server/src/index.test.ts b/packages/multiplayer-server/src/index.test.ts new file mode 100644 index 00000000..9cda1b5b --- /dev/null +++ b/packages/multiplayer-server/src/index.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { PlayerState } from './types' +import { multiplayerServerCreate } from './index' + +// ── Mock Socket.IO ───────────────────────────────────────────────────────── +type Handler = (...args: unknown[]) => void + +const makeSocket = (id: string) => { + const handlers = new Map() + return { + id, + emit: vi.fn(), + on: vi.fn((event: string, handler: Handler) => { + handlers.set(event, handler) + }), + trigger: (event: string, ...args: unknown[]) => { + handlers.get(event)?.(...args) + } + } +} + +const makeIo = () => { + const connectionHandlers: Handler[] = [] + return { + emit: vi.fn(), + on: vi.fn((event: string, handler: Handler) => { + if (event === 'connection') connectionHandlers.push(handler) + }), + off: vi.fn(), + connect: (socket: ReturnType) => { + connectionHandlers.forEach((h) => h(socket)) + } + } +} + +describe('multiplayerServerCreate', () => { + let io: ReturnType + + beforeEach(() => { + io = makeIo() + vi.clearAllMocks() + }) + + it('registers a connection listener on the server', () => { + multiplayerServerCreate(io as never) + expect(io.on).toHaveBeenCalledWith('connection', expect.any(Function)) + }) + + it('broadcasts user:list when a player connects', () => { + multiplayerServerCreate(io as never) + const socket = makeSocket('player-1') + io.connect(socket) + expect(io.emit).toHaveBeenCalledWith( + 'user:list', + expect.arrayContaining([expect.objectContaining({ id: 'player-1' })]) + ) + }) + + it('updates player state and re-broadcasts on user:updated', () => { + multiplayerServerCreate(io as never) + const socket = makeSocket('player-1') + io.connect(socket) + + const position = { x: 5, y: 0, z: 3 } + const rotation = { x: 0, y: 1, z: 0 } + socket.trigger('user:updated', { position, rotation }) + + const lastCall = (io.emit as ReturnType).mock.calls.at(-1) + const players = lastCall?.[1] as PlayerState[] + expect(players[0].position).toEqual(position) + expect(players[0].rotation).toEqual(rotation) + }) + + it('removes player and broadcasts on disconnect', () => { + multiplayerServerCreate(io as never) + const socket = makeSocket('player-1') + io.connect(socket) + socket.trigger('disconnect') + + const lastCall = (io.emit as ReturnType).mock.calls.at(-1) + expect(lastCall?.[1]).toEqual([]) + }) + + it('tracks multiple concurrent players', () => { + multiplayerServerCreate(io as never) + const s1 = makeSocket('p1') + const s2 = makeSocket('p2') + io.connect(s1) + io.connect(s2) + + const lastCall = (io.emit as ReturnType).mock.calls.at(-1) + const players = lastCall?.[1] as PlayerState[] + expect(players).toHaveLength(2) + }) + + it('calls onConnect config and sends initial data to the new socket', () => { + const onConnect = vi.fn(() => ({ 'coin:list': [{ id: 'c1' }] })) + multiplayerServerCreate(io as never, { onConnect }) + const socket = makeSocket('player-1') + io.connect(socket) + + expect(onConnect).toHaveBeenCalledWith('player-1') + expect(socket.emit).toHaveBeenCalledWith('coin:list', [{ id: 'c1' }]) + }) + + it('cleanup removes the connection listener', () => { + const { cleanup } = multiplayerServerCreate(io as never) + cleanup() + expect(io.off).toHaveBeenCalledWith('connection', expect.any(Function)) + }) +}) diff --git a/packages/multiplayer-server/src/index.ts b/packages/multiplayer-server/src/index.ts new file mode 100644 index 00000000..bdfef94a --- /dev/null +++ b/packages/multiplayer-server/src/index.ts @@ -0,0 +1,2 @@ +export type { PlayerPosition, PlayerRotation, PlayerState, MultiplayerServerConfig } from './types' +export { multiplayerServerCreate } from './server' diff --git a/packages/multiplayer-server/src/server.ts b/packages/multiplayer-server/src/server.ts new file mode 100644 index 00000000..65b04cb5 --- /dev/null +++ b/packages/multiplayer-server/src/server.ts @@ -0,0 +1,75 @@ +import type { Server, Socket } from 'socket.io' +import type { PlayerState, PlayerRotation, PlayerPosition, MultiplayerServerConfig } from './types' + +type PositionPayload = { position: PlayerPosition; rotation: PlayerRotation; name?: string } + +const broadcastPlayerList = (io: Server, players: Map): void => { + io.emit('user:list', [...players.values()]) +} + +const registerPlayerHandlers = ( + socket: Socket, + io: Server, + players: Map, + config: MultiplayerServerConfig +): void => { + const initialState: PlayerState = { + id: socket.id, + name: socket.id, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 } + } + players.set(socket.id, initialState) + + if (config.onConnect) { + const initialData = config.onConnect(socket.id) + Object.entries(initialData).forEach(([channel, payload]) => { + socket.emit(channel, payload) + }) + } + + broadcastPlayerList(io, players) + + socket.on('user:updated', (payload: PositionPayload) => { + const current = players.get(socket.id) ?? initialState + players.set(socket.id, { + ...current, + name: payload.name ?? current.name, + position: payload.position, + rotation: payload.rotation + }) + io.emit('user:list', [...players.values()]) + }) + + socket.on('disconnect', () => { + players.delete(socket.id) + broadcastPlayerList(io, players) + }) +} + +/** + * Attach multiplayer session handling to a Socket.IO server instance. + * Manages player registry, position relay, and generic data forwarding. + * @param io - A Socket.IO Server instance + * @param config - Optional server configuration + * @returns cleanup function that removes the connection listener + */ +export const multiplayerServerCreate = ( + io: Server, + config: MultiplayerServerConfig = {} +): { cleanup: () => void } => { + const players = new Map() + + const onConnection = (socket: Socket) => { + registerPlayerHandlers(socket, io, players, config) + } + + io.on('connection', onConnection) + + return { + cleanup: () => { + io.off('connection', onConnection) + players.clear() + } + } +} diff --git a/packages/multiplayer-server/src/types.ts b/packages/multiplayer-server/src/types.ts new file mode 100644 index 00000000..36e6836f --- /dev/null +++ b/packages/multiplayer-server/src/types.ts @@ -0,0 +1,23 @@ +export interface PlayerPosition { + x: number + y: number + z: number +} + +export interface PlayerRotation { + x: number + y: number + z: number +} + +export interface PlayerState { + id: string + name: string + position: PlayerPosition + rotation: PlayerRotation +} + +export interface MultiplayerServerConfig { + /** Called when a player connects, returns initial data to send them */ + onConnect?: (socketId: string) => Record +} diff --git a/packages/multiplayer-server/tsconfig.json b/packages/multiplayer-server/tsconfig.json new file mode 100644 index 00000000..7c9f9866 --- /dev/null +++ b/packages/multiplayer-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/packages/multiplayer-server/vite.config.ts b/packages/multiplayer-server/vite.config.ts new file mode 100644 index 00000000..bead86e5 --- /dev/null +++ b/packages/multiplayer-server/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(import.meta.dirname, 'src/index.ts'), + name: 'WebGameToolkitMultiplayerServer', + fileName: 'index' + }, + rollupOptions: { + external: ['socket.io'], + output: { globals: { 'socket.io': 'io' } } + } + } +}) diff --git a/packages/multiplayer/src/coins.ts b/packages/multiplayer/src/coins.ts deleted file mode 100644 index 36e39f29..00000000 --- a/packages/multiplayer/src/coins.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CoinState, CoinCollectedEvent, MultiplayerSession } from './types' - -/** - * Notify the server that the local player collected a coin. - * @param session - The active multiplayer session - * @param coinId - Unique identifier of the collected coin - */ -export const multiplayerCollectCoin = (session: MultiplayerSession, coinId: string): void => { - session.socket.emit('coin:collected', { coinId }) -} - -/** - * Subscribe to coin-collected events broadcast by the server (from any player). - * @param session - The active multiplayer session - * @param callback - Called with the event payload when a coin is collected - * @returns Unsubscribe function - */ -export const multiplayerOnCoinCollected = ( - session: MultiplayerSession, - callback: (event: CoinCollectedEvent) => void -): (() => void) => { - session.socket.on('coin:collected', callback) - return () => session.socket.off('coin:collected', callback) -} - -/** - * Subscribe to the initial coin state list sent by the server on connect. - * @param session - The active multiplayer session - * @param callback - Called with the full coin list from the server - * @returns Unsubscribe function - */ -export const multiplayerOnCoinsSync = ( - session: MultiplayerSession, - callback: (coins: CoinState[]) => void -): (() => void) => { - session.socket.on('coin:list', callback) - return () => session.socket.off('coin:list', callback) -} diff --git a/packages/multiplayer/src/connection.ts b/packages/multiplayer/src/connection.ts deleted file mode 100644 index 222c427c..00000000 --- a/packages/multiplayer/src/connection.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { io } from 'socket.io-client' -import type { MultiplayerConfig, MultiplayerSession } from './types' - -/** - * Connect to a Socket.IO multiplayer server. - * @param url - WebSocket server URL (e.g. "http://localhost:3000") - * @param config - Optional configuration overrides - * @returns A MultiplayerSession with the socket and a destroy function - */ -export const multiplayerCreate = ( - url: string, - config?: MultiplayerConfig -): MultiplayerSession & { _config: Required } => { - const socket = io(url) - const resolvedConfig: Required = { - throttleMs: config?.throttleMs ?? 30 - } - - const destroy = () => { - socket.disconnect() - } - - return { socket, destroy, _config: resolvedConfig } -} - -/** - * Disconnect and clean up a multiplayer session. - * @param session - The session returned by multiplayerCreate - */ -export const multiplayerDestroy = (session: MultiplayerSession): void => { - session.destroy() -} diff --git a/packages/multiplayer/src/index.test.ts b/packages/multiplayer/src/index.test.ts deleted file mode 100644 index ebd08338..00000000 --- a/packages/multiplayer/src/index.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { Socket } from 'socket.io-client' -import type { PlayerState, CoinState, CoinCollectedEvent } from './types' - -// ── Mock socket.io-client ──────────────────────────────────────────────────── -const mockListeners = new Map void)[]>() -const mockEmitted: Array<[string, unknown]> = [] - -const mockSocket = { - connected: false, - on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (!mockListeners.has(event)) mockListeners.set(event, []) - mockListeners.get(event)!.push(callback) - }), - off: vi.fn(), - emit: vi.fn((event: string, data: unknown) => { - mockEmitted.push([event, data]) - }), - disconnect: vi.fn() -} - -const emit = (event: string, ...args: unknown[]) => { - mockListeners.get(event)?.forEach((callback) => callback(...args)) -} - -vi.mock('socket.io-client', () => ({ - io: vi.fn(() => mockSocket) -})) - -// ── Import after mock ──────────────────────────────────────────────────────── -import { - multiplayerCreate, - multiplayerDestroy, - multiplayerSendPosition, - multiplayerOnPlayers, - multiplayerCollectCoin, - multiplayerOnCoinCollected, - multiplayerOnCoinsSync -} from './index' - -describe('multiplayerCreate', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('returns a session with a socket and destroy function', () => { - const session = multiplayerCreate('http://localhost:3000') - expect(session).toHaveProperty('socket') - expect(session).toHaveProperty('destroy') - expect(typeof session.destroy).toBe('function') - }) - - it('connects to the provided URL', async () => { - const { io } = await import('socket.io-client') - multiplayerCreate('http://example.com:4000') - expect(io).toHaveBeenCalledWith('http://example.com:4000') - }) -}) - -describe('multiplayerDestroy', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('calls disconnect on the socket', () => { - const session = multiplayerCreate('http://localhost:3000') - multiplayerDestroy(session) - expect(mockSocket.disconnect).toHaveBeenCalled() - }) - - it('destroy method on session also disconnects', () => { - const session = multiplayerCreate('http://localhost:3000') - session.destroy() - expect(mockSocket.disconnect).toHaveBeenCalled() - }) -}) - -describe('multiplayerSendPosition', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('emits user:updated event with position and rotation', () => { - const session = multiplayerCreate('http://localhost:3000') - const position = { x: 1, y: 2, z: 3 } - const rotation = { x: 0, y: 0.5, z: 0 } - - multiplayerSendPosition(session, position, rotation) - vi.advanceTimersByTime(30) - - expect(mockSocket.emit).toHaveBeenCalledWith('user:updated', { position, rotation }) - }) - - it('throttles rapid calls to one emission per interval', () => { - const session = multiplayerCreate('http://localhost:3000') - const position = { x: 1, y: 0, z: 0 } - const rotation = { x: 0, y: 0, z: 0 } - - multiplayerSendPosition(session, position, rotation) - multiplayerSendPosition(session, { x: 2, y: 0, z: 0 }, rotation) - multiplayerSendPosition(session, { x: 3, y: 0, z: 0 }, rotation) - vi.advanceTimersByTime(30) - - const userUpdatedCalls = mockEmitted.filter(([event]) => event === 'user:updated') - expect(userUpdatedCalls.length).toBe(1) - }) - - it('respects custom throttleMs from config', () => { - const session = multiplayerCreate('http://localhost:3000', { throttleMs: 100 }) - const position = { x: 1, y: 0, z: 0 } - const rotation = { x: 0, y: 0, z: 0 } - - multiplayerSendPosition(session, position, rotation) - vi.advanceTimersByTime(30) // Not enough - expect(mockSocket.emit).not.toHaveBeenCalledWith('user:updated', expect.anything()) - - vi.advanceTimersByTime(70) // Now 100ms total - expect(mockSocket.emit).toHaveBeenCalledWith( - 'user:updated', - expect.objectContaining({ position }) - ) - }) -}) - -describe('multiplayerOnPlayers', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('calls the callback when user:list is received', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - const players: PlayerState[] = [ - { id: 'abc', name: 'Alice', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } } - ] - - multiplayerOnPlayers(session, callback) - emit('user:list', players) - - expect(callback).toHaveBeenCalledWith(players) - }) - - it('returns an unsubscribe function', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - - const unsubscribe = multiplayerOnPlayers(session, callback) - unsubscribe() - - expect(mockSocket.off).toHaveBeenCalledWith('user:list', expect.any(Function)) - }) -}) - -describe('multiplayerCollectCoin', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('emits coin:collected with the coinId', () => { - const session = multiplayerCreate('http://localhost:3000') - multiplayerCollectCoin(session, 'coin-42') - expect(mockSocket.emit).toHaveBeenCalledWith('coin:collected', { coinId: 'coin-42' }) - }) -}) - -describe('multiplayerOnCoinCollected', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('calls callback with CoinCollectedEvent when server broadcasts', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - const event: CoinCollectedEvent = { coinId: 'coin-42', playerId: 'player-1' } - - multiplayerOnCoinCollected(session, callback) - emit('coin:collected', event) - - expect(callback).toHaveBeenCalledWith(event) - }) - - it('returns an unsubscribe function', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - - const unsubscribe = multiplayerOnCoinCollected(session, callback) - unsubscribe() - - expect(mockSocket.off).toHaveBeenCalledWith('coin:collected', expect.any(Function)) - }) -}) - -describe('multiplayerOnCoinsSync', () => { - beforeEach(() => { - vi.clearAllMocks() - mockListeners.clear() - mockEmitted.length = 0 - }) - - it('calls callback with initial coin list from server', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - const coins: CoinState[] = [ - { id: 'coin-1', position: { x: 0, y: 1, z: 0 }, collected: false }, - { id: 'coin-2', position: { x: 5, y: 1, z: 5 }, collected: true } - ] - - multiplayerOnCoinsSync(session, callback) - emit('coin:list', coins) - - expect(callback).toHaveBeenCalledWith(coins) - }) - - it('returns an unsubscribe function', () => { - const session = multiplayerCreate('http://localhost:3000') - const callback = vi.fn() - - const unsubscribe = multiplayerOnCoinsSync(session, callback) - unsubscribe() - - expect(mockSocket.off).toHaveBeenCalledWith('coin:list', expect.any(Function)) - }) -}) - -// ── Integration: socket typed as Socket for type safety ────────────────────── -describe('session.socket type', () => { - it('exposes the underlying socket.io Socket', () => { - const session = multiplayerCreate('http://localhost:3000') - // TypeScript ensures this compiles — cast for runtime check - const socket = session.socket as unknown as Socket - expect(socket).toBeDefined() - }) -}) diff --git a/packages/multiplayer/src/index.ts b/packages/multiplayer/src/index.ts deleted file mode 100644 index fced8113..00000000 --- a/packages/multiplayer/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type { - PlayerPosition, - PlayerRotation, - PlayerState, - CoinState, - CoinCollectedEvent, - MultiplayerConfig, - MultiplayerSession -} from './types' - -export { multiplayerCreate, multiplayerDestroy } from './connection' -export { multiplayerSendPosition, multiplayerOnPlayers } from './players' -export { multiplayerCollectCoin, multiplayerOnCoinCollected, multiplayerOnCoinsSync } from './coins' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 793ecefe..2f7e7d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@webgamekit/logic': specifier: workspace:^ version: link:packages/logic + '@webgamekit/multiplayer-client': + specifier: workspace:^ + version: link:packages/multiplayer-client + '@webgamekit/multiplayer-p2p': + specifier: workspace:^ + version: link:packages/multiplayer-p2p '@webgamekit/threejs': specifier: workspace:^ version: link:packages/threejs @@ -26,12 +32,18 @@ importers: pinia: specifier: ^2.3.0 version: 2.3.1(typescript@5.4.5)(vue@3.5.25(typescript@5.4.5)) + socket.io-client: + specifier: ^4.0.0 + version: 4.8.3 stats.js: specifier: ^0.17.0 version: 0.17.0 three: specifier: ^0.178.0 version: 0.178.0 + trystero: + specifier: ^0.23.0 + version: 0.23.0 vite-plugin-wasm: specifier: ^3.3.0 version: 3.5.0(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) @@ -203,10 +215,10 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages/audio: devDependencies: @@ -215,7 +227,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/controls: devDependencies: @@ -224,7 +236,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/game: devDependencies: @@ -233,7 +245,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/logic: devDependencies: @@ -242,12 +254,12 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) - packages/multiplayer: + packages/multiplayer-client: devDependencies: socket.io-client: specifier: ^4.8.1 @@ -257,10 +269,43 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + + packages/multiplayer-p2p: + devDependencies: + trystero: + specifier: ^0.23.0 + version: 0.23.0 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + + packages/multiplayer-server: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + socket.io: + specifier: ^4.8.0 + version: 4.8.3 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages/recording: devDependencies: @@ -269,7 +314,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/threejs: dependencies: @@ -291,10 +336,10 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages: @@ -911,6 +956,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/secp256k1@3.0.0': + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1204,6 +1252,12 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@trystero-p2p/core@0.23.0': + resolution: {integrity: sha512-ozhtgxKDZH11Gdef0wH8xivwAE/L0/lDFvEcNFWPJWnHZlxWPPyfeonwE287ssGevQNi10vnj6x2ZcOi0n1bQQ==} + + '@trystero-p2p/nostr@0.23.0': + resolution: {integrity: sha512-KSqUR2c1KVfv4zeErcntuegtyKzFTzNNiitIKGD0LiKA/4H3CeTF81ROk2h+X/PNvP4mv7Gp5eVxFYwfMu4Nrg==} + '@tsconfig/node20@20.1.8': resolution: {integrity: sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==} @@ -1213,6 +1267,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/dat.gui@0.7.13': resolution: {integrity: sha512-LmG4Pr0mbsB/1WVttf8xaHx45BvSBDGY5D9+0/9AZCbI4vvOguwBHZdKyJMxt5Yst8+icRSHKmr7ClV2u5ZtDA==} @@ -1234,6 +1291,9 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/p5@1.7.7': resolution: {integrity: sha512-WFuP7jqc5CkkMtCK/NphgvMnJz1Qi9CMuK7t6xLu/tuXkRdGQA4q4AD0dUYcChC0Oibe8PE8gbKSFPNF0BqVNw==} @@ -1258,6 +1318,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1460,6 +1523,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1533,6 +1600,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -1646,12 +1717,20 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1743,6 +1822,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2466,6 +2549,10 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -2492,6 +2579,10 @@ packages: nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} @@ -2814,6 +2905,9 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-client@4.8.3: resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} @@ -2822,6 +2916,10 @@ packages: resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} engines: {node: '>=10.0.0'} + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2953,6 +3051,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trystero@0.23.0: + resolution: {integrity: sha512-HsM0FT0w41XW9bhXmYAfxr7PT+F6KwMzOxfJK2MbfipnMk/XrAIsV8lMTgU8NwWQqwCiS/b0b1qDsOf6IGxTmg==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3018,6 +3119,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-plugin-wasm@3.5.0: resolution: {integrity: sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==} peerDependencies: @@ -3700,6 +3805,8 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 optional: true + '@noble/secp256k1@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3903,6 +4010,13 @@ snapshots: '@tanstack/virtual-core': 3.13.18 vue: 3.5.25(typescript@5.4.5) + '@trystero-p2p/core@0.23.0': {} + + '@trystero-p2p/nostr@0.23.0': + dependencies: + '@noble/secp256k1': 3.0.0 + '@trystero-p2p/core': 0.23.0 + '@tsconfig/node20@20.1.8': {} '@tweenjs/tween.js@23.1.3': {} @@ -3912,6 +4026,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.17 + '@types/dat.gui@0.7.13': {} '@types/deep-eql@4.0.2': {} @@ -3936,6 +4054,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@types/p5@1.7.7': {} '@types/postcss-modules-local-by-default@4.0.2': @@ -3964,6 +4086,10 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.2(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4073,6 +4199,20 @@ snapshots: - utf-8-validate - vite + '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) + playwright: 1.58.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.0.18(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': dependencies: '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) @@ -4090,6 +4230,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -4107,6 +4265,15 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + '@vitest/mocker@4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + optional: true + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -4115,6 +4282,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -4286,6 +4461,11 @@ snapshots: abbrev@2.0.0: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4346,6 +4526,8 @@ snapshots: balanced-match@1.0.2: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.9.19: {} boolbase@1.0.0: {} @@ -4447,6 +4629,8 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + cookie@0.7.2: {} + copy-anything@2.0.6: dependencies: is-what: 3.14.1 @@ -4455,6 +4639,11 @@ snapshots: dependencies: browserslist: 4.28.1 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4534,6 +4723,23 @@ snapshots: engine.io-parser@5.2.3: {} + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.19.17 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + entities@4.5.0: {} entities@6.0.1: {} @@ -5313,6 +5519,8 @@ snapshots: sax: 1.4.4 optional: true + negotiator@0.6.3: {} + node-addon-api@7.1.1: optional: true @@ -5341,6 +5549,8 @@ snapshots: nwsapi@2.2.22: {} + object-assign@4.1.1: {} + object-deep-merge@2.0.0: {} obug@2.1.1: {} @@ -5672,6 +5882,15 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -5690,6 +5909,20 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5822,6 +6055,10 @@ snapshots: dependencies: punycode: 2.3.1 + trystero@0.23.0: + dependencies: + '@trystero-p2p/nostr': 0.23.0 + ts-api-utils@2.4.0(typescript@5.4.5): dependencies: typescript: 5.4.5 @@ -5903,6 +6140,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite-plugin-wasm@3.5.0(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)): dependencies: vite: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) @@ -5921,6 +6160,20 @@ snapshots: stylus: 0.62.0 terser: 5.46.1 + vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + less: 4.5.1 + lightningcss: 1.30.2 + sass: 1.97.3 + stylus: 0.62.0 + terser: 5.46.1 + vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -5940,6 +6193,25 @@ snapshots: terser: 5.46.1 yaml: 2.8.2 + vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.5.1 + lightningcss: 1.30.2 + sass: 1.97.3 + stylus: 0.62.0 + terser: 5.46.1 + yaml: 2.8.2 + vitest@4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 @@ -5980,6 +6252,46 @@ snapshots: - tsx - yaml + vitest@4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18) + '@vitest/ui': 4.0.18(vitest@4.0.18) + jsdom: 24.1.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vscode-uri@3.1.0: {} vue-component-type-helpers@2.2.12: {} diff --git a/src/views/Experiments/MultiplayerClient/MultiplayerClient.vue b/src/views/Experiments/MultiplayerClient/MultiplayerClient.vue new file mode 100644 index 00000000..83afe4df --- /dev/null +++ b/src/views/Experiments/MultiplayerClient/MultiplayerClient.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue new file mode 100644 index 00000000..666f2f4f --- /dev/null +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 1a776af9..6d8fd5ad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,17 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import wasm from 'vite-plugin-wasm' -const packages = ['animation', 'threejs', 'audio', 'game', 'controls', 'recording', 'logic'] +const packages = [ + 'animation', + 'threejs', + 'audio', + 'game', + 'controls', + 'recording', + 'logic', + 'multiplayer-client', + 'multiplayer-p2p' +] const packageAliases = Object.fromEntries( packages.map((package_) => [ `@webgamekit/${package_}`, From 85cc37e53ed4903965145577d88261b3f5e586e6 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 21:00:49 +0200 Subject: [PATCH 04/22] fix: show peers immediately on join in P2P room Add p2pOnPeerJoin and p2pOnPeerLeave to expose Trystero's peer lifecycle events. Fix session.peerId to use Trystero's selfId instead of the first remote peer's ID. Update MultiplayerP2P view to add peers to the list on join and remove on leave, rather than waiting for their first position broadcast. Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/index.test.ts | 37 ++++++++++++++++- packages/multiplayer-p2p/src/index.ts | 2 +- packages/multiplayer-p2p/src/room.ts | 27 +++++++++---- .../MultiplayerP2P/MultiplayerP2P.vue | 40 ++++++++++++++----- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts index 71c9456b..c0524aa9 100644 --- a/packages/multiplayer-p2p/src/index.test.ts +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -31,7 +31,16 @@ vi.mock('trystero/nostr', () => ({ joinRoom: vi.fn(() => mockRoom) })) -import { p2pJoin, p2pLeave, p2pSendPosition, p2pOnPlayers, p2pSendData, p2pOnData } from './index' +import { + p2pJoin, + p2pLeave, + p2pSendPosition, + p2pOnPlayers, + p2pSendData, + p2pOnData, + p2pOnPeerJoin, + p2pOnPeerLeave +} from './index' describe('p2pJoin', () => { beforeEach(() => { @@ -78,6 +87,32 @@ describe('p2pLeave', () => { }) }) +describe('p2pOnPeerJoin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers a callback on room.onPeerJoin', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPeerJoin(session, callback) + expect(mockOnPeerJoin).toHaveBeenCalledWith(callback) + }) +}) + +describe('p2pOnPeerLeave', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers a callback on room.onPeerLeave', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPeerLeave(session, callback) + expect(mockOnPeerLeave).toHaveBeenCalledWith(callback) + }) +}) + describe('p2pSendPosition', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/packages/multiplayer-p2p/src/index.ts b/packages/multiplayer-p2p/src/index.ts index ae23550a..02941966 100644 --- a/packages/multiplayer-p2p/src/index.ts +++ b/packages/multiplayer-p2p/src/index.ts @@ -1,4 +1,4 @@ export type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' -export { p2pJoin, p2pLeave } from './room' +export { p2pJoin, p2pLeave, p2pOnPeerJoin, p2pOnPeerLeave } from './room' export { p2pSendPosition, p2pOnPlayers } from './players' export { p2pSendData, p2pOnData } from './data' diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts index f4f4d713..97177443 100644 --- a/packages/multiplayer-p2p/src/room.ts +++ b/packages/multiplayer-p2p/src/room.ts @@ -1,4 +1,4 @@ -import { joinRoom } from 'trystero/nostr' +import { joinRoom, selfId } from 'trystero/nostr' import type { P2PConfig, P2PSession } from './types' /** @@ -12,18 +12,13 @@ export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { const appId = config?.appId ?? 'webgamekit' const room = joinRoom({ appId }, roomId) - let peerId = '' - room.onPeerJoin((id: string) => { - if (!peerId) peerId = id - }) - const destroy = () => { room.leave() } return { get peerId() { - return peerId + return selfId }, room, destroy @@ -37,3 +32,21 @@ export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { export const p2pLeave = (session: P2PSession): void => { session.destroy() } + +/** + * Subscribe to a callback fired when a remote peer joins the room. + * @param session - The active P2P session + * @param callback - Called with the peer's ID when they join + */ +export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerJoin(callback) +} + +/** + * Subscribe to a callback fired when a remote peer leaves the room. + * @param session - The active P2P session + * @param callback - Called with the peer's ID when they leave + */ +export const p2pOnPeerLeave = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerLeave(callback) +} diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index 666f2f4f..5ee20dc2 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -5,33 +5,54 @@ import { p2pLeave, p2pSendPosition, p2pOnPlayers, + p2pOnPeerJoin, + p2pOnPeerLeave, type P2PSession, type PlayerState } from '@webgamekit/multiplayer-p2p' +type PeerEntry = { + id: string + position: PlayerState['position'] | null + rotation: PlayerState['rotation'] | null +} + const roomId = ref('demo-room') const session = ref(null) -const peers = ref([]) +const peers = ref([]) const isJoined = ref(false) -let unsubscribePlayers: (() => void) | null = null - const join = () => { const newSession = p2pJoin(roomId.value) session.value = newSession isJoined.value = true - unsubscribePlayers = p2pOnPlayers(newSession, (playerState) => { + p2pOnPeerJoin(newSession, (peerId) => { + if (!peers.value.some((peer) => peer.id === peerId)) { + peers.value = [...peers.value, { id: peerId, position: null, rotation: null }] + } + }) + + p2pOnPeerLeave(newSession, (peerId) => { + peers.value = peers.value.filter((peer) => peer.id !== peerId) + }) + + p2pOnPlayers(newSession, (playerState) => { peers.value = peers.value.some((peer) => peer.id === playerState.id) - ? peers.value.map((peer) => (peer.id === playerState.id ? playerState : peer)) - : [...peers.value, playerState] + ? peers.value.map((peer) => + peer.id === playerState.id + ? { ...peer, position: playerState.position, rotation: playerState.rotation } + : peer + ) + : [ + ...peers.value, + { id: playerState.id, position: playerState.position, rotation: playerState.rotation } + ] }) } const leave = () => { if (!session.value) return - unsubscribePlayers?.() - unsubscribePlayers = null p2pLeave(session.value) session.value = null peers.value = [] @@ -100,11 +121,12 @@ onUnmounted(() => {
  • {{ peer.id }} - + x={{ peer.position.x.toFixed(2) }} y={{ peer.position.y.toFixed(2) }} z={{ peer.position.z.toFixed(2) }} + waiting for position…

From 4c7178ff3fbcd5f8525635f3cdece31f4c78cc8e Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 21:01:14 +0200 Subject: [PATCH 05/22] fix: enable vertical scroll in navigation panel on mobile Add overflow-y: auto and max-height: 100% to .sheet-content so it becomes the scroll container. Previously pointer-events: none on .panel-container prevented touch-scroll from reaching the panel. Co-Authored-By: Claude Sonnet 4.6 --- src/components/panels/PanelContainer.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/panels/PanelContainer.vue b/src/components/panels/PanelContainer.vue index a797cd4a..50153028 100644 --- a/src/components/panels/PanelContainer.vue +++ b/src/components/panels/PanelContainer.vue @@ -81,6 +81,8 @@ defineProps<{ background-color: var(--color-background); padding: var(--spacing-2); overflow-x: hidden; + overflow-y: auto; + max-height: 100%; display: flex; flex-direction: column; From db256eebaefbf5ac654d11a694cd194db6ccc071 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 21:09:34 +0200 Subject: [PATCH 06/22] fix: improve p2p tests to verify callbacks fire, exclude trystero from Vite pre-bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test mock now simulates actual peer join/leave by calling registered callbacks — previously tests only verified registration, not invocation. Exclude trystero/nostr from Vite optimizeDeps to prevent it from being pre-bundled in Node context where crypto.subtle is unavailable, which caused "Cannot read properties of undefined (reading 'digest')" at runtime. Also remove duplicate overflow-y: auto from .sheet-content (caused double scrollbar on desktop). Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/index.test.ts | 42 +++++++++++++++++----- src/components/panels/PanelContainer.vue | 1 - vite.config.ts | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts index c0524aa9..f2bb222f 100644 --- a/packages/multiplayer-p2p/src/index.test.ts +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -6,11 +6,14 @@ type ActionCallback = (data: T, peerId: string) => void const mockSend = vi.fn() const mockLeave = vi.fn() -const mockOnPeerJoin = vi.fn() -const mockOnPeerLeave = vi.fn() const mockGetPeers = vi.fn(() => [] as string[]) const actionCallbacks = new Map>() +const peerJoinCallbacks: ((peerId: string) => void)[] = [] +const peerLeaveCallbacks: ((peerId: string) => void)[] = [] + +const simulatePeerJoin = (peerId: string) => peerJoinCallbacks.forEach((callback) => callback(peerId)) +const simulatePeerLeave = (peerId: string) => peerLeaveCallbacks.forEach((callback) => callback(peerId)) const mockRoom = { makeAction: vi.fn((channel: string): [typeof mockSend, ActionCallback] => { @@ -22,8 +25,8 @@ const mockRoom = { ] }), leave: mockLeave, - onPeerJoin: mockOnPeerJoin, - onPeerLeave: mockOnPeerLeave, + onPeerJoin: (callback: (peerId: string) => void) => peerJoinCallbacks.push(callback), + onPeerLeave: (callback: (peerId: string) => void) => peerLeaveCallbacks.push(callback), getPeers: mockGetPeers } @@ -46,6 +49,8 @@ describe('p2pJoin', () => { beforeEach(() => { vi.clearAllMocks() actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 }) it('returns a session with room and destroy function', async () => { @@ -72,6 +77,8 @@ describe('p2pLeave', () => { beforeEach(() => { vi.clearAllMocks() actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 }) it('calls room.leave()', () => { @@ -90,26 +97,43 @@ describe('p2pLeave', () => { describe('p2pOnPeerJoin', () => { beforeEach(() => { vi.clearAllMocks() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 }) - it('registers a callback on room.onPeerJoin', () => { + it('calls callback with peer ID when a peer joins', () => { const session = p2pJoin('room-1') const callback = vi.fn() p2pOnPeerJoin(session, callback) - expect(mockOnPeerJoin).toHaveBeenCalledWith(callback) + simulatePeerJoin('peer-abc') + expect(callback).toHaveBeenCalledWith('peer-abc') + }) + + it('supports multiple join callbacks', () => { + const session = p2pJoin('room-1') + const callbackA = vi.fn() + const callbackB = vi.fn() + p2pOnPeerJoin(session, callbackA) + p2pOnPeerJoin(session, callbackB) + simulatePeerJoin('peer-xyz') + expect(callbackA).toHaveBeenCalledWith('peer-xyz') + expect(callbackB).toHaveBeenCalledWith('peer-xyz') }) }) describe('p2pOnPeerLeave', () => { beforeEach(() => { vi.clearAllMocks() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 }) - it('registers a callback on room.onPeerLeave', () => { + it('calls callback with peer ID when a peer leaves', () => { const session = p2pJoin('room-1') const callback = vi.fn() p2pOnPeerLeave(session, callback) - expect(mockOnPeerLeave).toHaveBeenCalledWith(callback) + simulatePeerLeave('peer-abc') + expect(callback).toHaveBeenCalledWith('peer-abc') }) }) @@ -118,6 +142,8 @@ describe('p2pSendPosition', () => { vi.useFakeTimers() vi.clearAllMocks() actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 }) afterEach(() => { diff --git a/src/components/panels/PanelContainer.vue b/src/components/panels/PanelContainer.vue index 50153028..2b4af5b9 100644 --- a/src/components/panels/PanelContainer.vue +++ b/src/components/panels/PanelContainer.vue @@ -81,7 +81,6 @@ defineProps<{ background-color: var(--color-background); padding: var(--spacing-2); overflow-x: hidden; - overflow-y: auto; max-height: 100%; display: flex; flex-direction: column; diff --git a/vite.config.ts b/vite.config.ts index 6d8fd5ad..6e1d48ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: ['@dimforge/rapier3d-compat'] + exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr'] }, server: { allowedHosts: ['cnotv.xyz', 'test.cnotv.xyz', 'game.cnotv.xyz'] From 90ba16cf43d696287b9438a3f5f10ec41933a01b Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 22:07:23 +0200 Subject: [PATCH 07/22] test(p2p): add two-session integration test with room-aware mock The room simulation routes sends between sessions and fires onPeerJoin/ onPeerLeave across peers, catching cross-peer bugs that unit tests miss. Also adds journey/multiplayer-p2p.md documenting the DataPayload constraint, moduleResolution: Bundler, crypto.subtle Vite issue, peer visibility bug, and the integration testing strategy. Co-Authored-By: Claude Sonnet 4.6 --- documentation/docs/journey/multiplayer-p2p.md | 137 ++++++++++++++ .../src/p2p.integration.test.ts | 178 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 documentation/docs/journey/multiplayer-p2p.md create mode 100644 packages/multiplayer-p2p/src/p2p.integration.test.ts diff --git a/documentation/docs/journey/multiplayer-p2p.md b/documentation/docs/journey/multiplayer-p2p.md new file mode 100644 index 00000000..35591084 --- /dev/null +++ b/documentation/docs/journey/multiplayer-p2p.md @@ -0,0 +1,137 @@ +--- +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. + +## DataPayload constraint + +Trystero's `makeAction` has a strict generic constraint: `T extends DataPayload`, where `DataPayload = JsonValue | Blob | ArrayBuffer | ArrayBufferView`. `JsonValue` requires `{ [key: string]: JsonValue }` index signatures on all nested objects. + +Plain interfaces like `{ x: number; y: number; z: number }` do not satisfy this because they lack an index signature. The fix is intersection types: + +```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 +``` + +For generic data channels where the caller controls the type, constrain the generic: + +```ts +export const p2pSendData = ( + session: P2PSession, + channel: string, + payload: T +): void => { + const [send] = session.room.makeAction(channel) + send(payload) +} +``` + +## Module resolution: Bundler required + +Trystero ships `.d.mts` declaration files alongside `.mjs` entry points, accessed via subpath exports (`trystero/nostr`). TypeScript's classic `Node` moduleResolution cannot resolve `.d.mts` files. Set `"moduleResolution": "Bundler"` in the package `tsconfig.json`: + +```json +{ "compilerOptions": { "moduleResolution": "Bundler" } } +``` + +## Import path for Trystero types + +`@trystero-p2p/core` is a transitive dependency, not directly installable. Importing types from it fails: + +```ts +// Fails — not in local node_modules +import type { Room, DataPayload } from '@trystero-p2p/core' +``` + +Instead, import from `trystero/nostr`, which re-exports everything from `@trystero-p2p/core` via `export *`: + +```ts +import type { Room, DataPayload } from 'trystero/nostr' +``` + +## Vite pre-bundling breaks crypto.subtle + +Vite's dependency pre-optimizer runs in Node.js to bundle ESM packages ahead of time. Trystero calls `crypto.subtle.digest` at module initialization to generate `selfId`. Node.js requires `globalThis.crypto` to be explicitly set, which Vite does not do during pre-bundling, causing: + +``` +TypeError: Cannot read properties of undefined (reading 'digest') +``` + +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'] +} +``` + +## Peers only appear after broadcasting a position + +The initial implementation showed 0 peers until someone clicked "Broadcast position". The cause: the peer list was populated only via `p2pOnPlayers` (position data), with no join/leave tracking. + +Trystero exposes `room.onPeerJoin` and `room.onPeerLeave`. Wrapping these lets the view react immediately to peer presence: + +```ts +export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerJoin(callback) +} +``` + +The view now shows peers the moment they join, displaying "waiting for position…" until their first broadcast arrives. + +## Testing strategy + +Unit tests mock `trystero/nostr` and verify individual functions in isolation. But they do not catch cross-peer bugs — a test that only checks "callback was registered" passes even if the callback chain is broken. + +The integration test uses a room-aware mock that routes sends to the other session's receivers and fires `onPeerJoin`/`onPeerLeave` across sessions, matching real WebRTC behavior: + +```ts +// room-aware mock inside vi.mock('trystero/nostr', ...) +joinRoom: vi.fn((_config, roomId) => { + const peerId = `peer-${nextPeerId++}` + // ... + // Notify all existing peers that this peer joined + room.forEach((existingState) => { + existingState.peerJoinCallbacks.forEach((cb) => cb(peerId)) + }) + return { + makeAction: (channel) => { + const send = (data) => { + // Deliver to all other sessions' receivers + room.forEach((otherState, otherId) => { + if (otherId !== peerId) otherState.receivers.get(channel)?.(data, peerId) + }) + } + // ... + } + } +}) +``` + +This catches bugs that unit tests miss — for example, a sender calling `makeAction` on every send (creating a new instance) overwriting the receiver registered by another session. diff --git a/packages/multiplayer-p2p/src/p2p.integration.test.ts b/packages/multiplayer-p2p/src/p2p.integration.test.ts new file mode 100644 index 00000000..fc560c16 --- /dev/null +++ b/packages/multiplayer-p2p/src/p2p.integration.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ── Room simulation ──────────────────────────────────────────────────────── +// Simulates two peers joining the same WebRTC room and routing data between +// them, mirroring the real Trystero behaviour: +// - onPeerJoin fires on existing sessions when a new peer joins +// - makeAction sends are delivered to all other sessions' receivers +// - onPeerLeave fires on remaining sessions when a peer leaves + +type Receiver = (data: unknown, peerId: string) => void + +type PeerState = { + peerJoinCallbacks: ((id: string) => void)[] + peerLeaveCallbacks: ((id: string) => void)[] + receivers: Map +} + +const rooms = new Map>() +let nextPeerId = 0 + +const resetRooms = () => { + rooms.clear() + nextPeerId = 0 +} + +vi.mock('trystero/nostr', () => { + return { + get selfId() { + return 'local-peer' + }, + joinRoom: vi.fn((_config: unknown, roomId: string) => { + const peerId = `peer-${nextPeerId++}` + + if (!rooms.has(roomId)) { + rooms.set(roomId, new Map()) + } + const room = rooms.get(roomId)! + + const state: PeerState = { + peerJoinCallbacks: [], + peerLeaveCallbacks: [], + receivers: new Map() + } + + // Notify all existing peers that this peer has joined + room.forEach((existingState) => { + existingState.peerJoinCallbacks.forEach((callback) => callback(peerId)) + }) + + room.set(peerId, state) + + return { + onPeerJoin: (callback: (id: string) => void) => state.peerJoinCallbacks.push(callback), + onPeerLeave: (callback: (id: string) => void) => state.peerLeaveCallbacks.push(callback), + makeAction: (channel: string) => { + const send = (data: unknown) => { + room.forEach((otherState, otherId) => { + if (otherId !== peerId) { + otherState.receivers.get(channel)?.(data, peerId) + } + }) + } + const onReceive = (callback: Receiver) => { + state.receivers.set(channel, callback) + } + return [send, onReceive, vi.fn()] + }, + leave: () => { + room.delete(peerId) + room.forEach((otherState) => { + otherState.peerLeaveCallbacks.forEach((callback) => callback(peerId)) + }) + }, + getPeers: () => ({}) + } + }) + } +}) + +import { + p2pJoin, + p2pLeave, + p2pOnPeerJoin, + p2pOnPeerLeave, + p2pSendPosition, + p2pOnPlayers +} from './index' + +describe('P2P room integration — two peers', () => { + beforeEach(() => { + vi.useFakeTimers() + resetRooms() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('peer A sees peer B when B joins the same room', () => { + const sessionA = p2pJoin('room-1') + const onJoin = vi.fn() + p2pOnPeerJoin(sessionA, onJoin) + + p2pJoin('room-1') // peer B joins + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(onJoin).toHaveBeenCalledWith(expect.stringContaining('peer-')) + }) + + it('peer A receives the position broadcast by peer B', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + const position = { x: 3, y: 0, z: 5 } + const rotation = { x: 0, y: 1, z: 0 } + p2pSendPosition(sessionB, position, rotation) + vi.advanceTimersByTime(30) + + expect(onPlayers).toHaveBeenCalledWith(expect.objectContaining({ position, rotation })) + }) + + it('peer A is notified when peer B leaves', () => { + const sessionA = p2pJoin('room-1') + const onLeave = vi.fn() + p2pOnPeerLeave(sessionA, onLeave) + + const sessionB = p2pJoin('room-1') + p2pLeave(sessionB) + + expect(onLeave).toHaveBeenCalledTimes(1) + }) + + it('position from peer B includes the correct sender ID', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + p2pSendPosition(sessionB, { x: 1, y: 0, z: 2 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + const [{ id }] = onPlayers.mock.calls[0] as [{ id: string }] + expect(id).toMatch(/^peer-/) + }) + + it('peer A does not receive its own position broadcast', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + p2pSendPosition(sessionA, { x: 1, y: 0, z: 2 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + expect(onPlayers).not.toHaveBeenCalled() + }) + + it("three peers: B and C both appear in A's player list", () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + const sessionC = p2pJoin('room-1') + + p2pSendPosition(sessionB, { x: 1, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }) + p2pSendPosition(sessionC, { x: 2, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + expect(onPlayers).toHaveBeenCalledTimes(2) + const receivedPositions = onPlayers.mock.calls.map( + ([p]: [{ position: { x: number } }]) => p.position.x + ) + expect(receivedPositions).toContain(1) + expect(receivedPositions).toContain(2) + }) +}) From 6c1573c0c7145f53af66348dc960248133fa4e27 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sat, 4 Apr 2026 22:13:47 +0200 Subject: [PATCH 08/22] debug(p2p): add console.warn logs to surface runtime connection issues Log room join/leave, selfId, peer join/leave events, and position send/receive so runtime failures are visible in the browser console. Fix unit test mock to export selfId (required after room.ts started importing it from trystero/nostr). Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/index.test.ts | 7 +++++-- packages/multiplayer-p2p/src/players.ts | 2 ++ packages/multiplayer-p2p/src/room.ts | 13 +++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts index f2bb222f..67176115 100644 --- a/packages/multiplayer-p2p/src/index.test.ts +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -12,8 +12,10 @@ const actionCallbacks = new Map>() const peerJoinCallbacks: ((peerId: string) => void)[] = [] const peerLeaveCallbacks: ((peerId: string) => void)[] = [] -const simulatePeerJoin = (peerId: string) => peerJoinCallbacks.forEach((callback) => callback(peerId)) -const simulatePeerLeave = (peerId: string) => peerLeaveCallbacks.forEach((callback) => callback(peerId)) +const simulatePeerJoin = (peerId: string) => + peerJoinCallbacks.forEach((callback) => callback(peerId)) +const simulatePeerLeave = (peerId: string) => + peerLeaveCallbacks.forEach((callback) => callback(peerId)) const mockRoom = { makeAction: vi.fn((channel: string): [typeof mockSend, ActionCallback] => { @@ -31,6 +33,7 @@ const mockRoom = { } vi.mock('trystero/nostr', () => ({ + selfId: 'local-peer', joinRoom: vi.fn(() => mockRoom) })) diff --git a/packages/multiplayer-p2p/src/players.ts b/packages/multiplayer-p2p/src/players.ts index 0caaf553..1e47cf75 100644 --- a/packages/multiplayer-p2p/src/players.ts +++ b/packages/multiplayer-p2p/src/players.ts @@ -27,6 +27,7 @@ export const p2pSendPosition = ( session, setTimeout(() => { pendingTimers.delete(session) + console.warn(`[p2p] sending position`, position, rotation) sendPos({ position, rotation }) }, throttleMs) ) @@ -45,6 +46,7 @@ export const p2pOnPlayers = ( const [, onPos] = session.room.makeAction('pos') onPos((data: PositionRecord, peerId: string) => { + console.warn(`[p2p] received position from ${peerId}`, data) const pos = data['position'] as PlayerPosition const rot = data['rotation'] as PlayerRotation callback({ id: peerId, position: pos, rotation: rot }) diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts index 97177443..f5a85ae8 100644 --- a/packages/multiplayer-p2p/src/room.ts +++ b/packages/multiplayer-p2p/src/room.ts @@ -10,9 +10,12 @@ import type { P2PConfig, P2PSession } from './types' */ export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { const appId = config?.appId ?? 'webgamekit' + console.warn(`[p2p] joining room "${roomId}" with appId "${appId}"`) const room = joinRoom({ appId }, roomId) + console.warn(`[p2p] joined room "${roomId}", selfId=${selfId}`) const destroy = () => { + console.warn(`[p2p] leaving room "${roomId}"`) room.leave() } @@ -39,7 +42,10 @@ export const p2pLeave = (session: P2PSession): void => { * @param callback - Called with the peer's ID when they join */ export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => { - session.room.onPeerJoin(callback) + session.room.onPeerJoin((peerId) => { + console.warn(`[p2p] peer joined: ${peerId}`) + callback(peerId) + }) } /** @@ -48,5 +54,8 @@ export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => * @param callback - Called with the peer's ID when they leave */ export const p2pOnPeerLeave = (session: P2PSession, callback: (peerId: string) => void): void => { - session.room.onPeerLeave(callback) + session.room.onPeerLeave((peerId) => { + console.warn(`[p2p] peer left: ${peerId}`) + callback(peerId) + }) } From 3af58e37f4bcd4e4a61b4539aaca2cad555f12e3 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 01:33:28 +0200 Subject: [PATCH 09/22] fix: switch p2p from trystero/nostr to @trystero-p2p/torrent trystero/nostr uses crypto.subtle.importKey for NOSTR event signing, which requires a secure context (HTTPS or localhost). The app is served on custom hostnames without HTTPS, so crypto.subtle is undefined. @trystero-p2p/torrent uses BitTorrent WebSocket trackers for signaling and has no crypto.subtle dependency, working on any origin. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- packages/multiplayer-p2p/package.json | 4 +-- packages/multiplayer-p2p/src/data.ts | 2 +- packages/multiplayer-p2p/src/index.test.ts | 8 ++--- .../src/p2p.integration.test.ts | 2 +- packages/multiplayer-p2p/src/room.ts | 2 +- packages/multiplayer-p2p/src/types.ts | 2 +- packages/multiplayer-p2p/vite.config.ts | 4 +-- pnpm-lock.yaml | 29 +++++-------------- vite.config.ts | 2 +- 10 files changed, 22 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 47f557a6..d5283c9a 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "@webgamekit/multiplayer-client": "workspace:^", "@webgamekit/multiplayer-p2p": "workspace:^", "@webgamekit/threejs": "workspace:^", + "@trystero-p2p/torrent": "^0.23.0", "socket.io-client": "^4.0.0", - "trystero": "^0.23.0", "dat.gui": "^0.7.9", "p5": "^1.9.0", "pinia": "^2.3.0", diff --git a/packages/multiplayer-p2p/package.json b/packages/multiplayer-p2p/package.json index 76537bef..520096da 100644 --- a/packages/multiplayer-p2p/package.json +++ b/packages/multiplayer-p2p/package.json @@ -24,10 +24,10 @@ "access": "public" }, "peerDependencies": { - "trystero": "^0.23.0" + "@trystero-p2p/torrent": "^0.23.0" }, "devDependencies": { - "trystero": "^0.23.0", + "@trystero-p2p/torrent": "^0.23.0", "typescript": "^5.0.0", "vite": "^5.0.0", "vitest": "^4.0.18" diff --git a/packages/multiplayer-p2p/src/data.ts b/packages/multiplayer-p2p/src/data.ts index 84d18809..4af7b160 100644 --- a/packages/multiplayer-p2p/src/data.ts +++ b/packages/multiplayer-p2p/src/data.ts @@ -1,4 +1,4 @@ -import type { DataPayload } from 'trystero/nostr' +import type { DataPayload } from '@trystero-p2p/torrent' import type { P2PSession } from './types' /** diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts index 67176115..6ec7e4e2 100644 --- a/packages/multiplayer-p2p/src/index.test.ts +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { PlayerState } from './types' -// ── Mock trystero/nostr ──────────────────────────────────────────────────── +// ── Mock @trystero-p2p/torrent ──────────────────────────────────────────────────── type ActionCallback = (data: T, peerId: string) => void const mockSend = vi.fn() @@ -32,7 +32,7 @@ const mockRoom = { getPeers: mockGetPeers } -vi.mock('trystero/nostr', () => ({ +vi.mock('@trystero-p2p/torrent', () => ({ selfId: 'local-peer', joinRoom: vi.fn(() => mockRoom) })) @@ -64,13 +64,13 @@ describe('p2pJoin', () => { }) it('calls joinRoom with the provided roomId and default appId', async () => { - const { joinRoom } = await import('trystero/nostr') + const { joinRoom } = await import('@trystero-p2p/torrent') p2pJoin('level-abc') expect(joinRoom).toHaveBeenCalledWith({ appId: 'webgamekit' }, 'level-abc') }) it('uses custom appId from config', async () => { - const { joinRoom } = await import('trystero/nostr') + const { joinRoom } = await import('@trystero-p2p/torrent') p2pJoin('room-2', { appId: 'my-game' }) expect(joinRoom).toHaveBeenCalledWith({ appId: 'my-game' }, 'room-2') }) diff --git a/packages/multiplayer-p2p/src/p2p.integration.test.ts b/packages/multiplayer-p2p/src/p2p.integration.test.ts index fc560c16..1b43996a 100644 --- a/packages/multiplayer-p2p/src/p2p.integration.test.ts +++ b/packages/multiplayer-p2p/src/p2p.integration.test.ts @@ -23,7 +23,7 @@ const resetRooms = () => { nextPeerId = 0 } -vi.mock('trystero/nostr', () => { +vi.mock('@trystero-p2p/torrent', () => { return { get selfId() { return 'local-peer' diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts index f5a85ae8..158d9478 100644 --- a/packages/multiplayer-p2p/src/room.ts +++ b/packages/multiplayer-p2p/src/room.ts @@ -1,4 +1,4 @@ -import { joinRoom, selfId } from 'trystero/nostr' +import { joinRoom, selfId } from '@trystero-p2p/torrent' import type { P2PConfig, P2PSession } from './types' /** diff --git a/packages/multiplayer-p2p/src/types.ts b/packages/multiplayer-p2p/src/types.ts index 6d118c04..ba980679 100644 --- a/packages/multiplayer-p2p/src/types.ts +++ b/packages/multiplayer-p2p/src/types.ts @@ -1,4 +1,4 @@ -import type { Room, DataPayload } from 'trystero/nostr' +import type { Room, DataPayload } from '@trystero-p2p/torrent' export type { DataPayload } diff --git a/packages/multiplayer-p2p/vite.config.ts b/packages/multiplayer-p2p/vite.config.ts index 78db90e5..570523ff 100644 --- a/packages/multiplayer-p2p/vite.config.ts +++ b/packages/multiplayer-p2p/vite.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ fileName: 'index' }, rollupOptions: { - external: ['trystero', 'trystero/nostr'], - output: { globals: { trystero: 'trystero', 'trystero/nostr': 'trysteroNostr' } } + external: ['@trystero-p2p/torrent'], + output: { globals: { '@trystero-p2p/torrent': 'trysteroTorrent' } } } } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f7e7d87..59f5740e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@trystero-p2p/torrent': + specifier: ^0.23.0 + version: 0.23.0 '@webgamekit/controls': specifier: workspace:^ version: link:packages/controls @@ -41,9 +44,6 @@ importers: three: specifier: ^0.178.0 version: 0.178.0 - trystero: - specifier: ^0.23.0 - version: 0.23.0 vite-plugin-wasm: specifier: ^3.3.0 version: 3.5.0(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) @@ -276,7 +276,7 @@ importers: packages/multiplayer-p2p: devDependencies: - trystero: + '@trystero-p2p/torrent': specifier: ^0.23.0 version: 0.23.0 typescript: @@ -956,9 +956,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@noble/secp256k1@3.0.0': - resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1255,8 +1252,8 @@ packages: '@trystero-p2p/core@0.23.0': resolution: {integrity: sha512-ozhtgxKDZH11Gdef0wH8xivwAE/L0/lDFvEcNFWPJWnHZlxWPPyfeonwE287ssGevQNi10vnj6x2ZcOi0n1bQQ==} - '@trystero-p2p/nostr@0.23.0': - resolution: {integrity: sha512-KSqUR2c1KVfv4zeErcntuegtyKzFTzNNiitIKGD0LiKA/4H3CeTF81ROk2h+X/PNvP4mv7Gp5eVxFYwfMu4Nrg==} + '@trystero-p2p/torrent@0.23.0': + resolution: {integrity: sha512-EVy3NBRfLz+c9NRcFax+XA3q7P7gOpsZbHpTBAAaNBZXXu2ltm3k3/6jaJfIoMidiMIIG+cIV1G+tUzrjUBwVg==} '@tsconfig/node20@20.1.8': resolution: {integrity: sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==} @@ -3051,9 +3048,6 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} - trystero@0.23.0: - resolution: {integrity: sha512-HsM0FT0w41XW9bhXmYAfxr7PT+F6KwMzOxfJK2MbfipnMk/XrAIsV8lMTgU8NwWQqwCiS/b0b1qDsOf6IGxTmg==} - ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3805,8 +3799,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 optional: true - '@noble/secp256k1@3.0.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4012,9 +4004,8 @@ snapshots: '@trystero-p2p/core@0.23.0': {} - '@trystero-p2p/nostr@0.23.0': + '@trystero-p2p/torrent@0.23.0': dependencies: - '@noble/secp256k1': 3.0.0 '@trystero-p2p/core': 0.23.0 '@tsconfig/node20@20.1.8': {} @@ -4316,7 +4307,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) '@vitest/utils@4.0.18': dependencies: @@ -6055,10 +6046,6 @@ snapshots: dependencies: punycode: 2.3.1 - trystero@0.23.0: - dependencies: - '@trystero-p2p/nostr': 0.23.0 - ts-api-utils@2.4.0(typescript@5.4.5): dependencies: typescript: 5.4.5 diff --git a/vite.config.ts b/vite.config.ts index 6e1d48ba..0f741e3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr'] + exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr', '@trystero-p2p/torrent'] }, server: { allowedHosts: ['cnotv.xyz', 'test.cnotv.xyz', 'game.cnotv.xyz'] From 4e37c1e35a31a5552f52a1d10b8280f5898499c0 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 02:12:46 +0200 Subject: [PATCH 10/22] fix(p2p): detect missing crypto.subtle and surface clear error in UI All Trystero strategies require crypto.subtle (HTTPS or localhost only). Add p2pIsSupported() pre-flight check that logs the origin and returns false when crypto.subtle is unavailable, so p2pJoin throws a descriptive error instead of crashing async inside joinRoom. The view now checks p2pIsSupported on mount and shows a banner explaining the HTTPS requirement instead of silently failing. Add more debug logs: isSecureContext, origin, crypto.subtle availability, and joinRoom entry/exit points. Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/index.ts | 2 +- packages/multiplayer-p2p/src/room.ts | 34 +++++++++++++++++-- .../MultiplayerP2P/MultiplayerP2P.vue | 28 +++++++++++++-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/multiplayer-p2p/src/index.ts b/packages/multiplayer-p2p/src/index.ts index 02941966..8810ffe8 100644 --- a/packages/multiplayer-p2p/src/index.ts +++ b/packages/multiplayer-p2p/src/index.ts @@ -1,4 +1,4 @@ export type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' -export { p2pJoin, p2pLeave, p2pOnPeerJoin, p2pOnPeerLeave } from './room' +export { p2pJoin, p2pLeave, p2pOnPeerJoin, p2pOnPeerLeave, p2pIsSupported } from './room' export { p2pSendPosition, p2pOnPlayers } from './players' export { p2pSendData, p2pOnData } from './data' diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts index 158d9478..760ee07e 100644 --- a/packages/multiplayer-p2p/src/room.ts +++ b/packages/multiplayer-p2p/src/room.ts @@ -2,17 +2,45 @@ import { joinRoom, selfId } from '@trystero-p2p/torrent' import type { P2PConfig, P2PSession } from './types' /** - * Join a P2P room using WebRTC via Trystero (NOSTR signaling — no server required). + * Check whether the browser exposes crypto.subtle (requires HTTPS or localhost). + * @returns true if the environment supports P2P + */ +export const p2pIsSupported = (): boolean => { + const supported = typeof window !== 'undefined' && typeof window.crypto?.subtle !== 'undefined' + if (!supported) { + console.warn( + '[p2p] crypto.subtle is not available. ' + + 'P2P requires a secure context (HTTPS or localhost). ' + + `Current origin: ${typeof window !== 'undefined' ? window.location.origin : 'unknown'}` + ) + } + return supported +} + +/** + * Join a P2P room using WebRTC via Trystero (torrent signaling — no server required). * All peers in the same roomId can communicate directly. + * Requires a secure context (HTTPS or localhost) — throws if crypto.subtle is unavailable. * @param roomId - Shared identifier for the game room (e.g. "level-1-abc123") * @param config - Optional configuration overrides * @returns A P2PSession with the room handle and destroy function */ export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { + console.warn(`[p2p] p2pJoin called — roomId="${roomId}"`) + console.warn(`[p2p] isSecureContext=${window.isSecureContext}, origin=${window.location.origin}`) + console.warn(`[p2p] crypto.subtle available=${typeof window.crypto?.subtle !== 'undefined'}`) + + if (!p2pIsSupported()) { + throw new Error( + `P2P is not supported on this origin (${window.location.origin}). ` + + 'Serve the app over HTTPS or use localhost.' + ) + } + const appId = config?.appId ?? 'webgamekit' - console.warn(`[p2p] joining room "${roomId}" with appId "${appId}"`) + console.warn(`[p2p] calling joinRoom — appId="${appId}", roomId="${roomId}"`) const room = joinRoom({ appId }, roomId) - console.warn(`[p2p] joined room "${roomId}", selfId=${selfId}`) + console.warn(`[p2p] joinRoom returned — selfId=${selfId}`) const destroy = () => { console.warn(`[p2p] leaving room "${roomId}"`) diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index 5ee20dc2..95d34c6e 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -1,5 +1,5 @@ - - - - From 6fd1502b2adc541b3fa0829a828b5a614218ad73 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 22:12:35 +0200 Subject: [PATCH 14/22] feat(p2p): stickboy.glb players with animation sync over P2P - Add p2pSendAction/p2pOnAction to multiplayer-p2p for broadcasting animation names - Add PlayerAction type to package exports - Rewrite MultiplayerP2P view: canvas-only, auto-join on mount, WASD controls - Load stickboy.glb for local player (white) and remote peers (orange tint) - Animation buttons: wave (1), attack (2), jump (3), talk (4), sit (5), pick (6), death (7) - Keyboard 1-7 + gamepad buttons trigger animations and broadcast to all peers - Remote peers play received animation via playActionTimeline - Mobile: left faux-pad for movement, right button pad for actions - Integration tests cover action broadcast and self-exclusion Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/index.ts | 11 +- .../src/p2p.integration.test.ts | 28 +- packages/multiplayer-p2p/src/players.ts | 43 +- packages/multiplayer-p2p/src/types.ts | 3 + .../MultiplayerP2P/MultiplayerP2P.vue | 592 ++++++++++-------- 5 files changed, 427 insertions(+), 250 deletions(-) diff --git a/packages/multiplayer-p2p/src/index.ts b/packages/multiplayer-p2p/src/index.ts index d03f3236..a3ff1b17 100644 --- a/packages/multiplayer-p2p/src/index.ts +++ b/packages/multiplayer-p2p/src/index.ts @@ -1,4 +1,11 @@ -export type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' +export type { + PlayerPosition, + PlayerRotation, + PlayerState, + PlayerAction, + P2PConfig, + P2PSession +} from './types' export { p2pJoin, p2pLeave, @@ -7,5 +14,5 @@ export { p2pIsSupported, p2pGetPeerIds } from './room' -export { p2pSendPosition, p2pOnPlayers } from './players' +export { p2pSendPosition, p2pOnPlayers, p2pSendAction, p2pOnAction } from './players' export { p2pSendData, p2pOnData } from './data' diff --git a/packages/multiplayer-p2p/src/p2p.integration.test.ts b/packages/multiplayer-p2p/src/p2p.integration.test.ts index 01bd9e3d..ebe1ba35 100644 --- a/packages/multiplayer-p2p/src/p2p.integration.test.ts +++ b/packages/multiplayer-p2p/src/p2p.integration.test.ts @@ -87,7 +87,9 @@ import { p2pOnPeerLeave, p2pGetPeerIds, p2pSendPosition, - p2pOnPlayers + p2pOnPlayers, + p2pSendAction, + p2pOnAction } from './index' describe('P2P room integration — two peers', () => { @@ -189,4 +191,28 @@ describe('P2P room integration — two peers', () => { expect(receivedPositions).toContain(1) expect(receivedPositions).toContain(2) }) + + it('peer A receives animation action broadcast by peer B', () => { + const sessionA = p2pJoin('room-1') + const onAction = vi.fn() + p2pOnAction(sessionA, onAction) + + const sessionB = p2pJoin('room-1') + p2pSendAction(sessionB, 'wave') + + expect(onAction).toHaveBeenCalledTimes(1) + const [peerId, action] = onAction.mock.calls[0] as [string, { name: string }] + expect(peerId).toMatch(/^peer-/) + expect(action.name).toBe('wave') + }) + + it('peer A does not receive its own action broadcast', () => { + const sessionA = p2pJoin('room-1') + const onAction = vi.fn() + p2pOnAction(sessionA, onAction) + + p2pSendAction(sessionA, 'attack') + + expect(onAction).not.toHaveBeenCalled() + }) }) diff --git a/packages/multiplayer-p2p/src/players.ts b/packages/multiplayer-p2p/src/players.ts index 1e47cf75..c7797454 100644 --- a/packages/multiplayer-p2p/src/players.ts +++ b/packages/multiplayer-p2p/src/players.ts @@ -1,6 +1,16 @@ -import type { PlayerPosition, PlayerRotation, PlayerState, P2PConfig, P2PSession } from './types' +import type { + PlayerPosition, + PlayerRotation, + PlayerState, + PlayerAction, + P2PConfig, + P2PSession +} from './types' type PositionRecord = Record> +type ActionRecord = Record + +const defaultThrottleMs = 30 const pendingTimers = new WeakMap>() @@ -17,7 +27,7 @@ export const p2pSendPosition = ( rotation: PlayerRotation, config?: P2PConfig ): void => { - const throttleMs = config?.throttleMs ?? 30 + const throttleMs = config?.throttleMs ?? defaultThrottleMs const existing = pendingTimers.get(session) if (existing !== undefined) clearTimeout(existing) @@ -54,3 +64,32 @@ export const p2pOnPlayers = ( return () => {} } + +/** + * Broadcast a named animation action to all peers. + * @param session - The active P2P session + * @param actionName - Animation clip name to play (e.g. "wave", "attack") + */ +export const p2pSendAction = (session: P2PSession, actionName: string): void => { + const [sendAction] = session.room.makeAction('action') + console.warn(`[p2p] sending action: ${actionName}`) + sendAction({ name: actionName }) +} + +/** + * Subscribe to animation action broadcasts from other peers. + * @param session - The active P2P session + * @param callback - Called with the sender's peer ID and action name + * @returns Unsubscribe function + */ +export const p2pOnAction = ( + session: P2PSession, + callback: (peerId: string, action: PlayerAction) => void +): (() => void) => { + const [, onAction] = session.room.makeAction('action') + onAction((data: ActionRecord, peerId: string) => { + console.warn(`[p2p] received action from ${peerId}:`, data) + callback(peerId, data as PlayerAction) + }) + return () => {} +} diff --git a/packages/multiplayer-p2p/src/types.ts b/packages/multiplayer-p2p/src/types.ts index 6d118c04..74247041 100644 --- a/packages/multiplayer-p2p/src/types.ts +++ b/packages/multiplayer-p2p/src/types.ts @@ -29,3 +29,6 @@ export interface PlayerState { position: PlayerPosition rotation: PlayerRotation } + +/** Serializable animation action — compatible with Trystero DataPayload */ +export type PlayerAction = { name: string } & Record diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index d1de1e2d..e885a448 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -1,281 +1,383 @@ - - - - From f44d66e2a7bbb579cbc2c512efa6d275ccb57974 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 22:13:57 +0200 Subject: [PATCH 15/22] fix(p2p): increase stickboy player scale to 2x Co-Authored-By: Claude Sonnet 4.6 --- src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index e885a448..cc45e3c0 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -34,14 +34,15 @@ const MOVEMENT_SPEED = 0.5 const CAMERA_HEIGHT = 7 const CAMERA_DEPTH = 14 const CAMERA_OFFSET: CoordinateTuple = [0, CAMERA_HEIGHT, CAMERA_DEPTH] -const PLAYER_Y_OFFSET = -0.5 +const PLAYER_SCALE = 2 +const PLAYER_Y_OFFSET = -1 const GROUND_SIZE = 200 const stickboySettings = { model: { position: [0, PLAYER_Y_OFFSET, 0] as CoordinateTuple, rotation: [0, 0, 0] as CoordinateTuple, - scale: [1, 1, 1] as CoordinateTuple, + scale: [PLAYER_SCALE, PLAYER_SCALE, PLAYER_SCALE] as CoordinateTuple, restitution: -10, boundary: 0.5, hasGravity: false, @@ -62,7 +63,7 @@ const remoteSettings = { model: { position: [5, PLAYER_Y_OFFSET, 0] as CoordinateTuple, rotation: [0, 0, 0] as CoordinateTuple, - scale: [1, 1, 1] as CoordinateTuple, + scale: [PLAYER_SCALE, PLAYER_SCALE, PLAYER_SCALE] as CoordinateTuple, restitution: -10, boundary: 0.5, hasGravity: false, From 7f555ff2ed17e9da9140f631e9d931ad135e2288 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 22:17:01 +0200 Subject: [PATCH 16/22] fix(p2p): fix ground alignment, peer spawn overlap, and scene reinit on resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set PLAYER_Y_OFFSET to 0 so models sit on ground surface (y=-0.5) - Spawn remote peers at staggered positions using index-based spread (alternating left/right, increasing distance) instead of all at [5,0,0] - Remove window resize listener — getTools already handles canvas/camera resize internally; re-calling init was recreating the entire scene Co-Authored-By: Claude Sonnet 4.6 --- .../MultiplayerP2P/MultiplayerP2P.vue | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index cc45e3c0..da5741bf 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -35,8 +35,9 @@ const CAMERA_HEIGHT = 7 const CAMERA_DEPTH = 14 const CAMERA_OFFSET: CoordinateTuple = [0, CAMERA_HEIGHT, CAMERA_DEPTH] const PLAYER_SCALE = 2 -const PLAYER_Y_OFFSET = -1 +const PLAYER_Y_OFFSET = 0 const GROUND_SIZE = 200 +const REMOTE_SPAWN_SPREAD = 4 const stickboySettings = { model: { @@ -59,26 +60,21 @@ const stickboySettings = { } } -const remoteSettings = { - model: { - position: [5, PLAYER_Y_OFFSET, 0] as CoordinateTuple, - rotation: [0, 0, 0] as CoordinateTuple, - scale: [PLAYER_SCALE, PLAYER_SCALE, PLAYER_SCALE] as CoordinateTuple, - restitution: -10, - boundary: 0.5, - hasGravity: false, - castShadow: false, - material: 'MeshLambertMaterial', - color: 0xff6644 - }, - movement: { - requireGround: false, - maxGroundDistance: 5, - maxStepHeight: 0.5, - characterRadius: 1, - debug: false - } -} +const getRemoteModelSettings = (index: number) => ({ + position: [ + (index % 2 === 0 ? 1 : -1) * REMOTE_SPAWN_SPREAD * (Math.floor(index / 2) + 1), + PLAYER_Y_OFFSET, + 0 + ] as CoordinateTuple, + rotation: [0, 0, 0] as CoordinateTuple, + scale: [PLAYER_SCALE, PLAYER_SCALE, PLAYER_SCALE] as CoordinateTuple, + restitution: -10, + boundary: 0.5, + hasGravity: false, + castShadow: false, + material: 'MeshLambertMaterial', + color: 0xff6644 +}) const setupConfig = { orbit: { target: new THREE.Vector3(0, 1, 0), disabled: true }, @@ -289,7 +285,13 @@ const init = async (): Promise => { const addRemotePeer = async (peerId: string): Promise => { if (remoteModels.has(peerId)) return - const remoteModel = await getModel(scene, world, 'stickboy.glb', remoteSettings.model) + const spawnIndex = remoteModels.size + const remoteModel = await getModel( + scene, + world, + 'stickboy.glb', + getRemoteModelSettings(spawnIndex) + ) remoteModels.set(peerId, remoteModel) } @@ -338,12 +340,10 @@ const init = async (): Promise => { onMounted(async () => { await init() - window.addEventListener('resize', init) }) onUnmounted(() => { destroyControls() - window.removeEventListener('resize', init) if (p2pSession) { p2pLeave(p2pSession) p2pSession = null From b9a59574d2e5a9673655b98bcd7f897f55945dee Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Sun, 5 Apr 2026 22:24:53 +0200 Subject: [PATCH 17/22] fix(p2p): cache makeAction calls and add HUD with controls + player count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of lag: makeAction('pos') was called every frame (60fps) inside the animation loop, creating a new Trystero action channel each time. Fixed by caching [send] per session+channel via WeakMap — makeAction is now called at most once per channel per session. Also adds: - ControlsLogger HUD showing player count and keyboard bindings - FPS + peer count log every 2s (console.warn) to surface performance issues - makeAction call counter log to confirm caching works in production Co-Authored-By: Claude Sonnet 4.6 --- packages/multiplayer-p2p/src/players.ts | 72 ++++++++++++++----- .../MultiplayerP2P/MultiplayerP2P.vue | 31 +++++++- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/packages/multiplayer-p2p/src/players.ts b/packages/multiplayer-p2p/src/players.ts index c7797454..daa8d174 100644 --- a/packages/multiplayer-p2p/src/players.ts +++ b/packages/multiplayer-p2p/src/players.ts @@ -8,12 +8,48 @@ import type { } from './types' type PositionRecord = Record> -type ActionRecord = Record const defaultThrottleMs = 30 const pendingTimers = new WeakMap>() +type SendFunction = (data: unknown) => void +type ReceiveFunction = (callback: (data: unknown, peerId: string) => void) => void + +const sendCache = new WeakMap>() +const recvRegistered = new WeakMap>() +let makeActionCallCount = 0 + +const getSend = (session: P2PSession, channel: string): SendFunction => { + if (!sendCache.has(session)) sendCache.set(session, new Map()) + const cache = sendCache.get(session)! + if (!cache.has(channel)) { + makeActionCallCount++ + console.warn(`[p2p] makeAction('${channel}') called — total calls: ${makeActionCallCount}`) + const [send] = session.room.makeAction(channel) + cache.set(channel, send as SendFunction) + } + return cache.get(channel)! +} + +const getRecv = (session: P2PSession, channel: string): ReceiveFunction | null => { + if (!recvRegistered.has(session)) recvRegistered.set(session, new Set()) + const registered = recvRegistered.get(session)! + if (registered.has(channel)) { + console.warn( + `[p2p] WARNING: onReceive for channel '${channel}' already registered — duplicate call ignored` + ) + return null + } + makeActionCallCount++ + console.warn( + `[p2p] makeAction('${channel}') recv registered — total calls: ${makeActionCallCount}` + ) + registered.add(channel) + const [, onReceive] = session.room.makeAction(channel) + return onReceive as ReceiveFunction +} + /** * Broadcast the local player's position and rotation to all peers, throttled. * @param session - The active P2P session @@ -31,7 +67,7 @@ export const p2pSendPosition = ( const existing = pendingTimers.get(session) if (existing !== undefined) clearTimeout(existing) - const [sendPos] = session.room.makeAction('pos') + const sendPos = getSend(session, 'pos') pendingTimers.set( session, @@ -53,14 +89,16 @@ export const p2pOnPlayers = ( session: P2PSession, callback: (player: PlayerState) => void ): (() => void) => { - const [, onPos] = session.room.makeAction('pos') - - onPos((data: PositionRecord, peerId: string) => { - console.warn(`[p2p] received position from ${peerId}`, data) - const pos = data['position'] as PlayerPosition - const rot = data['rotation'] as PlayerRotation - callback({ id: peerId, position: pos, rotation: rot }) - }) + const onPos = getRecv(session, 'pos') + if (onPos) { + onPos((data: unknown, peerId: string) => { + const record = data as PositionRecord + console.warn(`[p2p] received position from ${peerId}`, record) + const pos = record['position'] as PlayerPosition + const rot = record['rotation'] as PlayerRotation + callback({ id: peerId, position: pos, rotation: rot }) + }) + } return () => {} } @@ -71,7 +109,7 @@ export const p2pOnPlayers = ( * @param actionName - Animation clip name to play (e.g. "wave", "attack") */ export const p2pSendAction = (session: P2PSession, actionName: string): void => { - const [sendAction] = session.room.makeAction('action') + const sendAction = getSend(session, 'action') console.warn(`[p2p] sending action: ${actionName}`) sendAction({ name: actionName }) } @@ -86,10 +124,12 @@ export const p2pOnAction = ( session: P2PSession, callback: (peerId: string, action: PlayerAction) => void ): (() => void) => { - const [, onAction] = session.room.makeAction('action') - onAction((data: ActionRecord, peerId: string) => { - console.warn(`[p2p] received action from ${peerId}:`, data) - callback(peerId, data as PlayerAction) - }) + const onAction = getRecv(session, 'action') + if (onAction) { + onAction((data: unknown, peerId: string) => { + console.warn(`[p2p] received action from ${peerId}:`, data) + callback(peerId, data as PlayerAction) + }) + } return () => {} } diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index da5741bf..d0034ef7 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -1,6 +1,6 @@ @@ -17,14 +29,40 @@ defineProps<{ display: flex; flex-direction: column; align-self: center; - font-size: 24px; - line-height: 1.2em; - margin: 1rem; + font-size: var(--font-size-md); + line-height: 1.4; + margin: var(--spacing-4); + gap: var(--spacing-1); +} + +.controls-logger__header { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.controls-logger__toggle { + display: none; + background: none; + border: none; + font-size: var(--font-size-md); + cursor: pointer; + color: inherit; + padding: 0 var(--spacing-1); + line-height: 1; } @media (max-width: 600px) { - .controls-logger { - line-height: 0.2em; + .controls-logger__toggle { + display: inline; + } + + .controls-logger__body { + display: none; + } + + .controls-logger__body--expanded { + display: block; } } diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue index 0ad62c8e..89773928 100644 --- a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -31,6 +31,8 @@ import ControlsLogger from '@/components/ControlsLogger.vue' const ROOM_ID = 'webgamekit-p2p' const MOVEMENT_SPEED = 0.25 +const IDLE_ANIM_SPEED = 5 +const WALK_ANIM_SPEED = 20 const CAMERA_HEIGHT = 7 const CAMERA_DEPTH = 14 const CAMERA_OFFSET: CoordinateTuple = [0, CAMERA_HEIGHT, CAMERA_DEPTH] @@ -129,13 +131,13 @@ const controlBindings = { } const actionConfig = { - wave: { allowMovement: false, allowRotation: false, allowActions: [], speed: 1.5 }, - attack: { allowMovement: false, allowRotation: false, allowActions: [], speed: 2 }, + wave: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1.5 }, + attack: { allowMovement: true, allowRotation: false, allowActions: [], speed: 2 }, jump: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1.5 }, - talk: { allowMovement: false, allowRotation: false, allowActions: [], speed: 1.5 }, - sit: { allowMovement: false, allowRotation: false, allowActions: [], speed: 1 }, - pick: { allowMovement: false, allowRotation: false, allowActions: [], speed: 1.5 }, - death: { allowMovement: false, allowRotation: false, allowActions: [], speed: 1.2 } + talk: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1.5 }, + sit: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1 }, + pick: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1.5 }, + death: { allowMovement: true, allowRotation: false, allowActions: [], speed: 1.2 } } const blockingActions = new Set(Object.keys(actionConfig)) @@ -162,6 +164,7 @@ let localPlayerReference: ComplexModel | null = null let getDeltaReference: (() => number) | null = null let p2pSession: P2PSession | null = null const remoteModels = new Map() +const movingPeers = new Set() const handleBlockingAction = (actionName: string): void => { if (!timelineManagerReference || !localPlayerReference || !getDeltaReference) return @@ -222,11 +225,12 @@ const init = async (): Promise => { const targetRotation = getRotation(currentActions) const isMoving = targetRotation !== null + const animationName = getAnimationName(currentActions) const animationData: AnimationData = { - actionName: getAnimationName(currentActions), + actionName: animationName, player: localPlayer, delta: getDelta() * 2, - speed: 20, + speed: animationName === 'walk' ? WALK_ANIM_SPEED : IDLE_ANIM_SPEED, backward: false, distance: MOVEMENT_SPEED } @@ -267,17 +271,19 @@ const init = async (): Promise => { name: 'remote-players', category: 'user-input', action: () => { - remoteModels.forEach((model) => { + remoteModels.forEach((model, peerId) => { if (!model.userData.performing) { + const isMoving = movingPeers.has(peerId) const animData: AnimationData = { - actionName: 'idle', + actionName: isMoving ? 'walk' : 'idle', player: model, delta: getDelta() * 2, - speed: 20, + speed: isMoving ? WALK_ANIM_SPEED : IDLE_ANIM_SPEED, backward: false, - distance: 0 + distance: isMoving ? MOVEMENT_SPEED : 0 } updateAnimation(animData) + movingPeers.delete(peerId) } }) } @@ -317,18 +323,12 @@ const init = async (): Promise => { p2pOnPlayers(session, (playerState) => { const model = remoteModels.get(playerState.id) if (!model) return + const positionChanged = + model.position.x !== playerState.position.x || model.position.z !== playerState.position.z model.position.set(playerState.position.x, playerState.position.y, playerState.position.z) model.rotation.set(playerState.rotation.x, playerState.rotation.y, playerState.rotation.z) - if (!model.userData.performing) { - const animData: AnimationData = { - actionName: 'walk', - player: model, - delta: getDelta() * 2, - speed: 20, - backward: false, - distance: 0 - } - updateAnimation(animData) + if (positionChanged) { + movingPeers.add(playerState.id) } })