Rivalis is a free, open-source framework for building real-time applications and multiplayer game servers on Node.js. It gives you rooms, actors, and a typed wire protocol out of the box, with WebSocket transport, presence, rate limiting, heartbeats, graceful shutdown, and a browser client that handles reconnection.
- Real-time applications β chat, presence, notifications, live dashboards, collaborative editing
- Multiplayer games β turn-based strategy, arena games, lobby/matchmaking systems
- Server-authoritative state β anywhere you need a single source of truth that broadcasts to many clients
- Server (
@rivalis/core) β Node framework: rooms, actors, auth middleware, WebSocket transport, per-actor rate limiting, per-IP connection limiting, configurable frame and topic size caps, graceful shutdown. - Client (
@rivalis/browser) β Browser WebSocket client: typed event listeners, exponential-backoff reconnect, ticket-refresh hook for short-lived JWTs, structuredclient:kickedevents. - Shared protocol β Single binary wire format (
{ topic, payload: bytes }) with documented WebSocket close codes. The@rivalis/handshakepackage is bundled into bothcoreandbrowserbuilds β consumers never install it.
Build a server in 30 lines:
import http from 'http'
import {
Rivalis, Transports, Room, AuthMiddleware,
type AuthResult, type Actor
} from '@rivalis/core'
type ActorData = { name: string }
class ChatRoom extends Room<ActorData> {
protected override presence = true // broadcast __presence:join / leave automatically
protected override onCreate() {
this.bind('chat', this.onChat)
}
private onChat(actor: Actor<ActorData>, payload: Uint8Array) {
this.broadcast('chat', payload) // fan-out to everyone in the room
}
}
class Auth extends AuthMiddleware<ActorData> {
override async authenticate(ticket: string): Promise<AuthResult<ActorData> | null> {
const name = ticket.trim()
if (!name || name.length > 20) return null
return { data: { name }, roomId: 'global' }
}
}
const server = http.createServer()
const rivalis = new Rivalis<ActorData>({
transports: [new Transports.WSTransport({ server })],
authMiddleware: new Auth()
})
rivalis.rooms.define('chat', ChatRoom)
rivalis.rooms.create('chat', 'global')
server.listen(8080)
process.on('SIGINT', async () => { await rivalis.shutdown(); process.exit(0) })β¦and connect a browser client:
import { WSClient } from '@rivalis/browser'
const ws = new WSClient('ws://localhost:8080', { reconnect: true })
const encoder = new TextEncoder()
const decoder = new TextDecoder()
ws.on('client:connect', () => console.log('connected'))
ws.on('client:kicked', ({ code, reason }) => console.log('kicked:', code, reason))
ws.on('chat', (payload) => console.log('chat:', decoder.decode(payload)))
ws.connect('alice') // ticket = "alice"
ws.send('chat', encoder.encode('hello world')) // payloads are opaque bytesRead on for full options:
- @rivalis/core β building servers, rooms, auth, transport tuning
- @rivalis/browser β the browser client API
| Package | Description | Published |
|---|---|---|
@rivalis/core |
Node.js server framework | β |
@rivalis/browser |
Browser WebSocket client | β |
@rivalis/handshake |
Wire-format primitives shared by core + browser |
private (bundled) |
@rivalis/demo |
End-to-end example: Express + Vite + React | private |
The demo ships a tiny app with three rooms β chat lobby, shared counter, two-player tic-tac-toe β to exercise every feature end-to-end.
git clone git@github.com:kalevski/rivalis.git
cd rivalis
npm install
npm run build
npm run demoThen open http://localhost:5173 (Vite client) which talks to the WebSocket server on :2334.
Server pipeline:
Client socket ββΊ Transport ββΊ TLayer ββΊ RoomManager ββΊ Room ββΊ Actor handlers
- Transport translates between its native protocol (currently WebSocket) and the framework boundary. Adding a new transport means subclassing
Transportand wiring four entry points. - TLayer owns the per-actor emitter and routes inbound frames into rooms; it also enforces
maxTopicLength, runs theRateLimiter, and manages the per-actor message buffer that makesactor.send()from insideonJoinwork without ceremony. - RoomManager is the registry of room classes (
define) and instances (create). - Room is the user extension point: bind topics to handlers, broadcast, kick, and override the
onCreate/onJoin/onLeave/onDestroylifecycle. - Actor is a per-connection handle inside a room; it carries the data your
AuthMiddleware.authenticatereturned and exposessend/kick.
Wire format is a single binary frame: { topic: string, payload: bytes }. The framework never inspects payload β encode it however you like (JSON, protobuf, msgpack, raw bytes).
The defaults are designed so that a fresh new Rivalis({ ... }) is not a trivial DoS target. Each is documented and tunable:
- Inbound frame size capped at 64 KiB per frame (
WSTransportOptions.maxPayload). - Topic length capped at 256 characters (
ConfigOptions.maxTopicLength). - Per-actor rate limit β token bucket, default 30 frames/sec (
TokenBucketRateLimiter). PassrateLimiter: nullto opt out. - Heartbeat β 30 s ping interval, 2-miss termination threshold (configurable, disable with
heartbeat: false). - Origin allow-list for CSWSH protection (opt-in via
allowedOrigins). - Per-IP connection rate limit (opt-in via
connectionLimiter). - Ticket logging β only an 8-char SHA-256 fingerprint, never the raw ticket.
MIT β see LICENSE.
