The Cartridge Pattern: Adding a Game Without Touching the Platform
How each OmniPlay game stays fully self-contained — a pure rules engine, a lazily-loaded bundle, and a five-field registry entry — so a fourth game costs the first three nothing.
The failure mode for a multi-game platform is predictable. You build the first game. You build the second, and factor out what they share. By the third, the "shared" layer knows about every game's quirks, and adding a fourth means touching code the other three depend on.
OmniPlay avoids this with what we call the cartridge pattern: each game is a sealed unit that the platform loads but never inspects. Adding a game touches no shared file except a registry entry.
The contract
The platform knows five things about a game:
{
id: "ludo",
titleKey: "game.ludo.title",
version: 3,
Component: lazy(() => import("../games/ludo/LudoGame.jsx")),
Thumbnail: LudoThumb,
}That's the whole interface. The platform routes /<id>, renders Thumbnail
on the home hub, mounts Component when someone opens the game, and uses
version to decide whether a saved game is still loadable.
It knows nothing about dice, boards, turns or captures. Ludo has four coloured seats and a dice roll; Checkers has two seats and no dice at all. Neither fact appears anywhere outside its own directory.
Why the thumbnail is eager and the game is lazy
The one deliberate asymmetry in that contract is worth calling out.
Component is wrapped in lazy(), so a game's engine, board geometry, physics
and sound ship as their own chunk, fetched only when someone opens that game.
Thumbnail is imported directly, because the hub has to render it immediately
— a lazily-loaded thumbnail would mean an empty card on first paint.
This works because the thumbnails are tiny inline SVG components with no dependencies. The expensive part of a game is behind the lazy boundary; the cheap part that the hub genuinely needs is not.
The result is the property that makes the pattern worth the discipline: adding a fourth game does not grow the bundle for anyone playing the first three. They pay for one more small SVG. That's it.
Note also that version is a literal in the registry rather than an import
from the game's engine. Importing it would pull the entire engine module into
the hub's bundle to read one number — exactly the leak the lazy boundary
exists to prevent. It's duplicated on purpose, with a comment saying so.
The rules engine is pure
Inside a cartridge, the split that matters most is between rules and rendering.
Each game's engine.js exports plain functions — newGame, applyRoll,
applyMove, removePlayer — that take state and return new state. No React,
no DOM, no timers, no side effects. They don't animate anything; they return
what happened:
export function applyMove(state, playerId, tokenId) {
// ...pure computation...
return {
state: nextState,
events: [
{ type: "capture", victim: "green", cell: 23 },
{ type: "extraTurn", player: playerId },
],
};
}The component consumes those events and decides what they look and sound like — a token sliding, a knock, a toast. The engine has no opinion about any of it.
Three things fall out of this:
The rules become testable in milliseconds. Fifty-eight unit tests cover the engines and the computer opponents. None of them mount a component or touch a DOM, so they run on every save rather than in CI ten minutes later.
The bot is just another caller. A computer opponent needs to ask "what
happens if I move this token?" many times per turn. Because applyMove is
pure, it can call it speculatively on a cloned state with no risk of triggering
an animation or a sound.
Presentation changes can't break the rules. Game logic and game feel change for completely different reasons and at completely different rates. Keeping them in separate modules means a redesign can't introduce a rules bug.
Events instead of callbacks
An earlier version had the engine take callbacks — onCapture, onFinish.
That looks equivalent and isn't.
Callbacks make the engine impure again: it now runs code it doesn't control, in an order it doesn't define, in the middle of computing state. Testing means supplying fakes. The bot has to pass no-ops for everything so its speculative moves don't fire sounds.
Returning an event array instead means the engine finishes its job completely before anything else happens. The caller decides whether to play the events, ignore them, or inspect them. The bot ignores them. The test asserts on them. The UI animates them. Same function, three consumers, no configuration.
Where the seam actually is
The honest part: sharing isn't free, and the boundary took a couple of attempts.
What genuinely belongs to the platform is anything with no game-specific logic in it — the dice component, dialogs, toasts, the settings sheet, the persistence wrapper, the sound synthesiser, the i18n provider. These are used by every game and know about none of them.
What belongs to a cartridge is anything that would need an if (game === ...)
to live in shared code. Board geometry, rules, bots, house-rule switches, the
setup screen.
The test we apply: if adding a fifth game would require editing this file, it's in the wrong place. Shared code that grows a branch per game isn't shared code — it's a switch statement with extra steps.
What it costs
This isn't free. There's duplication between cartridges — Ludo and Snakes & Ladders both have a concept of a token moving along a track, and they implement it separately. A more aggressive abstraction would remove that.
We've deliberately not built it. The two implementations differ in ways that matter (Ludo has captures, safe cells and a home column; S&L has neither), and a shared "track" abstraction would need enough configuration to express both that it would be harder to read than either. Duplication is cheaper than the wrong abstraction, and it stays cheap as long as the duplicated code is small and stable.
The rule we settled on: share mechanisms, duplicate policies. A dice component is a mechanism. "A roll of six grants another turn" is a policy, and it lives in exactly one game.