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 amyTracker({ minElo: 2000 })would forward{ minElo: 2000 }toinit(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
moveaction. - Capture — typically a
capturefollowed by amove. - Promotion — adds a
promotionon the same call (alongside the move or capture). - Castling — two
moveactions (king leg, then rook leg); both carry the samecastleflag.
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:
| Hook | Single-threaded (workers: false) | Multithreaded (default) |
|---|---|---|
init | At factory call | Once per worker, plus on your main-thread instances |
track | Each game / half-move | Each game / half-move, on workers |
onGameEnd | After each game (including replay skip) | After each game, on workers |
merge | — | On your instances, when worker snapshots arrive |
onFinish | Once at the end | Once 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 movetrack(see Whattrackreceives).ParsedGame/ParsedMove— the game summary passed to gametrack(ParsedMoveis just{ san }today).PieceName/Square— piece identity and board squares on action fields ('Pa','e4', …).
Good to know
workerModule: import.meta.urlneeds an unbundled Node ≥ 22 (or compatible) runtime (workers import the file by its URL).- A
filteron the sameanalyzePGNcall 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.