Skip to content

Commit d0d3b11

Browse files
committed
refactor: protocol-agnostic relay server
Replace game-specific protocol (join/state/chat/snapshot) with a universal relay model. Server manages rooms and connections but treats game data as opaque payloads — any msgpack client works. - Auto-join on WebSocket connect (no handshake required) - Relay messages immediately to peers (no server-side tick loop) - Remove SNAPSHOT_HZ env var - New message types: welcome, peer_joined, peer_left, relay, ping/pong
1 parent ee6f7da commit d0d3b11

File tree

7 files changed

+239
-232
lines changed

7 files changed

+239
-232
lines changed

.env.example

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ PORT=8080
44
# Comma-separated allowed origins for WebSocket connections (* = allow all)
55
ALLOWED_ORIGINS=*
66

7-
# Server tick rate — snapshots broadcast per second (20 = 50ms between ticks)
8-
SNAPSHOT_HZ=20
9-
107
# WebSocket keepalive ping interval in milliseconds
118
KEEPALIVE_MS=30000
129

README.md

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# bun-ws-gameserver
22

3-
Production-grade Bun-native WebSocket game server with room-based architecture, binary protocol (msgpack), server-authoritative tick loop, and per-client rate limiting. 5-8x faster than Node.js `ws` — same protocol, same clients.
3+
Protocol-agnostic Bun-native WebSocket relay server with room-based architecture, binary protocol (msgpack), and per-client rate limiting. 5-8x faster than Node.js `ws` — same protocol, same clients.
44

