Skip to content

PGN container (spec)

Status: draft v1 (2026-05-19) Prerequisite reading:

A backwards-compatible way to embed an OpenFile session manifest inside a standard PGN file, producing a single self-contained artifact that:

  • Opens cleanly in any chess tool that reads PGN. Lichess, ChessBase, scid, raw cat, GitHub renderer — all see the moves, headers, and comments. The manifest is invisible to them.
  • Verifies cryptographically when opened by an OpenFile-aware tool. The manifest provides the trust frame; the PGN provides the content; the manifest’s pgnHash binds them together.
  • Is detectable cheaply. A 16-byte peek at the start of the file is enough to determine whether a .pgn contains an OpenFile manifest.

The format uses PGN’s built-in %-comment-line escape mechanism (PGN spec §6), which conforming parsers MUST skip. This makes the embedding fully spec-compliant rather than relying on tool tolerance.

These shaped every choice below; documented for posterity.

  1. PGN’s 255-character line limit (§4.3) is a hard ceiling. The 80-character soft limit is “strongly discouraged” but exceeded by common fields like [FEN ...].
  2. Single file, no archive. No zip, no tar, no MIME multipart.
  3. Round-trip byte stability so pgnHash stays consistent across read/write cycles by editors that preserve line endings.
  4. No file-extension fragmentation. .pgn stays .pgn. (See “Why not .opgn” below.)

A leading block of %-prefixed lines, before any PGN content:

%OPGN/1 ofm-bytes=612 ofm-encoding=b64
%eyJ2ZXJzaW9uIjoib3BlbmZpbGUtbWFuaWZlc3QtdjQiLCJvd25lclB1Ympl…
%S2V5IjoiN0t6bE1KdlhNZzRaeFBjdGxuTFZGMy82OHJzay9MQ3BUK0RYWlBJa…
%[... more 76-char base64 lines, each prefixed with % ...]
%fSwic2lnbmF0dXJlIjoiYUhtdU5GekI2bk9NWHJ0QUNuQUh6eFhPOG13QWZW…
[Event "Sicilian study"]
[White "Carlsen, Magnus"]
[Black "Caruana, Fabiano"]
[Result "1-0"]
1. e4 c5 2. Nf3 d6 ...
%OPGN/<version> ofm-bytes=<bytes> ofm-encoding=<encoding>
  • OPGN/ is the magic prefix. The slash-version follows immediately with no space.
  • <version> is a positive integer file-format version. v1 is current. Loaders MUST reject unknown versions.
  • ofm-bytes=<bytes> declares the manifest’s length in bytes (after base64 decode). Used as a sanity check during load.
  • ofm-encoding=<encoding> declares the encoding. v1 defines only b64 (standard padded base64, RFC 4648). Loaders MUST reject unknown encodings.
  • Fields are space-separated. Key=value pairs may appear in any order after the magic, but writers SHOULD emit them in the order above for readability.
  • The line as a whole MUST be ≤ 255 characters (PGN hard limit).
  • Each line is %<chunk>\n where <chunk> is up to 76 chars of the base64-encoded manifest, MIME-wrapping convention (RFC 2045 §6.8).
  • With the % prefix and \n, total line length is at most 78 chars — comfortably under both the 255 hard limit and the 80 soft limit.
  • Lines are concatenated in order, dropping the % prefix, to recover the base64 string.
  • After base64 decode, the result MUST be a valid v4 OpenFile manifest (canonical JSON). The decoded length MUST equal ofm-bytes.

The block ends at the first non-%-prefixed line. From that line through end-of-file is the PGN content (one or more games, in standard PGN format).

A loader scans the file from the start, collecting consecutive %- prefixed lines. The block ends at the first non-%-line.

Within the collected %-block, the loader looks for the first line matching %OPGN/<digits> exactly (after the % and before any space or end-of-line). All %-lines:

  • Before the OPGN/ line: treated as ordinary PGN comments (other tools may have placed them there). Skipped during manifest extraction.
  • The OPGN/ line itself: parsed as the meta line.
  • After the OPGN/ line, until the first non-%-line: form the manifest body chunks.

If no %OPGN/-prefixed line appears in the leading %-block, the file is treated as plain PGN with no manifest (no error).

A file MUST contain at most one %OPGN/<version> line in its leading %-block. Loaders MUST reject files with multiple.

For computing pgnHash, the PGN portion (from the first non-%-line through end-of-file) is normalized to LF line endings + trailing whitespace trimmed per line, then UTF-8 encoded.

Producers and consumers MUST apply this normalization before hashing. The on-disk file MAY use CRLF or LF; the hash is over the normalized form.

Trailing whitespace on lines is whitespace at the end of a line before the newline. Whitespace within a line is preserved.

Export (exportOpgn(target, { ownerSigner }))

