npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 error event with a stable code.
  • Clocks are milliseconds remaining, sent by the server. Never compute them client-side.

Install

npm install @genesisproject/chess-sdk

Quick 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 } | undefined

seat 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 tournament

One 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