55
[![Deploy on Alternate Futures](https://app.alternatefutures.ai/badge/deploy.svg)](https://app.alternatefutures.ai/deploy/bun-ws-gameserver)
66

@@ -10,9 +10,9 @@ Production-grade Bun-native WebSocket game server with room-based architecture,
1010

1111
- **Bun-native WebSocket** — Uses `Bun.serve()` built-in WebSocket (5-8x faster than Node.js `ws`)
1212
- **Room-based architecture**`/ws/:roomId` with auto-created rooms and configurable player caps
13+
- **Protocol-agnostic relay** — Server relays any msgpack message between peers without inspecting payloads
1314
- **Binary protocol (msgpack)**~40% smaller payloads than JSON
14-
- **Server-authoritative tick loop** — Configurable Hz for snapshot broadcasting
15-
- **Player state sync** — Position, rotation, action, and timestamp per player
15+
- **Instant relay** — Messages forwarded immediately to peers (no server-side batching)
1616
- **Bun pub/sub** — Built-in topic-based broadcasting for efficient room messages
1717
- **Zero-allocation per-connection state**`ws.data` pattern for per-client metadata
1818
- **Per-client rate limiting** — Sliding window algorithm
@@ -51,31 +51,55 @@ docker run -p 8080:8080 bun-ws-gameserver
5151
|----------|---------|-------------|
5252
| `PORT` | `8080` | Server listen port |
5353
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins |
54-
| `SNAPSHOT_HZ` | `20` | Tick rate (snapshots/sec) |
5554
| `KEEPALIVE_MS` | `30000` | Idle timeout (ms) |
5655
| `MAX_MESSAGES_PER_SECOND` | `60` | Per-client rate limit |
5756
| `MAX_PLAYERS_PER_ROOM` | `50` | Room capacity |
5857

5958
## Protocol
6059

61-
Both [`node-ws-gameserver`](https://github.com/alternatefutures/node-ws-gameserver) and `bun-ws-gameserver` use the same **msgpack binary protocol**, so clients are backend-agnostic.
60+
Both [`node-ws-gameserver`](https://github.com/mavisakalyan/node-ws-gameserver) and `bun-ws-gameserver` use the same **msgpack binary relay protocol**, so clients are backend-agnostic.
6261

63-
### Client → Server
62+
The server is **protocol-agnostic** — it manages rooms and connections, but treats game data as opaque payloads. Any client that speaks msgpack can use it: multiplayer games, collaborative tools, IoT dashboards, chat apps, etc.
63+
64+
### Connection Flow
65+
66+
1. Client connects to `ws://host/ws/:roomId`
67+
2. Server auto-assigns a `playerId` and sends `welcome` with list of existing peers
68+
3. Client sends any msgpack messages — server wraps each in a `relay` envelope and forwards to all other peers
69+
4. When peers join/leave, server notifies all remaining peers
70+
71+
### Server → Client
6472

6573
```typescript
66-
{ type: "join", payload: { displayName: string } }
67-
{ type: "state", payload: { position: {x,y,z}, rotation: {x,y,z,w}, action: string } }
68-
{ type: "chat", payload: { message: string } }
74+
// Sent on connect
75+
{ type: "welcome", playerId: string, peers: string[] }
76+
77+
// Peer lifecycle
78+
{ type: "peer_joined", peerId: string }
79+
{ type: "peer_left", peerId: string }
80+
81+
// Relayed game data from another peer (data is passed through untouched)
82+
{ type: "relay", from: string, data: any }
83+
84+
// Keepalive response
85+
{ type: "pong", nonce: string, serverTime: number }
86+
87+
// Errors (rate limit, room full, bad message)
88+
{ type: "error", code: string, message: string }
6989
```
7090

71-
### ServerClient
91+
### ClientServer
7292

7393
```typescript
74-
{ type: "snapshot", payload: { players: Record<id, PlayerState>, timestamp: number } }
75-
{ type: "player_joined", payload: { id: string, displayName: string } }
76-
{ type: "player_left", payload: { id: string } }
77-
{ type: "chat", payload: { id: string, message: string } }
78-
{ type: "error", payload: { code: string, message: string } }
94+
// Optional keepalive
95+
{ type: "ping", nonce: string }
96+
97+
// ANYTHING ELSE is relayed to all other peers in the room.
98+
// The server does not inspect or validate your game data.
99+
// Examples:
100+
{ type: "position", x: 1.5, y: 0, z: -3.2 }
101+
{ type: "chat", text: "hello" }
102+
{ type: "snapshot", pos: [0, 1, 0], rotY: 3.14, locomotion: "run" }
79103
```
80104

81105
### Example Client (browser)
@@ -86,28 +110,38 @@ import { encode, decode } from '@msgpack/msgpack';
86110
const ws = new WebSocket('ws://localhost:8080/ws/lobby');
87111
ws.binaryType = 'arraybuffer';
88112

89-
ws.onopen = () => {
90-
ws.send(encode({ type: 'join', payload: { displayName: 'Player1' } }));
91-
};
113+
let myId: string;
92114

93115
ws.onmessage = (event) => {
94116
const msg = decode(new Uint8Array(event.data));
95-
if (msg.type === 'snapshot') {
96-
// Update game state with msg.payload.players
117+
118+
switch (msg.type) {
119+
case 'welcome':
120+
myId = msg.playerId;
121+
console.log(`Joined as ${myId}, peers:`, msg.peers);
122+
break;
123+
case 'peer_joined':
124+
console.log(`${msg.peerId} joined`);
125+
break;
126+
case 'peer_left':
127+
console.log(`${msg.peerId} left`);
128+
break;
129+
case 'relay':
130+
// msg.from = peer ID, msg.data = whatever they sent
131+
handlePeerData(msg.from, msg.data);
132+
break;
97133
}
98134
};
99135

100-
// Send player state at 30fps
136+
// Send your game state (any shape you want)
101137
setInterval(() => {
102138
ws.send(encode({
103-
type: 'state',
104-
payload: {
105-
position: { x: 0, y: 0, z: 0 },
106-
rotation: { x: 0, y: 0, z: 0, w: 1 },
107-
action: 'idle',
108-
},
139+
type: 'position',
140+
x: Math.random() * 10,
141+
y: 0,
142+
z: Math.random() * 10,
109143
}));
110-
}, 33);
144+
}, 50);
111145
```
112146

113147
## Why Bun?
@@ -126,7 +160,7 @@ Same protocol, same clients, same API — just faster.
126160

127161
| Path | Method | Description |
128162
|------|--------|-------------|
129-
| `/ws/:roomId` | WS | WebSocket game connection (default room: "lobby") |
163+
| `/ws/:roomId` | WS | WebSocket connection (default room: "lobby") |
130164
| `/health` | GET | Health check — status, rooms, connections, uptime |
131165
| `/metrics` | GET | Detailed metrics — memory, messages/sec per room |
132166

docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ services:
88
environment:
99
- PORT=8080
1010
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
11-
- SNAPSHOT_HZ=${SNAPSHOT_HZ:-20}
1211
- KEEPALIVE_MS=${KEEPALIVE_MS:-30000}
1312
- MAX_MESSAGES_PER_SECOND=${MAX_MESSAGES_PER_SECOND:-60}
1413
- MAX_PLAYERS_PER_ROOM=${MAX_PLAYERS_PER_ROOM:-50}

src/health.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface HealthInfo {
1010

1111
export interface MetricsInfo {
1212
uptime: number;
13-
rooms: Record<string, { players: number; messagesPerSecond: number; running: boolean }>;
13+
rooms: Record<string, { players: number; messagesPerSecond: number }>;
1414
totalConnections: number;
1515
memory: {
1616
rss: number;
@@ -50,11 +50,10 @@ export function getMetricsResponse(rooms: Map<string, Room>): Response {
5050

5151
for (const [id, room] of rooms) {
5252
totalConnections += room.playerCount;
53-
roomMetrics[id] = {
54-
players: room.playerCount,
55-
messagesPerSecond: room.messagesPerSecond,
56-
running: room.isRunning,
57-
};
53+
roomMetrics[id] = {
54+
players: room.playerCount,
55+
messagesPerSecond: room.messagesPerSecond,
56+
};
5857
}
5958

6059
const metrics: MetricsInfo = {

src/index.ts

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { getHealthResponse, getMetricsResponse } from './health';
77

88
const PORT = Number(process.env.PORT) || 8080;
99
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*').split(',').map(s => s.trim());
10-
const SNAPSHOT_HZ = Number(process.env.SNAPSHOT_HZ) || 20;
1110
const KEEPALIVE_MS = Number(process.env.KEEPALIVE_MS) || 30000;
1211
const MAX_MESSAGES_PER_SECOND = Number(process.env.MAX_MESSAGES_PER_SECOND) || 60;
1312
const MAX_PLAYERS_PER_ROOM = Number(process.env.MAX_PLAYERS_PER_ROOM) || 50;
@@ -21,13 +20,12 @@ const rateLimiter = new RateLimiter(MAX_MESSAGES_PER_SECOND);
2120
export interface WSData {
2221
clientId: string;
2322
roomId: string;
24-
joined: boolean;
2523
}
2624

2725
function getOrCreateRoom(roomId: string): Room {
2826
let room = rooms.get(roomId);
2927
if (!room) {
30-
room = new Room(roomId, MAX_PLAYERS_PER_ROOM, SNAPSHOT_HZ);
28+
room = new Room(roomId, MAX_PLAYERS_PER_ROOM);
3129
rooms.set(roomId, room);
3230
}
3331
return room;
@@ -65,7 +63,7 @@ const server = Bun.serve<WSData>({
6563
const clientId = crypto.randomUUID();
6664

6765
const upgraded = server.upgrade(req, {
68-
data: { clientId, roomId, joined: false } satisfies WSData,
66+
data: { clientId, roomId } satisfies WSData,
6967
});
7068

7169
if (!upgraded) {
@@ -88,7 +86,13 @@ const server = Bun.serve<WSData>({
8886
sendPings: true,
8987

9088
open(ws) {
91-
// Connection opened — waiting for "join" message
89+
// Auto-join room on connect
90+
const { clientId, roomId } = ws.data;
91+
const room = getOrCreateRoom(roomId);
92+
const joined = room.join(clientId, ws);
93+
if (!joined) {
94+
ws.close(1013, 'Room full');
95+
}
9296
},
9397

9498
message(ws, message) {
@@ -98,7 +102,8 @@ const server = Bun.serve<WSData>({
98102
if (!rateLimiter.allow(clientId)) {
99103
ws.sendBinary(encodeMessage({
100104
type: 'error',
101-
payload: { code: ErrorCodes.RATE_LIMITED, message: 'Rate limited' },
105+
code: ErrorCodes.RATE_LIMITED,
106+
message: 'Rate limited',
102107
}));
103108
return;
104109
}
@@ -112,41 +117,26 @@ const server = Bun.serve<WSData>({
112117
if (!decoded) {
113118
ws.sendBinary(encodeMessage({
114119
type: 'error',
115-
payload: { code: ErrorCodes.INVALID_MESSAGE, message: 'Invalid message format' },
120+
code: ErrorCodes.INVALID_MESSAGE,
121+
message: 'Invalid message format',
116122
}));
117123
return;
118124
}
119125

120-
const room = getOrCreateRoom(roomId);
121-
122-
switch (decoded.type) {
123-
case 'join': {
124-
if (ws.data.joined) break;
125-
const displayName = (decoded.payload.displayName || 'Anonymous').slice(0, 32);
126-
const success = room.join(clientId, ws, displayName);
127-
if (success) {
128-
ws.data.joined = true;
129-
}
130-
break;
131-
}
132-
133-
case 'state': {
134-
if (!ws.data.joined) {
135-
ws.sendBinary(encodeMessage({
136-
type: 'error',
137-
payload: { code: ErrorCodes.NOT_JOINED, message: 'Send a "join" message first' },
138-
}));
139-
break;
140-
}
141-
room.updatePlayerState(clientId, decoded.payload);
142-
break;
143-
}
126+
// Handle ping
127+
if (decoded.type === 'ping' && typeof decoded.nonce === 'string') {
128+
ws.sendBinary(encodeMessage({
129+
type: 'pong',
130+
nonce: decoded.nonce,
131+
serverTime: Date.now(),
132+
}));
133+
return;
134+
}
144135

145-
case 'chat': {
146-
if (!ws.data.joined) break;
147-
room.chat(clientId, decoded.payload.message);
148-
break;
149-
}
136+
// Everything else is game data — relay to peers
137+
const room = rooms.get(roomId);
138+
if (room) {
139+
room.relay(clientId, decoded);
150140
}
151141
},
152142

@@ -167,6 +157,14 @@ const server = Bun.serve<WSData>({
167157
},
168158
});
169159

160+
// ─── Periodic metrics update ─────────────────────────────────────
161+
162+
setInterval(() => {
163+
for (const room of rooms.values()) {
164+
room.updateMetrics();
165+
}
166+
}, 1000);
167+
170168
// ─── Rate limiter cleanup ────────────────────────────────────────
171169

172170
setInterval(() => {
@@ -179,4 +177,4 @@ console.log(`[bun-ws-gameserver] Listening on port ${server.port}`);
179177
console.log(`[bun-ws-gameserver] WebSocket endpoint: ws://localhost:${server.port}/ws/:roomId`);
180178
console.log(`[bun-ws-gameserver] Health: http://localhost:${server.port}/health`);
181179
console.log(`[bun-ws-gameserver] Metrics: http://localhost:${server.port}/metrics`);
182-
console.log(`[bun-ws-gameserver] Config: ${SNAPSHOT_HZ}Hz tick, ${MAX_PLAYERS_PER_ROOM} max/room, ${MAX_MESSAGES_PER_SECOND} msg/s limit`);
180+
console.log(`[bun-ws-gameserver] Config: ${MAX_PLAYERS_PER_ROOM} max/room, ${MAX_MESSAGES_PER_SECOND} msg/s limit`);

0 commit comments

Comments
 (0)