Section titled “Export (exportOpgn(target, { ownerSigner }))”
  1. pgnText = createGameFromTarget(target).getPgn() — get the canonical L1 form.
  2. Normalize pgnText (LF + trim trailing whitespace per line).
  3. pgnBytes = utf8(normalizedPgnText).
  4. manifest = await buildManifest(target, { ownerSigner, pgnText: normalizedPgnText }).
  5. b64 = base64(canonicalJson(manifest)).
  6. Wrap b64 to 76-char chunks; prefix each with %.
  7. Prepend %OPGN/1 ofm-bytes=<len> ofm-encoding=b64\n + wrapped chunks.
  8. Append the normalized PGN text.
  9. Return the resulting bytes.
  1. Decode bytes as UTF-8.
  2. Walk lines from the start. Collect %-prefixed lines until the first non-%-line. Call this the metaBlock.
  3. Within metaBlock, find the first line starting with %OPGN/. If none: return { pgnText: <rest>, manifest: null, valid: null } (plain PGN, no manifest).
  4. Parse the meta line. Reject if version, encoding, or fields are malformed.
  5. Reject if a second %OPGN/ line appears.
  6. Concatenate the body chunks (%-lines after the meta line within metaBlock), stripping %. Base64-decode → manifest bytes.
  7. Verify manifest bytes.length === ofm-bytes. Reject if mismatch.
  8. manifest = parseManifest(manifestBytes).
  9. Extract the PGN portion: bytes from the first non-%-line through end-of-file.
  10. Normalize the PGN (LF + trim trailing whitespace per line).
  11. result = await verifyManifest(manifest, normalizedPgnBytes, { verifier }).
  12. Return { pgnText: normalizedPgnText, manifest, valid: result.valid, error: result.error }.

Detection helper (isOpgn(bytesOrFirstChunk))

Section titled “Detection helper (isOpgn(bytesOrFirstChunk))”

Returns true iff the file starts with %OPGN/<digit>. Reads at most the first 16 bytes; safe to call on any candidate .pgn file.

loadOpgn returns one of:

CodeMeaning
valid: trueManifest is present and verifies against the PGN.
valid: nullNo manifest; file is plain PGN.
valid: false, error: 'unknown-version'Meta line has a version we don’t support.
valid: false, error: 'unknown-encoding'Meta line declares an encoding we don’t support.
valid: false, error: 'malformed-meta'Meta line is structurally invalid.
valid: false, error: 'multiple-opgn-lines'More than one %OPGN/ line in the leading block.
valid: false, error: 'length-mismatch'Decoded manifest length ≠ ofm-bytes.
valid: false, error: 'decode-error'Base64 decode failed.
valid: false, error: <code from verifyManifest>Manifest itself failed verification (pgn-hash-mismatch, owner-signature-invalid, etc.).
  • Extension: .pgn (not .opgn). The whole point of this format is that it remains a valid PGN file. A separate extension would fragment the ecosystem — Lichess uploads, file pickers, OS-level associations all already understand .pgn.
  • MIME type: application/vnd.chess+pgn (standard +pgn structured- suffix). Tools that want to indicate “this PGN carries OpenFile metadata” can use application/vnd.openfile+pgn at the HTTP layer, but the file extension stays .pgn.

For UI labeling, apps can read isOpgn(bytes) to detect manifest presence and badge accordingly.

The detection question — “how do I find OpenFile-signed files in a directory of PGNs?” — is solved by a content-based magic check, not by file-extension fragmentation. The %OPGN/ magic on line 1 is detectable in a 16-byte peek. Library/file-browser UIs can show a badge.

In return, OpenFile-signed files retain universal interop:

  • Lichess upload of a manifest-bearing .pgn → moves come through.
  • ChessBase / scid / Hiarcs open it cleanly.
  • Email attachment works without OS-level “unknown file type” warnings.
  • cat shows recognizable PGN content (with a small meta block at the top).

This is the same pattern as .docx being a .zip, or .svg being .xml, or .geojson being .json: extend the popular format using its existing escape mechanisms; keep the extension.

An earlier design considered embedding the manifest in PGN tag pairs (e.g. [OpenFileManifest "<base64>"]). It was rejected because PGN’s 255-character hard line limit forced chunking into many headers — and the 80-character soft limit was violated by all reasonable chunk sizes.

The %-comment mechanism is explicitly designed for non-PGN content within a PGN file (spec §6), supports arbitrary line counts, and stays within both line-length limits with comfortable margin. It’s the mechanism the PGN spec itself points to for this use case.

Reference: ~150 LOC of source + ~150 of tests.

The module exports:

exportOpgn(target, { ownerSigner }): Promise<Uint8Array>
loadOpgn(bytes, { verifier }): Promise<{
pgnText: string,
manifest: Manifest | null,
valid: boolean | null,
error?: string,
}>
isOpgn(bytes): boolean

No new dependencies on top of OpenFile’s existing surface (buildManifest, parseManifest, verifyManifest, base64 helpers, canonicalize).