Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chess-api

One unified TypeScript wrapper over the Lichess and Chess.com public APIs.

Both platforms expose similar data in incompatible shapes — different field names, different time-control notations, different rating-pool keys, different timestamp units and completely different game-termination vocabularies. This library normalizes all of it into a single model so you can write platform-agnostic code.

  • Zero runtime dependencies (native fetch, runs TypeScript directly on Node ≥ 23.6)
  • Full raw clients too, if you want the platform-native shapes
  • Serialized + retrying HTTP layer (both APIs ask for one request at a time)
  • NDJSON streaming for large Lichess exports
import { createChessApi } from './src/index.ts';

const api = createChessApi({ userAgent: 'my-app (me@example.com)' });

const player = await api.getPlayer('erik', 'chesscom');   // UnifiedPlayer
const stats  = await api.getStats('thibault', 'lichess'); // UnifiedStats
const games  = await api.getGames('hikaru', { max: 20 }); // UnifiedGame[]
$ node examples/demo.ts thibault erik

=== CHESSCOM: erik
    games: 14990 (rated 14990) — 8073W/6260L/657D
    bullet            1712  ± 42    8110 games  4187W/3700L/223D    peak 2071
    correspondence    1474  ± 62    5452 games  3085W/1995L/372D    peak 2065
    ...
=== Recent games (chess.com, normalized)
    date       platform  speed          time      variant     result     status
    2026-08-05 chesscom  correspondence 7d/move   standard    white won  resignation
    2026-08-01 chesscom  correspondence 1d/move   chess960    black won  resignation

The unified model

Time controls — TimeControl

The single hardest thing to unify. Sources:

Platform Raw Meaning
Lichess clock: { initial: 180, increment: 2, totalTime: 260 } seconds (limitSeconds/incrementSeconds in lila)
Lichess daysPerTurn: 3 correspondence
Chess.com "180+2" 3 min + 2 sec increment
Chess.com "600" 10 min, no increment
Chess.com "1/259200" PGN moves-per-period: 1 move per 259200s = 3 days/move
Chess.com "40/7200+30" PGN moves-per-period with increment: 2h base, +30s

All become:

interface TimeControl {
  kind: 'clock' | 'correspondence';
  initial: number | null;               // seconds
  increment: number | null;             // seconds
  daysPerTurn: number | null;           // correspondence
  estimatedTotalSeconds: number | null; // initial + 40*increment (clock games)
  secondsPerMove: number | null;        // days * 86400 (correspondence only)
  label: string;                        // "3m+2s", "10m", "1m", "3d/move"
}

Note: Lichess timestamps (createdAt, lastMoveAt, since, until) are in milliseconds, while its clock values are in seconds. Chess.com timestamps are in seconds. The wrapper converts everything to Date.

Helpers:

parseTimeControl('1/259200'); // { kind:'correspondence', daysPerTurn:3, label:'3d/move' }
parseTimeControl('180+2');    // { kind:'clock', initial:180, increment:2, label:'3m+2s' }
clockToTimeControl(180, 2);   // from a Lichess clock (seconds)
daysPerTurnToTimeControl(3);
classifyClockSpeed(182);      // 'blitz'
timeControlToSpeed(tc);       // 'blitz' | ... | 'correspondence'

Time formats (speeds) — Speed

ultraBullet | bullet | blitz | rapid | classical | correspondence

Chess.com's time_class maps in: daily → correspondence, standard → classical, ultrabullet → ultraBullet, others 1:1. When time_class is missing the speed is derived from the clock using Lichess's own estimate of a 40-move game, initial + 40 × increment — the value Lichess reports as clock.totalTime:

estimate speed
< 30s ultraBullet
< 180s bullet
< 480s blitz
< 1500s rapid
>= 1500s classical

Using initial + increment instead is wrong: 2+2 would come out as bullet, while Lichess (and this library) classify it as blitz (120 + 40×2 = 200).

Rating pools — PerfKey

Speed | Variant | 'puzzle'. Chess.com's {rules}_{time_class} keys collapse:

Chess.com unified
chess_bullet / chess_blitz / chess_rapid bullet / blitz / rapid
chess_daily correspondence
chess960_daily chess960
bughouse_daily bughouse
kingofthehill_daily kingOfTheHill
threecheck_daily threeCheck
crazyhouse_daily crazyhouse
tactics / lessons / puzzle_rush stats.tactics / .lessons / .puzzleRush

Lichess perf keys map 1:1 (storm/racer/streak are skipped).

Variants — Variant

standard | chess960 | crazyhouse | bughouse | antichess | atomic | horde | kingOfTheHill | racingKings | threeCheck | unknown (from Lichess variant and Chess.com rules, where chess → standard).

Game endings — UnifiedGameStatus

Lichess reports one status per game; Chess.com reports a result code per player. Both fold into one status plus a per-player win/loss/draw:

