src/domain/types.ts — the shapes of everything
src/domain/types.ts — the shapes of everything
In one sentence: describes what every piece of data looks like. No logic at all — just definitions.
Size: 160 lines. Depends on: nothing.
Why this file exists
TypeScript types are compile-time only. They vanish completely when the code is built; not one line of this file exists in the running server.
So what are they for? They catch mistakes while you type. If you write
player.scores when the field is player.score, the editor underlines it before
you ever run anything.
Keeping the shapes in one file also means there is exactly one place to look up "what is in a snapshot?" — and one place to change it.
The code, line by line
1. Game status (lines 3–6)
export type GameStatus = 'waiting' | 'countdown' | 'running' | 'finished';
export type FinishReason = 'completed' | 'abandoned';
A union of string literals. GameStatus is not "any text" — it is exactly one
of those four words. Writing status = 'runnning' (three n's) is caught
immediately.
This is also a self-documenting list of every state a lobby can be in:
waiting ──▶ countdown ──▶ running ──▶ finished
▲ │ ▲
└─────────────┘ │
(everyone left) (all collected, or abandoned)
FinishReason records why it ended, and this is saved to the database:
completed— every collectible was taken. A real result.abandoned— everyone left mid-game. Still recorded, so the history is honest.
2. Player input (lines 13–20)
export interface PlayerInput {
dx: number;
dy: number;
seq?: number;
}
What a browser is allowed to send about movement — and notice what is missing:
there is no x or y. A client can say which way, never where.
dx/dy describe a direction as an arrow. {dx: 1, dy: 0} is right,
{dx: 0, dy: -1} is up (screen coordinates grow downward), {dx: 0.7, dy: 0.7}
is diagonal.
seq? — the ? means optional. A counter the client increases with each message,
useful for debugging which message arrived when. The server records it but does
not act on it.
3. Player state (lines 22–33)
export interface PlayerState {
id: string;
username: string;
x: number;
y: number;
score: number;
connected: boolean;
lastInputAt: number;
lastSeq: number;
}
The public view of a player. The token is deliberately absent. GameRoom
defines a private PlayerRecord that extends this with the token, so the secret
cannot leak into anything typed as PlayerState.
id—"p1","p2". Unique inside one room only.connected—falsefor someone who dropped. They stay on the leaderboard but stop moving and stop collecting.lastInputAt— when we last heard from them, used to expire stale movement.
4. Collectible state (lines 35–41)
export interface CollectibleState {
id: string;
x: number;
y: number;
active: boolean;
}
active: false once claimed. The item is not deleted from the array.
That is deliberate: flipping a flag is what makes the exactly-once guarantee simple. Removing an item mid-loop would shift every later index and could cause one to be skipped.
5. Leaderboard entry (lines 43–49)
export interface LeaderboardEntry {
playerId: string;
username: string;
score: number;
connected: boolean;
rank: number;
}
rank is calculated, not stored on the player. Equal scores share a rank
(1, 2, 2, 4) — standard competition ranking.
connected is included so the UI can grey out players who left, while still
showing their score.
6. The snapshot — what browsers actually receive (lines 51–83)
export interface RoomSnapshot {
roomId: string;
status: GameStatus;
tick: number;
serverTime: number;
startsAt: number | null;
world: { width: number; height: number };
players: Array<{ id, username, x, y, score, connected }>;
collectibles?: Array<{ id: string; x: number; y: number }>;
collectiblesRemaining: number;
collectiblesTotal: number;
leaderboard: LeaderboardEntry[];
}
Sent 20 times a second to every player. Three fields deserve attention:
serverTime — the server's clock at the tick that produced this snapshot.
Subtracting it from the moment of arrival gives the delivery delay. This is how
the stress test measures real latency rather than guessing.
collectibles? — the ? is the measured optimisation. Collectibles never
move, so the full list is sent only on joining and once every 2 seconds after
that. In between, browsers remove them one by one as claim events arrive.
The payload shrank about 20× and ticks over budget fell from 16 to 3.
startsAt: number | null — when the countdown ends, or null when there is
no countdown. The | null forces every reader to handle the empty case.
7. Domain events (lines 93–141)
export interface CollectibleClaimedEvent {
type: 'collectible_claimed';
roomId: string;
collectibleId: string;
playerId: string;
username: string;
newScore: number;
remaining: number;
}
This is the architectural centrepiece of the file.
GameRoom never calls a socket and never calls the database. It returns a list of
events describing what happened, and other layers decide what to do with them:
GameRoom returns events
│
├──▶ socketServer → broadcasts to browsers
└──▶ persistence → queues a database write
Neither subscriber can block or corrupt the simulation. That is what keeps
await out of the game's critical section — which is the whole basis of the
exactly-once guarantee.
Every event carries roomId. That is not decoration: it is how the socket layer
knows which lobby to send to, and it is what enforces room isolation on the wire.
export type GameEvent =
| PlayerJoinedEvent
| PlayerLeftEvent
| GameStartedEvent
| CollectibleClaimedEvent
| GameFinishedEvent;
A union of the five event types. When you write switch (event.type), TypeScript
knows exactly which fields exist in each branch — and warns you if you forget one.
8. Join results (lines 143–160)
export type JoinRejectReason =
| 'room_full'
| 'room_finished'
| 'username_taken'
| 'name_reserved'
| 'room_not_found'
| 'invalid_username';
export type JoinResult =
| { ok: true; player: PlayerState; token: string; reconnected: boolean; events: GameEvent[] }
| { ok: false; reason: JoinRejectReason };
Another discriminated union. A failed join is a normal outcome, not an
exception — so it is returned as a value, and the caller is forced to check ok
before reading player.
Two reasons look similar but are deliberately separate:
username_taken— someone is actively playing under that name.name_reserved— it is a disconnected player's slot and you have not proved it is yours.
They need different advice, so the browser can tell you to use "Rejoin your game" rather than just "pick another name".
token appears only in the success branch, and the socket layer sends it only
to its owner in a direct reply — never in a broadcast.
If an interviewer asks
"Why return events instead of calling the socket directly?"
"So the game rules stay free of I/O. The room announces what happened; the socket layer and the persistence layer decide what to do about it. That's what keeps
awaitout of the collision path, which is the basis of the exactly-once guarantee."
"Why is the token not in
PlayerState?""So it can't leak.
PlayerStateis the public shape; the token lives on a privatePlayerRecordinsideGameRoom. There's a test asserting it never appears in a snapshot."
"Why unions of string literals instead of enums?"
"They're simpler, they vanish at compile time, and they serialise straight to JSON. An enum would need converting at the network boundary."