boardgame-core/README.md

262 lines
8.6 KiB
Markdown
Raw Normal View History

# boardgame-core
2026-04-01 22:00:10 +08:00
A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/).
2026-04-02 21:58:11 +08:00
Build turn-based board games with reactive state, entity collections, spatial regions, and a command-driven game loop.
2026-04-01 22:00:10 +08:00
## Features
2026-04-01 22:00:10 +08:00
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/)
2026-04-03 10:07:12 +08:00
- **Type Safe**: Full TypeScript support with strict mode and generic context extension
2026-04-01 22:00:10 +08:00
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
2026-04-03 10:07:12 +08:00
- **Command System**: CLI-style command parsing with schema validation, type coercion, and prompt support
2026-04-01 22:00:10 +08:00
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
## Installation
```bash
npm install boardgame-core
```
2026-04-02 21:58:11 +08:00
## Writing a Game
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
### Defining a Game
2026-04-01 22:00:10 +08:00
2026-04-03 11:20:35 +08:00
Each game defines a command registry and exports a `createInitialState` function:
2026-04-01 22:00:10 +08:00
```ts
2026-04-03 11:20:35 +08:00
import {
createGameCommandRegistry,
Region,
createRegion,
2026-04-03 11:20:35 +08:00
} from 'boardgame-core';
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
// 1. Define your game-specific state
type MyGameState = {
board: Region;
parts: Record<string, { id: string; regionId: string; position: number[] }>;
2026-04-03 11:20:35 +08:00
score: { white: number; black: number };
currentPlayer: 'white' | 'black';
winner: 'white' | 'black' | 'draw' | null;
2026-04-03 10:07:12 +08:00
};
2026-04-01 22:00:10 +08:00
2026-04-03 11:20:35 +08:00
// 2. Create initial state factory
export function createInitialState(): MyGameState {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: 5 },
{ name: 'y', min: 0, max: 5 },
]),
parts: {},
2026-04-03 11:20:35 +08:00
score: { white: 0, black: 0 },
currentPlayer: 'white',
winner: null,
};
2026-04-03 10:07:12 +08:00
}
2026-04-01 22:00:10 +08:00
2026-04-03 11:20:35 +08:00
// 3. Create registry and register commands
const registration = createGameCommandRegistry<MyGameState>();
export const registry = registration.registry;
registration.add('place <row:number> <col:number>', async function(cmd) {
const [row, col] = cmd.params as [number, number];
const player = this.context.value.currentPlayer;
// Mutate state via produce()
this.context.produce(state => {
state.score[player] += 1;
});
return { success: true };
});
2026-04-01 22:00:10 +08:00
```
2026-04-03 10:07:12 +08:00
### Running a Game
2026-04-01 22:00:10 +08:00
```ts
2026-04-03 11:20:35 +08:00
import { createGameContext } from 'boardgame-core';
import { registry, createInitialState } from './my-game';
2026-04-02 21:58:11 +08:00
2026-04-03 11:20:35 +08:00
const game = createGameContext(registry, createInitialState);
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
// Run commands through the context
2026-04-03 11:20:35 +08:00
const result = await game.commands.run('place 2 3');
2026-04-03 10:07:12 +08:00
if (result.success) {
2026-04-03 11:20:35 +08:00
console.log(result.result);
}
// Access reactive game state
console.log(game.state.value.score.white);
```
### Handling Player Input
Commands can prompt for player input using `this.prompt()`. Use `promptQueue.pop()` to wait for prompt events:
```ts
import { createGameContext } from 'boardgame-core';
import { registry, createInitialState } from './my-game';
const game = createGameContext(registry, createInitialState);
// Start a command that will prompt for input
const runPromise = game.commands.run('turn X 1');
// Wait for the prompt event
const promptEvent = await game.commands.promptQueue.pop();
console.log(promptEvent.schema.name); // e.g. 'play'
// Validate and submit player input
const error = promptEvent.tryCommit('play X 1 2');
2026-04-03 11:20:35 +08:00
if (error) {
console.log('Invalid move:', error);
// tryCommit can be called again with corrected input
} else {
// Input accepted, command continues
const result = await runPromise;
2026-04-03 10:07:12 +08:00
}
2026-04-03 11:20:35 +08:00
```
If the player needs to cancel instead of committing:
2026-04-01 22:00:10 +08:00
2026-04-03 11:20:35 +08:00
```ts
promptEvent.cancel('player quit');
2026-04-01 22:00:10 +08:00
```
2026-04-03 10:07:12 +08:00
## Sample Games
2026-04-02 21:58:11 +08:00
2026-04-03 10:07:12 +08:00
### Tic-Tac-Toe
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
The simplest example. Shows the basic command loop, 2D board regions, and win detection.
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts).
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
### Boop
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
2026-04-02 21:58:11 +08:00
2026-04-03 11:20:35 +08:00
See [`src/samples/boop/index.ts`](src/samples/boop/index.ts).
## Region System
```ts
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core';
2026-04-03 11:20:35 +08:00
2026-04-03 10:07:12 +08:00
// Compact cards in a hand towards the start
applyAlign(handRegion, parts);
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
// Shuffle positions of all parts in a region
shuffle(handRegion, parts, rng);
// Move a part from one region to another
moveToRegion(part, sourceRegion, targetRegion, [0, 0]);
2026-04-02 21:58:11 +08:00
```
2026-04-01 22:00:10 +08:00
2026-04-03 11:20:35 +08:00
## Command Parsing
2026-04-02 21:58:11 +08:00
```ts
2026-04-03 10:07:12 +08:00
import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core';
// Parse a command string
const cmd = parseCommand('move card1 hand --force -x 10');
// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } }
2026-04-01 22:00:10 +08:00
2026-04-03 10:07:12 +08:00
// Define and validate against a schema
const schema = parseCommandSchema('move <from> <to> [--force] [-x: number]');
const result = validateCommand(cmd, schema);
// { valid: true }
2026-04-01 22:00:10 +08:00
```
2026-04-03 11:20:35 +08:00
## Random Number Generation
2026-04-02 21:58:11 +08:00
2026-04-03 10:07:12 +08:00
```ts
import { createRNG } from 'boardgame-core';
2026-04-02 21:58:11 +08:00
2026-04-03 10:07:12 +08:00
const rng = createRNG(12345);
rng.nextInt(6); // 0-5
rng.next(); // [0, 1)
rng.next(100); // [0, 100)
rng.setSeed(999); // reseed
```
2026-04-02 21:58:11 +08:00
2026-04-01 22:00:10 +08:00
## API Reference
### Core
| Export | Description |
|---|---|
2026-04-03 11:20:35 +08:00
| `IGameContext` | Base interface for the game context (state, commands) |
| `createGameContext(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function |
| `createGameCommandRegistry<TState>()` | Create a command registry with fluent `.add()` API |
2026-04-01 22:00:10 +08:00
### Parts
| Export | Description |
|---|---|
| `Part<TMeta>` | Type representing a game piece with sides, position, and region. `TMeta` for game-specific fields |
| `PartTemplate<TMeta>` | Template type for creating parts (excludes `id`, requires metadata) |
| `PartPool<TMeta>` | Pool of parts with `draw()`, `return()`, and `remaining()` methods. `parts` field is `Record<string, Part>` |
| `createPart(template, id)` | Create a single part from a template |
| `createParts(template, count, idPrefix)` | Create multiple identical parts with auto-generated IDs |
| `createPartPool(template, count, idPrefix)` | Create a pool of parts for lazy loading |
| `mergePartPools(...pools)` | Merge multiple part pools into one |
| `findPartById(parts, id)` | Find a part by ID in a Record |
| `isCellOccupied(parts, regionId, position)` | Check if a cell is occupied |
| `getPartAtPosition(parts, regionId, position)` | Get the part at a specific position |
2026-04-01 22:00:10 +08:00
| `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 |
|---|---|
| `Region` | Type for spatial grouping of parts with axis-based positioning |
2026-04-01 22:00:10 +08:00
| `RegionAxis` | Axis definition with min/max/align |
| `createRegion(id, axes)` | Create a new region |
| `applyAlign(region, parts)` | Compact parts according to axis alignment |
| `shuffle(region, parts, rng)` | Randomize part positions |
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | Move a part to another region. `sourceRegion` is optional for first placement |
| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `parts` is `Record<string, Part>`. `sourceRegion` is optional for first placement |
| `removeFromRegion(part, region)` | Remove a part from its region |
2026-04-01 22:00:10 +08:00
### 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 |
2026-04-03 10:07:12 +08:00
| `parseCommandWithSchema(cmd, schema)` | Parse and validate in one step |
| `applyCommandSchema(cmd, schema)` | Apply schema validation and return validated command |
| `createCommandRegistry<TContext>()` | Create a new command registry |
| `registerCommand(registry, runner)` | Register a command runner |
| `unregisterCommand(registry, name)` | Remove a command from the registry |
| `hasCommand(registry, name)` | Check if a command exists |
| `getCommand(registry, name)` | Get a command runner by name |
| `runCommand(registry, context, input)` | Parse and run a command string |
| `runCommandParsed(registry, context, command)` | Run a pre-parsed command |
| `createCommandRunnerContext(registry, context)` | Create a command runner context |
| `PromptEvent` | Event dispatched when a command prompts for input |
2026-04-01 22:00:10 +08:00
### Utilities
| Export | Description |
|---|---|
| `createRNG(seed?)` | Create a seeded RNG instance |
| `Mulberry32RNG` | Mulberry32 PRNG class |
## Scripts
```bash
2026-04-03 11:20:35 +08:00
npm run build # Build ESM bundle + declarations to dist/
2026-04-01 22:00:10 +08:00
npm run test # Run tests in watch mode
npm run test:run # Run tests once
npm run typecheck # Type check with TypeScript
```
## License
MIT