boardgame-core/README.md

251 lines
7.5 KiB
Markdown

# 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<State>()`
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<Part & { player: Player }>[];
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<GameState>();
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 <player>', async function(cmd) {
const [turnPlayer] = cmd.params as [Player];
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>',
(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<GameState>, 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<State>()` | 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<T>()` | 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