move-by-move
v4.1.0
Published
Simple game engine for turn based games
Readme
move-by-move
A simple, dependency-free game engine for turn-based games.
move-by-move gives you a tiny state container in the spirit of Redux, but built around rules instead of reducers. Every move runs through a pipeline of rules, each of which reads the current state and the action and produces the next state. This makes it natural to express turn-based game logic — validation, the move itself, win checks, turn switching — as a sequence of small, composable steps.
Installation
npm install move-by-moveQuick start
import createGame from "move-by-move";
type State = { count: number };
type Action = { type: "INCREMENT"; by: number };
const game = createGame<State, Action>(
[
// each rule receives [state, action, context] and returns [state, action, context]
([state, action, context]) => {
if (action.type === "INCREMENT") {
return [{ count: state.count + action.by }, action, context];
}
return [state, action, context];
},
],
{ count: 0 }, // initial state
);
const unsubscribe = game.subscribe(() =>
console.log("state:", game.getState()),
);
game.move({ type: "INCREMENT", by: 1 }); // logs: state: { count: 1 }
game.move({ type: "INCREMENT", by: 5 }); // logs: state: { count: 6 }
unsubscribe(); // stop listeningThe Quick start writes rules by hand to show the raw
[state, action, context]contract. In a real game you'll usually reach forcreateRule, which lets you mutate state directly (via immer) and run a rule only for a chosen action type — see below. The third tuple slot,context, is a scratch bag shared by every rule — raw orcreateRule— for passing information downstream; this example just threads it through untouched.
Core concept: rules
A rule is a function over a [state, action, context] tuple:
type RuleIO<State, Action extends GameAction> = [
state: State,
action: Action,
context: Record<string, unknown>,
];
type Rule<State, Action extends GameAction> = (
input: RuleIO<State, Action>,
) => RuleIO<State, Action>;Treat the state and action a rule receives as read-only — that's the convention the pipeline relies on (the engine enforces nothing at runtime). To evolve state, return a new one (or mutate the draft inside createRule, where immer does the copying for you); to pass information downstream, write to context.
When you call game.move(action), the engine pipes [state, action, context] through every rule in order and uses the resulting state as the new state:
[state, action, context] → rule1 → rule2 → rule3 → [newState, action, context]
└── becomes the new state(context starts as a fresh, empty object on every move and is discarded once the move finishes — see Context.)
Because each rule receives the (possibly already-transformed) state, the action, and a shared context, rules can:
- transform state based on the action,
- ignore actions they don't care about (just return the input unchanged),
- share information with later rules through the
contextbag, - run unconditionally, e.g. a rule that switches the active player or checks for a winner after every move.
Order matters — a validation rule should run before the rule that applies the move, and a "check for win" rule should run after it.
Authoring rules with createRule
Writing [state, action, context] tuples by hand gets verbose, and hand-rolled immutable updates ({ ...state, board }) are easy to get wrong. createRule lets you mutate a draft of the state with immer — the engine turns your mutations into a new immutable state for you.
move-by-move stays dependency-free and doesn't ship immer, so you inject your own produce once — binding your game's State and Action union at the same time:
import createGame, { createRuleFactory } from "move-by-move";
import { produce } from "immer";
type State = {
board: (string | null)[];
turn: "X" | "O";
winner: string | null;
};
type Action = { type: "PLACE"; index: number } | { type: "GIVE_UP" };
const createRule = createRuleFactory<State, Action>(produce);A recipe receives (draft, action, context):
draft— a mutable draft of the state; mutate it directly, no spreading.action— the action being processed; treat it as read-only.context— a mutable bag shared by every rule in this move (see below).
Pass an action type as the first argument to run the rule only for that action — action is then narrowed to the matching member of your Action union, so its payload is fully typed (and passing a type your union doesn't include is a compile error). Omit it to run on every action, where action is the full union. Here's tic-tac-toe:
const placeMark = createRule("PLACE", (draft, action, context) => {
if (draft.winner) return;
if (draft.board[action.index] !== null) return; // square already taken
draft.board[action.index] = draft.turn; // narrowed to PLACE, so action.index is typed
context.placed = true; // tell downstream rules the move was accepted
});
const switchTurn = createRule("PLACE", (draft, action, context) => {
if (!context.placed) return; // only switch when placeMark accepted the move
draft.turn = draft.turn === "X" ? "O" : "X";
});
// Handles the other action; the current player resigns and their opponent wins.
const giveUp = createRule("GIVE_UP", (draft) => {
if (draft.winner) return;
draft.winner = draft.turn === "X" ? "O" : "X";
});
const game = createGame<State, Action>([placeMark, switchTurn, giveUp], {
board: Array(9).fill(null),
turn: "X",
winner: null,
});For brevity placeMark omits win detection: a complete game adds a rule after it that checks the board and sets draft.winner — which is what placeMark's early return guards against.
Context: sharing data between rules
Rules run one after another, and a later rule often needs to know what an earlier one decided. Above, both rules already fire only for PLACE actions, but placeMark does extra validation (no winner yet, square empty) before making the move. switchTurn should only swap the active player when a mark was actually placed — otherwise an invalid PLACE would still pass the turn.
Without a shared channel, switchTurn would have to repeat every check placeMark already did — duplicated logic that drifts out of sync the moment a rule changes. context solves this: placeMark records its decision with context.placed = true, and switchTurn just reads it.
Why not put this on state or the action?
stateis your persistent game state — you don't want transient bookkeeping like "was this move valid?" leaking into it (and into your subscribers).- the
actiondescribes what the player did, not what the rules decided — it should stay untouched.
So context is a separate, mutable scratch space with a clear lifetime:
- It's created fresh (
{}) at the start of everymove(), threaded through all rules, and discarded when the move ends. It never persists across moves. - It's a plain object — read it, write it, mutate it freely. (Unlike
state, it is not an immer draft.) - It's typed as
Record<string, unknown>: writing any value is fine, and reads come back asunknown, so narrow or cast when you need a specific type. (Truthiness checks likeif (context.placed)work directly.)
Phases: binding rules to game phases
Many turn-based games move through phases — setup, play, ended — where different rules apply. withPhases groups rules by phase and returns a single rule you drop into the pipeline:
import createGame, { createRuleFactory, withPhases } from "move-by-move";
type Phase = "setup" | "play";
type State = { phase: Phase; players: string[]; score: number };
type Action =
| { type: "JOIN"; name: string }
| { type: "START" }
| { type: "SCORE"; points: number };
const game = createGame<State, Action>(
[
withPhases((state) => state.phase, {
setup: [join, start], // start sets draft.phase = "play"
play: [score],
}),
audit, // unphased — runs on every move, whatever the phase
],
{ phase: "setup", players: [], score: 0 },
);How it works:
- The phase is ordinary state — any rule can change it (e.g.
draft.phase = "play"in acreateRulerecipe). It serializes with the rest of your state, and subscribers see it like any other field. withPhasesreads the phase once, when the move enters the block, and pipes the move through that phase's rules only. A rule that changes the phase never re-routes the move it belongs to — the new phase applies from the next move on.- The map is exhaustive: TypeScript requires an entry for every member of your
Phaseunion (an empty array is fine), so adding a phase breaks compilation until everywithPhaseshandles it — and unknown phase names are compile errors. - It composes with
createRuleorthogonally:createRule(type, …)gates by action type,withPhasesgates by phase. Two independent axes, no combined API to learn.
Two details worth knowing:
- Rules inside the selected phase still see mid-move changes, like everywhere else in the pipeline: if
startsetsdraft.phase = "play", a later rule in the samesetuplist that readsstate.phasesees"play". Only the routing is pinned for the move. - With several
withPhasesblocks, each reads the phase at its own entry — so a phase change in an earlier block affects how later blocks route the same move. Keep all phased rules in one block if a phase change should take effect strictly on the next move.
Throwing inside a rule: moves are atomic
A move is all-or-nothing by design. The engine runs the entire pipeline first and only then commits the resulting state — so if any rule throws, the whole move is aborted: the state keeps its pre-move value (work already done by earlier rules in the same move is discarded too), subscribers are not notified, and the error propagates to the move() caller.
const add = createRule("ADD", (draft, action) => {
draft.count += action.amount;
});
// Runs after `add` — throwing here discards the work `add` already did.
const capAt10 = createRule("ADD", (draft) => {
if (draft.count > 10) throw new Error("count must stay ≤ 10");
});
const game = createGame<State, Action>([add, capAt10], { count: 4 });
try {
game.move({ type: "ADD", amount: 20 });
} catch {
game.getState().count; // still 4 — the move never happened
}Use each mechanism for what it's for: an expected invalid move (occupied square, not your turn) is normal game flow — ignore it with an early return and let downstream rules check context. Reserve throw for hard failures that should never happen (broken invariants, programmer errors), where aborting loudly beats playing on from a corrupt state.
Two notes on the guarantee:
createRulerecipes are fully covered: when a recipe throws, immer discards the draft, so even mutations made before thethrownever reach your state.- Hand-written rules keep the guarantee as long as they follow the standing convention: don't mutate the incoming state, return a new one.
API
createGame<State, Action>(rules, initialState)
Creates a new game instance (see the game object for what it returns).
State— your game state.Action— your action union (a discriminated union ontype); this typesmove()and the action each rule sees.rules— an array of rule functions, applied in order on every move.initialState— the starting state.
createRuleFactory<State, Action>(produce)
Binds immer's produce (which you supply — move-by-move is dependency-free) plus your game's types, and returns a createRule helper:
import { produce } from "immer";
import { createRuleFactory } from "move-by-move";
const createRule = createRuleFactory<State, Action>(produce);State— your game state.Action— your action union (a discriminated union ontype). Enables payload narrowing onactionand rejects unknown types passed to thecreateRule(type, …)form.
createRule has two forms, both returning a Rule<State, Action>:
| Call | Runs | action type |
| -------------------------- | -------------------------------- | ----------------------- |
| createRule(recipe) | on every action | the full Action union |
| createRule(type, recipe) | only when action.type === type | narrowed to that member |
A recipe is (draft, action, context) => void: draft is a mutable immer draft of the state (your mutations become the next immutable state), action is the action (narrowed to the matching member in the createRule(type, …) form; treat it as read-only), and context is the per-move shared bag. To pass information to later rules, write to context (see Context).
withPhases(getPhase, phases)
Returns a single Rule<State, Action> that routes each move to the rules of the current phase (see Phases).
getPhase—(state) => Phase; reads the current phase from your state.phases— an exhaustive map from every member of yourPhaseunion to that phase's rules (an empty array is fine).
The move runs through the matching phase's rules in order; every other phase's rules are skipped. The phase is read once per move, when the move enters the block, so a rule that changes the phase never re-routes its own move — the new phase applies from the next move on.
The game object
| Method | Description |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| getState(): State | Returns the current state. |
| move(action: Action): void | Runs action through the rule pipeline, updates state, and notifies subscribers. Atomic: if a rule throws, the move is discarded — state unchanged, no notification — and the error is rethrown (see Throwing inside a rule). |
| subscribe(listener: Function): () => void | Registers a listener called (with no arguments) after every move. Use getState() inside it to read the new state. Returns an unsubscribe function — call it to remove the listener. |
Actions
Every action is an object with a required type string — that's the upper bound on the Action generic throughout the API:
type Action = { type: "PLACE"; index: number } | { type: "GIVE_UP" };Model yours as a discriminated union on type; that's what powers the narrowing in createRule(type, …) and the typing of move().
License
MIT © Andreas Riedmüller
