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,
};
  • id must match the id in games.config.json
  • register() / unregister() receive GamePluginDependencies: runtime (the in-process GameRuntime), reduxStore, analyticsContextRegistry, and cosmeticLoadoutRegistry. Wire up game services, analytics providers, etc. here.
  • GameLayer is 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 from useServices(): reduxStore, gameRuntime). The engine registers its start-run handler on runtime.lifecycle and 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, before register() 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's FileLoader, Pixi's Assets), so the boot joins the requests instead of repeating them; frontend/src/trashcat/engine/trashcat-boot-prefetch.ts is 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:

  1. Set "enabled": true on the game entry in games.config.json.
  2. Add a GAME_TEST_OVERLAYS entry in e2e/src/games/game.registry.ts so @critical specs (notably critical-path.spec.ts) can drive the game.
  3. 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-failure and trashcat-<env>-external-<gameId>-canary-tests-failure.
  4. 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 via frontend/src/game-selection/ui/game-selection-menu.tsx).
  • Exposes the shared speedrun-button HUD testid (automatic if the game renders the standard learning HUD).
  • Transitions to GameStateType.Game when the speedrun starts.