Chessalyzer
Trackers

Custom trackers

Teach Chessalyzer your own statistics with defineGameTracker and defineMoveTracker.

The built-in trackers cover common questions, but the fun starts when you have a question nobody asked before. Writing a custom tracker means describing three things: what your state looks like initially (init), how one game or move changes it (track), and how two partial states combine (merge). Chessalyzer handles everything else.

A first tracker in five minutes

Let's count captures across a file — a single total the built-ins don't hand you, and only a few lines away. Create a new separate file for your tracker:

// capture-counter.ts
import { defineMoveTracker } from 'chessalyzer/trackers';

export default defineMoveTracker({
    id: 'capture-counter',
    workerModule: import.meta.url,
    init: () => ({ captures: 0 }),
    track: (state, actions) => {
        for (const action of actions) {
            if (action.type === 'capture') {
                state.captures += 1;
            }
        }
    },
    merge: (state, other) => {
        state.captures += other.captures;
    },
});

Then use it like any built-in:

import { analyzePGN } from 'chessalyzer';
import captureCounter from './capture-counter.ts';

const counter = captureCounter();

await analyzePGN('games.pgn', { trackers: [counter] });

console.log(counter.state); // { captures: 3 }

Three captures on our example file — that's the whole API.

Why the separate file and the extra fields?

By default, Chessalyzer analyzes on several threads at once. Threads can't receive functions from your main program — so each worker imports your tracker module itself and creates its own copy via the default-exported factory. That's why the definition needs an id and workerModule: import.meta.url, and why state travels back as plain data to be combined with your merge function. Multithreading walks through the whole flow.

Two consequences worth remembering:

  • State must be plain data (numbers, strings, arrays, plain objects) — no class instances, Maps, or functions, because it crosses thread boundaries via structured clone.
  • Options go to the factory call, and they must be plain data too: captureCounter() takes none, but a myTracker({ minElo: 2000 }) would forward { minElo: 2000 } to init(options) on every worker.

What track receives

Move and game trackers see different inputs. The hook signature is the same (track(state, …)), but what gets passed in depends on which factory you used.

Move trackers

defineMoveTracker calls track(state, actions) once per half-move. The second argument is an Action[] — one or more replay events for that half-move, not a single object.

Each action is a discriminated union on type:

// Quiet move / castle leg
{ type: 'move', san, player, piece, from, to, castle? }

// Capture (incl. en passant)
{ type: 'capture', san, player, takingPiece, takenPiece, on, from?, enPassant? }

// Promotion (extra action on the same half-move)
{ type: 'promotion', san, player, promotion, on }

How many actions you get per call depends on the move:

  • Quiet move — usually one move action.
  • Capture — typically a capture followed by a move.
  • Promotion — adds a promotion on the same call (alongside the move or capture).
  • Castling — two move actions (king leg, then rook leg); both carry the same castle flag.

Loop over actions and branch on action.type — that's the pattern in the example above.

Action objects are recycled

In a move tracker's track, the Action objects are reused from half-move to half-move for performance. Read what you need and store values in your state — never keep a reference to an action itself, or you'll be looking at the next move's data.

Piece names (action.piece, takingPiece, takenPiece) identify pieces by origin square — Pa, Nb, … — with promoted pawns getting synthetic names like Q17. Use isStartingPieceName() before indexing starting-piece matrices.

Game trackers

defineGameTracker works the same way, except track(state, game) runs once per game with a ParsedGame:

{
    moves: [{ san: 'e4' }, { san: 'e5' }, /* … */],
    result?: '1-0' | '0-1' | '1/2-1/2' | '*',
    headers?: { White: 'alice', BlackElo: '1892', /* … */ },
}

moves is the mainline in SAN; result comes from movetext and/or the Result tag; headers is present when header parsing is on (automatic when you use a game tracker or a filter). See Parsing PGN for a full annotated example.

Which hook runs where?

With the default multithreaded pool, per-game work happens on the workers, and only the final combination happens back on the main thread:

HookSingle-threaded (workers: false)Multithreaded (default)
initAt factory callOnce per worker, plus on your main-thread instances
trackEach game / half-moveEach game / half-move, on workers
onGameEndAfter each game (including replay skip)After each game, on workers
mergeOn your instances, when worker snapshots arrive
onFinishOnce at the endOnce at the end, after all merges

Use onGameEnd(state) for per-game flush logic and onFinish(state) for final touches like computing averages — knowing that onFinish only ever sees fully merged state.

Helpers and types

import { defineGameTracker, defineMoveTracker } from 'chessalyzer/trackers';
import type { Action } from 'chessalyzer/replay';
import type { ParsedGame, ParsedMove } from 'chessalyzer/pgn';
import type { PieceName, Square } from 'chessalyzer/board';
  • defineMoveTracker / defineGameTracker — factories that turn your definition into a tracker. Default-export the result from your module so workers can import it.
  • Action — the per-half-move replay events passed to move track (see What track receives).
  • ParsedGame / ParsedMove — the game summary passed to game track (ParsedMove is just { san } today).
  • PieceName / Square — piece identity and board squares on action fields ('Pa', 'e4', …).

Good to know

  • workerModule: import.meta.url needs an unbundled Node ≥ 22 (or compatible) runtime (workers import the file by its URL).
  • A filter on the same analyzePGN call forces single-threaded mode, so custom trackers in a filtered analysis don't need the multithreading setup.
  • manual-tests/custom-game-tracker.ts is a minimal working example you can run.

On this page