board game state management
Go to file
hyper b71ba12454 feat: add new functions 2026-04-02 21:58:11 +08:00
src feat: add new functions 2026-04-02 21:58:11 +08:00
tests feat: add new functions 2026-04-02 21:58:11 +08:00
.gitignore Initial commit: boardgame-core with build fixes 2026-03-31 18:01:57 +08:00
AGENTS.md refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
README.md feat: add new functions 2026-04-02 21:58:11 +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
  • 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:

  1. Define your game state type
  2. Create a command registry with createGameCommandRegistry<State>()
  3. Register commands for setup, turn, and any actions
  4. Run setup to 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