src/realtime/validation.ts — checking untrusted messages
src/realtime/validation.ts — checking untrusted messages
In one sentence: every message arriving from a browser is inspected here before the game is allowed to see it.
Size: 117 lines. Depends on: gameConfig only.
Why this file exists
Anyone can open developer tools and send whatever they like down the socket. Not
just wrong values — wrong types. A message might contain null, a number where
text belongs, NaN, a 10-megabyte string, or an array instead of an object.
If unchecked data reached the game, one bad message could set a player's position
to NaN permanently. Every comparison against NaN is false, so that player
could never collect anything again — and no error would ever be logged.
So: nothing reaches the game until it has been proved to be the right shape.
Why hand-written rather than a library like Zod
validateInput runs about 40,000 times a second at full load. A schema
library builds and walks a schema object on every call, allocating as it goes.
These checks are a handful of typeof comparisons with no allocation at all.
There are exactly two message shapes here. With a dozen evolving message types a library would earn its cost; at two, it does not.
That is a narrow, defensible trade — and worth saying out loud rather than letting an interviewer assume you had not heard of Zod.
The code, line by line
1. The result type (line 35)
export type Validated<T> = { ok: true; value: T } | { ok: false; error: string };
A discriminated union — the result is one of two shapes, told apart by ok.
<T> is a generic: a placeholder for whatever type is being validated. The
same result shape works for join messages and input messages.
This is why validation never throws an exception. Bad input from the internet is
an ordinary event, not an emergency. The caller must check ok before reading
value, and TypeScript enforces that.
2. The two patterns (lines 37–42)
const ROOM_CODE_RE = /^[A-Z0-9]{4,12}$/;
const USERNAME_RE = /^[\p{L}\p{N} _.\-]+$/u;
Regular expressions — patterns text must match.
ROOM_CODE_RE reads as: ^ start, [A-Z0-9] capital letters or digits,
{4,12} between 4 and 12 of them, $ end. Nothing else at all.
USERNAME_RE reads as: ^ start, then one or more of \p{L} any letter in
any language, \p{N} any digit, space, underscore, dot, hyphen — then $ end.
The u flag switches on Unicode support.
\p{L} is why Zoë and 玩家一 are accepted. A rule of "English letters only"
would be quietly hostile to most of the world.
The pattern is a whitelist (what is allowed) rather than a blacklist (what is banned). Blacklists always miss something.
3. The object guard (lines 44–46)
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Three checks, and each catches something real:
typeof value === 'object'— rejects text, numbers, booleans.value !== null— essential, becausetypeof nullis famously'object'in JavaScript. Without this,nullwould pass and the next line would crash.!Array.isArray(value)— arrays are also objects.[1,2,3]is not a valid message.
value is Record<string, unknown> is a type predicate. It tells TypeScript
"if this returns true, treat the value as an object from here on", which unlocks
property access safely.
4. validateJoin (lines 48–91)
if (!isPlainObject(raw)) return { ok: false, error: 'payload must be an object' };
const { username, roomId, token, newRoom } = raw;
Shape first, then fields.
Username
if (typeof username !== 'string') return { ok: false, error: 'username must be a string' };
const name = username.trim();
if (name.length < config.usernameMinLength || name.length > config.usernameMaxLength) {
return { ok: false, error: `username must be … characters` };
}
if (!USERNAME_RE.test(name)) {
return { ok: false, error: 'username contains unsupported characters' };
}
Type, then length, then contents — cheapest check first.
.trim() before measuring, so " " becomes "" and is rejected as too short.
Otherwise a player could be called three spaces.
The length limit matters for more than tidiness: usernames appear in every broadcast, 20 times a second. A 10,000-character name would be sent to every player in the lobby continuously.
Room code
if (roomId !== undefined && roomId !== null && roomId !== '') {
if (typeof roomId !== 'string') return { ok: false, error: 'roomId must be a string' };
const code = roomId.trim().toUpperCase();
if (!ROOM_CODE_RE.test(code)) return { ok: false, error: 'invalid room code' };
result.roomId = code;
}
Optional, so three "not supplied" cases are treated the same.
.toUpperCase() — typing wdzyap finds the same lobby as WDZYAP. A small
kindness that prevents a lot of confused support.
The strict pattern also blocks path-traversal attempts like ../../etc/passwd,
since the code is later used in a URL.
The new-room flag
if (newRoom !== undefined && newRoom !== null) {
if (typeof newRoom !== 'boolean') return { ok: false, error: 'newRoom must be a boolean' };
result.newRoom = newRoom;
}
Strictly boolean. The string "false" is truthy in JavaScript, so accepting
strings here would mean newRoom: "false" created a new room. Demanding a real
boolean removes the whole class of bug.
Token
if (token !== undefined && token !== null && token !== '') {
if (typeof token !== 'string' || token.length > 64) {
return { ok: false, error: 'invalid token' };
}
result.token = token;
}
token.length > 64 — a length cap so nobody can make the server compare
megabyte-long strings. Real tokens are 36 characters.
5. validateInput — the hot path (lines 98–117)
export function validateInput(raw: unknown): Validated<ValidInputPayload> {
if (!isPlainObject(raw)) return { ok: false, error: 'payload must be an object' };
const { dx, dy, seq } = raw;
if (typeof dx !== 'number' || !Number.isFinite(dx)) {
return { ok: false, error: 'dx must be a finite number' };
}
if (typeof dy !== 'number' || !Number.isFinite(dy)) {
return { ok: false, error: 'dy must be a finite number' };
}
const value: ValidInputPayload = { dx, dy };
if (seq !== undefined) {
if (typeof seq !== 'number' || !Number.isFinite(seq)) {
return { ok: false, error: 'seq must be a finite number' };
}
value.seq = seq;
}
return { ok: true, value };
}
The busiest function in the project.
Number.isFinite is the critical check. It rejects both NaN and Infinity,
and this is not theoretical:
NaNwould spread into the player's position. Every comparison withNaNis false, so they could never collect anything again — and nothing would log an error.Infinitywould put a player at an unreachable coordinate.
Note that typeof NaN is 'number', so the type check alone is not enough.
Both checks are needed.
Rejected, not corrected. We could quietly replace a bad value with 0, but
silently fixing malformed input hides bugs. Rejecting means the counter
inputsInvalid on /metrics goes up, and someone can see something is wrong.
Note what this function deliberately does not do: it does not clamp the direction's length.
{dx: 1000, dy: 0}passes validation, because it is two finite numbers. Shrinking it to length 1 happens inGameRoom.setInput, which is the authority on speed. Validation checks shape; the game decides meaning.
What it rejects, tested
From tests/unit/infrastructure.test.ts:
const bad: unknown[] = [
null, undefined, 'a string', 42, [],
{ username: null }, { username: 42 }, { username: '' }, { username: ' ' },
{ username: 'x'.repeat(21) },
{ username: 'ok', roomId: 42 },
{ username: 'ok', roomId: 'has spaces' },
{ username: 'ok', roomId: '../../etc/passwd' },
{ username: 'ok', token: 'x'.repeat(65) },
{ username: '<script>alert(1)</script>' },
];
for (const payload of bad) {
expect(validateJoin(payload, cfg).ok).toBe(false);
}
If an interviewer asks
"Why not Zod?"
"
validateInputruns about 40,000 times a second, and a schema library allocates and walks a schema on every call. There are two message shapes here, so hand-written type guards are cheaper and there's no schema evolution problem at this size. With a dozen evolving message types I'd use a library."
"What's the worst thing a malformed message could do?"
"Set a position to
NaN. It would spread silently, every comparison against it is false, and that player could never score again — with nothing in the logs. That's whyNumber.isFiniteis checked rather than justtypeof."
"Why reject instead of coercing to a safe value?"
"Silently fixing bad input hides bugs. Rejecting increments a counter on
/metrics, so a broken client is visible instead of invisible."