PlayMesh/Docs
HomeGitHubnpm

Client API

Complete reference for @playmesh/client - connecting, emitting, and receiving events.

PlayMeshClient

Import from @playmesh/client.

Constructor

import { PlayMeshClient } from '@playmesh/client';
const client = new PlayMeshClient(options: PlayMeshClientOptions);
Method / PropertyDescriptionReturns
urlServer URL. e.g. "https://game.example.com"string
authAuth payload for the server's authenticate hook, or a function called on every connection attempt (useful for token refresh).object | () => object
socketOptions forwarded to the underlying Socket.IO client.Partial<SocketOptions>
signingSign application events and data-bearing protocol messages with ECDSA P-256. The server must enable signing too.boolean
serializationWire 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 / PropertyDescriptionReturns
connect()Connect and authenticate. Resolves with SessionInfo once the server establishes the session.Promise<SessionInfo>
disconnect()Disconnect from the server.void
connectedWhether 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 / PropertyDescriptionReturns
sessionCurrent session info. Updated automatically as instances change.SessionInfo | undefined
instancesInstance paths ("domainId/instanceId") this session belongs to.string[]
session.rolesCurrent roles. Kept up to date when the server changes them live.string[]

Events

Method / PropertyDescriptionReturns
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 / PropertyDescriptionReturns
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 / PropertyDescriptionReturns
ConnectionVERSION_MISMATCH, AUTH_FAILED, USER_CONNECTED, SIGNING_REQUIRED, CONNECTION_FAILEDconnect()
AccessROLE_REQUIRED, JOIN_DENIED, TARGET_DENIED, REQUEST_DENIEDjoin(), emitTo(), requestTo()
AbuseRATE_LIMITED, INVALID_PAYLOAD, SIGNATURE_INVALID, CHAT_REJECTEDonError
HandlersAPP_ERROR (plain Error), INTERNAL, or any code the server throws with PlayMeshErrorany
Client-sideTIMEOUT, DISCONNECTED, LEFT_INSTANCErequestTo(), join()
KicksKICKED, REPLACED, RATE_LIMITED, or a server codeonKick

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 });