npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-move

Quick 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 listening

The Quick start writes rules by hand to show the raw [state, action, context] contract. In a real game you'll usually reach for createRule, 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 or createRule — 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 context bag,
  • 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?

  • state is your persistent game state — you don't want transient bookkeeping like "was this move valid?" leaking into it (and into your subscribers).
  • the action describes 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 every move(), 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 as unknown, so narrow or cast when you need a specific type. (Truthiness checks like if (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 a createRule recipe). It serializes with the rest of your state, and subscribers see it like any other field.
  • withPhases reads 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 Phase union (an empty array is fine), so adding a phase breaks compilation until every withPhases handles it — and unknown phase names are compile errors.
  • It composes with createRule orthogonally: createRule(type, …) gates by action type, withPhases gates 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 start sets draft.phase = "play", a later rule in the same setup list that reads state.phase sees "play". Only the routing is pinned for the move.
  • With several withPhases blocks, 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:

  • createRule recipes are fully covered: when a recipe throws, immer discards the draft, so even mutations made before the throw never 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 on type); this types move() 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 on type). Enables payload narrowing on action and rejects unknown types passed to the createRule(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 your Phase union 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