Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/multiplayer-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@webgamekit/multiplayer-client",
"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"
}
}
33 changes: 33 additions & 0 deletions packages/multiplayer-client/src/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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<MultiplayerClientConfig> } => {
const socket = io(url)
const defaultThrottleMs = 30
const resolvedConfig: Required<MultiplayerClientConfig> = {
throttleMs: config?.throttleMs ?? defaultThrottleMs
}

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()
}
31 changes: 31 additions & 0 deletions packages/multiplayer-client/src/data.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(
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 = <T>(
session: MultiplayerClientSession,
channel: string,
callback: (payload: T) => void
): (() => void) => {
session.socket.on(channel, callback)
return () => session.socket.off(channel, callback)
}
203 changes: 203 additions & 0 deletions packages/multiplayer-client/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((...args: unknown[]) => 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' })
})
})
11 changes: 11 additions & 0 deletions packages/multiplayer-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
46 changes: 46 additions & 0 deletions packages/multiplayer-client/src/players.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { PlayerPosition, PlayerRotation, PlayerState, MultiplayerClientSession } from './types'

type InternalSession = MultiplayerClientSession & { _config: { throttleMs: number } }

const pendingTimers = new WeakMap<MultiplayerClientSession, ReturnType<typeof setTimeout>>()

/**
* Broadcast the local player's position and rotation to the server, throttled.
* 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 multiplayerClientSendPosition = (
session: MultiplayerClientSession,
position: PlayerPosition,
rotation: PlayerRotation
): void => {
const internal = session as InternalSession
const defaultThrottleMs = 30
const throttleMs = internal._config?.throttleMs ?? defaultThrottleMs
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 client session
* @param callback - Called with the full list of connected players on each update
* @returns Unsubscribe function
*/
export const multiplayerClientOnPlayers = (
session: MultiplayerClientSession,
callback: (players: PlayerState[]) => void
): (() => void) => {
session.socket.on('user:list', callback)
return () => session.socket.off('user:list', callback)
}
30 changes: 30 additions & 0 deletions packages/multiplayer-client/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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 MultiplayerClientConfig {
/** Minimum ms between position broadcasts (default: 30) */
throttleMs?: number
}

export interface MultiplayerClientSession {
/** The underlying socket.io Socket */
socket: import('socket.io-client').Socket
/** Disconnect and cancel pending broadcasts */
destroy: () => void
}
Loading
Loading