# boardgame-core A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/). Build turn-based board games with reactive state, entity collections, spatial regions, and a command-driven game loop. ## Features - **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/) - **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.) - **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling - **Command Parsing**: CLI-style command parsing with schema validation and type coercion - **Rule Engine**: Generator-based rule system with reactive context management - **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states ## Installation ```bash npm install boardgame-core ``` ## Writing a Game The core pattern for writing a game is: 1. Define your game state type 2. Create a command registry with `createGameCommandRegistry()` 3. Register commands for `setup`, `turn`, and any actions 4. Run `setup` to start the game loop ### Step 1: Define Your State Every game needs a state type. Use `RegionEntity` for boards/zones and plain fields for everything else. ```ts import { RegionEntity, Entity } from 'boardgame-core'; type Player = 'X' | 'O'; type GameState = { board: RegionEntity; parts: Entity[]; currentPlayer: Player; winner: Player | 'draw' | null; turn: number; }; ``` ### Step 2: Create the Command Registry `createGameCommandRegistry` ties your state type to the command system. Commands are registered with a schema string and an async handler. ```ts import { createGameCommandRegistry } from 'boardgame-core'; const registration = createGameCommandRegistry(); export const registry = registration.registry; ``` ### Step 3: Register the `setup` Command The `setup` command is the main game loop. It runs turns until a winner is determined. ```ts registration.add('setup', async function() { const { context } = this; while (true) { const currentPlayer = context.value.currentPlayer; const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer}`); if (!turnOutput.success) throw new Error(turnOutput.error); context.produce(state => { state.winner = turnOutput.result.winner; if (!state.winner) { state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; state.turn++; } }); if (context.value.winner) break; } return context.value; }); ``` ### Step 4: Register the `turn` Command The `turn` command handles a single player's turn. Use `this.prompt()` to request validated input from the player. ```ts registration.add('turn ', async function(cmd) { const [turnPlayer] = cmd.params as [Player]; const playCmd = await this.prompt( 'play ', (command) => { const [player, row, col] = command.params as [Player, number, number]; if (player !== turnPlayer) return `Wrong player.`; if (isCellOccupied(this.context, row, col)) return `Cell occupied.`; return null; // null = valid } ); const [, row, col] = playCmd.params as [Player, number, number]; placePiece(this.context, row, col, turnPlayer); const winner = checkWinner(this.context); return { winner }; }); ``` ### Managing Part Movement Move parts between regions with `moveToRegion`, `moveToRegionAll`, and `removeFromRegion`. ```ts import { moveToRegion, moveToRegionAll, removeFromRegion } from 'boardgame-core'; // Move a single piece to a new region with a new position moveToRegion(card, handRegion, [0]); // Move a single piece, keeping its current position moveToRegion(card, handRegion); // Move multiple pieces at once with new positions moveToRegionAll([card1, card2, card3], discardPile, [[0], [1], [2]]); // Remove a piece from its region (without adding to another) removeFromRegion(card); ``` ### Step 5: Manage Parts on the Board Parts are game pieces placed inside regions. Use `entity()` to create reactive entities and `produce()` to mutate state. ```ts import { entity } from 'boardgame-core'; function placePiece(host: Entity, row: number, col: number, player: Player) { const board = host.value.board; const piece = { id: `piece-${player}-${host.value.parts.length + 1}`, region: board, position: [row, col], player, }; host.produce(state => { const e = entity(piece.id, piece); state.parts.push(e); board.produce(draft => { draft.children.push(e); }); }); } ``` ### Step 6: Run the Game ```ts import { createGameContextFromModule } from 'boardgame-core'; import * as yourGame from './your-game'; const game = createGameContextFromModule(yourGame); game.commands.run('setup'); ``` Or use `createGameContext` directly: ```ts import { createGameContext } from 'boardgame-core'; import { registry, createInitialState } from './your-game'; const game = createGameContext(registry, createInitialState); game.commands.run('setup'); ``` ## Sample Games ### Tic-Tac-Toe The simplest example. Shows the basic command loop, 2D board regions, and win detection. See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts). ### Boop A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules. See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](src/samples/boop/rules.md). ## API Reference ### Core | Export | Description | |---|---| | `createGameContext(root?)` | Create a new game context instance | | `createGameCommandRegistry()` | Create a typed command registry for your game state | | `GameContext` | The game context model class | | `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management | ### Parts | Export | Description | |---|---| | `Part` | Entity type representing a game piece | | `entity(id, data)` | Create a reactive entity | | `flip(part)` | Cycle to the next side | | `flipTo(part, side)` | Set to a specific side | | `roll(part, rng)` | Randomize side using RNG | ### Regions | Export | Description | |---|---| | `RegionEntity` | Entity type for spatial grouping of parts | | `RegionAxis` | Axis definition with min/max/align | | `applyAlign(region)` | Compact parts according to axis alignment | | `shuffle(region, rng)` | Randomize part positions | | `moveToRegion(part, targetRegion, position?)` | Move a part to another region | | `moveToRegionAll(parts, targetRegion, positions?)` | Move multiple parts to another region | | `removeFromRegion(part)` | Remove a part from its region | ### Commands | Export | Description | |---|---| | `parseCommand(input)` | Parse a command string into a `Command` object | | `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` | | `validateCommand(cmd, schema)` | Validate a command against a schema | ### Utilities | Export | Description | |---|---| | `createEntityCollection()` | Create a reactive entity collection | | `createRNG(seed?)` | Create a seeded RNG instance | | `Mulberry32RNG` | Mulberry32 PRNG class | ## Scripts ```bash npm run build # Build with tsup npm run test # Run tests in watch mode npm run test:run # Run tests once npm run typecheck # Type check with TypeScript ``` ## License MIT