Skip to content

twinwire/server ​

Node 22 or newer. The room server: sessions, the lobby, WebRTC, and TURN credentials.

For createEventServer, see named channels.

createGameServer ​

ts
import { createGameServer, createNativeRtcFactory, turnCredentials } from 'twinwire/server';

const server = await createGameServer({
  host: '127.0.0.1', port: 8787, origins: ['https://game.example.com'],
  rtc: await createNativeRtcFactory({ port: 8788, maxMessageSize: 64 * 1024 + 14 }),
  iceServers: session => [turnCredentials({ secret: process.env.TURN_SECRET, urls: 'turn:turn.example.com:3478', user: session.id })],
  trustProxy: true, resumeGraceMs: 90_000,
  authenticate: ({ auth, room, resuming, ip }) => validBuild(auth) ? 'anon' : null,
  endpoint: { bytesPerSecond: 48 * 1024 },
});
await server.listen();   // resolves with the port
OptionDefault
originsrequiredExact page origins, or a predicate (origin) => boolean. A '*' is refused
allowMissingOriginfalseLet clients without an Origin header in (non-browser clients)
port, host, path0, 127.0.0.1, /connectWhere to listen, and the WebSocket path
rtcnoneA WebRTC factory, usually createNativeRtcFactory(...). Without it: WebSocket only
webtransportnoneOptional authenticated HTTP/3 host from createWebTransportHost(...); deployment requirements
iceServers[]An array, or (session) => array | Promise, sent in each welcome
authenticate({ room, auth, resuming, ip }) => subject | null. Return a stable account subject, or null to refuse. Checked again on resume, and a resumed session must have the same subject
trustProxyfalseTake the client address from the last X-Forwarded-For hop when the peer is a local proxy
resumeGraceMs90 000How long a dropped session (and its lobby seat) waits
pumpHz60How often queued states and events go out; a game ticking faster sets its tick rate (10 to 250), or flushes each session after its snapshot
heartbeatTimeoutMs15 000A connection silent this long is closed
handshakeTimeoutMs5 000A connection that hasn't said hello by then is closed
maxSessions, maxConnections, maxPerIp256, 512, 32Sessions (connected or waiting to resume), sockets, and sockets per address
maxSessionsPerIpmaxPerIpSessions per address. At the limit a new one replaces that address's longest-disconnected session, so one address can't fill the table
rateLimit{ messages: 300, bytes: 256 KiB }Per connection, per second, both transports together
allowImpairmentfalseHonour a client's impair request. Development only
endpointEndpoint options for every session
onSession(session, resumed), onDisconnect(session), onExpire(session)Lifecycle callbacks; on() adds more listeners
onError(error)Problems that don't stop the server
http(req, res)404Your handler for plain HTTP requests on the same port (a /health route, static files)

GameServer ​

Member
listen()Start listening and pumping; resolves with the port
sessionsMap of id to GameSession
on('session' | 'disconnect' | 'expire', fn)Extra listeners; each returns a function that removes it
endSession(session, reason)Kick: the client gets onSessionLost(reason) and doesn't reconnect into it
broadcast(room, json)An event to every session that said hello with that room. Returns { queued, rejected }
metrics()Sessions, connections, how many on rtc, wt and ws, bytes each way, pending events, protocol errors, resumes, expiries. For /health
addressOf(req)The address the per-address limits use
close()Close every connection and stop

GameSession ​

Member
id, room, subjectIdentity: the hello's room namespace, and your authenticate result
connected, transport, epochWhether a connection is up, on rtc, wt or ws, and which connection it is
ipThe address of the current (or last) connection
helloThe client's latest auth JSON (names, build ids). Validate before use
dataYours
sendEvent(json), sendState(stream, bytes, options?)As on the client
onEvent, onState, onStateAckAs on the client. A lobby takes onEvent and onState over
endpointThe session's Endpoint, with its stats

Lobby ​

ts
import { Lobby } from 'twinwire/server';

