@playmesh/server
v0.6.0
Published
PlayMesh multiplayer server framework — Socket.IO, Redis and BullMQ infrastructure for multiplayer worlds.
Downloads
172
Maintainers
Readme
@playmesh/server
PlayMesh multiplayer server framework — Socket.IO, Redis and BullMQ infrastructure for multiplayer worlds.
Website · GitHub · npm · Example
Overview
@playmesh/server is the core server framework for building scalable multiplayer games, virtual worlds, social experiences, and real-time collaborative applications. It provides:
- Socket.IO Integration — WebSocket communication with automatic reconnection handling
- Session Management — User sessions, authentication hooks, and lifecycle management
- Distributed Architecture — Multi-node deployments with Redis synchronization
- Presence Tracking — Real-time user presence across domains and instances
- Domain & Instance Management — Hierarchical organization of multiplayer environments
- Message Distribution — Targeted messaging, broadcasts, and server-to-client communication
- Background Queues — Job processing with BullMQ for async operations
- Horizontal Scaling — Built-in support for multi-node deployments
Installation
npm install @playmesh/serversocket.io, ioredis, and bullmq are bundled as dependencies — no need to install them separately.
Quick Start
import { PlayMesh } from '@playmesh/server'
const mesh = new PlayMesh()
const world = mesh.createDomain('world')
const city = world.createInstance('city-center')
city.onJoin(session => {
console.log(`Player ${session.userId} joined`)
})
city.onLeave(session => {
console.log(`Player ${session.userId} left`)
})
city.on('chat', (session, payload) => {
city.broadcast('chat', { sender: session.userId, message: payload })
})
await mesh.start()Core Concepts
Universe
The root PlayMesh deployment representing your entire server cluster.
const mesh = new PlayMesh()Domain
A logical grouping of related multiplayer experiences (e.g., lobby, ranked, open-world, marketplace).
const domain = mesh.createDomain('lobby')Instance
A specific multiplayer environment within a domain (e.g., a game world, match, or chat room).
const instance = domain.createInstance('world-1')Session
An authenticated user connection with lifecycle management.
instance.onJoin(session => {
console.log(session.id, session.userId, session.data)
})Configuration
Single-Node (Development)
Redis is optional. Omit it to run in single-node mode with in-memory state and presence:
const mesh = new PlayMesh()Multi-Node (Production)
Configure Redis for multi-node deployments:
const mesh = new PlayMesh({
redis: {
host: 'localhost',
port: 6379,
password: process.env.REDIS_PASSWORD
}
})Redis can also be passed as a connection URL string:
const mesh = new PlayMesh({ redis: process.env.REDIS_URL })Redis Namespace
Multiple deployments (or environments) can share one Redis database by giving each a namespace:
const mesh = new PlayMesh({
namespace: 'my-game:production',
redis: process.env.REDIS_URL
})Every Redis key PlayMesh writes is prefixed with the namespace: presence
(sessions, heartbeats, memberships), unique-user claims, synced state and
its revision counters, the shared signing keypair, the BullMQ queue
prefix (<namespace>:bull — queue names are unchanged, and an explicit
prefix in your queue options is never double-prefixed) and the
Socket.IO adapter channels (<namespace>:socket.io). Deployments with
different namespaces never interact.
The default namespace is playmesh, which produces exactly the same keys
as releases before 0.6.0. Switching an existing deployment to a custom
namespace abandons the old keys — PlayMesh does not migrate or read them
(see MIGRATION.md).
Namespaces allow letters, digits, hyphens, underscores, colons and
periods; empty or unsafe values throw at construction.
Message Signing
Enable signing to sign every application event and every data-bearing
PlayMesh protocol message in both directions with ECDSA P-256 / SHA-256
(WebCrypto, no extra dependencies). Signing is optional and disabled by
default; when enabled it must be enabled on both the server and the
client — a signing server rejects unsigned clients, a signing client
rejects an unsigned server, and there is no silent downgrade to unsigned
mode.
What is signed (signing enabled): application events in both
directions, scoped events (playmesh:emit-to), requests and responses
(playmesh:request / playmesh:response), chat in both directions
(playmesh:chat), state changes (playmesh:state), state recovery
(playmesh:state-sync / playmesh:state-snapshot), join snapshots
(playmesh:joined), membership requests and confirmations
(playmesh:join, playmesh:leave, playmesh:left) and presence changes
(playmesh:presence). Routing information — instance paths, event names,
request ids — is inside the signed payload, so it cannot be redirected
without breaking the signature, and signed application events can never
impersonate reserved protocol events.
What stays unsigned (deliberate, documented): playmesh:session
(it bootstraps the key exchange itself; its integrity rests on TLS),
playmesh:error (must remain deliverable when signing is what failed)
and playmesh:kicked (immediately followed by a forced disconnect,
possibly issued by a node that does not own the socket). Socket.IO
transport internals are never signed.
Protocol and application messages share one sequence stream per connection, so signing cannot reorder them against each other, and every reconnect establishes a fresh signing session.
const mesh = new PlayMesh({ signing: true })Every signed envelope (signing protocol version 1) is bound to:
- A session nonce — a random 128-bit value the server generates per
socket connection and delivers in
playmesh:session. A new connection (including every reconnect) gets a new nonce, so a captured envelope can never be replayed on another connection, after a reconnect, or against another server node. - A direction (
client-to-server/server-to-client) and the event name, so envelopes cannot be reflected or replayed under a different event. - A strictly increasing sequence number, which prevents replay within the same signing session.
Sessions accumulating repeated verification failures are disconnected, and
the wire error is a single generic Signature verification failed for
every failure mode.
Payload restrictions: signed payloads must be JSON-compatible
(objects, arrays, strings, booleans, null, finite numbers). Date
values are serialized as ISO strings and arrive as strings. Binary values
(Buffer, typed arrays, ArrayBuffer, Blob), BigInt, functions,
symbols, non-finite numbers and circular structures are rejected with a
thrown error instead of being silently corrupted.
What signing does and does not guarantee
- Signing does not replace TLS. The public keys and session nonce are exchanged over the transport at connect time, so production deployments must use TLS/WSS — without it an active attacker can substitute keys during the handshake. (Plain HTTP against localhost is fine for development.)
- Signing does not prevent cheating. A malicious client holds its own valid private key and can sign any payload it likes. Signing does not replace server-side validation or authorization: the server must remain authoritative for movement, inventory, damage, purchases, permissions and all game state.
- What it does give you: verified per-connection message integrity, replay protection (per session via sequence numbers, across sessions and reconnects via nonces), and event-name/direction binding.
Redis and the cluster signing key
In multi-node deployments the signing keypair is shared between nodes
through Redis (stored unencrypted, without TTL, at
playmesh:signing:keypair) so every node signs with the same key.
Compromise of Redis exposes the signing private key, so production
Redis must be treated as part of the trust boundary:
- Require authentication (
requirepass/ ACLs) and use TLS to Redis where supported. - Keep Redis on a private network with restricted inbound access; never expose it publicly.
- Use separate Redis instances (or at minimum strictly isolated keyspaces) for development, QA and production.
- Restrict operational access; never log, dump or print the
playmesh:signing:keypairvalue, and protect backups that contain it.
The current implementation does not provide automatic key rotation. To
rotate the key, delete playmesh:signing:keypair and restart the cluster.
Port
const mesh = new PlayMesh({ port: 4000 })Synced State
Each instance has three state stores:
instance.state— server-only runtime state. Never leaves the server.instance.publicState— synced live to every client in the instance. Clients get a snapshot when they join and everyset/delete/clearafterwards (client.stateOf(path)/client.onStateChange(...)).instance.userState(userId)— private per-user state, synced only to that user's own clients (their hand of cards, quest progress). Other members never receive it.
await match.publicState.set('round', 3) // everyone sees this
await match.userState('alice').set('hand', cards) // only Alice sees this
await match.state.set('spawn-seed', seed) // nobody but the serverAll synced state is server-writable only — clients hold read replicas.
Atomic Operations
Concurrent handlers (and multiple nodes) must never read-modify-write. Every state scope supports atomic operations — with Redis they run as single Lua scripts, atomic across the whole cluster:
const kills = await match.publicState.increment('kills', 1)
const health = await match.publicState.increment('health', -10, { min: 0, max: 100 })
const started = await match.state.compareAndSet('phase', 'waiting', 'running')
const claimed = await match.state.setIfAbsent('owner', session.userId)increment() returns the new value, treats missing keys as 0, rejects
non-numeric stored values and non-finite deltas, and applies bounds
atomically. compareAndSet() compares JSON-compatible values by
canonical JSON (object key order is irrelevant); expected: undefined
matches a missing key, while a stored null matches only null.
Successful mutations sync to clients like set(); failed conditional
mutations emit no state event.
Revisions and Recovery
Every synced mutation carries a monotonic revision — one sequence per instance for public state, one per user per instance for private state, allocated atomically with the mutation (and monotonic across nodes with Redis). Join snapshots include the current revision, and client replicas apply changes strictly in revision order: duplicates and older changes are ignored, and a detected gap triggers an automatic scoped snapshot recovery over the existing connection — no reconnect, no application code. A private-state recovery can never expose another user's state.
Presence
Joins and leaves are broadcast to instance members in real time
(client.onPresence(...), client.presenceOf(path)), and join snapshots
include the current member list. On the server, mesh.sessionsOf(userId)
finds all sessions of a user across nodes.
In multi-node (Redis) deployments, nodes heartbeat their sessions and periodically sweep ghost sessions left behind by crashed nodes. Each ghost is claimed atomically, so concurrent sweepers on different nodes never process the same session twice; the sweeping node broadcasts one leave presence event per removed membership with the cluster-wide member count after removal, releases the ghost's unique-user claim, and arms auto-destroy when the ghost was an instance's final member. (Auto-destroy after a ghost eviction relies on a surviving node holding a local copy of the instance — the normal case when topology is created at bootstrap on every node. Instances created dynamically on only the crashed node are not reaped; there is no distributed instance registry.)
Built-in Chat with Moderation and Kick
client.chat(text) delivers the message once to every instance the
sender is a member of — a player in a world room and a minigame speaks in
both with one call, and each room receives its own payload carrying that
room's instance path. client.chatTo(path, text) targets exactly one
instance the sender is a member of; invalid or unauthorized targets get
one generic error.
// 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!')The moderation hook runs once per destination instance and receives the target instance, so each room is moderated independently:
mesh.onChatMessage(({ session, instance, text }) => {
if (containsSlurs(text)) {
session.kick('Watch your language') // stops all remaining deliveries
return false
}
if (instance.path.startsWith('minigames/')) {
return `[${instance.id}] ${text}` // rewrite for this room only
}
return true // deliver as-is
})Returning false (or throwing) blocks the message for that instance
only; other destinations are unaffected unless the sender was kicked.
Only the sender is notified of rejections. session.kick(reason) works
anywhere, not just in chat hooks.
Client Join Requests (Server Veto)
Clients can ask to join an instance with client.join(path). These
requests are denied by default — the server stays authoritative. Opt
an instance in with a veto hook:
vipLounge.onJoinRequest(session => session.data.level >= 10)Return true to admit; return false or throw to veto (a thrown error's
message is sent to the client). Server-side session.join(...) and
admission are unaffected. client.leave(path) is always honored.
Scoped Events and Request/Response
client.emit() dispatches to every joined instance (and relevant domain)
with a matching handler. client.emitTo(path, event, payload) 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 error that does not leak topology.
For request/response flows, instances register request handlers:
shop.onRequest('shop:buy', async (session, payload) => {
const ok = await chargePlayer(session.userId, payload.itemId)
if (!ok) throw new Error('Not enough gold') // rejects the client promise
return { success: true, balance: 950 }
})const result = await client.requestTo('shop/main', 'shop:buy', { itemId: 'sword' }, { timeout: 5_000 })The handler runs only on the explicitly targeted instance; the client
must be a member. Responses correlate by unique request id, support
undefined and any JSON-compatible value, and requests reject on timeout
(10s default), disconnect, or when the client leaves the target instance.
A handler's throw new Error(...) message is forwarded; any other thrown
value becomes a generic Request failed so internals never reach the
wire. Type both sides with a request map:
type Requests = {
'shop:buy': { request: { itemId: string }; response: { success: boolean; balance: number } }
}
const mesh = new PlayMesh<ClientEvents, ServerEvents, Requests>()Single Session Per User (uniqueUser)
new PlayMesh({ uniqueUser: 'replace' }) // new login kicks the previous session
new PlayMesh({ uniqueUser: 'reject' }) // new login is refused while a session is liveBoth policies are cluster-safe: they are backed by an atomic per-user
claim in Redis (TTL-bound, renewed by presence heartbeats, released with
owner validation). Simultaneous logins are deterministic — reject
admits exactly one connection across all nodes, and replace keeps the
newest accepted connection and kicks exactly the previous one. Failed
authentication never creates a claim, failed connection setup releases
it, and a crashed node's claim expires (or is taken over once its
heartbeats go stale), so a user is never permanently blocked. Omit the
option to allow concurrent sessions per user (the default).
Rate Limiting
Per-session, per-event token buckets as a middleware. '*' catches
events without their own limit; over-limit events are rejected with an
error the client sees via onError:
import { rateLimit } from '@playmesh/server'
mesh.use(rateLimit({ 'player:move': 30, chat: 2, '*': 100 })) // events/secondAuto-Destroy Instances
Temporary instances (matches, dungeon runs) can clean themselves up once everyone leaves, while permanent instances — your base world — never do:
world.createInstance('city') // permanent
world.createInstance('match-42', { autoDestroy: 60_000 }) // gone 60s after emptyingautoDestroy: true uses a 30-second grace period. The timer arms when
the last member leaves (a never-visited instance is not reaped), cancels
if anyone joins during the grace period, and checks cluster-wide
emptiness through presence before destroying. instance.temporary tells
the two kinds apart.
Typed Events
Both SDKs accept event maps (compile-time only — still validate payloads at runtime):
type ClientEvents = { 'player:move': { x: number; y: number } }
type ServerEvents = { 'player:update': { x: number; y: number; by: string } }
const mesh = new PlayMesh<ClientEvents, ServerEvents>()
city.on('player:move', (session, payload) => {
// payload is { x: number; y: number }
session.send('player:update', { ...payload, by: session.userId })
})API Reference
PlayMesh
Constructor options (PlayMeshOptions):
| Option | Type | Description |
| ------------ | ------------------------ | ---------------------------------------------------------------------------------------------- |
| port | number | Port to listen on. Defaults to 3000. |
| server | HttpServer | Optional pre-created HTTP server to attach to. |
| redis | RedisOptions \| string | Redis connection. Omit for single-node in-memory mode. |
| namespace | string | Redis key namespace isolating this deployment. Defaults to playmesh (the pre-0.6 keys). |
| socket | Partial<ServerOptions> | Options forwarded to the underlying Socket.IO server. |
| signing | boolean | Sign application events and data-bearing protocol messages. Clients must enable signing too. |
| uniqueUser | 'replace' \| 'reject' | One live session per user, cluster-safe. Omit to allow concurrent sessions (default). |
Generics: new PlayMesh<ClientEvents, ServerEvents, Requests>() types event and request payloads end to end.
Topology:
createDomain(id: string): Domain— Create a new domaindomain(id: string): Domain— Access an existing domain (throws if not found)hasDomain(id: string): boolean— Check if a domain existsresolveInstance(ref: string | Instance): Instance— Resolve adomainId/instanceIdpath or bare instance id
Hooks:
bootstrap(hook)— Run async setup before the server accepts connectionsonAuthenticate(hook)— Validate client credentials; return{ userId, data? }onAdmission(hook)— Decide which instances a session joins on connect; return{ instances }onChatMessage(hook)— Moderate built-in chat: returntrue, a rewritten string, orfalse/throw to blockonSessionCreate(hook)— Called when a session is createdonConnect(hook)— Called after a session fully connectsonDisconnect(hook)— Called when a session disconnectsonStarted(hook)— Called after the server startsonShutdown(hook)— Called during graceful shutdown
Messaging:
broadcast(event, payload?)— Send an event to every connected session across all nodessessionsOf(userId): Promise<string[]>— Session ids of a user across all nodes
Other:
use(extension)— Register a middleware function or install a pluginmetrics(): Metrics— Returns{ sessions, domains, instances, uptimeMs }start(): Promise<{ port: number }>— Start the servershutdown(): Promise<void>— Gracefully shut downio— The underlying Socket.IO server (available afterstart())redis— The shared Redis client (throws if Redis is not configured)queues— BullMQ queue manager (throws if Redis is not configured)
Domain
createInstance(id, options?)— Create a new instance;{ autoDestroy: ms | true }makes it temporaryinstance(id: string): Instance— Access an existing instance (throws if not found)hasInstance(id: string): boolean— Check if an instance existsdestroyInstance(id: string): Promise<void>— Destroy an instance and evict all sessionsbroadcast(event, payload?)— Broadcast to all sessions in any instance of this domainonInstanceCreated(hook)— Called when an instance is createdonInstanceDestroyed(hook)— Called when an instance is destroyed
Instance
on(event, handler)— Handle an event sent by clients in this instance;handler(session, payload)off(event, handler)— Remove a handleronRequest(event, handler)— Answerclient.requestTo(...)requests; the return value is the response (one handler per event)offRequest(event)— Remove a request handlerhasRequestHandler(event): boolean— Check for a request handleronJoin(hook)— Called when a session joins this instanceonLeave(hook)— Called when a session leaves this instanceonJoinRequest(hook)— Allow/veto client-initiated joins (denied by default without this hook)broadcast(event, payload?)— Send an event to all sessions in this instance across all nodesmemberCount(): Promise<number>— Number of member sessions across all nodes (presence-backed)path— Globally unique reference indomainId/instanceIdformtemporary— Whether this instance auto-destroys when it empties outsessions— Sessions on this node that are members of this instancestate— Server-only runtime state scoped to this instancepublicState— State synced live to every client in this instance (revisioned)userState(userId)— Private state synced only to that user's clients (revisioned per user)
All three state scopes support get, set, delete, keys, entries, clear and the atomic increment(key, delta, { min?, max? }), compareAndSet(key, expected, value) and setIfAbsent(key, value).
Session
id— Unique session identifier (socket id)userId— User ID returned by the authentication hookdata— Custom data attached by the authentication hookinstances— Instances this session is currently a member ofjoin(instance): Promise<void>— Join an instanceleave(instance): Promise<void>— Leave an instanceisIn(instance): boolean— Check if the session is a member of an instancesend(event, payload?)— Send an event to this client onlydisconnect()— Forcibly disconnect this clientkick(reason?)— Notify the client why (playmesh:kicked), then force-disconnect it
Building
npm run build # Build with tsup
npm run typecheck # Type check with TypeScript
npm test # Run tests with vitest
npm run test:redis # Redis integration tests (requires PLAYMESH_TEST_REDIS=redis://...)Philosophy
PlayMesh is infrastructure, not a game engine or persistence layer. It focuses on:
- Real-time networking
- Session management
- Presence tracking
- Distributed messaging
- Horizontal scaling
Your application provides:
- Authentication logic
- Database persistence
- Game logic
- Business rules
License
MIT
