A minimal two-player chess server. Create a game, send the link, play. No accounts, no lobby, no matchmaking.
Built to be self-hosted on Coolify at chess.ciya.so.
- Share-a-link games with no sign-up. The first person to open the link takes the open seat; everyone after that watches.
- Server-authoritative rules and clocks, so a tampered client cannot cheat.
- Time controls from 3 minutes to 30, with increment, plus unlimited.
- Resign, draw offers, and rematch with colours swapped.
- In-game chat between the two players, readable by spectators.
- Sound cues for moves, captures, checks, castling, and game end.
- Games survive a redeploy: state lives in SQLite on a volume.
- Works on a phone. The board scales and the layout stacks.
Requires Node 24 or newer (the server uses the built-in node:sqlite).
npm install
npm run devThat starts the API and WebSocket server on port 3000 and the Vite dev server on port 5173. Open http://localhost:5173.
To test both sides yourself, open the invite link in a private window. Seat
ownership is stored in localStorage, so a second tab in the same browser is
recognised as the same player and will show the same colour.
| Command | What it does |
|---|---|
npm run build |
Build the client and bundle the server into dist/ |
npm start |
Run the production build |
npm run typecheck |
Typecheck everything |
npm test |
Game-rule checks: flag-fall, draws, seating |
npm run test:e2e |
Protocol checks against a running server |
npm run test:e2e expects a server already running, and takes an optional base
URL: npm run test:e2e -- http://localhost:3000.
- Point an
Arecord forchess.ciya.soat your Coolify host. - In Coolify, create a new Application from this Git repository and choose the Dockerfile build pack.
- Set the domain to
https://chess.ciya.so. Coolify's Traefik handles the Let's Encrypt certificate and proxies WebSocket upgrades with no extra configuration. - Set the port to
3000. - Under Storages, add a persistent volume mounted at
/data. Without it, games are lost on every redeploy. - Deploy.
The container runs as the non-root node user (uid 1000). If you mount a host
directory rather than a Docker volume, give it to that user or the database
cannot be written:
chown -R 1000:1000 /path/on/hostThe server does not crash when /data is unwritable. It logs the problem, keeps
games in memory only, and reports "persistent": false from /healthz, so this
shows up as lost games after a redeploy rather than an outage.
The client works out which WebSocket URL to use from the page it was served from, so there is no build-time environment variable to keep in sync with the domain.
Coolify can enable Gzip compression on a resource, which interferes with streamed connections. If games connect but never receive moves, turn Gzip off in the application's advanced settings.
Every value is optional; the defaults are what a personal instance wants. See .env.example.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP port |
DATA_DIR |
/data in Docker |
Where chess.db is written |
CREATE_PASSPHRASE |
empty | When set, starting a game requires this passphrase |
RETENTION_HOURS |
168 |
How long finished games are kept |
ABANDONED_HOURS |
24 |
How long a game nobody joined is kept |
MAX_GAMES_PER_IP_PER_HOUR |
30 |
Game-creation rate limit |
MAX_CHAT_PER_MINUTE |
30 |
Chat rate limit |
Because the instance is on the public internet, game creation is rate-limited by
IP. If you would rather nobody but you could start games, set
CREATE_PASSPHRASE; invite links still work for whoever you send them to.
Browser ──HTTP──> Node server ──> static client build
└──WS───> Node server ──> in-memory games ──> SQLite (/data/chess.db)
One process serves the client, owns every game, and holds the WebSocket connections. Games live in memory and are written to SQLite on each change, so a restart reloads them from the stored PGN.
There is no login, so a game seat is proved by a random token:
- Creating a game reserves your colour and hands your browser a token, stored in
localStorageunderchess:seat:<gameId>. - Opening the link without a token claims whichever seat is still free and gets a token for it.
- Opening it once both seats are taken makes you a spectator.
- Reconnecting with a token always returns you to your own side, which is what makes refreshing safe.
Clocks are kept and enforced on the server; the browser only interpolates between updates so the countdown looks smooth. Times are sent as remaining milliseconds plus which side is running, and the client measures from when it received the message, so a client whose system clock is wrong still shows the right time.
The clock starts on White's first move rather than at creation, so no one burns time waiting for a friend to open the link. Flagging is detected by a timer on the server. Running out of time against an opponent who cannot possibly mate (a lone king, or king and a single minor piece) is scored a draw, matching the usual rule.
After a restart the clock resumes when a player reconnects rather than charging anyone for the downtime.
MIT