A self-hosted UDP relay server for peer-to-peer multiplayer games. Players connect with a short code and password — no IP addresses, no port forwarding, no accounts.
- Zero configuration for players — host shares a code and password, that's it
- Public lobby — list visible sessions and inspect their peers before joining
- Project isolation — multiple games on the same instance, each with its own API key and session limits
- No database — entirely in-memory, zero infrastructure dependencies
- Stateless sessions — sessions expire automatically after inactivity
- Self-hosted — deploy anywhere that runs Node.js or Docker
- Node.js + TypeScript
- Express — HTTP signaling server
- dgram — UDP relay (Node built-in, no extra dependencies)
- Zod — Request validation
- The host calls
POST /sessions(optionally providing acode) → receivescode,password,sessionToken,peerToken, andpeerIndex: 0 - The host shares the code and password with their players out-of-band (in-game lobby, chat, etc.)
- Each player calls
POST /sessions/:code/joinwith the password → receivessessionToken,peerToken, and their assignedpeerIndex - All players open a UDP socket to the relay and prefix every packet with a 9-byte header:
[sessionToken (4 bytes BE)][peerToken (4 bytes)][targetPeerIndex (1 byte)] - The relay identifies the peer via
peerToken, then forwards the packet to the target peer (unicast) or all other peers (broadcast,targetPeerIndex = 0xFF), prepending the sender'speerIndexas a 4-byte big-endian header
The relay never inspects packet payload — it only reads the 9-byte header to identify and route traffic, and prepends the sender index so clients can distinguish peers.
Edit projects.yaml:
projects:
- id: example-game
maxPeersPerSession: 16 # max peers per session (default: 16)
maxSessions: 50 # max concurrent sessions, 0 = unlimited (default: 0)Generate a secret key for each project (e.g. openssl rand -hex 32) and set it as an environment variable. The name is derived from the project id: uppercased, hyphens replaced with underscores, _API_KEY appended.
EXAMPLE_GAME_API_KEY=$(openssl rand -hex 32)So example-game → EXAMPLE_GAME_API_KEY. Your game client uses this key in the Authorization header when calling the HTTP API.
npm install
npm run devThe HTTP signaling server starts on port 3000 and the UDP relay on port 7777.
The server is a single Node.js process with no external dependencies. Deploy it anywhere:
projects.yaml is baked into the image at build time. Build and run:
docker build -t openrelay .
docker run -p 3000:3000 -p 7777:7777/udp \
-e EXAMPLE_GAME_API_KEY=your_secret_key \
openrelayMake sure to expose both the HTTP port (TCP) and the relay port (UDP).
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP signaling port |
PORT_UDP |
7777 |
UDP relay port |
SESSION_TTL_MS |
300000 |
Session expiry after inactivity (ms) |
LOG_LEVEL |
info |
Pino log level |
All endpoints require Authorization: Bearer <apiKey>.
POST /sessions
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
code |
string (6 chars, A-Z0-9) | No | Custom code; server generates one if absent |
password |
string (min 6 chars) | No | If set, joining requires the correct password. Omit for open access |
label |
string (max 64 chars) | No | Human-readable name shown in the lobby |
visibility |
"public" | "private" |
No | Default "private". Public sessions appear in GET /sessions |
Returns 409 Conflict if the code is already in use.
Response:
{
"code": "ROOM01",
"password": "m2qn8pzv",
"sessionToken": 2947183621,
"peerToken": "a1b2c3d4",
"peerIndex": 0
}password is null for public sessions without a password. Share code and password with your players out-of-band. Use sessionToken and peerToken to connect to the UDP relay.
POST /sessions/:code/join
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
password |
string | No | Required for password-protected sessions |
Response:
{
"sessionToken": 2947183621,
"peerToken": "b2c3d4e5",
"peerIndex": 1
}GET /sessions
Returns all visible sessions for the project.
{
"sessions": [
{
"code": "ROOM01",
"label": "Friday night",
"peers": 3,
"maxPeers": 8,
"passwordRequired": false
}
]
}peers reflects only active peers (seen within the last 10 seconds).
GET /sessions/:code
Returns details and the active peer count for any session (public or private).
{
"code": "ROOM01",
"label": "Friday night",
"visibility": "public",
"passwordRequired": false,
"peers": 2
}Only active peers appear in the count.
GET /health
Every packet sent to the relay must begin with a 9-byte header:
[0–3] sessionToken (uint32 BE)
[4–7] peerToken (4 bytes, from session response)
[8] targetPeerIndex (uint8; 0xFF = broadcast to all peers)
[9…] payload
Set targetPeerIndex to the recipient's peerIndex for unicast, or 0xFF to broadcast to all peers in the session.
Peers are registered automatically on their first packet — no explicit handshake required.
Every packet forwarded by the relay is prefixed with a 4-byte sender index:
[0–3] senderIndex (uint32 BE)
[4…] payload
Use senderIndex to map incoming packets to the correct peer. The host is always peerIndex 0.
npm run dev # Start with hot reload
npm run build # Compile TypeScript
npm start # Run compiled output
npm run lint # ESLint
npm run lint:fix # ESLint with auto-fix
npm run format # PrettierMIT