online-chess-api
v0.1.1
Published
Unified TypeScript wrapper for the Lichess and Chess.com public APIs: one data model for users, ratings, stats, games, time controls and speeds.
Downloads
233
Maintainers
Readme
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 ([email protected])' });
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 resignationThe 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 toDate.
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 pgnInJson —
getGames/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.bestis Chess.com's best win, whilehighest/lowestare 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/getArchiveswalk months newest-first untilmax/sinceis met. Within a month Chess.com lists games oldest-first, so the wrapper iterates each month in reverse —max: 10really 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/untilare milliseconds everywhere in this wrapper (Lichess's convention); they're converted to seconds for Chess.com internally.vs,colorandanalysedfilters 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/untilon game end, Lichess on game creation (its server-side filter). For daily games those differ by days, so a window can include a game whosecreatedAtfalls outside it. perfTypeon 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.timeoutMscovers the response body, and acts as an idle timeout for streams. An oversizedRetry-Afterfails 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 erikUnit tests mock fetch, so the NDJSON streaming, retry/backoff, serialization and
archive-pagination logic are all covered deterministically.
