One Folder Per Game
Long Distance Games is a little arcade for two people who are not in the same room. You pick a game, send the link, and play. Eight games are live: Tic-Tac-Toe, Connect 4, Word Duel, Dots & Boxes, Reversi, Battleship, Checkers and Dice Duel.
The thing I care about most in that codebase is not any of the games. It is that adding the ninth one costs a folder and a single line.
The failure mode I was trying to avoid
The obvious way to build this is to get Tic-Tac-Toe working end to end, then add Connect 4 by threading a second case through everything Tic-Tac-Toe touched. The room component learns about two board shapes. The move endpoint grows a switch. The lobby grows a second card with its own copy. Realtime grows a special case for the game where both players move at once.
By the fourth game you are not adding games any more, you are editing five files and hoping you found all of them. That is the point where a side project quietly stops getting new games.
So the first thing I wrote was not a game. It was the shape of one.
The interface
Everything in the app talks to GameDefinition and nothing else:
export interface GameDefinition<S = unknown, M = unknown> {
id: string
name: string
blurb: string
category: 'classic' | 'word' | 'strategy'
icon: string
seo: GameSeo
options?: GameOption[]
createInitialState(seed: string, options: GameOptions): S
currentSeat(state: S): Seat | null
validateMove(state: S, move: M, seat: Seat): MoveResult
applyMove(state: S, move: M, seat: Seat): S
getStatus(state: S): GameStatus
redactFor?(state: S, seat: Seat): S
getBotMove?(state: S, seat: Seat): M | null
Board: ComponentType<BoardProps<S, M>>
}
S is whatever that game wants its state to be, M is whatever it calls a
move. Connect 4 says a move is a column number. Word Duel says it is a word.
Battleship has two move shapes, placement and firing, and folds them into one
union. The room does not know or care about any of that. It has an S, it
hands it to Board, and when Board calls onMove it posts an M back.
Every game is a folder under src/games/ with a rules.ts of pure functions,
a Board.tsx, and an index.ts that assembles them into one exported object.
src/games/registry.ts imports it and puts it in a list.
What falls out for free
The nice part was not planned so much as discovered. Once the registry exists, a surprising amount of the app becomes derived rather than written.
The lobby maps over the registry. The sitemap maps over the registry. So does
robots.txt. Each game carries its own landing-page copy in a seo block, so
/games/connect-4 and /games/reversi are the same component rendered with
different data, complete with structured data, an FAQ and a generated Open
Graph image. A new game folder brings its own indexable page along with it.
There is no second place to remember.
That last sentence is the whole design goal. Every "remember to also update X" is a bug with a delay fuse on it.
The two hooks that pay for themselves
Two of the optional methods carry more weight than their size suggests.
redactFor strips whatever the given seat must not see, and it runs on the
server before state is sent to a browser. Word Duel is the reason it exists.
Both players race the same secret word, and I wanted you to watch your
opponent's tiles turn green without ever learning their letters:
export function redactFor(state: WordDuelState, seat: Seat): WordDuelState {
const over = getStatus(state).kind !== 'playing'
const opponent: Seat = seat === 0 ? 1 : 0
const guesses: [Guess[], Guess[]] = [[], []]
guesses[seat] = state.guesses[seat]
guesses[opponent] = state.guesses[opponent].map(({ word, pattern }) => ({
word: over ? word : '',
pattern,
}))
return { ...state, answer: over ? state.answer : '', guesses }
}
Colours go out, letters stay home, and the answer itself is blank until the round ends. Because this is a method on the definition rather than a rule in the transport layer, a future game with hidden information gets the same guarantee by writing eight lines.
getBotMove is how solo play exists at all. A game that supplies one gets a
"Play solo" button in the lobby, and the server takes the other seat. No
separate single-player mode, no second code path, no forked board component.
The bot's reply is played inside the same move request, so it lands in one
update instead of arriving a beat later.
Variants without a combinatorial explosion
Board sizes, Pop Out, Infinite Tic-Tac-Toe, hard mode, word length. Each of
these is a variant, and variants are the other classic way this kind of
codebase rots, because it is tempting to register connect-4-8x7 as a
separate game.
Instead a definition declares its options as data:
options: [
{
key: 'length',
label: 'Word length',
default: '5',
choices: [
{ value: '4', label: '4' },
{ value: '5', label: '5' },
{ value: '6', label: '6' },
],
},
]
The picker in the lobby is generated from that. A shared normalizeOptions
turns whatever a client actually sent into a set of declared values, so
createInitialState never sees anything it did not advertise. Validating
untrusted input becomes a set membership test rather than a per-game chore. The
room stores what it was created with, which is also how a rematch rebuilds the
same variant.
There is one reserved key the room sets rather than the player: firstSeat.
Rematch flips it, so the opening advantage evens out. Games where going first
does not matter simply ignore it.
What this costs
It is not free. The interface is a commitment, and a game that does not fit it
is genuinely awkward. Battleship pushed hardest: simultaneous fleet placement
does not have a "current seat" in the way Checkers does, which is why
currentSeat is allowed to return null for simultaneous games. Dice Duel
needed the server to roll, since a client that rolls its own dice is a client
that rolls five sixes.
Each of those was a real decision about whether to widen the interface or bend the game. I widened it twice and both times the addition was small and paid off in the next game. If I had had to widen it for every single game, that would have been the signal that the abstraction was wrong.
The companion piece to this one is Realtime as a Doorbell, on why clients in this app never write game state at all. And if you want the non-technical reason any of this exists, that is here.
Or you could just go play something. It takes about four seconds and no account.