PlayMeshClient
Import from @playmesh/client.
Constructor
import { PlayMeshClient } from '@playmesh/client';
const client = new PlayMeshClient(options: PlayMeshClientOptions);
| Method / Property | Description | Returns |
|---|
url | Server URL. e.g. "https://game.example.com" | string |
auth | Auth payload for the server's authenticate hook, or a function called on every connection attempt (useful for token refresh). | object | () => object |
socket | Options forwarded to the underlying Socket.IO client. | Partial<SocketOptions> |
signing | Sign application events and data-bearing protocol messages with ECDSA P-256. The server must enable signing too. | boolean |
serialization | Wire encoding: 'json' (default) or 'msgpack' (binary, via socket.io-msgpack-parser). Must match the server. | 'json' | 'msgpack' |
ℹ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 on both sides.
Connection
| Method / Property | Description | Returns |
|---|
connect() | Connect and authenticate. Resolves with SessionInfo once the server establishes the session. | Promise<SessionInfo> |
disconnect() | Disconnect from the server. | void |
connected | Whether the client is currently connected. | boolean |
const session = await client.connect();
// session: { id: string, userId: string, roles: string[], instances: string[] }
console.log('Connected as', session.userId);
console.log('Instances:', session.instances);
client.disconnect();
Session
| Method / Property | Description | Returns |
|---|
session | Current session info. Updated automatically as instances change. | SessionInfo | undefined |
instances | Instance paths ("domainId/instanceId") this session belongs to. | string[] |
session.roles | Current roles. Kept up to date when the server changes them live. | string[] |
Events
| Method / Property | Description | Returns |
|---|
emit(event, payload?) | Send an event to the server - dispatched to every joined instance (and relevant domain) with a handler. Throws if not connected. | void |
emitTo(instancePath, event, payload?) | Send an event to exactly one instance you are a member of. Never hits domain handlers. | void |
requestTo(instancePath, event, payload?, opts?) | Request/response against one instance you are in. { timeout } defaults to 10s; rejects on timeout, disconnect, or leaving the target. | Promise<Response> |
on(event, handler) | Listen for an event from the server. Returns an unsubscribe function. | Unsubscribe |
off(event, handler) | Remove a listener registered with on. | void |
join(instancePath) | Ask to join an instance. The server can veto; rejects with a PlayMeshError (code JOIN_DENIED, ROLE_REQUIRED, or the server's own). | Promise<void> |
leave(instancePath) | Ask to leave an instance. Always honored. | Promise<void> |
chat(text) | Send a chat message to every instance you are in (server-moderated per instance). | void |
chatTo(instancePath, text) | Send a chat message to one instance you are in. | void |
client.on('chat', payload => {
const { sender, message } = payload as { sender: string; message: string };
appendMessage(`${sender}: ${message}`);
});
client.emit('move', { x: 100, y: 250 });
client.emitTo('world/town-square', 'move', { x: 100, y: 250 });
const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' });
// Every listener registration returns an unsubscribe function
const unsubscribe = client.on('update', payload => { /* ... */ });
unsubscribe(); // safe to call more than once
Connection lifecycle
| Method / Property | Description | Returns |
|---|
onDisconnect(handler) | Called when the connection drops. Receives the reason string. | Unsubscribe |
onReconnect(handler) | Called when the connection is automatically re-established. | Unsubscribe |
onError(handler) | Called when the server reports an error. Every error carries a code. | Unsubscribe |
onKick(handler) | Called with (reason, code) when the server kicks this client, just before the disconnect. REPLACED means the user signed in elsewhere. | Unsubscribe |
onJoin(handler) | Called with the instance path whenever this session joins an instance - your join(), admission, or a server move. The instance's state is already in stateOf(). | Unsubscribe |
onLeave(handler) | Called with the instance path whenever this session leaves an instance - your leave(), a server move or removal, or a disconnect. | Unsubscribe |
onRolesChange(handler) | Called with the new roles when the server changes them (session.roles is already updated). | Unsubscribe |
Every listener API - on, onPresence, onStateChange, onChat, onKick, onJoin, onLeave, onRolesChange, onDisconnect, onReconnect, onError - returns an Unsubscribe function (() => void, safe to call repeatedly). This composes with effect-style cleanup like React useEffect.
client.onDisconnect(reason => {
console.warn('Disconnected:', reason);
showReconnectUI();
});
client.onReconnect(() => {
hideReconnectUI();
});
client.onError(error => {
// { scope: 'connection' | 'event' | 'join' | 'chat',
// code, event?, instance?, message }
if (error.code === 'RATE_LIMITED') slowDown();
});
Error codes
Every error carries a machine-readable code - never match on message text. connect(), join() and requestTo() reject with a PlayMeshError (exported from @playmesh/client); onError receives a ServerError with the same field.
import { PlayMeshError } from '@playmesh/client';
try {
await client.connect();
} catch (error) {
if (error instanceof PlayMeshError && error.code === 'VERSION_MISMATCH') showUpdatePrompt();
if (error instanceof PlayMeshError && error.code === 'APP_ONLY') showDownloadApp(); // server-defined code
}
await client.requestTo('world/shop', 'shop:buy', { itemId }).catch(error => {
if (error.code === 'NOT_ENOUGH_COINS') openCoinShop();
});
| Method / Property | Description | Returns |
|---|
Connection | VERSION_MISMATCH, AUTH_FAILED, USER_CONNECTED, SIGNING_REQUIRED, CONNECTION_FAILED | connect() |
Access | ROLE_REQUIRED, JOIN_DENIED, TARGET_DENIED, REQUEST_DENIED | join(), emitTo(), requestTo() |
Abuse | RATE_LIMITED, INVALID_PAYLOAD, SIGNATURE_INVALID, CHAT_REJECTED | onError |
Handlers | APP_ERROR (plain Error), INTERNAL, or any code the server throws with PlayMeshError | any |
Client-side | TIMEOUT, DISCONNECTED, LEFT_INSTANCE | requestTo(), join() |
Kicks | KICKED, REPLACED, RATE_LIMITED, or a server code | onKick |
Authentication
// Static token
const client = new PlayMeshClient({
url: 'https://game.example.com',
auth: { token: localStorage.getItem('jwt') },
});
// Dynamic - refreshed on each reconnect
const client = new PlayMeshClient({
url: 'https://game.example.com',
auth: async () => ({ token: await authService.getToken() }),
});
Instance membership
The client automatically maintains client.instances as the server sends playmesh:joined and playmesh:left events. Clients can also request to join or leave instances:
await client.join('world/vip-lounge'); // rejects with the server's denial message
await client.leave('world/vip-lounge'); // always honored
The server can also move you (session.move(), admin moves, admission on reconnect). onJoin and onLeave fire for every membership change, whoever caused it - use them to switch worlds instead of a custom goto event:
client.onLeave(path => world.unload(path));
client.onJoin(path => world.load(path, client.stateOf(path))); // state is already seeded
ℹThe server stays authoritative: client joins are denied by default, unless the instance opts in with an onJoinRequest hook server-side.
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: 'public' | 'user' ('user' marks your private state)
// change.revision: monotonic per-scope revision
});
Replicas apply changes strictly in revision order: duplicates and stale changes are ignored, and a detected gap triggers a transparent snapshot recovery over the same connection. stateOf() / userStateOf() are always the authoritative view.
A batched server write (patch()) arrives as one change with patch (keys set) and deleted (keys removed) instead of key/value. Keys the server restricts to other players (visibleTo) are simply absent from your replica - their values never reach your client.
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 - 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.
client.chat('hello everyone'); // every room you are in
client.chatTo('minigames/race-42', 'Ready!'); // exactly one room
client.onChat(message => console.log(`[${message.instance}] ${message.userId}: ${message.text}`));
client.onKick((reason, code) => console.log('Kicked:', code, reason));
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 response
Types
type Unsubscribe = () => void; // returned by every listener API
interface SessionInfo {
id: string;
userId: string;
roles: string[]; // kept current by live role updates
instances: string[]; // "domainId/instanceId" paths
}
interface ServerError {
scope: 'connection' | 'event' | 'join' | 'chat';
code: string; // e.g. 'RATE_LIMITED', 'ROLE_REQUIRED', or an app code
event?: string;
instance?: string; // set for join errors and per-instance chat rejections
message: string;
}
interface ChatMessage {
instance: string;
sessionId: string;
userId: string;
text: string;
at: number; // server timestamp (epoch ms)
}
interface PresenceEvent {
instance: string;
type: 'join' | 'leave';
sessionId: string;
userId: string;
count: number; // cluster-wide, after this change
}
interface PresenceInfo {
count: number;
users: string[]; // unique user ids currently in the instance
}
interface StateChange {
instance: string;
scope: 'public' | 'user'; // 'user' marks your private per-user state
revision?: number; // monotonic per-scope revision
key?: string; // absent for a clear or a batch
value?: unknown; // absent on delete
cleared?: boolean;
patch?: Record<string, unknown>; // batch: keys set
deleted?: string[]; // batch: keys removed
}
class PlayMeshError extends Error {
code: string;
}
type Serialization = 'json' | 'msgpack';
Full example
◆game-client.ts
import { PlayMeshClient } from '@playmesh/client';
const client = new PlayMeshClient({
url: import.meta.env.VITE_SERVER_URL,
auth: async () => ({ token: await authService.getAccessToken() }),
});
client.on('world-state', payload => gameRenderer.updateWorld(payload));
client.on('player-joined', payload => gameRenderer.spawnPlayer(payload.userId));
client.on('player-left', payload => gameRenderer.removePlayer(payload.userId));
client.onDisconnect(() => gameRenderer.showDisconnectedOverlay());
client.onReconnect(() => gameRenderer.hideDisconnectedOverlay());
export async function startGame() {
const session = await client.connect();
gameRenderer.setLocalPlayerId(session.userId);
}
export const movePlayer = (x: number, y: number) => client.emit('move', { x, y });
export const sendChat = (message: string) => client.emit('chat', { message });