Rooms & Actors
Model your game as rooms full of actors with a clean lifecycle. Two concepts, one mental model.
OPEN SOURCE · MIT · NODE.JS
Build real-time multiplayer for Phaser, PixiJS, Three.js, or any browser game. Open-source Node.js framework — rooms, actors, typed binary protocol. Free for commercial use, forever.
Define your room on the server. The client subscribes to topics. Frames flow in both directions over a single binary protocol.
// GameRoom.ts
import { Room, type Actor } from '@rivalis/core'
type PlayerData = { name: string; score: number }
export class GameRoom extends Room<PlayerData> {
protected override presence = true
protected override onCreate() {
this.bind('move', this.onMove)
}
private onMove(actor: Actor<PlayerData>, payload: Uint8Array) {
this.broadcast('move', payload)
}
}Model your game as rooms full of actors with a clean lifecycle. Two concepts, one mental model.
Bind a topic to a handler, broadcast or unicast — no manual switch statements.
Validate any ticket — JWT, session token, anything — and attach typed actor data.
A typed { topic, payload } format keeps clients and servers in lockstep.
The same room code runs over a direct peer-to-peer connection — players connect to each other while your server just helps them find one another. Lower latency, near-zero hosting cost for casual and co-op.
Tie many Rivalis servers into one cluster and place rooms on the least-loaded one — a live in-memory view of the fleet, with no database to run.
Strict types end-to-end. Actor data, room handlers, and the wire format share generics.
MIT licensed. Free forever, even for commercial games. No per-seat, no per-CCU. Your server, your rules.
CSWSH protection on by default — tunable per transport. 64 KiB frame cap, 30/s token-bucket rate limit, 30 s heartbeat with two-miss disconnect.
Anywhere multiple humans need to share state in real time.
One Rivalis server is plenty to start. When you outgrow it — more players, more regions, more rooms than a single process should hold — @rivalis/fleet ties many servers together so clients always find the right one.
Honestly — probably not on day one. A single Rivalis server happily holds thousands of players, so start there and don't think about fleets until the numbers push back. You've outgrown one box when a single process can't keep up: more concurrent players, rooms, or regions than one machine should carry — or when you want players in Europe and the US to land on servers close to them. A fleet is the step after one server, not a prerequisite for your first.
Your group of game servers and all the rooms running on them — one logical cluster instead of a pile of separate processes.
The brain. It knows which servers are alive, what rooms run where, and creates or destroys rooms on any of them — embed it in your own code or run the bundled binary.
The little reporter inside each server. It tells the orchestrator what it is running and quietly carries out create/destroy commands sent back to it.
Embed an agent in every server, run one orchestrator, then ask it to place rooms.
// fleet-agent.ts — embeds in each game server
import { Rivalis } from '@rivalis/core'
import { FleetAgent } from '@rivalis/fleet'
import { MatchRoom } from './MatchRoom'
const rivalis = new Rivalis({ /* transports, authMiddleware */ })
rivalis.rooms.define('match', MatchRoom)
const agent = new FleetAgent(rivalis, {
url: 'ws://orchestrator.internal:7350', // where the orchestrator listens
key: process.env.FLEET_AGENT_KEY!, // agent key (sent via WS subprotocol)
endpointUrl: 'wss://eu1.game.example.com', // URL handed to game clients
name: 'eu1',
labels: { region: 'eu' },
capacity: { maxConnections: 2000, maxRooms: 100 }
})
// reports rooms + capacity, then runs create/destroy commands pushed back to it
await agent.connect()
// SIGTERM → drain → wait until empty → disconnect → rivalis.shutdown()
agent.enableGracefulShutdown({ emptyTimeoutMs: 60_000 })It only places rooms inside servers you are already running — spinning machines up and down stays with your platform (k8s, Agones, autoscalers), and matchmaking is something you build on top. In-memory, restart-safe, MIT.
With WebRTC peer-to-peer, players connect straight to each other instead of routing every frame through your server. Your server only helps peers find each other once — after that the game traffic skips it entirely. That means near-zero hosting cost and lower latency, which is exactly what casual and co-op games want.
The room code does not change. Swap WSClient for RTCClient and point it at a @rivalis/signal server — the ticket and topics stay identical.
// game.ts — the room code is identical; only the client class changes.
import { WSClient, RTCClient } from '@rivalis/browser'
// Before — classic client/server: every frame is routed through your server.
const client = new WSClient('wss://eu1.game.example.com', { reconnect: true })
// After — peer-to-peer: your server only helps peers find each other,
// then frames travel directly between players.
const client = new RTCClient('wss://signal.game.example.com', { reconnect: true })
// Everything below stays exactly the same — same ticket, same topics.
await client.connect(ticket) // ticket: "roomId|playerName"
client.on('chat', (payload) => render(decode(payload)))
client.send('chat', encode({ text: 'gg!' }))Pick the topology that fits your game — from a simple mesh to a single peer running the whole simulation.
Every peer connects to every other peer directly, and Rivalis is used only for discovery. No node is in charge.
Use it when you are building a small chat or co-op game with up to 10 peers.
One peer becomes the host and everyone else connects only to it. The host relays messages out to the rest — no per-pair links.
Use it when one peer should relay for everyone instead of every pair wiring up.
One peer runs the actual game: it owns the shared state, applies inputs on a fixed tick, and broadcasts snapshots — including a catch-up sync for late joiners.
Use it when one peer should run the simulation with a tick loop and late-join sync.
A browser host is great for casual and co-op play. For competitive games you still want an authoritative server — keep those rooms on @rivalis/core. MIT, same wire protocol either way.
“Peer-to-peer” sounds like there is no server at all — but two browsers can’t find each other on their own. They need a quick introduction first. That introduction is signaling, and it’s the one small piece of Rivalis that still runs as a server. The good news: it’s tiny, and it steps out of the way the moment players are connected.
Two browsers can only connect directly after they swap connection details (SDP/ICE). @rivalis/signal relays that handshake and nothing else — like a switchboard operator who connects the call, then hangs up. Gameplay traffic never passes through it.
Peers join the signaling room in order, and the first one in becomes the host. If that host leaves, the next-oldest peer is elected automatically — so there is always exactly one peer in charge, with no extra coordination on your side.
Some networks block direct connections. When that happens a TURN relay (coturn) forwards the traffic instead. The signal server is not the relay — it only mints short-lived TURN credentials so peers can use coturn when they need it.
It’s just a small Rivalis app. Give it a port and a ticket secret and it’s relaying handshakes — point your RTCClient at it and players take it from there.
// signal.ts — stand up a signaling server in a few lines.
import { SignalServer } from '@rivalis/signal'
// Constructing with a port starts listening immediately — no extra step.
const server = new SignalServer({
port: 9000,
secrets: [process.env.SIGNAL_SECRET!], // ticket secret(s); rotate freely
})
// Shut down cleanly on exit.
process.on('SIGTERM', () => server.shutdown({ timeoutMs: 10_000 }))
// In production you also run a coturn sidecar; the signal server just mints
// short-lived TURN credentials for it (ICE_TURN_URLS / ICE_TURN_SECRET).That’s the whole server side of P2P: relay the handshake, pick a host, hand out TURN credentials. For production you add a coturn sidecar next to it as the actual relay. MIT, same wire protocol as the rest of Rivalis.
Each demo is a few hundred lines — clone, run, hack.
Both work. Pick by philosophy — not features.
Both are MIT. Both are Node.js. Try both — neither is wrong.
Rivalis ships a single-file skill manifest at https://rivalis.dev/SKILL.md. Load it into Claude Code, Cursor, or any agent with a custom-instructions slot, and the assistant will know the recipes, pitfalls, close codes, and security defaults — so the code it generates actually compiles and follows the framework rules. Same approach Phaser uses with its own AI skill files — load once, the assistant follows the rules.
A YAML-fronted Markdown file describing when to use Rivalis, the minimal server & client, the wire protocol, all four room recipes, the auth middleware pattern, close codes, and rate-limiting defaults — everything a coding agent needs in one fetch.
Without a skill, AI agents guess. They forget that __-prefixed topics are reserved, or that rooms must be defined before connections. The skill encodes those rules — your agent stops inventing APIs and starts shipping working code.
The file is served from the same domain as this page. Re-fetch it anytime to pull the latest recipes — or pin a copy into your repo if you want a frozen reference.
~/.claude/skills/# Install the Rivalis skill into Claude Code
mkdir -p ~/.claude/skills/rivalis
curl -fsSL https://rivalis.dev/SKILL.md -o ~/.claude/skills/rivalis/SKILL.mdOnce installed, Claude Code picks up the skill on its own — no config, no restart. Project-scoped install: drop it under .claude/skills/rivalis/SKILL.md at the repo root instead.
# Drop the skill next to your repo so the agent picks it up
mkdir -p .cursor/rules
curl -fsSL https://rivalis.dev/SKILL.md -o .cursor/rules/rivalis.mdThe format is plain Markdown — paste it into any agent that accepts custom rules, system prompts, or knowledge files.
Read the Rivalis skill at https://rivalis.dev/SKILL.md
and use it as the source of truth when you write or review code that
imports @rivalis/core or @rivalis/browser.For chat UIs without a skills system (ChatGPT, Gemini, etc.), the simplest path is to ask the model to fetch the URL and treat it as the framework's reference. The file is small enough to paste inline if web-fetch is unavailable.
Rivalis is early but stable. The author is actively looking for feedback on API ergonomics — anything that feels off, missing, or weird. Open an issue or DM.