PGN is a fast, lightweight TypeScript parser for Portable Game Notation — the standard format for recording chess games.
It parses PGN input into structured move objects with decomposed SAN, paired white/black moves, and full support for annotations and variations. Zero runtime dependencies. The smallest PGN parser on npm.
| Package | Pack size | Unpacked |
|---|---|---|
@echecs/pgn |
42 KB | 195 KB |
pgn-parser |
99 KB | 606 KB |
@mliebelt/pgn-parser |
148 KB | 595 KB |
chess.js |
150 KB | 724 KB |
Most PGN parsers on npm either give you raw strings with no structure, or fail on anything beyond a plain game record. If you're building a chess engine, opening book, or game viewer, you need more:
- Decomposed SAN — every move is parsed into
piece,from,to,capture,promotion,check, andcheckmatefields. No regex on your side. - Paired move structure — moves are returned as
[moveNumber, whiteMove, blackMove]tuples, ready to render or process without further work. - RAV support — recursive annotation variations (
(...)sub-lines) are parsed into avariantstree on each move. Essential for opening books and annotated games. - NAG support — symbolic (
!,?,!!,??,!?,?!) and numeric ($1–$255) annotations are surfaced as anannotationsarray. Essential for Lichess and ChessBase exports. - Multi-game files — parse entire PGN databases in one call. Tested on files with 3 500+ games.
- Fast — built on a Peggy PEG parser. Throughput is within 1.1–1.2x of the fastest parsers on npm, which do far less work per move (see BENCHMARK_RESULTS.md).
If you only need raw SAN strings and a flat move list, any PGN parser will do. If you need structured, engine-ready output with annotations and variations, this is the one.
npm install @echecs/pgnimport parse from '@echecs/pgn';
const games = parse(`
[Event "Example"]
[White "Player1"]
[Black "Player2"]
[Result "1-0"]
1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0
`);
console.log(games[0].moves[0]);
// [1, { piece: 'P', to: 'e4' }, { piece: 'P', to: 'e5' }]Takes a PGN string and returns an array of game objects — one per game in the file.
parse(input: string, options?: ParseOptions): PGN[]Deprecated. Use
parse()instead — it already handles multi-game input.stream()will be removed in the next major version. It emits aconsole.warnon first call.
Converts one or more parsed PGN objects back into a valid PGN string,
providing semantic round-trip fidelity.
stringify(input: PGN | PGN[], options?: StringifyOptions): stringReconstructs SAN from Move fields, re-serializes annotation commands
([%cal], [%csl], [%clk], [%eval]) back into comment blocks, and
preserves RAVs and NAGs. Pass onWarning to observe recoverable issues (e.g.
invalid castling destination, negative clock). StringifyOptions is a subset of
ParseOptions — onError is not accepted since stringify never fails hard.
import parse, { stringify } from '@echecs/pgn';
const games = parse(pgnString);
const output = stringify(games); // valid PGN stringBy default, parse() silently returns [] on parse failure. Pass an onError
callback to observe failures:
import parse, { type ParseError } from '@echecs/pgn';
const games = parse(input, {
onError(err: ParseError) {
console.error(
`Parse failed at line ${err.line}:${err.column} — ${err.message}`,
);
},
});onError receives a ParseError with:
| Field | Type | Description |
|---|---|---|
message |
string |
Human-readable description from the parser |
offset |
number |
Character offset in the input (0-based) |
line |
number |
1-based line number |
column |
number |
1-based column number |
Pass onWarning to observe spec-compliance issues that do not prevent parsing:
import parse, { type ParseWarning } from '@echecs/pgn';
const games = parse(input, {
onWarning(warn: ParseWarning) {
console.warn(warn.message);
},
});onWarning receives a ParseWarning with the same fields as ParseError:
message, offset, line, column.
Currently fires for:
- Missing STR tags (
Black,Date,Event,Result,Round,Site,White) — emitted in alphabetical key order; position fields are nominal placeholders - Move number mismatch (declared move number in the PGN text doesn't match the move's actual position) — position fields are nominal placeholders
- Result tag mismatch (
[Result "..."]tag value differs from the game termination marker) — position fields are nominal placeholders - Duplicate tag names —
lineandcolumnpoint to the opening[of the duplicate tag
The same option is accepted by stringify().
{
meta: Meta, // tag pairs (Event, Site, Date, White, Black, …)
moves: MoveList, // paired move list
result: 1 | 0 | 0.5 | '?'
}meta is an index of all tag pairs from the PGN header. The Result key is
optional — games with no tag pairs return meta: {}. Use game.result (always
present) as the authoritative game outcome.
{
piece: 'P' | 'R' | 'N' | 'B' | 'Q' | 'K', // always present
to: string, // destination square, e.g. "e4"
from?: string, // disambiguation: file "e", rank "2", or square "e2"
capture?: true,
castling?: true,
check?: true,
checkmate?: true,
promotion?: 'R' | 'N' | 'B' | 'Q',
annotations?: string[], // e.g. ["!", "$14"]
comment?: string,
arrows?: Arrow[], // from [%cal ...] command
squares?: SquareAnnotation[], // from [%csl ...] command
clock?: number, // from [%clk ...] — seconds remaining
eval?: Eval, // from [%eval ...] — engine evaluation
variants?: MoveList[], // recursive annotation variations
}Moves are grouped into tuples: [moveNumber, whiteMove, blackMove]. Both move
slots can be undefined — whiteMove when a variation begins on black's turn,
blackMove when the game or variation ends on white's move.
12. Nf3! $14 { White has a slight advantage }
{
piece: 'N', to: 'f3',
annotations: ['!', '14'], // numeric NAGs stored without '$' prefix
comment: 'White has a slight advantage'
}PGN files produced by GUIs and engines embed structured commands inside move
comments using the [%cmd ...] syntax. This library parses the four most common
commands and exposes them as dedicated fields on Move:
| Field | Type | PGN command | Description |
|---|---|---|---|
arrows |
Arrow[] |
[%cal ...] |
Coloured arrows drawn on the board |
squares |
SquareAnnotation[] |
[%csl ...] |
Coloured square highlights |
clock |
number |
[%clk ...] |
Remaining time in seconds (sub-second preserved) |
eval |
Eval |
[%eval ...] |
Engine evaluation (centipawns or mate-in-N) |
Command strings are stripped from move.comment. Unknown [%...] commands are
left in the comment string unchanged.
type AnnotationColor = 'B' | 'C' | 'G' | 'O' | 'R' | 'Y'; // Blue, Cyan, Green, Orange, Red, Yellow
interface Arrow {
color: AnnotationColor;
from: string; // e.g. "e2"
to: string; // e.g. "e4"
}
interface SquareAnnotation {
color: AnnotationColor;
square: string; // e.g. "e4"
}
type Eval =
| { type: 'cp'; value: number; depth?: number } // centipawn score
| { type: 'mate'; value: number; depth?: number }; // mate in N1. e4 { [%cal Ge2e4,Re4e5] [%clk 0:05:00] } e5
{
piece: 'P', to: 'e4',
// comment is absent — no free text remains after stripping commands
arrows: [
{ color: 'G', from: 'e2', to: 'e4' },
{ color: 'R', from: 'e4', to: 'e5' },
],
clock: 300, // 5 minutes in seconds
}5... Ba5 (5... Be7 6. d4) 6. Qb3
The alternative line appears as a variants array on the move where it
branches:
{
piece: 'B', to: 'a5',
variants: [
[ [5, undefined, { piece: 'B', to: 'e7' }], [6, { piece: 'P', to: 'd4' }] ]
]
}All public types are exported as named type exports:
import type {
AnnotationColor, // 'B' | 'C' | 'G' | 'O' | 'R' | 'Y'
Arrow, // { color, from, to }
Eval, // { type: 'cp' | 'mate', value, depth? }
Meta, // { [key: string]: string | undefined }
Move, // single parsed move object
MoveList, // MovePair[]
MovePair, // [number, Move | undefined, Move?]
ParseError, // { message, offset, line, column }
ParseOptions, // { onError?, onWarning? }
ParseWarning, // { message, offset, line, column }
PGN, // { meta, moves, result }
Piece, // 'P' | 'R' | 'N' | 'B' | 'Q' | 'K'
Result, // '1-0' | '0-1' | '1/2-1/2' | '?'
SquareAnnotation, // { color, square }
StringifyOptions, // { onWarning? }
Variation, // MoveList[]
} from '@echecs/pgn';Contributions are welcome. Please read CONTRIBUTING.md for guidelines on how to submit issues and pull requests.