const lobby = new Lobby(server, {
  maxMembers: 4, codeLength: 4, emptyCloseMs: 30_000,
  joinablePhases: ['lobby', 'playing'],          // join by code mid-game (drop-in)
  quickPlayPhases: ['lobby'],                    // quick play only into rooms still waiting
  ready: { countdownMs: 3000, minMembers: 1 },   // or false
  evictDisconnected: true, allowHostKick: true, maxRoomsPerIpPerHour: 30,
  clientMetaKeys: ['skin'],                      // member meta keys clients may set (default: any)
  canCreate: (session, request) => null,         // or an error message
  canJoin: (room, session, request) => room.meta.build !== session.hello?.build ? 'A NEW VERSION IS OUT' : null,
});
OptionDefault
maxMembers4Seats per room unless a request asks for fewer (at most 64)
codeLength4Room code length (3 to 12), from an alphabet without I and O
emptyCloseMs30 000A room with nobody left closes after this long; a join by code cancels it
maxRooms500
maxRoomsPerIpPerHour30
joinablePhases['lobby', 'playing']Phases a code can join
quickPlayPhases['lobby']Phases quick play may drop a player into
evictDisconnectedtrueWhen a room is full, free the seat of whoever has been disconnected longest
ready{ countdownMs: 3000, minMembers: 1 }Once every connected member is ready (and at least minMembers), count down and start. false: no ready-up
allowHostKicktrue
clientMetaKeysanyMember meta keys a client may set. Keys your server sets stay out of this list
canCreate(session, request)Return an error message to refuse
canJoin(room, session, request)Return an error message to refuse; used for codes and quick play

The lobby owns each session's onEvent and onState. Its callbacks, with room and member null for a session that isn't in a room: onCreate(room, creator, request), onJoin(room, member), onLeave(room, member, reason), onDisconnect(room, member), onReconnect(room, member), onStart(room), onClose(room, reason), onEvent(room, member, data, session), onState(room, member, state, session), onError(error).

Members: rooms (by code), createRoom({ mode, visibility, maxMembers, meta }) (a room on the server's own initiative), roomOf(session), memberOf(session), dispose().

Room: code, mode, visibility, phase, meta, data (yours), members (by session id), host, memberAt(seat), connectedCount, countdownEndsAt, broadcast(json, except?), send(member, json), setPhase(phase) ('lobby' clears ready), setMeta(patch), setVisibility(v), start() (skip the countdown), close(reason).

Member: session, room, seat (stable), joinedAt, name, ready, connected, disconnectedAt, meta, data (yours).

A member keeps its seat while disconnected, until its session expires. Seats are the lowest free number. The first member is host; when the host drops or leaves, the earliest-joined connected member takes over.

WebRTC ​

ts
const rtc = await createNativeRtcFactory({ port: 8788, publicIp: '203.0.113.7' });

createNativeRtcFactory({ port, publicIp, bindAddress, iceServers, maxMessageSize }) loads node-datachannel and fails loudly if it is missing. Every connection shares the one UDP port (ICE mux). publicIp rewrites IPv4 host candidates for a server behind 1:1 NAT; maxMessageSize (64 KiB + 14) should match the endpoints' maxStateBytes plus the longest (14-byte) header. nativeRtcFactory(module, options) does the same with a module you've loaded yourself.

TURN ​

ts
turnCredentials({ secret, urls, user, ttlSeconds })      // coturn use-auth-secret: one IceServer, valid ttlSeconds (3600)
cloudflareTurn({ keyId, apiToken, ttlSeconds })          // Cloudflare's relay: () => Promise<IceServer[]>, cached

Both are made to be returned from iceServers, so secrets stay on the server and clients get short-lived credentials.

Endpoint options ​

Both sides of a session run an Endpoint. Pass these as endpoint to the server or the client; both ends should agree on maxStateBytes.

OptionDefault
bytesPerSecond, burstBytes32 KiB, max(rate / 2, 16 KiB)Budget for states and events. A message bigger than the burst goes once the bucket is full
stateBudgetShare0.5Protected state share while both classes are busy; idle credit is borrowed. Strictly between 0 and 1
maxBufferedBytes64 KiBSocket backpressure
maxStateBytes64 KiBLargest state (at most 256 KiB)
datagramStateBytes8 KiBBigger states use the WebSocket even when datagrams are up
datagramEventstrueMirror events on the datagram path
stateAckIntervalMs50How often state acknowledgements go back
lateStates'drop''deliver': a state overtaken by a newer one on its stream still arrives once (within the last 32), with late: true
probeIntervalMs, probeTimeoutMs250, 1500Datagram proof: how often to probe, and how long without a reply before giving the path up
preferredDatagram, pathSwitchMarginMs, pathSwitchHoldMswt, 5, 1000Preferred comparable path, RTT advantage to switch, and minimum hold time
trace, traceClockdisabledOptional metadata observer and clock estimate. StateTraceBuffer and render marks
maxStreams1024
events{ maxEvents: 512, maxBytes: 1 MiB, maxEventBytes: 64 KiB, maxEarly: 256, maxEarlyBytes: 256 KiB }The event outbox, and events waiting for an earlier one
impairSimulated trouble on this endpoint's sends, for testing

Twinwire is MIT licensed. Source on GitHub, package on npm. Made by Studio Kimeiga.

MIT licensed. Source on GitHub, package on npm.