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:

  1. 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.
  2. 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.
bulletchess.dev architecture The browser runs a React single-page app with the chessground board and Stockfish 19 compiled to WebAssembly; it only displays clocks. It calls one Cloudflare Worker over HTTPS for the API, auth and static assets. The Worker authenticates WebSocket upgrades and forwards them, with the user id attached, to a GameRoom Durable Object (one per game: authoritative state, clocks, move validation, a flag alarm, WebSocket hibernation) or to the single global Lobby Durable Object (presence, matchmaking, notifications, the live-game index, scheduled starts on alarms, and rating updates one at a time). After that the browser holds two sockets: a game socket to its GameRoom and a lobby socket, open on every page, to the Lobby. The Lobby creates GameRooms; a GameRoom reports its result to the Lobby, which writes the finished game and both ratings to D1. The Worker reads and writes D1 for auth, challenges and the archive, and sends mail through Cloudflare Email Sending. A cron trigger runs every minute. /api/* send_email auth, challenges, archive, bot games upgrade + user id upgrade + user id result create games, ratings game socket lobby socket, open on every page: who's online, seeks, challenges, "start" Everything runs on one Cloudflare Worker and its bindings Browser React 19 single-page app chessground board premoves, auto-queen clocks shown, never kept: counts down from the last server reading pings its game every 2 s Stockfish 19 lite WASM, 8 levels bot games run here; the server replays one before saving it Worker one, stateless · Hono Better Auth: email + password challenges, profiles, leaderboard the SPA's static assets cron, every minute: expire challenges, send reminders Email Sending mail.bulletchess.dev verification, password reset, challenge invites, reminders, "your game has started" GameRoom Durable Object · one per game authoritative position and clocks checks every move with chess.js estimates each player's lag alarm set for the flag instant hibernating sockets: idle spectators cost nothing Lobby Durable Object · one, global presence: online, seeking, playing matchmaking, a pass every second notifications to every open tab live-game index scheduled starts on alarms rating updates, one at a time D1 SQLite user, session ratings rating_history games challenges user_prefs live games stay in their GameRoom; only finished ones land here
bulletchess.dev architecture: the browser calls one Worker over HTTPS; the Worker authenticates WebSocket upgrades and forwards them to a GameRoom Durable Object per game or to the single Lobby Durable Object; the browser then holds a game socket and a lobby socket; the Lobby creates GameRooms and writes finished games and ratings to D1; the Worker sends mail through Email Sending and runs a cron every minute

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.

bulletchess.dev: how one move is charged to the clock A timeline of one turn. The GameRoom broadcasts the opponent's move and starts the mover's turn at turnStartedAt. The move reaches the browser, the player thinks, and sends a move, which reaches the server at now. Elapsed is now minus turnStartedAt. Part of it, comp, is refunded as network lag: comp is the smallest of the player's lag estimate, their lag quota, 250 milliseconds and elapsed. The clock becomes the larger of 1 and clock minus (elapsed minus comp), plus the increment. The quota becomes the smaller of 2000 and quota minus comp plus 100, starting at 1000 milliseconds. The lag estimate comes from server pings every two seconds: the browser echoes the server's timestamp, a sample is half the round trip, clamped to 0 to 1000 milliseconds, and the estimate is 0.7 times the old one plus 0.3 times the sample. A Durable Object alarm is set for the instant the side to move's flag would fall; when it fires the game ends on time, or as a draw if the other side cannot mate. Clocks start only after both sides have made a first move. One turn, as the server sees it Browser GameRoom the player thinks; the shown clock counts down locally opponent's move broadcast move turnStartedAt now elapsed = now − turnStartedAt charged to the clock comp, refunded as network lag The lag estimate Every 2 s the server sends its own time s; the browser echoes it straight back. sample = (now − s) / 2 clamped to 0–1000 ms lag ← 0.7·lag + 0.3·sample Both timestamps are the server's own. On every move comp = min(lag, quota, 250 ms, elapsed) clock ← max(1, clock − (elapsed − comp)) + increment quota ← min(2000, quota − comp + 100) The quota starts at 1000 ms and earns back 100 ms a move, so refunds above 100 ms a move run out. The flag After each move the GameRoom sets an alarm for turnStartedAt + clock[side to move] When it fires, the game ends on time, or as a draw if the other side cannot mate. A move that arrives after the flag fell loses on time too. No clock runs until both sides have moved once; each first move gets 15–20 s (3 min if scheduled).
How one move is charged: the GameRoom starts the turn when it broadcasts the opponent's move; the player thinks and sends a move, which arrives at now; elapsed is now minus turnStartedAt; comp, the smaller of the lag estimate, the quota, 250 ms and elapsed, is refunded; the clock is charged the rest and gains the increment; the quota earns back 100 ms a move up to 2,000; the lag estimate is half the round trip of server pings, smoothed; an alarm is set for the flag instant

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.

bulletchess.dev: two ways into a game Matchmaking: a player seeks in one of six pools (quarter-minute, half-minute, 1+0, 1+1, 2+0, 2+1); one seek per player, and a seek from another tab replaces it. The Lobby runs a pairing pass every second while anyone is seeking: within a pool, oldest seek first, it picks the closest rating inside both players' windows. A window is 100 rating points plus 60 per second waited plus half of any rating deviation above 100, and after 12 seconds anyone in the pool will do. A pair gets random colours and a new rated GameRoom, and a start message goes to every open tab of both players. Challenges: a challenge is pending, to a player for 2 minutes, open to anyone for 10 minutes, or scheduled until its start time. Accepting an on-demand challenge starts the game at once. Accepting a scheduled one moves it to accepted; a reminder email goes out 10 minutes before and a Lobby alarm starts the game on time. A pending challenge can be declined, cancelled or expire. Accepting is one conditional update on the row, so when two people accept the same open challenge only one wins. Matchmaking, inside the Lobby Seek ¼+0 ½+0 1+0 1+1 2+0 2+1 One seek per player: a seek from another tab replaces it. It lives on the socket, so it survives hibernation. Pairing pass every second while anyone seeks Per pool, oldest seek first: take the closest rating that sits inside both players' windows. Create the game colours at random a new rated GameRoom added to the live-game index "start" pushed to every open tab of both players, which opens the board The rating window 100 + 60·seconds waited + max(0, RD − 100) / 2 A new player's high RD widens it. After 12 s, anyone in the pool. 100 820 at 12 s any 0 s 12 s window (rating points) against seconds waited, for a settled rating Challenges, rows in D1 pending to a player: 2 min open to anyone: 10 min scheduled: 2 min to 30 days ahead, open until then accepted a reminder email 10 min before; either side can still withdraw, and leaving an open one reopens it started a GameRoom is created and "start" reaches both players. Scheduled: 3 min to move first; anyone offline gets an email declined · cancelled · expired accept, scheduled Lobby alarm, on time accept, on demand: the game starts at once decline · cancel · expire Accepting is one UPDATE … WHERE status = 'pending', so two people accepting one open challenge cannot both win; the second gets a 409. A cron every minute expires, reminds and re-arms.
Two ways into a game. Matchmaking: seek in one of six pools; the Lobby's pairing pass every second matches the closest rating inside both players' windows, which start at 100 points, grow by 60 a second and open to anyone after 12 seconds; the game gets random colours and a new GameRoom, and "start" goes to every tab of both players. Challenges: pending (2 minutes to a player, 10 minutes open, or until a scheduled time); an on-demand accept starts the game at once; a scheduled accept waits, with a reminder email, until a Lobby alarm starts it; pending challenges can be declined, cancelled or expire; accepting is one conditional UPDATE so only one person can win an open challenge

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 +1The 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 deploy builds 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.