251 lines
7.5 KiB
Markdown
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
|