返回 Skill 列表
extension
分类: 开发与工程无需 API Key

add-game

为Ancient Games平台搭建一个新的游戏。当用户说“添加一个游戏”、“创建新游戏”、“实现[游戏名]”或类似的话时使用。指导所有必需的后端和前端组件的实现。

person作者: jakexiaohubgithub

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 manifests
  • backend/ — Node.js + Express + Socket.io + game logic
  • frontend/ — 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:

  1. Engine-level changes (shared types, backend engine, backend registry) — committed first
  2. 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:

  1. Board layout and number of positions
  2. Number of pieces per player
  3. Dice mechanic (or whether it's dice-free — use rollDice() returning 1 always)
  4. Win condition
  5. 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 currentTurn to (currentTurn + 1) % 2, unless the game has an extra-turn mechanic
  • Return a new BoardState object (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.tsx for SVG patterns, MorrisBoard.tsx for grid patterns
  • SVG must be responsive on mobile: Use viewBox attribute and width="100%" with style={{ maxWidth: SVG_W }} instead of fixed width={SVG_W}. This ensures the board scales down on narrow viewports while staying centered via parent items-center flex 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 by playerNumber and position
  • Use session.sessionCode (not session.code) when emitting socket events
  • game:move requires top-level playerId in 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 from GAME_MANIFESTS automatically
  • MoveLog.tsx — reads player colors from manifest
  • gameHandlers.ts — uses GameRegistry and engine methods (including isCaptureMove)
  • AnimationOverlay.tsx — only activated for games with supportsAnimation: true

Common pitfalls

  1. applyMove must set diceRoll: null — the server checks this to know a move was applied
  2. applyMove must advance currentTurn — or the same player moves forever
  3. validateMove reads board.diceRoll, not the move's diceRoll — the server stores the roll on board before calling validate
  4. Position 99 = finished, not "captured" — filter !== 99 when computing available pieces
  5. Morris exception: diceRoll is repurposed as a phase indicator. Only do this if your game needs multi-phase turns.
  6. Mongoose enum must be updatedbackend/src/models/Session.ts has a separate hardcoded enum array. MongoDB will reject session creation with "not a valid enum value" until this is updated.
  7. session.sessionCode not session.code — use session.sessionCode in socket events
  8. game:move requires top-level playerId — payload is { sessionCode, playerId, move }
  9. isCaptureMove must be implemented — even if just returning false. The server calls this on every move to determine capture status.
  10. SVG board must use viewBox + responsive width — Don't use fixed width={SVG_W}. Instead use viewBox={0 0 ${SVG_W} ${SVG_H}} with width="100%" and style={{ 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.tsGameType union updated
  • [ ] shared/types/game.tsGAME_MANIFESTS entry added (with title, emoji, description, colors)
  • [ ] backend/src/models/Session.ts — Mongoose gameType enum updated
  • [ ] backend/src/games/GAME_ID/GAMECLASSGame.ts — engine created with isCaptureMove
  • [ ] 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.tsxGAMECLASSPiecePreview exported
  • [ ] 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.tsxboardComponents record entry added
  • [ ] GameRules.tsxrulesComponents record entry added
  • [ ] gameScoreInfo.ts — registry entry added (if applicable)
  • [ ] GameControls.tsxcontrolComponents record entry added (if applicable)
  • [ ] SessionLobby.tsxGAME_NAMES entry added
  • [ ] npm run build passes with no TypeScript errors