npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@playmesh/server

v0.6.0

Published

PlayMesh multiplayer server framework — Socket.IO, Redis and BullMQ infrastructure for multiplayer worlds.

Downloads

172

Readme

@playmesh/server

PlayMesh multiplayer server framework — Socket.IO, Redis and BullMQ infrastructure for multiplayer worlds.

npm License: MIT

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/server

socket.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:keypair value, 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 every set/delete/clear afterwards (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 server

All 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 live

Both 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/second

Auto-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 emptying

autoDestroy: 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 domain
  • domain(id: string): Domain — Access an existing domain (throws if not found)
  • hasDomain(id: string): boolean — Check if a domain exists
  • resolveInstance(ref: string | Instance): Instance — Resolve a domainId/instanceId path or bare instance id

Hooks:

  • bootstrap(hook) — Run async setup before the server accepts connections
  • onAuthenticate(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: return true, a rewritten string, or false/throw to block
  • onSessionCreate(hook) — Called when a session is created
  • onConnect(hook) — Called after a session fully connects
  • onDisconnect(hook) — Called when a session disconnects
  • onStarted(hook) — Called after the server starts
  • onShutdown(hook) — Called during graceful shutdown

Messaging:

  • broadcast(event, payload?) — Send an event to every connected session across all nodes
  • sessionsOf(userId): Promise<string[]> — Session ids of a user across all nodes

Other:

  • use(extension) — Register a middleware function or install a plugin
  • metrics(): Metrics — Returns { sessions, domains, instances, uptimeMs }
  • start(): Promise<{ port: number }> — Start the server
  • shutdown(): Promise<void> — Gracefully shut down
  • io — The underlying Socket.IO server (available after start())
  • 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 temporary
  • instance(id: string): Instance — Access an existing instance (throws if not found)
  • hasInstance(id: string): boolean — Check if an instance exists
  • destroyInstance(id: string): Promise<void> — Destroy an instance and evict all sessions
  • broadcast(event, payload?) — Broadcast to all sessions in any instance of this domain
  • onInstanceCreated(hook) — Called when an instance is created
  • onInstanceDestroyed(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 handler
  • onRequest(event, handler) — Answer client.requestTo(...) requests; the return value is the response (one handler per event)
  • offRequest(event) — Remove a request handler
  • hasRequestHandler(event): boolean — Check for a request handler
  • onJoin(hook) — Called when a session joins this instance
  • onLeave(hook) — Called when a session leaves this instance
  • onJoinRequest(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 nodes
  • memberCount(): Promise<number> — Number of member sessions across all nodes (presence-backed)
  • path — Globally unique reference in domainId/instanceId form
  • temporary — Whether this instance auto-destroys when it empties out
  • sessions — Sessions on this node that are members of this instance
  • state — Server-only runtime state scoped to this instance
  • publicState — 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 hook
  • data — Custom data attached by the authentication hook
  • instances — Instances this session is currently a member of
  • join(instance): Promise<void> — Join an instance
  • leave(instance): Promise<void> — Leave an instance
  • isIn(instance): boolean — Check if the session is a member of an instance
  • send(event, payload?) — Send an event to this client only
  • disconnect() — Forcibly disconnect this client
  • kick(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