@playmesh/client
v0.6.0
Published
PlayMesh client SDK — connect games and applications to PlayMesh servers.
Maintainers
Readme
@playmesh/client
PlayMesh client SDK — connect games and applications to PlayMesh servers.
Website · GitHub · npm · Example
Overview
@playmesh/client is the client-side SDK for communicating with PlayMesh servers. It provides:
- Connection Management — Reliable WebSocket connections with automatic reconnection
- Event Communication — Send and receive events from the server
- Session Lifecycle — Track session info and instance membership
- Server Messaging — Listen for targeted and broadcast messages
- Type-Safe APIs — Full TypeScript support for client-server communication
Installation
npm install @playmesh/clientQuick Start
import { PlayMeshClient } from '@playmesh/client'
const client = new PlayMeshClient({
url: 'https://game.example.com',
auth: { token: 'user-jwt-token' }
})
const session = await client.connect()
console.log(session.userId, session.instances)
// Send events to the server
client.emit('player:move', { x: 100, y: 200 })
// Send an event to exactly one instance you are a member of
client.emitTo('world/town-square', 'player:move', { x: 100, y: 200 })
// Request/response against one instance
const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' }, { timeout: 5_000 })
// Receive events from the server
client.on('player:update', data => {
console.log('Player update:', data)
})
// Every listener API returns an unsubscribe function
const stop = client.onDisconnect(reason => {
console.log('Disconnected:', reason)
})
stop()
client.onReconnect(() => {
console.log('Reconnected')
})The client and server must run matching PlayMesh releases: the SDK announces its wire-protocol version during the handshake, and a mismatch is rejected with a clear error instead of failing unpredictably.
Configuration
Basic Connection
const client = new PlayMeshClient({
url: 'https://game.example.com'
})With Authentication
const client = new PlayMeshClient({
url: 'https://game.example.com',
auth: {
token: 'jwt-token-or-session-id',
userId: 'player1'
}
})Dynamic Auth (Token Refresh)
Pass a function to re-evaluate auth on every connection attempt:
const client = new PlayMeshClient({
url: 'https://game.example.com',
auth: async () => ({ token: await authService.getToken() })
})Socket.IO Options
Reconnection and other Socket.IO options are passed under socket:
const client = new PlayMeshClient({
url: 'https://game.example.com',
socket: {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity
}
})Synced State
The client keeps live read replicas of the synced state of every instance it is in, seeded on join and updated in real time. Public state is shared by all members; your per-user private state is delivered only to you. All of it is server-written — clients read:
client.stateOf('world/city') // public state replica
client.userStateOf('world/city') // your private state (only you receive it)
client.onStateChange(change => {
// change.scope === 'user' marks your private state
// change.revision is the monotonic per-scope revision
})Every change carries a monotonic revision. The replica applies changes
strictly in order: duplicates and stale changes are ignored, and if a
change is ever lost the client detects the gap and transparently fetches
a fresh snapshot over the same connection — stateOf() /
userStateOf() are always the authoritative view. (During a recovery,
individual onStateChange callbacks for the skipped changes may not
fire; re-read the replica instead of accumulating changes yourself.)
Presence
See who is in each of your instances and react to joins and leaves:
client.presenceOf('world/city') // { count, users }
client.onPresence(event => {
// { instance, type: 'join' | 'leave', userId, sessionId, count }
})Chat
Built-in chat delivers one message per instance you are a member of — a
player in a world room and a minigame speaks in both with one call, and
each delivery carries its instance's path. chatTo targets one room.
The server moderates each delivery in real time and may rewrite it,
block it, or kick you:
// Speak in every room the player currently belongs to.
client.chat('Hello everyone')
// Speak only in the minigame room.
client.chatTo('minigames/race-42', 'Ready!')
client.onChat(message => console.log(`[${message.instance}] ${message.userId}: ${message.text}`))
client.onKick(reason => console.log('Kicked:', reason))Join Requests
Ask to join or leave instances. Joins are vetoed by the server unless
the instance explicitly allows them (onJoinRequest server-side):
await client.join('world/vip-lounge') // rejects with the server's denial message
await client.leave('world/vip-lounge')Scoped Events and Requests
emit() reaches every instance you have joined (with a matching server
handler). emitTo() targets exactly one instance you are a member of,
and requestTo() awaits a typed response from that instance's
onRequest handler:
client.emitTo('world/town-square', 'player:move', { x: 100, y: 200 })
const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' }, { timeout: 5_000 })Requests use a unique correlation id and reject on timeout (default 10s), on disconnect, when you leave the target instance, or with the server's safe error message. Unknown and unauthorized targets produce one generic error.
Typed Events
Pass event maps to type emit/on end to end, and a request map to
type requestTo (compile-time only):
type ClientEvents = { 'player:move': { x: number; y: number } }
type ServerEvents = { 'player:update': { x: number; y: number; by: string } }
type Requests = {
'shop:buy': { request: { itemId: string }; response: { success: boolean; balance: number } }
}
const client = new PlayMeshClient<ClientEvents, ServerEvents, Requests>({ url })
client.emit('player:move', { x: 1, y: 2 }) // payload type-checked
const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' }) // typed responseMessage Signing
When the server enables signing, enable it on the client too — signing
is optional but must match on both sides. A signing client refuses to talk
to a server that does not sign (and vice versa); there is no silent
downgrade to unsigned mode.
The client generates an ephemeral ECDSA P-256 keypair per connect()
(the private key is created non-extractable), exchanges public keys with
the server during the handshake, and signs/verifies every application
event and every data-bearing protocol message (scoped events,
requests and responses, chat, state changes and snapshots, join
snapshots, presence) with SHA-256. Instance paths, event names and
request ids ride inside the signed payload, so routing cannot be tampered
with. Only playmesh:session (the key exchange itself), playmesh:error
and playmesh:kicked stay unsigned — see the server README for the full
model:
const client = new PlayMeshClient({
url: 'https://game.example.com',
signing: true
})Each connection receives a random session nonce from the server that
is signed into every envelope, and every envelope carries a strictly
increasing sequence number. Together they prevent replay: within a
session (sequence numbers) and across connections, reconnects and server
nodes (nonces). After a reconnect the client waits for the new session
nonce before sending signed events; events queued for the old connection
are dropped and reported via onError rather than replayed.
Messages that fail verification are dropped and reported via onError.
Payload restrictions: signed payloads must be JSON-compatible
(objects, arrays, strings, booleans, null, finite numbers). Date
values serialize as ISO strings and arrive as strings. Binary values
(typed arrays, ArrayBuffer, Blob, Node Buffer), BigInt,
functions, symbols, non-finite numbers and circular structures make
emit() throw instead of silently corrupting the message.
Security notes: signing does not replace TLS — the key/nonce exchange
depends on transport security, so production must use https/wss
(plain http://localhost is fine for development, and the client warns
when signing is enabled over an insecure non-local URL). Signing also
does not prevent cheating: a malicious client can sign arbitrary payloads
with its own key, so the server must still validate and authorize
everything.
API Reference
PlayMeshClient
Constructor options (PlayMeshClientOptions):
| Option | Type | Description |
| --------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| url | string | Server URL, e.g. https://game.example.com or http://localhost:3000 |
| auth | Record<string, unknown> \| () => Record<string, unknown> | Auth payload sent to the server's authentication hook |
| socket | Partial<ManagerOptions & SocketOptions> | Options forwarded to the underlying Socket.IO client |
| signing | boolean | Sign application events and data-bearing protocol messages. The server must enable signing too. |
Methods:
connect(): Promise<SessionInfo>— Connect and authenticate. Resolves once the server has established the session.disconnect(): void— Disconnect from the serveremit(event, payload?): void— Send an event to the server (dispatched to every joined instance and relevant domain)emitTo(instancePath, event, payload?): void— Send an event to exactly one instance you are a member ofrequestTo(instancePath, event, payload?, { timeout? }): Promise<Response>— Request/response against one instance (default timeout 10s)join(instancePath: string): Promise<void>— Ask to join an instance; the server can vetoleave(instancePath: string): Promise<void>— Ask to leave an instance (always honored)chat(text: string): void— Send a chat message to every instance you are in (server-moderated per instance)chatTo(instancePath: string, text: string): void— Send a chat message to one instance you are instateOf(instancePath)/userStateOf(instancePath)— Public / private synced-state replicas (revision-consistent)presenceOf(instancePath): PresenceInfo | undefined— Live{ count, users }for an instance
Listeners — every listener registration returns an Unsubscribe function (() => void, safe to call repeatedly). off(event, handler) also removes on listeners:
on(event, handler): Unsubscribe— Listen for an event from the serveroff(event, handler): void— Remove a listener registered withononStateChange(handler): Unsubscribe— Called when synced public or private state changesonPresence(handler): Unsubscribe— Called when a session joins or leaves an instance you are inonChat(handler): Unsubscribe— Called for chat messages in your instancesonKick(handler): Unsubscribe— Called when the server kicks this client, just before the disconnectonDisconnect(handler): Unsubscribe— Called when the connection dropsonReconnect(handler): Unsubscribe— Called when the connection is automatically re-establishedonError(handler): Unsubscribe— Called when the server reports a session error
Getters:
session: SessionInfo | undefined— Session details, available onceconnect()resolvesinstances: string[]— Current instance paths (domainId/instanceId) the session belongs toconnected: boolean— Whether the socket is currently connected
SessionInfo
Returned by connect() and available via client.session.
id— Session IDuserId— User ID established by the server's authentication hookinstances— Instance paths (domainId/instanceId) the session is a member of
ServerError
Passed to onError() handlers.
scope: 'connection' | 'event' | 'join' | 'chat'— Where the error occurredevent?: string— Event name, when scope is'event'instance?: string— The instance involved, for join errors and per-instance chat rejectionsmessage: string— Error description
Examples
Multiplayer Chat
const client = new PlayMeshClient({ url: 'https://chat.example.com' })
await client.connect()
client.on('chat', data => {
console.log(`${data.username}: ${data.text}`)
})
client.emit('send-message', { text: 'Hello everyone!' })Game World
const client = new PlayMeshClient({ url: 'https://game.example.com' })
await client.connect()
client.on('player:update', player => {
updatePlayerPosition(player.id, player.position)
})
client.emit('player:move', { x: mouse.x, y: mouse.y })Building
npm run build # Build with tsup
npm run typecheck # Type check with TypeScriptBrowser Compatibility
The client works in modern browsers (ES2020+) and Node.js 18+. It requires WebSocket support for real-time communication.
Philosophy
PlayMesh clients are simple and focused:
- Connect to the server
- Send events to the server
- Receive events from the server
Complex logic (authentication, persistence, game mechanics) lives on your server or in your application.
License
MIT