Unified Lichess Chess.com (loser's code)
checkmate mate checkmated
resignation resign resigned
timeout timeout timeout
out-of-time outoftime
agreed draw agreed
stalemate stalemate stalemate
repetition repetition
insufficient-material insufficientMaterialClaim insufficient
fifty-move 50move
timeout-vs-insufficient timevsinsufficient
variant-end variantEnd kingofthehill, threecheck, bughousepartnerlose
abandoned abandoned (a loss for the abandoning player)
aborted / cheat / no-start aborted / cheat / noStart
created / started created / started

On Lichess a finished game with no winner is a draw — this matters for outoftime, where a flag fall against insufficient material is scored as a draw rather than a loss. Games with no winner and a non-final status (created, started, aborted, cheat, noStart, unknownFinish) get no result; cheat/noStart normally do carry a winner and therefore do get one.

Note that Lichess does not expose why a game was drawn — agreement, threefold, fifty-move and insufficient material all arrive as status: "draw", which maps to the unified draw. Only Chess.com distinguishes them (agreed, repetition, 50move, insufficient).

UnifiedGame

{
  platform, id, url, rated,
  variant, speed, perf, timeControl,
  status, winner?, result?,            // 'white' | 'black' | 'draw'
  players: { white: { username, rating, result, resultCode, accuracy?, analysis? }, black: {...} },
  opening?: { eco?, name?, ply? },
  fen?, initialFen?, moves, pgn?,      // moves = bare SAN on both platforms
  createdAt?, startedAt?, endedAt?,    // real Dates, both epochs handled
  accuracy?: { white?, black? },
  clocks?: number[],                   // centiseconds per ply
  tournament?: { id?, name? },
}

Chess.com omits start_time for live games, so startedAt is recovered from the PGN UTCDate/UTCTime tags, and clocks are parsed out of [%clk ...] comments. Timestamps are never fabricated: if a payload carries none, createdAt is undefined rather than "now".

fen requires Lichess's lastFen parameter, and pgn requires pgnInJsongetGames/getGame request both for you, but raw LichessClient calls do not by default, in which case those fields are simply absent rather than filled with a placeholder.

UnifiedPlayer / UnifiedStats / UnifiedPerf

UnifiedPlayer  { platform, id, username, url, title?, createdAt?, seenAt?,
                 perfs: Record<PerfKey, UnifiedPerf>, counts, playTimeSeconds?, profile? }

UnifiedPerf    { games, rating, rd, prog, prov, lastDate?,
                 best?, highest?, lowest?,        // best = chess.com best win, highest/lowest = lichess
                 record?: {win,loss,draw}, tournament?, opAvg?, rank?, percentile? }

UnifiedStats   { platform, username, perfs, tactics?, lessons?, puzzleRush? }

getPlayer(user, 'chesscom') transparently merges /player + /stats so ratings are populated on both platforms, and aggregates counts from the per-pool records.

Chess.com only publishes rated win/loss/draw records per pool, so for that platform counts.all === counts.rated (unrated games aren't exposed). Lichess reports both independently. UnifiedPerf.best is Chess.com's best win, while highest/lowest are Lichess's all-time extremes — they're deliberately separate fields rather than being conflated.


API

const api = createChessApi({
  userAgent,        // recommended: identify your app + contact
  retries = 2,      // on 429 / 5xx, exponential backoff, honours Retry-After
  retryDelayMs,
  serialize = true, // one request at a time (both APIs ask for this)
  timeoutMs = 30000,
  fetchImpl,        // inject your own fetch (used by the test suite)
});
Method Notes
getPlayer(username, platform) unified profile (+ratings)
getPlayers(username) both platforms, missing ones omitted
getStats(username, platform) full stats; on Lichess fans out over every played perf
getGames(username, opts) merged, newest-first; platforms, since, until (ms), max, perfType, rated, vs, color, onGame, sort, withPgn, withClocks, maxArchives
getGame({ platform: 'lichess', id }) single game
getArchives(username, 'chesscom') monthly archives, newest first
getRatingHistory(username) Lichess, per perf
getCrosstable(u1, u2) Lichess head-to-head (lifetime points, not win counts)

Raw clients: api.lichess (getUser, getPerf, getRatingHistory, getCrosstable, exportGames, exportGame, exportGamesByIds, getTvChannels) and api.chesscom (getPlayer, getStats, getArchives, getMonthlyGames, getGamesByTimeControl, getCurrentGames, getGamesToMove, isOnline, getLeaderboards, getTitledPlayers, getPuzzle, getStreamers).

Errors: ApiError, NotFoundError (404), RateLimitedError (429). getGames treats a 404 as "no games on that platform" (so merging across platforms works for users who only exist on one) but propagates everything else — rate limits, timeouts and malformed payloads are never silently swallowed.

onGame may itself call the API: the per-platform request queue is released before the response body is streamed, so enriching each game as it arrives does not deadlock.

Platform quirks worth knowing

  • Chess.com has no single-game endpoint. Games come from monthly archives, so getGames/getArchives walk months newest-first until max/since is met. Within a month Chess.com lists games oldest-first, so the wrapper iterates each month in reverse — max: 10 really means "the 10 most recent games".
  • Chess.com data can be up to 12–24h stale (their cache policy) and rate-limits only parallel requests; Lichess rate-limits aggressively — keep serialize: true (requests are serialized per host, so the two platforms don't block each other).
  • since/until are milliseconds everywhere in this wrapper (Lichess's convention); they're converted to seconds for Chess.com internally.
  • vs, color and analysed filters only exist on Lichess; they're ignored for Chess.com, which has no server-side filtering beyond month and time control.
  • Chess.com filters since/until on game end, Lichess on game creation (its server-side filter). For daily games those differ by days, so a window can include a game whose createdAt falls outside it.
  • perfType on Chess.com is filtered client-side after each monthly fetch, so a rare pool on an old account means many serialized requests — maxArchives (default 24) bounds that.
  • timeoutMs covers the response body, and acts as an idle timeout for streams. An oversized Retry-After fails fast (retryAfterCapMs, default 60s) instead of parking the whole queue.

Development

npm install
npm run typecheck        # tsc --noEmit
npm test                 # unit tests (no network)
npm run test:integration # live API tests (NETWORK=1)
npm run demo             # node examples/demo.ts thibault erik

Unit tests mock fetch, so the NDJSON streaming, retry/backoff, serialization and archive-pagination logic are all covered deterministically.

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages