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 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'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).
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).
standard | chess960 | crazyhouse | bughouse | antichess | atomic | horde | kingOfTheHill | racingKings | threeCheck | unknown
(from Lichess variant and Chess.com rules, where chess → standard).
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).
{
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 { 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.
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.
- 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.
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.