PlayMesh/Docs
HomeGitHubnpm

Deployment

Running PlayMesh in production - Redis, multi-node, Docker, and environment configuration.

Single-node vs. multi-node

Single-node

  • No Redis required
  • In-memory state & presence
  • No background queues
  • Great for development

Multi-node (Redis)

  • Horizontal scaling
  • Distributed state & presence
  • BullMQ queues available
  • Required for production at scale

Environment configuration

◆server.ts
import { PlayMesh } from '@playmesh/server';

const mesh = new PlayMesh({
  port: Number(process.env.PORT ?? 3000),
  redis: process.env.REDIS_URL ?? undefined,
  socket: {
    cors: {
      origin: process.env.CLIENT_ORIGIN ?? '*',
      methods: ['GET', 'POST'],
    },
  },
});
◆.env
PORT=4000
REDIS_URL=redis://localhost:6379
CLIENT_ORIGIN=https://game.example.com
JWT_SECRET=your-secret-here

Redis namespaces

Multiple deployments (or environments) can share one Redis 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, unique-user claims, synced state and its revision counters, the shared signing keypair, the BullMQ queue prefix (<namespace>:bull - queue names are unchanged), and Socket.IO adapter channels (<namespace>:socket.io). Deployments with different namespaces never interact. Namespaces must be non-empty and use only letters, digits, hyphens, underscores, colons or periods.
ℹ
The default namespace is playmesh, which produces exactly the pre-0.6 keys - upgrading without setting namespace reads all your existing Redis data. Switching an existing deployment to a new namespace abandons the old keys (nothing is migrated): drain BullMQ queues first and clean up the old keys manually.

Serialization

JSON is the default wire encoding. serialization: 'msgpack' switches Socket.IO to socket.io-msgpack-parser - binary frames, usually noticeably smaller for game traffic. Server and every client must use the same encoding; a mismatched client cannot connect, so switch both at once. Keep payloads JSON-compatible either way (signing requires it).
// Server
const mesh = new PlayMesh({ serialization: 'msgpack', redis: process.env.REDIS_URL });

// Every client
const client = new PlayMeshClient({ url, serialization: 'msgpack' });

Admin tools

Build admin panels on top of these instead of tracking rooms and players yourself. The commands reach a user's sessions on any node.
await mesh.inspect();
// { nodeId, metrics, online,
//   domains: [{ id, instances: [{ id, path, temporary, roles?, members: { sessionId: userId } }] }],
//   sessions: [{ id, userId, roles, address, connectedAt, instances }] }  // this node's sessions

mesh.metrics();
// { sessions, domains, instances, uptimeMs, eventsReceived, errors: { RATE_LIMITED: 3, ... } }

mesh.kickUser(userId, 'Banned', 'BANNED');      // any node
await mesh.moveUser(userId, 'world/jail');      // any node
await mesh.setUserRoles(userId, ['moderator']); // any node
ℹ
Instance members in inspect()are cluster-wide; session details (roles, addresses) are only known to the node that holds the connection. Instances created dynamically on another node only appear in that node's inspect().

Crash recovery

When a node crashes, its sessions stay in shared presence as ghosts until the presence sweeper reaps them (once their heartbeat is 60 seconds old). Every node sweeps periodically, and a restarted node also sweeps on startup. Each ghost is claimed atomically, so its leave is broadcast - and onMemberLeft runs - exactly once across the cluster. Put per-player cleanup there instead of a manual cleanup step on startup:
arena.onMemberLeft(async ({ userId }, reason) => {
  // reason: 'leave' | 'disconnect' | 'ghost' (node crashed)
  await arena.publicState.delete(`player:${userId}`);
});

Upgrades

⚠
@playmesh/server and @playmesh/client must run matching releases. The wire protocol is versioned in the handshake, and a mismatch is rejected on both sides with code VERSION_MISMATCH - deploy server and client upgrades together.

0.6 to 0.7

0.7.0 moves to wire protocol v3, so 0.6 clients cannot connect to 0.7 servers (and vice versa). Custom StateStore implementations must add patch(), ServerError.code is always present, and connection-setup failures now reject connect()with the server's real message and code CONNECTION_FAILED. If you enable msgpack, switch server and clients in the same deploy. The full guide, including a table mapping common workarounds to the new APIs, is in MIGRATION.md.

Docker

Dockerfile

◆Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
ENV NODE_ENV=production PORT=4000
EXPOSE 4000
CMD ["node", "dist/server.js"]

Docker Compose (with Redis)

◆docker-compose.yml
version: '3.9'
services:
  redis:
    image: redis:7-alpine
    volumes: [redis-data:/data]

  server:
    build: .
    environment:
      PORT: 4000
      REDIS_URL: redis://redis:6379
      CLIENT_ORIGIN: http://localhost:3000
    ports: ["4000:4000"]
    depends_on: [redis]

volumes:
  redis-data:

Kubernetes (multi-node)

Scale horizontally by running multiple pods. Each pod connects to the same Redis. Socket.IO's Redis adapter broadcasts across pods.
◆deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: playmesh-server
spec:
  replicas: 3
  selector:
    matchLabels: { app: playmesh-server }
  template:
    metadata:
      labels: { app: playmesh-server }
    spec:
      containers:
        - name: server
          image: your-registry/playmesh-server:latest
          ports: [{ containerPort: 4000 }]
          env:
            - name: REDIS_URL
              valueFrom:
                secretKeyRef:
                  name: playmesh-secrets
                  key: redis-url
ℹ
WebSocket connections require sticky sessions at the load balancer. Configure your ingress with cookie-based or IP-based session affinity.

Cloud platforms

Fly.io

Native multi-region support. Use Fly Redis. Enable sticky sessions in fly.toml.

Railway

Add a Redis service. Set REDIS_URL from Railway's connection string.

AWS ECS

Use ElastiCache for Redis. Configure ALB with duration-based sticky cookies.

AWS EKS

Same as the Kubernetes setup. Use ElastiCache for Redis.

DigitalOcean

App Platform + Managed Redis. Enable session affinity in app settings.

Heroku

Add Redis Cloud. REDIS_URL is set automatically.

Attaching to an existing HTTP server

import express from 'express';
import { createServer } from 'node:http';
import { PlayMesh } from '@playmesh/server';

const app = express();
const httpServer = createServer(app);

app.get('/health', (_req, res) => res.json({ ok: true }));
app.get('/metrics', (_req, res) => res.json(mesh.metrics()));

const mesh = new PlayMesh({ server: httpServer, port: 4000 });
await mesh.start();

Graceful shutdown

process.on('SIGTERM', async () => {
  await mesh.shutdown();
  process.exit(0);
});

process.on('SIGINT', async () => {
  await mesh.shutdown();
  process.exit(0);
});