src/realtime/socketServer.ts — talking to browsers
src/realtime/socketServer.ts — talking to browsers
In one sentence: the doorway between the internet and the game. It receives messages from browsers, checks them, passes them to the game, and sends the game's announcements back out.
Size: 312 lines. Depends on: Socket.IO, GameManager, validation,
rateLimiter.
Why this file exists
The game rules must not know the internet exists — that is what makes them testable. But something has to actually talk to browsers.
This file is that something, and it is deliberately thin. It does three things and nothing else:
- Check and rate-limit incoming messages.
- Translate them into method calls on the game.
- Translate the game's announcements back into messages.
It contains no game rules. If a rule lived here it could not be tested without a network — which is exactly what we avoided.
What WebSocket is, briefly
A normal web request is one question and one answer, then the connection closes. That is useless for a game: the server needs to push updates without being asked.
A WebSocket stays open. Either side can send a message at any time. Socket.IO
is a library on top of WebSocket that adds named events (emit('state', …)),
automatic reconnection, and rooms — a way to send one message to a named
group of connections.
The code, line by line
1. The session (lines 9–15)
interface SocketSession {
roomId: string;
playerId: string;
username: string;
bucket: TokenBucket;
}
What we remember about one connected browser. Note bucket — every connection
gets its own rate limiter, so one player flooding cannot affect anyone else.
2. The counters (lines 17–26)
export interface SocketMetrics {
connections: number;
peakConnections: number;
joins: number;
rejectedJoins: number;
inputsAccepted: number;
inputsRateLimited: number;
inputsInvalid: number;
broadcasts: number;
}
These appear on /metrics. inputsRateLimited and inputsInvalid are the
interesting pair — if either climbs, either someone is attacking or a client has
a bug.
3. Server setup (lines 53–73)
this.io = new Server(httpServer, {
perMessageDeflate: false,
pingInterval: 10_000,
pingTimeout: 8_000,
maxHttpBufferSize: 4096,
cors: { origin: '*' },
});
Every one of these is a deliberate choice:
perMessageDeflate: false— turn compression off. Compressing costs CPU per message. At 20 messages per second × 2,000 sockets for a payload of about a kilobyte, compression would be the single most expensive thing on the server. Bandwidth is not our problem; CPU is.pingInterval: 10_000/pingTimeout: 8_000— check every 10 seconds that a connection is alive, and give up after 8 seconds of silence. Dead connections get cleaned up quickly so lobby seats free up.maxHttpBufferSize: 4096— reject any message larger than 4 KB before parsing it. Our biggest legitimate message is tiny. This stops someone sending a 100 MB message to exhaust memory.cors: { origin: '*' }— allow connections from any origin. Fine for an assessment; a real deployment would list specific domains.
this.manager.onEvent((event) => this.routeDomainEvent(event));
this.io.on('connection', (socket) => this.onConnection(socket));
Two subscriptions: one to the game (to hear announcements), one to Socket.IO (to hear about new browsers).
4. A browser connects (lines 79–102)
private onConnection(socket: Socket): void {
this.metrics.connections++;
this.metrics.peakConnections = Math.max(this.metrics.peakConnections, this.metrics.connections);
socket.on('join', (payload: unknown, ack?: unknown) => { … });
socket.on('input', (payload: unknown) => this.handleInput(socket, payload));
socket.on('leave', () => { this.releaseSession(socket); });
socket.on('disconnect', () => {
this.metrics.connections--;
this.releaseSession(socket);
});
}
Note the type: payload: unknown, not payload: any.
unknown means "we have no idea what this is, and TypeScript will refuse to let
you use it until you check". any would switch off all checking. Since this data
comes from the open internet, unknown is the honest type.
disconnect fires whether the browser closed politely, crashed, or lost wifi.
5. Joining (lines 104–170)
if (socket.data.session) {
this.reject(ack, 'already_joined', 'This connection is already in a game.');
return;
}
const parsed = validateJoin(payload, this.config);
if (!parsed.ok) {
this.metrics.rejectedJoins++;
this.reject(ack, 'invalid_payload', parsed.error);
return;
}
Guard first: one connection, one game. Then validate before the game sees anything.
const { result, room } = this.manager.join(parsed.value.username, now, {
roomId: parsed.value.roomId,
token: parsed.value.token,
forceNew: parsed.value.newRoom === true,
createIfMissing: true,
});
The three intents from the browser are passed straight through. createIfMissing
means an agreed code opens the lobby for whoever arrives first, so two friends can
decide on a code out loud.
const session: SocketSession = {
roomId: room.id,
playerId: result.player.id,
username: result.player.username,
bucket: new TokenBucket(
this.config.inputRateLimitBurst,
this.config.inputRateLimitPerSec,
now,
),
};
socket.data.session = session;
socket.join(room.id);
socket.join(room.id) — Socket.IO's rooms feature. This connection now belongs to
a named group, and io.to(roomId).emit(...) reaches exactly that group.
This is what enforces room isolation on the wire. A player physically cannot receive packets for a lobby they did not join.
ack?.({
ok: true,
roomId: room.id,
playerId: result.player.id,
token: result.token,
reconnected: result.reconnected,
config: { world: …, playerRadius: …, tickRateHz: …, maxPlayers: … },
state: room.getSnapshot(),
});
An ack is a reply function the browser supplied with its request — like a return value that travels over the network.
Two details:
token: result.token— sent only to its owner, in a direct reply. It never appears in a broadcast. There is a test asserting that.state: room.getSnapshot()— the full board immediately, so the browser can draw at once rather than waiting up to 50 ms for the next tick.
6. Movement input (lines 172–193)
private handleInput(socket: Socket, payload: unknown): void {
const session = socket.data.session as SocketSession | undefined;
if (!session) return;
const now = Date.now();
if (!session.bucket.tryConsume(now)) {
this.metrics.inputsRateLimited++;
return;
}
const parsed = validateInput(payload);
if (!parsed.ok) {
this.metrics.inputsInvalid++;
return;
}
const room = this.manager.getRoom(session.roomId);
if (!room) return;
room.setInput(session.playerId, parsed.value, now);
this.metrics.inputsAccepted++;
}
The order is deliberate: rate limit before validating.
Validating is more expensive than checking a counter. If someone floods us, we want to reject them as cheaply as possible. Checking the bucket first means a flood costs almost nothing.
return; with no error reply, on purpose. Telling a flooder "you are being rate
limited" means sending them a message for every message they send — which doubles
the traffic they are already causing. Silence is the correct response.
Notice this function cannot cheat. It passes the direction to
room.setInput, which normalises it. Nothing here can move a player.
7. Leaving (lines 195–201)
private releaseSession(socket: Socket): void {
const session = socket.data.session as SocketSession | undefined;
if (!session) return;
socket.data.session = undefined;
socket.leave(session.roomId);
this.manager.disconnect(session.roomId, session.playerId, Date.now());
}
Called on both leave and disconnect. Clearing socket.data.session first
makes it safe to call twice — the second call returns immediately.
8. Sending announcements out (lines 220–255)
private routeDomainEvent(event: GameEvent): void {
switch (event.type) {
case 'game_started':
this.io.to(event.roomId).emit('game_started', { … });
break;
case 'collectible_claimed':
this.io.to(event.roomId).emit('claim', {
collectibleId: event.collectibleId,
username: event.username,
playerId: event.playerId,
score: event.newScore,
remaining: event.remaining,
});
break;
case 'game_finished':
this.io.to(event.roomId).emit('game_over', {
reason: event.reason,
durationMs: event.durationMs,
leaderboard: event.leaderboard,
});
break;
case 'player_joined':
case 'player_left':
this.io.to(event.roomId).emit('roster', { type: event.type, username: event.username });
break;
}
}
Every branch uses this.io.to(event.roomId). That is the isolation guarantee.
Every event carries the room it belongs to, and every send is scoped to it.
collectibleId in the claim message does double duty: the browser uses it to
remove that collectible from its local list (which is what allows the small
per-tick messages), and the load test uses it to prove no collectible is ever
announced twice.
9. broadcastAll — the per-tick update (lines 257–283)
broadcastAll(): void {
this.broadcastTick++;
const includeCollectibles = this.broadcastTick % RESYNC_EVERY_N_BROADCASTS === 0;
for (const room of this.manager.listRooms()) {
if (room.connectedCount === 0) continue;
if (room.status === 'finished') continue;
this.io.to(room.id).emit('state', room.getSnapshot(includeCollectibles));
this.metrics.broadcasts++;
}
}
Called once per tick by the game loop.
% RESYNC_EVERY_N_BROADCASTS === 0 — "every 40th time". At 20 ticks a second
that is a full collectible list every 2 seconds. In between, browsers keep their
own list and remove entries as claim events arrive.
This is the measured optimisation: collectibles never move, and re-sending them 20 times a second was the biggest cost in the server. Payload shrank roughly 20× and ticks over budget fell from 16 to 3.
The two continue guards skip pointless work — nobody to talk to, or the game is
already over and everyone got the final scoreboard via game_over.
Why one emit per room, not per player? Socket.IO converts the message to JSON once for a room send and writes the same bytes to each member. A 20-player lobby costs one conversion, not twenty.
10. Error messages (lines 289–312)
function joinErrorMessage(result: { ok: boolean; reason?: string }): string {
switch (result.reason) {
case 'room_full': return 'That lobby is full.';
case 'room_finished': return 'That game has already finished.';
case 'username_taken': return 'That username is already in use in this lobby.';
case 'name_reserved':
return (
'That name belongs to a player who disconnected from this lobby. ' +
'Use "Rejoin your game" from the browser you played in, or pick another name.'
);
…
}
}
Turns internal codes into sentences a player can act on.
username_taken and name_reserved deliberately say different things. "Someone
is playing under that name" and "that is your own slot but you cannot prove it"
need different advice — and the second one tells the player exactly what to do
about it.
The full message protocol
Browser → server
| Message | Contents | Replies? |
|---|---|---|
join | {username, roomId?, newRoom?, token?} | yes |
input | {dx, dy, seq?} | no — too frequent |
leave | nothing | no |
Server → browser
| Message | When | Contents |
|---|---|---|
state | every tick | positions, scores, leaderboard |
game_started | countdown ends | start time, player count |
claim | someone scores | who, which collectible, how many left |
roster | someone joins/leaves | who |
game_over | game ends | reason, duration, final leaderboard |
If an interviewer asks
"How do you stop one lobby seeing another's data?"
"Every event carries its room id and every send is scoped with
io.to(roomId). A socket only belongs to the room it joined, so it physically cannot receive another lobby's packets. There's an integration test that scores in room A and asserts room B never sees it."
"Why is compression turned off?"
"It costs CPU per message. At 40,000 messages a second on roughly 1 KB payloads, it would be the most expensive thing on the server. Bandwidth isn't the constraint here — CPU is."
"Why rate limit before validating?"
"Validating costs more than checking a counter, so a flood should be rejected as cheaply as possible. And the rejection is silent — replying to a flooder doubles the traffic they're already generating."