You are helping add a new game to the Ancient Games platform. This is a full-stack TypeScript monorepo (npm workspaces) with:
shared/— types and game manifestsbackend/— Node.js + Express + Socket.io + game logicfrontend/— React 18 + Vite + Tailwind CSS
Architecture: Game Isolation
Each game is self-contained in its own folder. The platform uses registries and manifests so that adding a new game never requires modifying shared UI components (Home.tsx, MoveLog, etc.). Changes are limited to:
- Engine-level changes (shared types, backend engine, backend registry) — committed first
- Game-specific resources (board, rules, controls, score, manifest entry) — committed second
Arguments
Parse $ARGUMENTS as: <game-id> "<Display Name>" "<emoji>"
game-id: kebab-case identifier (e.g.fox-and-geese)Display Name: human-readable title (e.g.Fox & Geese)emoji: single emoji for the game picker (e.g.🦊)
If any argument is missing, ask the user before proceeding.
Step 0: Understand the game
Before writing any code, if the game rules are not obvious or well-known, ask the user to describe:
- Board layout and number of positions
- Number of pieces per player
- Dice mechanic (or whether it's dice-free — use
rollDice()returning1always) - Win condition
- Any special squares, captures, or multi-phase mechanics
COMMIT 1: Engine & Shared Types
This commit adds the game engine and all shared type changes. No frontend changes.
Step 1: Shared types — add GameType and GameManifest entry
Edit shared/types/game.ts:
1a. Add to the GameType union:
export type GameType = ... | 'GAME_ID';
1b. Add entry to GAME_MANIFESTS:
'GAME_ID': {
type: 'GAME_ID',
title: 'DISPLAY NAME',
emoji: 'EMOJI',
description: '2 players', // or appropriate description
playerColors: ['#COLOR1', '#COLOR2'], // choose distinct colors for each player
supportsHistory: true, // set true if the game has a move log
// supportsAnimation: true, // only if implementing piece animation
// disabled: true, // if not yet playable
// aiGenerated: true, // if AI-designed game
},
The manifest drives: Home.tsx game picker, MoveLog player colors, title display everywhere, and animation gating. No manual changes to those files needed.
Step 2: Backend — create the game engine
Create backend/src/games/GAME_ID/GAMECLASSGame.ts:
import { GameEngine } from '../GameEngine';
import { BoardState, Move, Player, PiecePosition } from '@ancient-games/shared';
export class GAMECLASSGame extends GameEngine {
gameType = 'GAME_ID' as const;
playerCount = 2;
private readonly PIECES_PER_PLAYER = N;
initializeBoard(): BoardState {
const pieces: PiecePosition[] = [];
for (let player = 0; player < 2; player++) {
for (let i = 0; i < this.PIECES_PER_PLAYER; i++) {
pieces.push({ playerNumber: player, pieceIndex: i, position: -1 });
}
}
return {
pieces,
currentTurn: Math.floor(Math.random() * 2),
diceRoll: null,
lastMove: null,
};
}
rollDice(): number {
// Standard d6: return Math.ceil(Math.random() * 6);
// Binary dice (0–4): sum of 4 coin flips
// No dice (Morris-style): return 1;
return Math.ceil(Math.random() * 6);
}
validateMove(board: BoardState, move: Move, player: Player): boolean {
const piece = board.pieces.find(
(p) => p.playerNumber === player.playerNumber && p.pieceIndex === move.pieceIndex,
);
if (!piece) return false;
if (board.diceRoll === null) return false;
// TODO: implement game-specific validation
return false;
}
applyMove(board: BoardState, move: Move): BoardState {
const newPieces = board.pieces.map((p) => ({ ...p }));
const pieceIdx = newPieces.findIndex(
(p) => p.playerNumber === board.currentTurn && p.pieceIndex === move.pieceIndex,
);
if (pieceIdx === -1) return board;
newPieces[pieceIdx] = { ...newPieces[pieceIdx], position: move.to };
// IMPORTANT: applyMove must always:
// 1. Advance currentTurn (unless extra-turn rule applies)
// 2. Set diceRoll: null
const extraTurn = false;
return {
...board,
pieces: newPieces,
currentTurn: extraTurn ? board.currentTurn : (board.currentTurn + 1) % 2,
diceRoll: null,
lastMove: move,
};
}
checkWinCondition(board: BoardState): number | null {
for (let playerNumber = 0; playerNumber < 2; playerNumber++) {
const playerPieces = board.pieces.filter((p) => p.playerNumber === playerNumber);
if (playerPieces.every((p) => p.position === 99)) return playerNumber;
}
return null;
}
getValidMoves(board: BoardState, playerNumber: number, diceRoll: number): Move[] {
const moves: Move[] = [];
const playerPieces = board.pieces.filter(
(p) => p.playerNumber === playerNumber && p.position !== 99,
);
for (const piece of playerPieces) {
// TODO: compute legal destinations from piece.position + diceRoll
}
return moves;
}
canMove(board: BoardState, playerNumber: number, diceRoll: number): boolean {
return this.getValidMoves(board, playerNumber, diceRoll).length > 0;
}
isCaptureMove(board: BoardState, move: Move): boolean {
// Return true if this move captures an opponent piece by landing on it.
// Return false if game has no capture-by-landing mechanic (e.g. Morris).
return false;
}
}
Key rules for applyMove:
- Always set
diceRoll: null— the server checks this to know a move was applied - Always advance
currentTurnto(currentTurn + 1) % 2, unless the game has an extra-turn mechanic - Return a new
BoardStateobject (spread...board, then override fields) — never mutate in place
Step 2b: Backend — add to Mongoose schema enum
Edit backend/src/models/Session.ts. The gameType field has a hardcoded enum that MongoDB validates against — if you skip this step, session creation will fail with "not a valid enum value":
gameType: { type: String, enum: [..., 'GAME_ID'], required: true },
Step 3: Backend — register in GameRegistry
Edit backend/src/games/GameRegistry.ts:
import { GAMECLASSGame } from './GAME_ID/GAMECLASSGame';
// Add to the Map:
['GAME_ID', new GAMECLASSGame() as GameEngine],
Step 3b: Backend — write game engine tests
Create backend/src/games/GAME_ID/GAMECLASSGame.test.ts (colocated with the engine):
import { describe, it, expect } from 'vitest';
import { GAMECLASSGame } from './GAMECLASSGame';
import { Move, Player } from '@ancient-games/shared';
const game = new GAMECLASSGame();
function makePlayer(playerNumber: number): Player {
return { id: 'p', displayName: 'P', socketId: 's', ready: true, playerNumber, status: 'active' };
}
describe('GAMECLASSGame', () => {
describe('initializeBoard', () => {
it('creates the correct number of pieces', () => {
const board = game.initializeBoard();
expect(board.pieces).toHaveLength(EXPECTED_TOTAL);
expect(board.pieces.filter((p) => p.playerNumber === 0)).toHaveLength(EXPECTED_PER_PLAYER);
});
it('starts with null diceRoll', () => {
expect(game.initializeBoard().diceRoll).toBeNull();
});
it('currentTurn is 0 or 1', () => {
expect([0, 1]).toContain(game.initializeBoard().currentTurn);
});
});
describe('rollDice', () => {
it('returns values within expected range', () => {
const results = new Set<number>();
for (let i = 0; i < 200; i++) results.add(game.rollDice());
expect(Math.min(...results)).toBeGreaterThanOrEqual(MIN_ROLL);
expect(Math.max(...results)).toBeLessThanOrEqual(MAX_ROLL);
});
});
describe('validateMove', () => {
it('rejects move when diceRoll is null', () => {
const board = game.initializeBoard();
const move: Move = { playerId: '', pieceIndex: 0, from: -1, to: 0 };
expect(game.validateMove(board, move, makePlayer(board.currentTurn))).toBe(false);
});
// TODO: add game-specific validation tests
});
describe('applyMove', () => {
// TODO: test piece movement, diceRoll cleared, currentTurn advances, captures
});
describe('checkWinCondition', () => {
it('returns null at game start', () => {
expect(game.checkWinCondition(game.initializeBoard())).toBeNull();
});
// TODO: test win detection
});
describe('getValidMoves', () => {
it('returns moves from initial position', () => {
const board = game.initializeBoard();
board.diceRoll = TYPICAL_ROLL;
const moves = game.getValidMoves(board, board.currentTurn, TYPICAL_ROLL);
expect(moves.length).toBeGreaterThan(0);
});
});
});
Replace EXPECTED_TOTAL, EXPECTED_PER_PLAYER, MIN_ROLL, MAX_ROLL, TYPICAL_ROLL with actual values. Fill in all TODO sections with concrete tests.
Step 3c: Verify and commit
npm run build --workspace=shared
npm run build --workspace=backend
npm test # or: cd backend && npx vitest run
Commit message: feat: add DISPLAY NAME game engine
This commit touches: shared/types/game.ts, backend/src/games/GAME_ID/, backend/src/games/GameRegistry.ts, backend/src/models/Session.ts
COMMIT 2: Game-Specific Frontend Resources
This commit adds all frontend pieces. It should NOT modify any shared platform files (GameRoom.tsx board dispatch, Home.tsx, GameControls.tsx dispatcher, etc.) — only add new files to the game folder and register in lookup records.
Step 4: Frontend — create the board component
Create frontend/src/components/games/GAME_ID/GAMECLASSBoard.tsx:
import { Session, GameState } from '@ancient-games/shared';
import { socketService } from '../../../services/socket';
interface GAMECLASSBoardProps {
session: Session;
gameState: GameState;
playerId: string;
isMyTurn: boolean;
animatingPiece?: { playerNumber: number; pieceIndex: number } | null;
}
export default function GAMECLASSBoard({
session,
gameState,
playerId,
isMyTurn,
}: GAMECLASSBoardProps) {
const { board } = gameState;
function handleRollDice() {
if (!isMyTurn || board.diceRoll !== null) return;
socketService.getSocket()?.emit('game:roll-dice', {
sessionCode: session.sessionCode,
playerId,
});
}
function handleMove(pieceIndex: number, from: number, to: number) {
if (!isMyTurn || board.diceRoll === null) return;
socketService.getSocket()?.emit('game:move', {
sessionCode: session.sessionCode,
playerId,
move: { playerId, pieceIndex, from, to, diceRoll: board.diceRoll },
});
}
// TODO: render the board, pieces, and controls
return (
<div className="flex flex-col items-center gap-4 p-4">
{isMyTurn && board.diceRoll === null && !gameState.finished && (
<button onClick={handleRollDice} className="btn btn-primary px-6 py-3 text-lg font-semibold">
Roll Dice
</button>
)}
{board.diceRoll !== null && (
<div className="text-2xl font-bold" style={{ color: '#E8C870' }}>
Roll: {board.diceRoll}
</div>
)}
<div className="text-gray-400 text-sm">[Board rendering not yet implemented]</div>
</div>
);
}
Board rendering notes:
- Use SVG or CSS grid — look at
UrBoard.tsxfor SVG patterns,MorrisBoard.tsxfor grid patterns - SVG must be responsive on mobile: Use
viewBoxattribute andwidth="100%"withstyle={{ maxWidth: SVG_W }}instead of fixedwidth={SVG_W}. This ensures the board scales down on narrow viewports while staying centered via parentitems-centerflex layout.const SVG_W = 412; // your computed width <svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} width="100%" style={{ maxWidth: SVG_W, ... }}> - Pieces are in
board.pieces, filtered byplayerNumberandposition - Use
session.sessionCode(notsession.code) when emitting socket events game:moverequires top-levelplayerIdin the payload
Step 5: Frontend — create rules component
Create frontend/src/components/games/GAME_ID/GAMECLASSRules.tsx:
import { Section } from '../../GameRules';
export default function GAMECLASSRules() {
return (
<>
<div className="text-center pb-1">
<div className="text-2xl mb-1">EMOJI</div>
<p className="font-bold" style={{ color: '#F0D090' }}>
DISPLAY NAME
</p>
<p className="text-xs mt-1" style={{ color: '#7A6A50' }}>
Brief description
</p>
</div>
<Section title="Objective">How to win.</Section>
<Section title="Movement">How pieces move.</Section>
<Section title="Special Rules">Any special mechanics.</Section>
</>
);
}
Step 5b: Frontend — export a piece preview component
Export a <GAMECLASSPiecePreview> component from the board file:
// In GAMECLASSBoard.tsx, add near the top (after imports, before the default export):
export function GAMECLASSPiecePreview({ playerNumber, size = 20 }: { playerNumber: 0 | 1; size?: number }) {
// Render a small SVG of the player's piece at the given size.
// Player 0 gets their piece, Player 1 gets theirs.
// If the game has no persistent piece identity (e.g. RPS), return null.
const color = playerNumber === 0 ? '#PLAYER0_COLOR' : '#PLAYER1_COLOR';
return (
<svg viewBox="0 0 20 20" width={size} height={size}>
<circle cx="10" cy="10" r="8" fill={color} />
</svg>
);
}
Then register it in frontend/src/components/games/GamePiecePreview.tsx — add a case 'GAME_ID': to the switch statement:
case 'GAME_ID':
return <GAMECLASSPiecePreview playerNumber={playerNumber} size={size} />;
And add the import at the top of GamePiecePreview.tsx:
import { GAMECLASSPiecePreview } from './GAME_ID/GAMECLASSBoard';
Step 6: Frontend — create score info (optional)
Create frontend/src/components/games/GAME_ID/gameIdScoreInfo.ts if the game has meaningful score display:
import { PiecePosition } from '@ancient-games/shared';
export function getScoreInfo(pieces: PiecePosition[], seatIndex: number): string | null {
const finished = pieces.filter((p) => p.playerNumber === seatIndex && p.position === 99).length;
const onBoard = pieces.filter(
(p) => p.playerNumber === seatIndex && p.position >= 0 && p.position < 99,
).length;
return `${onBoard} on board \u00B7 ${finished} finished`;
}
Step 7: Frontend — create controls (optional)
Create frontend/src/components/games/GAME_ID/GAMECLASSControls.tsx if the game needs custom dice/controls UI beyond what the board component provides. Import GameControlsProps from ../../GameControls.
Step 8: Register in frontend lookup records
These are the only shared files that need editing — adding entries to lookup records:
8a. frontend/src/components/GameRoom.tsx — add to boardComponents record:
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSBoard')),
8b. frontend/src/components/GameRules.tsx — add to rulesComponents record:
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSRules')),
8c. frontend/src/utils/gameScoreInfo.ts — add import and registry entry (if score info created):
import { getScoreInfo as gameIdScore } from '../components/games/GAME_ID/gameIdScoreInfo';
// In registry:
'GAME_ID': gameIdScore,
8d. frontend/src/components/GameControls.tsx — add to controlComponents record (if controls created):
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSControls')),
8e. frontend/src/components/lobby/SessionLobby.tsx — replace GAME_NAMES usage with getGameTitle:
Note: SessionLobby still has a local GAME_NAMES record. Replace it with getGameTitle from @ancient-games/shared, or add the new entry to the existing record until that cleanup is done:
GAME_ID: 'DISPLAY NAME',
Step 8f: Verify and commit
npm run build
npm run lint 2>&1 | head -20 # fix errors if any
Commit message: feat: add DISPLAY NAME frontend (board, rules, controls)
Files NOT touched when adding a game
Thanks to the manifest and registry architecture, these files need no changes:
Home.tsx— reads fromGAME_MANIFESTSautomaticallyMoveLog.tsx— reads player colors from manifestgameHandlers.ts— usesGameRegistryand engine methods (includingisCaptureMove)AnimationOverlay.tsx— only activated for games withsupportsAnimation: true
Common pitfalls
applyMovemust setdiceRoll: null— the server checks this to know a move was appliedapplyMovemust advancecurrentTurn— or the same player moves forevervalidateMovereadsboard.diceRoll, not the move'sdiceRoll— the server stores the roll onboardbefore calling validate- Position 99 = finished, not "captured" — filter
!== 99when computing available pieces - Morris exception:
diceRollis repurposed as a phase indicator. Only do this if your game needs multi-phase turns. - Mongoose enum must be updated —
backend/src/models/Session.tshas a separate hardcodedenumarray. MongoDB will reject session creation with "not a valid enum value" until this is updated. session.sessionCodenotsession.code— usesession.sessionCodein socket eventsgame:moverequires top-levelplayerId— payload is{ sessionCode, playerId, move }isCaptureMovemust be implemented — even if just returningfalse. The server calls this on every move to determine capture status.- SVG board must use
viewBox+ responsivewidth— Don't use fixedwidth={SVG_W}. Instead useviewBox={0 0 ${SVG_W} ${SVG_H}}withwidth="100%"andstyle={{ maxWidth: SVG_W, ... }}. This ensures the board centers on mobile and scales properly without horizontal overflow.
Checklist
After implementing, verify:
Commit 1 (Engine):
- [ ]
shared/types/game.ts—GameTypeunion updated - [ ]
shared/types/game.ts—GAME_MANIFESTSentry added (with title, emoji, description, colors) - [ ]
backend/src/models/Session.ts— MongoosegameTypeenum updated - [ ]
backend/src/games/GAME_ID/GAMECLASSGame.ts— engine created withisCaptureMove - [ ]
backend/src/games/GameRegistry.ts— engine registered - [ ]
backend/src/games/GAME_ID/GAMECLASSGame.test.ts— tests written and passing
Commit 2 (Frontend):
- [ ]
frontend/src/components/games/GAME_ID/GAMECLASSBoard.tsx— board created (default export) - [ ]
frontend/src/components/games/GAME_ID/GAMECLASSBoard.tsx—GAMECLASSPiecePreviewexported - [ ]
frontend/src/components/games/GamePiecePreview.tsx— new game registered in switch - [ ]
frontend/src/components/games/GAME_ID/GAMECLASSRules.tsx— rules created (default export) - [ ]
frontend/src/components/games/GAME_ID/gameIdScoreInfo.ts— score info (if applicable) - [ ]
frontend/src/components/games/GAME_ID/GAMECLASSControls.tsx— controls (if applicable) - [ ]
GameRoom.tsx—boardComponentsrecord entry added - [ ]
GameRules.tsx—rulesComponentsrecord entry added - [ ]
gameScoreInfo.ts— registry entry added (if applicable) - [ ]
GameControls.tsx—controlComponentsrecord entry added (if applicable) - [ ]
SessionLobby.tsx—GAME_NAMESentry added - [ ]
npm run buildpasses with no TypeScript errors
Scan to join WeChat group