board game state management
Go to file
hypercross 22d40fdc50 refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
src refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
tests refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
.gitignore Initial commit: boardgame-core with build fixes 2026-03-31 18:01:57 +08:00
AGENTS.md docs: update docs 2026-04-04 01:03:12 +08:00
README.md docs: update docs 2026-04-04 01:03:12 +08:00
package-lock.json chore: switch to npm link based dependencies 2026-04-02 21:07:43 +08:00
package.json chore: switch to npm link based dependencies 2026-04-02 21:07:43 +08:00
tsconfig.json refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
tsup.config.ts refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
vitest.config.ts refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00

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
  • 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
  • Game Lifecycle Management: GameHost class provides clean setup/reset/dispose lifecycle for game sessions
  • 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');

Managing Game Lifecycle with GameHost

For a cleaner game lifecycle (setup, reset, dispose), use GameHost:

import { createGameHost } from 'boardgame-core';
import { registry, createInitialState } from './my-game';

// Create a game host with a setup command
const host = createGameHost(
    { registry, createInitialState },
    'setup'  // command to run when setting up/resetting the game
);

// Reactive state — use in effects or computed values
console.log(host.state.value.currentPlayer);
console.log(host.status.value); // 'created' | 'running' | 'disposed'

// Check if a prompt is active and what schema it expects
const schema = host.activePromptSchema.value;
if (schema) {
    console.log('Waiting for:', schema.name, schema.params);
}

// Submit player input to the active prompt
const error = host.onInput('play X 1 2');
if (error) {
    console.log('Invalid move:', error);
}

// Reset the game (cancels active prompt, resets state, runs setup)
await host.setup('setup');

// Dispose when done (cleans up listeners, cancels active prompts)
host.dispose();

The GameHost provides:

  • state: ReadonlySignal<TState> — reactive game state
  • status: ReadonlySignal<'created' | 'running' | 'disposed'> — lifecycle status
  • activePromptSchema: ReadonlySignal<CommandSchema | null> — reactive current prompt schema
  • onInput(input): Submit input to active prompt, returns error or null
  • setup(command): Reset and reinitialize the game
  • dispose(): Clean up all resources
  • on(event, listener): Listen to 'setup' or 'dispose' events

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
GameHost<TState> Game lifecycle manager class with setup/reset/dispose
createGameHost(module, setupCommand, options?) Create a GameHost instance from a game module
GameHostStatus Type: 'created' | 'running' | 'disposed'
GameHostOptions Options: { autoStart?: boolean }

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
CommandRunnerEvents Event types: prompt (when prompt starts), promptEnd (when prompt completes)

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