REDWEBCapability examples
From first server to production multiplayer

See how the pieces fit together.

These examples progress from Redweb’s smallest shared HTTP/WebSocket setup to the optional controls used by multiplayer services. Each section is complete enough to adapt, while the API reference remains the source for every option and signature.

Start here

1. HTTP and WebSockets on one listener

Top

Build the Express side without binding, attach route classes to the same Node server, then listen once. Redweb leaves the server visible and caller-owned.

js
const { HttpServer, METHODS, SocketServer } = require('redweb')

const http = new HttpServer({
  port: 3030,
  listen: false,
  publicPaths: ['./public'],
  services: [{
    serviceName: '/health',
    method: METHODS.GET,
    function: (_request, response) => response.json({ ok: true }),
  }],
})

const sockets = new SocketServer({
  server: http.server,
  routes: [ChatRoute, PresenceRoute],
})

http.server.listen(3030)

process.once('SIGTERM', async () => {
  await sockets.shutdown()
  await http.shutdown()
})
  • Static files and JSON APIs share the Express app.
  • WebSocket upgrades are routed by path.
  • Await both shutdown helpers when the process exits.
Messages

2. JSON routing, broadcast, and binary frames

Top

Text messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.

js
const { BaseHandler, SocketRoute } = require('redweb')

class ChatHandler extends BaseHandler {
  constructor() { super('chat') }

  onMessage(socket, message) {
    socket.broadcast({ type: 'chat', text: message.text })
  }
}

class SnapshotHandler extends BaseHandler {
  constructor() { super('snapshot') }
  onMessage() {}
  acceptsBinary(_socket, buffer) { return buffer.length > 0 }
  onBinaryMessage(socket, buffer) {
    socket.sendJson({ type: 'snapshot:received', bytes: buffer.length })
  }
}

class RealtimeRoute extends SocketRoute {
  constructor() {
    super({
      path: '/realtime',
      handlers: [ChatHandler, SnapshotHandler],
      websocketOptions: { maxPayload: 64 * 1024 },
    })
  }
}
  • sendJson and broadcast share the same outbound policy.
  • acceptsBinary can select among multiple binary handlers.
  • Async handler failures become safe client errors.
Production

3. Bound admission, work, and slow peers

Top

Production controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.

js
class MatchRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [InputHandler],
      admission: {
        origins: ['https://game.example'],
        timeoutMs: 3000,
        authenticate: (request, { signal }) =>
          verifyPlayer(request, signal),
      },
      maxPendingUpgrades: 64,
      limits: {
        maxConnections: 5000,
        maxBufferedBytes: 1024 * 1024,
        maxPendingMessages: 64,
        messageRate: { capacity: 60, refillPerSecond: 30 },
      },
      orderedMessages: true,
      heartbeat: { intervalMs: 30000, timeoutMs: 10000 },
      websocketOptions: { maxPayload: 64 * 1024 },
    })
  }
}
  • Authentication completes before onInitialContact.
  • Ordered overflow closes pending work synchronously.
  • Disabled controls add no per-connection queue or timer.
Players

4. Rooms and resumable ownership

Top

Use bounded route-local rooms for fan-out and application-issued sessions for reconnect or takeover. Redweb owns cleanup; your application owns credential issuance and data size.

js
const { BaseHandler, SocketRoute } = require('redweb')

class MatchHandler extends BaseHandler {
  constructor() { super('match') }

  onMessage(socket, message) {
    if (message.action === 'join') {
      return socket.sendJson({ joined: socket.joinRoom(message.roomId) })
    }
    if (message.action === 'move') {
      return socket.roomBroadcast(message.roomId, {
        type: 'player:moved',
        playerId: socket.context.principal.playerId,
        position: message.position,
      }, { except: socket })
    }
    if (message.action === 'resume') {
      return socket.sendJson({ state: socket.resumeSession(message.sessionId) })
    }
  }
}

class MatchRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [MatchHandler],
      rooms: { maxRooms: 1000, maxMembersPerRoom: 32 },
      sessions: { ttlMs: 30000, maxSessions: 10000 },
    })
  }
}
  • Joins, leaves, and disconnect cleanup are idempotent.
  • A takeover closes the previous owner atomically.
  • Disconnected sessions expire through one route timer.
Simulation

5. Fixed-step work without overlapping ticks

Top

FixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.

js
const { FixedStepService, SocketRoute } = require('redweb')

class Simulation extends FixedStepService {
  constructor() {
    super('simulation', 50, 3, 250)
  }

  async onTick(stepMs, tick) {
    await authoritativeGame.update(stepMs, tick)
  }

  onLagDropped(milliseconds) {
    console.warn('Simulation lag discarded', { milliseconds })
  }
}

class SimulationRoute extends SocketRoute {
  constructor() {
    super({
      path: '/simulation',
      handlers: [InputHandler],
      services: [Simulation],
    })
  }
}
  • The active async tick must finish before another begins.
  • maxCatchUpTicks prevents a spiral of death.
  • maxRetainedLagMs bounds remembered delay.
Clients

6. Versioned envelopes and the dependency-free client

Top

Negotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.

js
const { SocketRoute } = require('redweb')

class ProtocolRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [MoveHandler],
      protocol: {
        versions: ['2', '1'],
        binary: {
          maxBytes: 64 * 1024,
          encode: (state) => codec.encode(state),
          decode: (bytes) => codec.decode(bytes),
        },
      },
    })
  }
}

// Browser client
const { ProtocolClient, ERROR_CODES } = require('redweb/client')
const socket = new WebSocket(
  'wss://game.example/match?redwebVersion=2'
)
const client = new ProtocolClient(socket, '2')

socket.addEventListener('message', (event) => {
  const message = client.parse(event)
  if (message.error?.code === ERROR_CODES.RATE_LIMITED) backOff()
})

client.send('move', { x: 4, y: 2 }, { sequence: 17 })
  • Browsers negotiate with redwebVersion in the query.
  • requestId correlates; sequence expresses application ordering.
  • Neither field promises durability or exactly-once delivery.
Multiple nodes

7. Bring your own broker adapter

Top

Redweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.

js
const { SocketRoute } = require('redweb')

class DistributedMatchRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [MatchHandler],
      rooms: true,
      distribution: {
        adapter: brokerAdapter,
        channel: 'matches',
        nodeId: process.env.INSTANCE_ID,
        required: true,
        maxEventBytes: 64 * 1024,
        maxConcurrentPublishes: 32,
        onEvent(event, route) {
          route.rooms.broadcast(event.payload.roomId, {
            type: event.type,
            payload: event.payload,
          })
        },
      },
    })
  }
}

// From a connected socket:
await socket.publishEvent('match:update', update)
  • Required adapters affect readiness; best-effort adapters do not.
  • Source-node events are ignored to prevent reflection loops.
  • Authoritative state and partition reconciliation remain application work.
Operations

8. Readiness first, then bounded shutdown

Top

Stop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.

js
const { HttpServer, SocketServer } = require('redweb')

const http = new HttpServer({ listen: false })
const socketServer = new SocketServer({
  server: http.server,
  routes: [MatchRoute],
})

http.app.get('/ready', (_request, response) => {
  response.sendStatus(socketServer.isReady() ? 200 : 503)
})

http.server.listen(3000)

process.once('SIGTERM', async () => {
  socketServer.beginDrain()
  await stopExternalPlacement()
  await socketServer.shutdown()
  await http.shutdown()
})

// In a handler with drainHandlers: true
await saveCheckpoint({ signal: socket.context.signal })
  • New upgrades receive 503 once draining starts.
  • The route signal is shared through socket.context.signal.
  • A hard deadline terminates non-cooperating peers.