Adding New Games to the Monorepo
This monorepo supports multiple games. Each game has a frontend plugin, an entry in games.config.json, and a feature folder under frontend/src/. Use frontend/src/example-web-game/ as the reference template.
Checklist
1. Add Entry to games.config.json
Add a new object to the games array at root games.config.json:
{
"id": "my-game",
"type": "web",
"enabled": true,
"label": "MY GAME",
"pageTitle": "My Game",
"iconSrc": "/my-game/images/game-icon.png",
"faviconHref": "/my-game/images/fav-icon.png"
}
| Field | Required | Notes |
|---|---|---|
id |
Yes | Must match the frontend feature folder name (frontend/src/<id>/) |
type |
Yes | "web" |
enabled |
Yes | false hides from selection and excludes from tooling |
label |
Yes | Display name in game selection menu (uppercase) |
pageTitle |
Yes | Browser tab title |
iconSrc |
No | Path to icon served from frontend/public/ |
faviconHref |
No | Path to favicon served from frontend/public/ |
2. Create Frontend Feature Directory
Create a feature folder at frontend/src/<id>/ following Feature Sliced Design. Mirror frontend/src/example-web-game/:
frontend/src/<id>/
├── <id>.plugin.ts
└── ui/
└── <id>-game-layer.tsx
3. Create the Game Plugin
The plugin implements GamePlugin from frontend/src/game-selection/models/game-plugin.model.ts:
import { GameStateType } from '@shared/game';
import type { GamePlugin } from '../game-selection/models/game-plugin.model';
import { MyGameLayer } from './ui/my-game-layer';
let unregisterStartRun: (() => void) | null = null;
export const myGamePlugin: GamePlugin = {
id: 'my-game',
register({ runtime }) {
unregisterStartRun = runtime.lifecycle.registerStartRunHandler(() => runtime.lifecycle.startRunAsync());
runtime.lifecycle.announceGameState(GameStateType.None);
},
unregister() {
unregisterStartRun?.();
unregisterStartRun = null;
},
GameLayer: MyGameLayer,
};
idmust match theidingames.config.jsonregister()/unregister()receiveGamePluginDependencies:runtime(the in-processGameRuntime),reduxStore,analyticsContextRegistry, andcosmeticLoadoutRegistry. Wire up game services, analytics providers, etc. here.GameLayeris the root React component rendered when this game is active. Mount<GamesSharedLayer />and<LearningLayer />alongside game-specific overlays.- The game's canvas host boots the engine with
Engine.create(host, { store, runtime })(both fromuseServices():reduxStore,gameRuntime). The engine registers its start-run handler onruntime.lifecycleand announces game state / run status through it; see.cursor/rules/trashcat/auto-attached/frontend/game-communication.mdc. reducers(optional) holds the game's Redux reducers (frontend/src/<id>/<id>.store.ts). They are injected into the store when the game activates, beforeregister()runs, so nothing game-specific ships in the app's boot chunk.preload()(optional) starts the downloads the game's first boot makes that need no account. The platform calls it when a guest taps PLAY, alongside the guest-account request, and ignores its outcome. Load through the loaders the engine boot uses (three'sFileLoader, Pixi'sAssets), so the boot joins the requests instead of repeating them;frontend/src/trashcat/engine/trashcat-boot-prefetch.tsis the reference.
4. Register the Plugin
Add the plugin to the plugins array passed to bootstrapPlatform in frontend/src/main.tsx. Declare it lazily so the game's code is a separate chunk fetched only when the game activates:
plugins: [
// ... existing plugins
{ id: MY_GAME_ID, load: () => import('./my-game/my-game.plugin').then((module) => module.myGamePlugin) },
],
An eager entry (myGamePlugin itself) also works, at the cost of bundling the game into the entry chunk. In DEV mode, the platform warns if an enabled game has no matching plugin entry.
5. Add Game Icons
For the game selection menu, place icon assets in frontend/public/<id>/images/ (or another public path). Those paths must match the iconSrc / faviconHref values in games.config.json.
Game-Specific Runtime Services
Cross-cutting domains reach the game through the in-process runtime (runtime.<domain>); a game does not define lifecycle messages of its own. Game-specific services a game exposes to its own React layer live under frontend/src/<id>/runtime/ and are registered from the plugin's register().
Systems That Auto-Discover Games from games.config.json
These systems read games.config.json and operate on all enabled games automatically:
| System | What it does | Filter |
|---|---|---|
init-platform-plugins.ts (frontend/src/platform/services/) |
DEV warning for missing plugins | All enabled games |
loadGamesConfig() (@shared/games-config) |
Single source of truth for parsing games.config.json |
All games — consumers filter as needed |
loadCanaryTestsGames() (infra/utils/canary-tests-games.util.ts) |
Filters config for canary-tests alarms and metrics | enabled === true |
e2e/src/games/game.registry.ts (GAME_IDS, GAME_TEST_OVERLAYS) |
Drives @critical Playwright specs (including canary tests) |
enabled === true and a GAME_TEST_OVERLAYS entry |
Canary tests coverage
The Canary Tests workflow (.github/workflows/canary-tests.yml) runs Chromium @critical Playwright checks every 10 minutes in integration and production. Every game with "enabled": true in games.config.json is included automatically — there is no separate opt-in flag.
To add a new game to canary tests:
- Set
"enabled": trueon the game entry ingames.config.json. - Add a
GAME_TEST_OVERLAYSentry ine2e/src/games/game.registry.tsso@criticalspecs (notablycritical-path.spec.ts) can drive the game. - Redeploy monitored integration or production through the corresponding Deploy Integration or Deploy Production GitHub Actions workflow. Infrastructure keeps the EventBridge schedule enabled and adds the per-game CloudWatch alarms
trashcat-<env>-<gameId>-canary-tests-failureandtrashcat-<env>-external-<gameId>-canary-tests-failure. - Verify one scheduled run in GitHub Actions (workflow Canary Tests) or run the critical gate locally:
pnpm --filter trashcat-e2e exec playwright test --project=chromium --grep @critical
Prerequisites for a game to pass the canary critical path:
- Exposes the shared
game-selection-<gameId>testid in the game-selection menu (automatic viafrontend/src/game-selection/ui/game-selection-menu.tsx). - Exposes the shared
speedrun-buttonHUD testid (automatic if the game renders the standard learning HUD). - Transitions to
GameStateType.Gamewhen the speedrun starts.
