PlayMesh/Docs
HomeGitHubnpm

Communication

Event-driven messaging between clients and server - from single sessions to global broadcasts.

Client to server

Emitting from the client

client.emit('move', { x: 100, y: 250 });
client.emit('chat', { message: 'Hello!' });
client.emit('buy-item', { itemId: 'sword-of-doom' });

Handling on the server

Handlers are registered on instances or domains. Events from a session are routed to all instances the session belongs to (and their domains) that have a handler for that event.
city.on('move', async (session, payload) => {
  const { x, y } = payload as { x: number; y: number };
  city.broadcast('player-moved', { userId: session.userId, x, y });
});

city.on('chat', (session, payload) => {
  const { message } = payload as { message: string };
  city.broadcast('chat', { sender: session.userId, message });
});

Targeting one instance: emitTo

client.emit() dispatches to every joined instance (and relevant domain) with a matching handler. client.emitTo() delivers to exactly one instance the session is a member of - never domain handlers, so nothing dispatches twice. Unknown and unauthorized targets get one generic Event target denied error that does not leak topology.
client.emitTo('world/town-square', 'player:move', { x: 100, y: 200 });

Domain-level handlers

Domains can handle events themselves, so the same event name can mean different things in different domains - a kick-userin the moderation domain is independent of any other domain's kick-user. Only sessions inside the domain trigger its handlers, so pairing a domain handler with domain roles gives you role-scoped commands for free.
const moderation = mesh.createDomain('moderation', { roles: ['admin', 'moderator'] });

moderation.on('kick-user', (session, payload) => {
  for (const target of mesh.getUserSessions(payload.userId)) {
    target.kick('Moderated');
  }
});

moderation.on('announce', (session, payload) => {
  mesh.getInstance(payload.instance)?.broadcast('announcement', { text: payload.text });
});

Mesh-wide handlers

Shop, wardrobe, game selection and admin events belong to no particular room. Register them on the mesh itself: they receive the event from any session - even one in no instance at all - after the instance and domain handlers. No middleware tricks needed.
mesh.on('shop:buy', async (session, payload) => {
  const balance = await economy.buy(session.userId, payload.itemId);
  session.send('shop:bought', { balance });
});

mesh.off('shop:buy', handler); // remove
ℹ
client.emitTo() stays scoped to its instance and never reaches mesh handlers.

Server to client (targeted)

city.on('buy-item', async (session, payload) => {
  const result = await economy.processPayment(session.userId, payload);

  if (result.success) {
    session.send('purchase-success', { item: result.item, balance: result.balance });
  } else {
    session.send('purchase-failed', { reason: result.error });
  }
});

Request/response

Instances can answer client requests directly. The handler's return value resolves the client's promise. Throw a PlayMeshError(code, message) to reject it with a machine-readable code; a plain throw new Error('...') rejects with that message and code APP_ERROR. Any other thrown value becomes a generic Request failed (code INTERNAL) so internals never reach the wire.
import { PlayMeshError } from '@playmesh/server';

// Server
shop.onRequest('shop:buy', async (session, payload) => {
  const ok = await chargePlayer(session.userId, payload.itemId);
  if (!ok) throw new PlayMeshError('NOT_ENOUGH_GOLD', 'Not enough gold');
  return { success: true, balance: 950 };
});

// Client
try {
  const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' }, { timeout: 5_000 });
} catch (error) {
  if (error.code === 'NOT_ENOUGH_GOLD') openCoinShop();
  // built-in: TIMEOUT, DISCONNECTED, LEFT_INSTANCE, REQUEST_DENIED, RATE_LIMITED...
}
Requests target one instance the client is currently a member of. They reject on timeout (10 seconds by default), on disconnect, and when the client leaves the target instance. Responses are correlated by a unique per-request id; undefined and any JSON-compatible value are valid responses. Type both sides with a request map - a third generic on both SDKs:
type Requests = {
  'shop:buy': { request: { itemId: string }; response: { success: boolean; balance: number } };
};

const mesh = new PlayMesh<ClientEvents, ServerEvents, Requests>();
const client = new PlayMeshClient<ClientEvents, ServerEvents, Requests>({ url });

Instance broadcast

Broadcast to all sessions in an instance, across all nodes.
city.broadcast('world-event', { event: 'dragon-spawned' });

dungeon.on('boss-killed', async (session, payload) => {
  const loot = await lootService.generate(payload.bossId);
  dungeon.broadcast('loot-dropped', { items: loot, killer: session.userId });
});

Filtered broadcast

Send only to the members matching a filter - players in range of an explosion, a team, everyone but the shooter. The filter sees each member's sessionId and userId from cluster-wide presence, so recipients on any node are reached:
await world.broadcastWhere(
  member => inRange(member.userId, explosion),
  'explosion',
  explosion
);
For synced state, use visibleTo instead.

Moving players

The server can move a session between instances in one call: session.move(to, from?) leaves from (default: every instance in the target's domain), then joins to. A role-protected target throws ROLE_REQUIRED before anything is left. mesh.moveUser(userId, to, from?) moves every session of a user on any node.
// Server: portals and teleports, no custom 'goto' event
city.on('portal:enter', session => session.move(mesh.resolveInstance('world/caves')));
await mesh.moveUser(userId, 'world/jail');

// Client: react to any membership change, whoever caused it
client.onLeave(path => world.unload(path));
client.onJoin(path => world.load(path, client.stateOf(path))); // state already seeded

Domain broadcast

Send to every session in any instance of a domain.
world.broadcast('server-announcement', { text: 'Maintenance in 10 minutes' });

Global broadcast

Send to every connected session, across all domains and nodes.
mesh.broadcast('global-announcement', { text: 'Double XP weekend starts now!' });

Receiving on the client

client.on('world-event', payload => {
  gameEngine.triggerEvent(payload);
});

// Every listener registration returns an unsubscribe function
const unsubscribe = client.on('chat', payload => renderChat(payload));
unsubscribe(); // remove when no longer needed (off(event, handler) also works)

Reserved events

EventDirectionPurpose
playmesh:sessionServer → ClientSent once after auth. Contains sessionId, userId, and initial instances.
playmesh:joinedServer → ClientSent when the session joins an instance.
playmesh:leftServer → ClientSent when the session leaves an instance.
playmesh:errorServer → ClientSent on connection setup or event handling errors, with a machine-readable code.
playmesh:presenceServer → ClientBroadcast to an instance when a session joins or leaves it.
playmesh:stateServer → ClientBroadcast when an instance's synced state changes. Carries a monotonic revision.
playmesh:state-sync / playmesh:state-snapshotBothScoped snapshot recovery when the client detects a state revision gap.
playmesh:emit-toClient → ServerScoped event delivery to exactly one instance (emitTo).
playmesh:request / playmesh:responseBothRequest up, correlated response down (requestTo / onRequest).
playmesh:joinClient → ServerRequest to join an instance; the server can veto it.
playmesh:leaveClient → ServerRequest to leave an instance.
playmesh:chatBothChat request up, moderated broadcast down to instance members.
playmesh:kickedServer → ClientSent just before the server force-disconnects the client, with a reason and code.
playmesh:rolesServer → ClientSent when the server changes the session's roles.
⚠
Do not emit events with the playmesh: prefix from application code. They are filtered on both sides.