Awesome-omni-skill add-game
Scaffold a new game for the Ancient Games platform. Use when the user says "add a game", "create a new game", "implement [game name]", or similar. Guides implementation of all required backend and frontend pieces.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/add-game" ~/.claude/skills/diegosouzapw-awesome-omni-skill-add-game && rm -rf "$T"
skills/development/add-game/SKILL.mdYou are helping add a new game to the Ancient Games platform. This is a full-stack TypeScript monorepo (npm workspaces) with:
— types and game manifestsshared/
— Node.js + Express + Socket.io + game logicbackend/
— React 18 + Vite + Tailwind CSSfrontend/
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>"
: kebab-case identifier (e.g.game-id
)fox-and-geese
: human-readable title (e.g.Display Name
)Fox & Geese
: single emoji for the game picker (e.g.emoji
)🦊
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
returningrollDice()
always)1 - 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
union:GameType
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
— the server checks this to know a move was applieddiceRoll: null - Always advance
tocurrentTurn
, unless the game has an extra-turn mechanic(currentTurn + 1) % 2 - Return a new
object (spreadBoardState
, then override fields) — never mutate in place...board
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
for SVG patterns,UrBoard.tsx
for grid patternsMorrisBoard.tsx - SVG must be responsive on mobile: Use
attribute andviewBox
withwidth="100%"
instead of fixedstyle={{ maxWidth: SVG_W }}
. This ensures the board scales down on narrow viewports while staying centered via parentwidth={SVG_W}
flex layout.items-centerconst SVG_W = 412; // your computed width <svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} width="100%" style={{ maxWidth: SVG_W, ... }}> - Pieces are in
, filtered byboard.pieces
andplayerNumberposition - Use
(notsession.sessionCode
) when emitting socket eventssession.code
requires top-levelgame:move
in the payloadplayerId
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.
— add to frontend/src/components/GameRoom.tsx
boardComponents record:
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSBoard')),
8b.
— add to frontend/src/components/GameRules.tsx
rulesComponents record:
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSRules')),
8c.
— add import and registry entry (if score info created):frontend/src/utils/gameScoreInfo.ts
import { getScoreInfo as gameIdScore } from '../components/games/GAME_ID/gameIdScoreInfo'; // In registry: 'GAME_ID': gameIdScore,
8d.
— add to frontend/src/components/GameControls.tsx
controlComponents record (if controls created):
'GAME_ID': lazy(() => import('./games/GAME_ID/GAMECLASSControls')),
8e.
— replace frontend/src/components/lobby/SessionLobby.tsx
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:
— reads fromHome.tsx
automaticallyGAME_MANIFESTS
— reads player colors from manifestMoveLog.tsx
— usesgameHandlers.ts
and engine methods (includingGameRegistry
)isCaptureMove
— only activated for games withAnimationOverlay.tsxsupportsAnimation: true
Common pitfalls
must setapplyMove
— the server checks this to know a move was applieddiceRoll: null
must advanceapplyMove
— or the same player moves forevercurrentTurn
readsvalidateMove
, not the move'sboard.diceRoll
— the server stores the roll ondiceRoll
before calling validateboard- Position 99 = finished, not "captured" — filter
when computing available pieces!== 99 - Morris exception:
is repurposed as a phase indicator. Only do this if your game needs multi-phase turns.diceRoll - Mongoose enum must be updated —
has a separate hardcodedbackend/src/models/Session.ts
array. MongoDB will reject session creation with "not a valid enum value" until this is updated.enum
notsession.sessionCode
— usesession.code
in socket eventssession.sessionCode
requires top-levelgame:move
— payload isplayerId{ sessionCode, playerId, move }
must be implemented — even if just returningisCaptureMove
. The server calls this on every move to determine capture status.false- SVG board must use
+ responsiveviewBox
— Don't use fixedwidth
. Instead usewidth={SVG_W}
0 0 ${SVG_W} ${SVG_H}viewBox={
with}
andwidth="100%"
. This ensures the board centers on mobile and scales properly without horizontal overflow.style={{ maxWidth: SVG_W, ... }}
Checklist
After implementing, verify:
Commit 1 (Engine):
-
—shared/types/game.ts
union updatedGameType -
—shared/types/game.ts
entry added (with title, emoji, description, colors)GAME_MANIFESTS -
— Mongoosebackend/src/models/Session.ts
enum updatedgameType -
— engine created withbackend/src/games/GAME_ID/GAMECLASSGame.tsisCaptureMove -
— engine registeredbackend/src/games/GameRegistry.ts -
— tests written and passingbackend/src/games/GAME_ID/GAMECLASSGame.test.ts
Commit 2 (Frontend):
-
— board created (default export)frontend/src/components/games/GAME_ID/GAMECLASSBoard.tsx -
—frontend/src/components/games/GAME_ID/GAMECLASSBoard.tsx
exportedGAMECLASSPiecePreview -
— new game registered in switchfrontend/src/components/games/GamePiecePreview.tsx -
— rules created (default export)frontend/src/components/games/GAME_ID/GAMECLASSRules.tsx -
— score info (if applicable)frontend/src/components/games/GAME_ID/gameIdScoreInfo.ts -
— controls (if applicable)frontend/src/components/games/GAME_ID/GAMECLASSControls.tsx -
—GameRoom.tsx
record entry addedboardComponents -
—GameRules.tsx
record entry addedrulesComponents -
— registry entry added (if applicable)gameScoreInfo.ts -
—GameControls.tsx
record entry added (if applicable)controlComponents -
—SessionLobby.tsx
entry addedGAME_NAMES -
passes with no TypeScript errorsnpm run build