2026-03-31 18:01:57 +08:00
|
|
|
# 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-03-31 18:01:57 +08:00
|
|
|
|
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-03-31 18:01:57 +08:00
|
|
|
|
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
|
|
|
- **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
|
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,
|
|
|
|
|
RegionEntity,
|
|
|
|
|
Entity,
|
|
|
|
|
} 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 = {
|
2026-04-03 11:20:35 +08:00
|
|
|
board: RegionEntity;
|
|
|
|
|
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: new RegionEntity('board', {
|
|
|
|
|
id: 'board',
|
|
|
|
|
axes: [
|
|
|
|
|
{ name: 'x', min: 0, max: 5 },
|
|
|
|
|
{ name: 'y', min: 0, max: 5 },
|
|
|
|
|
],
|
|
|
|
|
children: [],
|
|
|
|
|
}),
|
|
|
|
|
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({
|
|
|
|
|
name: 'play',
|
|
|
|
|
params: ['X', 1, 1],
|
|
|
|
|
options: {},
|
|
|
|
|
flags: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 { RegionEntity, applyAlign, shuffle } from 'boardgame-core';
|
|
|
|
|
|
2026-04-03 10:07:12 +08:00
|
|
|
// Compact cards in a hand towards the start
|
|
|
|
|
applyAlign(handRegion);
|
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, rng);
|
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
|
|
|
## Entity Collections
|
2026-04-01 22:00:10 +08:00
|
|
|
|
|
|
|
|
```ts
|
2026-04-03 10:07:12 +08:00
|
|
|
import { createEntityCollection } from 'boardgame-core';
|
2026-04-01 22:00:10 +08:00
|
|
|
|
2026-04-03 10:07:12 +08:00
|
|
|
const collection = createEntityCollection();
|
|
|
|
|
collection.add({ id: 'a', name: 'Item A' }, { id: 'b', name: 'Item B' });
|
2026-04-01 22:00:10 +08:00
|
|
|
|
2026-04-03 10:07:12 +08:00
|
|
|
const accessor = collection.get('a');
|
|
|
|
|
console.log(accessor.value); // reactive access
|
2026-04-02 21:58:11 +08:00
|
|
|
|
2026-04-03 10:07:12 +08:00
|
|
|
collection.remove('a');
|
|
|
|
|
```
|
2026-04-02 21:58:11 +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 |
|
|
|
|
|
|---|---|
|
2026-04-03 11:20:35 +08:00
|
|
|
| `Part` | Type representing a game piece with sides, position, and region |
|
|
|
|
|
| `entity(id, data)` | Create a reactive `Entity<T>` |
|
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 |
|
|
|
|
|
|---|---|
|
2026-04-03 11:20:35 +08:00
|
|
|
| `RegionEntity` | Entity 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 |
|
|
|
|
|
| `applyAlign(region)` | Compact parts according to axis alignment |
|
|
|
|
|
| `shuffle(region, rng)` | Randomize part positions |
|
2026-04-02 21:58:11 +08:00
|
|
|
| `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 |
|
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 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `createEntityCollection<T>()` | Create a reactive entity collection |
|
|
|
|
|
| `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
|