8.6 KiB
8.6 KiB
boardgame-core
A state management library for board games using Preact 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
- Type Safe: Full TypeScript support with strict mode and generic context extension
- Region System: Spatial management with multi-axis positioning, alignment, and shuffling
- Command System: CLI-style command parsing with schema validation, type coercion, and prompt support
- Deterministic RNG: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
Installation
npm install boardgame-core
Writing a Game
Defining a Game
Each game defines a command registry and exports a createInitialState function:
import {
createGameCommandRegistry,
Region,
createRegion,
} from 'boardgame-core';
// 1. Define your game-specific state
type MyGameState = {
board: Region;
parts: Record<string, { id: string; regionId: string; position: number[] }>;
score: { white: number; black: number };
currentPlayer: 'white' | 'black';
winner: 'white' | 'black' | 'draw' | null;
};
// 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: {},
score: { white: 0, black: 0 },
currentPlayer: 'white',
winner: null,
};
}
// 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 };
});
Running a Game
import { createGameContext } from 'boardgame-core';
import { registry, createInitialState } from './my-game';
const game = createGameContext(registry, createInitialState);
// Run commands through the context
const result = await game.commands.run('place 2 3');
if (result.success) {
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:
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');
if (error) {
console.log('Invalid move:', error);
// tryCommit can be called again with corrected input
} else {
// Input accepted, command continues
const result = await runPromise;
}
If the player needs to cancel instead of committing:
promptEvent.cancel('player quit');
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.
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.
Region System
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core';
// Compact cards in a hand towards the start
applyAlign(handRegion, parts);
// 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]);
Command Parsing
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' } }
// Define and validate against a schema
const schema = parseCommandSchema('move <from> <to> [--force] [-x: number]');
const result = validateCommand(cmd, schema);
// { valid: true }
Random Number Generation
import { createRNG } from 'boardgame-core';
const rng = createRNG(12345);
rng.nextInt(6); // 0-5
rng.next(); // [0, 1)
rng.next(100); // [0, 100)
rng.setSeed(999); // reseed
API Reference
Core
| Export | Description |
|---|---|
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 |
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 |
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 |
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 |
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 |
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 |
Utilities
| Export | Description |
|---|---|
createRNG(seed?) |
Create a seeded RNG instance |
Mulberry32RNG |
Mulberry32 PRNG class |
Scripts
npm run build # Build ESM bundle + declarations to dist/
npm run test # Run tests in watch mode
npm run test:run # Run tests once
npm run typecheck # Type check with TypeScript
License
MIT