|
|
||
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| README.md | ||
| package-lock.json | ||
| package.json | ||
| tsconfig.json | ||
| tsup.config.ts | ||
| vitest.config.ts | ||
README.md
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
- 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
npm install boardgame-core
Writing a Game
The core pattern for writing a game is:
- Define your game state type
- Create a command registry with
createGameCommandRegistry<State>() - Register commands for
setup,turn, and any actions - Run
setupto 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.
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.
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.
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.
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.
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.
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
import { createGameContextFromModule } from 'boardgame-core';
import * as yourGame from './your-game';
const game = createGameContextFromModule(yourGame);
game.commands.run('setup');
Or use createGameContext directly:
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.
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 and Boop rules.
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
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