bulletchess.dev
Free, open-source bullet chess from 15 seconds to 3 minutes, on one Cloudflare Worker: a Durable Object per game keeps the clocks, refunds measured network lag on every move and flags on an alarm. Matchmaking, Glicko-2 ratings, challenges on demand or on a schedule, and Stockfish 19 in the browser.
- A chess site that only does bullet: every time control from ¼+0 to 3+0, and nothing slower
- Clocks run on the server. Each game is its own Durable Object that checks every move, charges the clock and ends the game on an alarm the instant a flag falls. The browser only draws the clock
- Lag compensation that a player can't game: the server measures each player's latency itself and refunds it per move, capped per move and by a quota that refills slowly
- Rated matchmaking in six pools, Glicko-2 ratings, challenges to a player, to anyone or by link, on demand or scheduled days ahead, and a list of who is online
- Stockfish 19 runs in the browser at 8 levels; a finished bot game is replayed on the server before it is saved
- One Worker, two kinds of Durable Object, a D1 database and Cloudflare Email Sending. Live at bulletchess.dev, source on GitHub under GPL-3.0
Problem
In bullet chess a whole game lasts a minute or two, so a move is often worth less than a second, and the clock decides as many games as the board. That makes the clock the product. If the browser keeps time, it can be paused or wound back. If the server keeps time and charges you for the moment your move spent crossing the network, a player on a slow connection loses games they won. The big sites solve this, but bullet is a side pool there. I wanted a site where it is the whole point, running on nothing but Cloudflare's edge.
Solution
bulletchess.dev is a single Cloudflare Worker. It serves the React app, the API and the auth, and hands every live connection to one of two kinds of Durable Object:
- GameRoom, one per game. It holds the authoritative position and both clocks, validates every move with chess.js and keeps its players' sockets. Its storage is the live game; when the game ends it is written to D1 and the object deletes itself 15 minutes later.
- Lobby, one for the whole site. It knows who is online and what they are doing, pairs seeks, pushes notifications and "your game is starting" to every open tab, starts scheduled games on time, and applies rating changes one at a time so two results for the same player can never race.
The rules of the game and the clock live in one pure module, src/shared/core.ts, with no I/O: time is always passed in. The GameRoom wraps it, the tests call it directly, and the client imports its types.
How
The clock. No clock runs until both sides have made a first move; each first move gets 15 to 20 seconds, or three minutes for a scheduled game, before the game is aborted. After that the server remembers when the turn started. When a move arrives, the elapsed time is charged to the mover, minus a refund for network lag, plus the increment. The new clock goes out to both players with the server's timestamp, and each browser counts down locally from that reading. After every move the GameRoom sets a Durable Object alarm for the exact instant the side to move would flag. When it fires the game ends on time, or as a draw if the other side has only a king, or a king and a single minor piece. A move that arrives after the flag fell loses on time too, so a slow alarm can't save anyone.
Lag compensation. Every two seconds the server sends a ping carrying its own time, and the browser echoes it. Half the round trip, clamped to a second, feeds a smoothed estimate (0.7·old + 0.3·sample). Both timestamps belong to the server, so a client can't report a ping it doesn't have. It can still answer slowly, which is why the refund is bounded three ways: at most 250 ms a move, never more than the time actually spent, and never more than the player's quota. The quota starts at a second, and each move takes out what it was refunded and puts back 100 ms, up to two seconds. A real slow connection gets its lag back on every move. A faked one gets about 100 ms a move once the starting second is spent.
Premoves and resyncs. Every move carries the ply it was made at. A premove queued against a position that has since changed, or a move sent twice, doesn't match and gets a fresh copy of the game state instead of an error. Messages over 256 characters are dropped unread.
Hibernation. Both Durable Objects accept their sockets in hibernation mode and keep per-socket state (user id, seek) on the socket's attachment. A room full of spectators that isn't moving costs nothing, and a seek survives the object being evicted.
Matchmaking. Six pools: ¼+0, ½+0, 1+0, 1+1, 2+0 and 2+1. A player has one seek at a time; a seek from another tab replaces it. While anyone is seeking, the Lobby runs a pairing pass every second. In each pool it takes the oldest seek first and pairs it with the closest rating that falls inside both players' windows. The window starts at 100 rating points, widens by 60 a second, is wider for a player whose rating is still uncertain, and after 12 seconds takes anyone in the pool. Seeks are cleared before the first await, so an overlapping pass can't pair the same player twice.
Challenges. Any time control the site allows can be a challenge: a base of 15 seconds to 3 minutes and an increment of 0 to 2, as long as base + 40 × increment stays within three minutes. A challenge goes to a named player, to anyone (listed in the lobby, first to accept plays), or out as a link. It is either on demand, where the game opens for both players the moment it is accepted, or scheduled between two minutes and thirty days ahead. A scheduled game sends a reminder email ten minutes before, then starts on a Lobby alarm on whatever page the players have open, and emails anyone who isn't online. Players can turn challenges off. A cron trigger every minute expires stale challenges, sends reminders, and re-registers the next hour's starts with the Lobby in case an alarm was lost.
Ratings. Glicko-2, one game per rating period, with τ = 0.75 and the deviation kept between 45 and 350; a rating is provisional while its deviation is over 110, and the deviation grows back for every day without a rated game. Bullet and UltraBullet are rated separately, and UltraBullet is anything with an estimated length under 30 seconds, which in practice is ¼+0. Every finished live game goes through the Lobby's queue before it touches D1, with rating history kept for the profile charts and a leaderboard.
The bot. Stockfish 19, the lite WebAssembly build, runs in the player's browser at eight levels, from a one-ply search with skill 0 up to full strength. It costs the server nothing. When a signed-in player finishes a bot game, the Worker replays every move with chess.js and checks that the claimed result matches the final position (a mate must be a mate, a resignation can't come from a finished position) before it is saved, unrated.
Accounts. Better Auth with email and password on the D1 database, PBKDF2 through WebCrypto, email verification, password reset, signing out other sessions and deleting the account, with rate limits stored in the database. Mail goes out through Cloudflare Email Sending from mail.bulletchess.dev.
Stack. TypeScript throughout. Hono for the API, Drizzle as Better Auth's adapter, React 19 with React Router, chessground for the board, chess.js for the rules. GPL-3.0, to match chessground and Stockfish.
Tests
- Unit tests (Vitest, 21): the game core (clocks, increments, flagging, a move arriving after the flag, lag compensation bounded by its quota, aborts, draw offers, rematches, threefold repetition, insufficient material on time, a serialisation round trip as after hibernation, PGN with clocks), Glicko-2, and the id generator.
- End to end: a script drives the running dev server with two real accounts through sign-up and verification, matchmaking, a full game over WebSockets, rating changes, the archive and PGN, a rematch, server-side flagging, bot-game validation, password reset, a CSRF refusal, presence, direct, open and scheduled challenges, two accepts racing for one open challenge, and the challenge opt-out.
Results
The bulletchess.dev home page: a big tilted, blurred chessboard replaying a game behind a cream party-game menu, with 'Bullet Chess!' in chunky outlined letters above six candy-coloured time-control buttons from 0:15 to 2:00 +1
- Live at bulletchess.dev, with the source public at github.com/JacobFV/bulletchess.dev.
- One deployable. A single
pnpm deploybuilds the app, applies the D1 migrations and deploys the Worker with both Durable Objects. There is no other server. - Built and shipped in a day, 9 November 2026: the game, ratings, accounts, archive and bot in the first commit, then online players and on-demand and scheduled challenges a few hours later.
- No usage numbers yet. It's a working site, not a traction story.
Lessons
Make time a parameter. Because the core never reads the clock, every timing rule could be tested by passing in a number: a flag at exactly zero, a move one millisecond late, a refund capped by an empty quota. The Durable Object then only has to supply Date.now() and set alarms. Nearly every bug I'd worry about in a real-time game is a timing bug, and none of them needed a running server to reproduce.
Durable Objects fit games. One object per game gives each game a single thread, its own storage and its own alarm, which is exactly what an authoritative clock needs, and a hibernating socket makes an idle spectator free. The Lobby is the part that won't scale forever, as one object for the whole site, and that was the honest trade for getting ratings serialised and presence in one place without a queue.
The bugs were in the glue. The ones that turned up weren't in the clock. The id generator took a byte modulo 58 over an alphabet of 57 characters, so now and then an id came out a character short. The home page crashed for signed-out visitors because the API left the ratings out of a signed-out response. And a game starting elsewhere would pull a player off the game page they were watching. Each fix added a check to the end-to-end script.