@genesisproject/chess-sdk
v1.24.0
Published
JavaScript/TypeScript client SDK for the chess-engine socket protocol.
Readme
@genesisproject/chess-sdk
TypeScript/JavaScript client for the chess-engine session WebSocket.
Framework-agnostic, zero runtime dependencies, runs in the browser and Node. It owns the wire protocol — auth, matchmaking, moves, draws, latency pings, reconnect — so your app never hand-rolls socket messages.
- One socket per login session, not per game. The same socket queues for a match, plays it, then plays the next one — and can watch other games at the same time.
- Every game message carries
room. Route on it; a client can be in several rooms at once. - UCI everywhere. Moves are UCI strings (
"e2e4"), never SAN. - Server is authoritative. The engine validates every move and computes every
clock; the client only sends intent. Rejections come back as an
errorevent with a stablecode. - Clocks are milliseconds remaining, sent by the server. Never compute them client-side.
Install
npm install @genesisproject/chess-sdkQuick start
import { EngineClient } from "@genesisproject/chess-sdk";
const client = new EngineClient({
url: "wss://engine.example.com/ws",
token: sessionToken, // from your platform
});
client.on("gameState", (s) => render(s.room, s.fen, s.turn, s.you));
client.on("move", (m) => render(m.room, m.fen, m.turn));
client.on("gameOver", (g) => {
showResult(g.result, g.reason);
// Both seats, white first. ratingDiff is absent on an unrated game or an abort.
for (const p of g.players ?? []) showSeat(p.username ?? p.userId, p.ratingAfter, p.ratingDiff);
});
client.on("matchFound", (m) => client.subscribe(m.matchId));
client.on("error", (e) => console.warn(e.code, e.message));
client.connect();
client.queue({ timeControl: "blitz_3_2" });
client.move("e2e4");Authentication
The session token is sent in the WebSocket subprotocol list — browsers cannot set headers, and it keeps the token out of URLs and access logs:
new WebSocket(url, ["chess.v2", sessionToken])The server negotiates and echoes back "chess.v2" only. There are no query
params, no ticket, and no token rotation — the same token is reused for every
reconnect.
Close codes
Three application close codes are terminal; the client will not retry them and emits a dedicated event instead:
| Code | Event | Meaning | What your app does |
|------|---------------|---------|--------------------|
| 4001 | authExpired | the session token is dead | ask your platform for a fresh token, build a new client, connect() |
| 4002 | replaced | a newer socket of the same device took over | stop — retrying would kick the other tab, which would kick you back |
| 4003 | signedOut | this device was signed out elsewhere — a logout, or the device cap making room for a new login | stop, and show it. Do not mint a fresh token: that would evict whichever device replaced this one |
Anything else is an unexpected drop and reconnects with backoff.
4001 and 4003 look similar and are not: on 4001 the right move is a new token, on 4003 a new token is the one thing you must not ask for.
Several devices at once
Since engine 1.9.0 a user may be signed in on several devices at the same time (three by default, an operator setting). Every device gets its own token, its own socket, and the same games: a board opened on a phone and a laptop mirrors, and a move may come from either. Nothing in this SDK changes for it — you build one client per device, as you already do.
What changed is what 4002 means. It used to fire when the user opened a second socket anywhere; now it only fires when the same device does, which is what a duplicate tab or a reconnect race looks like.
Rooms
game_over ends one room, not the client — the user plays another game on
the same socket. Per-room state lives in a map:
client.rooms; // ["m-1", "m-2"] — every room the client knows
client.roomState("m-1"); // { seat: "white", finished: false } | undefinedseat is "white" | "black" | "spectator", set from that room's game_state.
Events
Subscribe with client.on(event, handler); unsubscribe with client.off(...).
Every game event includes room.
| Event | Payload | When |
|----------------|------------------------------------------------|------|
| gameState | room, fen, turn, you, moves, status, whiteMs, blackMs, gate fields, variant, timeControl, players | subscribe / re-attach / spectate |
| move | room, uci, by, fen, turn, whiteMs, blackMs | a move was validated |
| gameOver | room, result, reason, players? | one game ended (result: white_win | black_win | draw | aborted). players (engine ≥ 1.4.0) is both seats white-first, each with ratingBefore / ratingAfter / ratingDiff on a rated result — render the end screen from this alone |
| drawOffer | room, by | opponent offered a draw |
| drawDeclined | room | your draw offer was declined |
| matchFound | matchId, variant, timeControl, players, source?, rematchOf? | you were seated in a game (pool pairing, bot, tournament round, private match, rematch, instant match). source (engine ≥ 3.1.0) says which |
| rematchOffer | matchId, from, to, variant, timeControl, rated, expiresAt | a rematch is pending (engine ≥ 3.1.0), sent to both players — from is who asked |
| rematchDeclined | matchId, reason | the offer closed without a game: declined · expired · cancelled · unavailable |
| matchCode | code, variant, timeControl, rated, expiresAt | answer to createMatch() — show the code; no game exists until it is redeemed |
| tournamentStarted | tournamentId, format, variant, timeControl | a tournament you are in has begun — call joinLobby(tournamentId) |
| tournamentFinish | tournamentId | pairing has stopped (engine ≥ 1.12.0) — stop waiting for an opponent. Games already on the board play on |
| tournamentComplete | tournamentId, standings, matches | every game is in: final standings, and the matches behind them (result includes aborted — a no-show forfeits and still scores) |
| error | code, message, room? | a command was rejected — branch on code |
| open | — | socket opened |
| close | code, reason, willReconnect | socket closed |
| reconnecting | attempt | a reconnect attempt is scheduled |
| authExpired | code, reason | closed 4001 — get a fresh token |
| replaced | code, reason | closed 4002 — a newer socket of this device took over |
| signedOut | code, reason | closed 4003 — signed out elsewhere; do not re-mint a token |
| message | any ServerMessage | catch-all, fires for every server message |
players is MatchSeat[], white first: userId, username? and avatar?
(platform display name + picture URL — both absent for a bot seat or when the
engine had no lookup), color, rating?, isBot?, botLevel?. gameState
repeats it, so a client that missed matchFound still renders both players.
The avatar URL passes straight through from your platform's
GET /users/{userId} — the engine never stores or proxies it.
Error codes
code is the stable identifier; message is human text that may change.
not_active · not_started · not_your_turn · invalid_move · illegal_move
· draw_too_soon · no_draw_to_claim · too_late_to_abort · not_found ·
spectator_limit · already_in_game · already_queued · rate_limited ·
bots_unavailable · platform_unavailable · invalid_request ·
undo_unavailable · redo_unavailable · match_not_found
Actions
Client → engine. All are no-ops if the socket is not open. Game actions carry no room id — the server resolves the room from the player.
| Method | Sends | Purpose |
|-----------------------|-----------------|---------|
| connect() | — | open the session socket (no credential argument) |
| move(uci) | move | submit a UCI move |
| resign() | resign | resign the game |
| abort() | abort | abort before both sides' first move |
| accept() | accept | confirm presence under a ready_check gate |
| offerDraw() | offer_draw | propose a draw (rate-limited by the engine) |
| acceptDraw() | accept_draw | accept the opponent's pending offer |
| declineDraw() | decline_draw | decline the opponent's pending offer |
| claimDraw() | claim_draw | claim a threefold / 50-move draw when eligible |
| undo() | undo | take back your last move — bot games only (engine ≥ 1.2.0) |
| redo() | redo | put back what one undo() took (engine ≥ 1.3.0); a move played since discards it |
| queue(opts) | queue | enter matchmaking |
| cancelQueue() | queue_cancel | stop waiting — leaves the pool and cancels an open private match |
| subscribe(room?) | subscribe | watch a live match; no argument re-attaches to your own game |
| unsubscribe(room) | unsubscribe | stop watching; on your OWN room the server reads this as absence |
| playBot(opts) | play_bot | start a game against a bot |
| createMatch(opts) | create_match | open a private match — the 5-character code arrives as a matchCode event. rated is required (no default) |
| joinMatch(code) | join_match | redeem a private-match code (case/space insensitive) |
| offerRematch() | offer_rematch | offer your last opponent the same game again, colours swapped (engine ≥ 3.1.0; within 5 min, not bot/tournament) |
| acceptRematch() · declineRematch() | accept_rematch · decline_rematch | answer the offer you were sent — 60 s, then it expires |
| cancelRematch() | cancel_rematch | withdraw your own offer |
| joinLobby(id) | subscribe | enter a tournament lobby — what makes you pairable (engine ≥ 1.6.0) |
| leaveLobby(id) | unsubscribe | leave it; not a withdrawal, you keep your place and score |
| close() | — | close the socket and stop reconnecting |
client.queue({ timeControl: "blitz_3_2" }); // variant "standard", rated true
client.queue({ variant: "chess960", timeControl: "rapid_10_0", rated: false });
client.playBot({ level: 5, color: "white", timeControl: "blitz_3_2" });
client.subscribe("m-42"); // watch someone else's game
client.subscribe(); // back at my own board
client.unsubscribe("m-42"); // stop watching
client.undo(); // bot game: rewind to my turn — arrives as a gameState
client.redo(); // put it back — also a gameState; refused once I move on
// Private match: play one specific person, no pool involved.
client.on("matchCode", (m) => show(m.code)); // e.g. "K7M2Q", valid 30 min
client.createMatch({ timeControl: "blitz_3_2", rated: false }); // rated is REQUIRED
client.joinMatch("k7m2q"); // the other side → matchFound
// Rematch (engine ≥ 3.1.0): after a game, same variant and clock, colours swapped.
client.on("rematchOffer", (o) => (o.from === me ? showWaiting(o.expiresAt) : askAccept(o)));
client.on("rematchDeclined", (d) => hideRematch(d.reason)); // declined | expired | cancelled | unavailable
client.offerRematch(); // the opponent has 60 s
client.acceptRematch(); // → matchFound (source "rematch") + gameState, then accept()rated has no default on createMatch() — the engine rejects a request that
omits it. Neither default is safe: false strands ratings for anyone expecting
symmetry with queue(), true opens a rating-farm for anyone who never thought
about it. rated: true also needs the operator's private.rated_allowed switch;
while it is off the engine answers invalid_request.
queue() never sends a rating — the engine pulls it from the platform.
Subscriptions are remembered and replayed automatically after a reconnect, so a dropped socket comes back to the same boards — and to the same tournament lobbies. That replay is load-bearing, not cosmetic: the engine drops you from every lobby when your socket goes, since a socket that is gone cannot be at a board, and in a round-based tournament that means elimination if it happens inside the lobby window.
// Tournaments: presence is yours to claim.
client.on("tournamentStarted", (t) => client.joinLobby(t.tournamentId));
client.leaveLobby("trn_44"); // stop being paired; you stay in the tournamentOne arena, start to finish
The loop a tournament client runs. Leaving the lobby while you play is optional — the engine will not pair someone who is already in a game — but re-entering it afterwards is not: an arena pairs the players who are present, so a client that never comes back is simply never paired again.
client.on("tournamentStarted", (t) => client.joinLobby(t.tournamentId));
client.on("matchFound", (m) => {
if (tournamentId) client.leaveLobby(tournamentId); // playing, not waiting
client.subscribe(); // arrive at the board
});
client.on("gameOver", () => {
// Ask the user, then re-enter the lobby to be paired again — unless the
// tournament has stopped pairing, in which case there is nothing to go back to.
if (!tournamentOver) showContinuePrompt();
});
client.on("tournamentFinish", () => {
tournamentOver = true; // no more pairings; finish the game you are in
});
client.on("tournamentComplete", (t) => {
showFinalStandings(t.standings, t.matches);
});subscribe() on matchFound is the one step with no fallback in the client's
hands: it is what tells the engine you are at the board. Playing counts too from
engine 1.12.0 — a move marks you present — but until your first move, only the
subscribe does, and a tournament game whose deadline passes with nobody present
is forfeited.
Node usage
There is no global WebSocket in Node, so pass an implementation:
import { EngineClient } from "@genesisproject/chess-sdk";
import { WebSocket } from "ws";
const client = new EngineClient({ url: "ws://localhost:8080/ws", token, WebSocket });Options
new EngineClient({
url: "wss://engine.example.com/ws", // required, no query params
token: sessionToken, // required, session token from the platform
WebSocket, // WebSocket impl; defaults to the global (browser)
autoReconnect: true, // reconnect after an unexpected drop (default true)
reconnectDelayMs: 500, // base backoff (default 500)
maxReconnectDelayMs: 8000,// backoff cap (default 8000)
maxReconnectAttempts: 10, // give up after this many failures (default 10)
});Reconnect uses exponential backoff (reconnectDelayMs · 2ⁿ, capped at
maxReconnectDelayMs) with the same token, and is suppressed for close codes
4001, 4002 and 4003.
Rendering helpers
Pure, protocol-free helpers for drawing a board from a FEN:
import { parseBoard, fenTurn, isPromotion, FILES } from "@genesisproject/chess-sdk";
const board = parseBoard(fen); // { e1: "K", e8: "k", ... }
fenTurn(fen); // "white" | "black"
isPromotion(board, "e7", "e8"); // true if this move promotes a pawn
FILES; // ["a".."h"]Develop
npm install
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
npm test # node --test (runs against dist/, so build first)src/protocol.ts mirrors the engine's internal/game/room/protocol.go
byte-for-byte — if the engine's wire protocol changes, that file changes in
lockstep. Consumers import from dist/, so rebuild after any change.
License
MIT
