Chessmark
LLM agents playing chess, against each other and against you — a benchmark where every token, tool call and reasoning trace is recorded and replayable.
Chess is a clean test of the thing agents are supposed to be good at and are usually measured on badly: holding a position in mind over dozens of turns, acting only through tools, and never once emitting an illegal action. A model cannot talk its way past a board. It either finds the move or it does not.
So Chessmark pairs models continuously in an open pool, streams each game as it happens with the reasoning running beside the board, and keeps every token, tool call and taunt for replay. You can sit down and play one yourself.
Most of the interesting decisions are exclusions
A leaderboard is only worth reading if you know what it refuses to count.
- A ranked game runs one fixed, versioned configuration — a recorded prompt version and tool schema version, no personas, no chat. A game played under an older version measured a different task, so it leaves the rated set rather than being quietly mixed in.
- A harness bound is never a finding about a player. Our ceilings, our budget, our provider’s outage — those fail a turn; they do not forfeit a model. A forfeit for illegal moves, or for never calling a tool at all, does count. That is the benchmark’s whole subject.
- A rule that decides a game is stated in the prompt. A model cannot be scored against a condition nobody told it about.
The second rule was written after it had already cost someone a game. We asked an endpoint for more output tokens than it was able to produce, so every response stopped short of what we had requested — which is the exact signature the harness read as the model failing on its own. It threw away a game that model had won with a rook and two bishops against a lone pawn.
Source-available, deliberately
The code is public so the benchmark can be audited, because a leaderboard nobody can inspect is not worth much. Running it as a service stays here. That pair is the whole licence.
Releases
A spectator no longer holds a lock on games.
A spectator no longer holds a lock on
games. The 0.9.0 migration failed on its lock timeout with every worker drained, behind the live view of a game.Fixed
- The live game stream lets go of the database once it has caught a reader up. It read the
missed events and then kept that transaction open, idle, for as long as the tab stayed open:
ACCESS SHARE on
gamesandgame_events, and one of the API’s 30 pooled connections, per viewer. Nothing after that read touches the database — the live half is Redis alone — so the session is now closed there. The lock blocked anyALTER TABLEon either table, which is how the 0.9.0 deploy failed; the connections would have run the pool dry at about 30 viewers per API process.
Deploying from before 0.9.1 needs the API stopped first, because
deploymigrates while the old API is still serving:docker compose -f docker-compose.yml -f docker-compose.prod.yml stop api, then./chessmark deploy, which starts it again. From 0.9.1 on,deployalone.Published, not deployed: production changes when
./chessmark deployis run on the server. To run exactly this release:./chessmark deploy v0.9.1.- The live game stream lets go of the database once it has caught a reader up. It read the
missed events and then kept that transaction open, idle, for as long as the tab stayed open:
ACCESS SHARE on
Game and model pages say what happened, and only pages with something to say ask to be indexed.
Search Console was holding back hundreds of pages as near-duplicates: game pages whose only text was a move list, and pages for models that never played. A finished game now opens with who played, the opening, how it ended and the moments that mattered, quoted from the models’ own reasoning; a model page shows its record in tables; retired models are listed again; and a model page can start a game with it.
One migration, additive:
0cc5ce1e26e5adds a nullablegames.digest. After deploying, run./chessmark digeststo fill it for the archive; until then each game computes its digest on its first view.Changed
- Pages with nothing to say no longer ask to be indexed
(ADR-0068). A model
that has never finished a game, and a game shorter than 10 plies, are now
noindex, followand left out of the sitemap. That was 272 of the 299 model pages it listed. Model and tournament entries now carrylastmod, the date of their latest finished game.
Added
- Game pages say what happened, in words, and model pages show it
(ADR-0069). A finished game
opens with who played, the opening, how it ended, the material and the cost, and quotes the
models’ own reasoning at the first illegal move, the biggest capture and the end. A model page
shows its results by colour and by ending, its illegal-move rate beside every model’s, a
head-to-head table of every opponent and its most frequent openings. Openings are named from
Lichess’s opening table. After deploying, run
./chessmark digestsso every old game has its digest, and the model pages count every game’s opening. - Retired models are listed again.
/models, the sitemap and the game archive’s filter now include every model that has finished a game here, marked retired when OpenRouter no longer lists it. Twelve such models, Qwen3.8 27B among them, had pages nothing linked to. - A model’s name on a game’s info card links to its page.
- A model page can start a game with it. Play against it and Match it against a model
open
/playwith the model already chosen (?model=…&mode=…), and a signed-out visitor is returned there, choice intact, after signing in. Not shown for a retired model.
Published, not deployed: production changes when
./chessmark deployis run on the server. To run exactly this release:./chessmark deploy v0.9.0.- Pages with nothing to say no longer ask to be indexed
(ADR-0068). A model
that has never finished a game, and a game shorter than 10 plies, are now
32 commits since v0.7.0.
32 commits since
v0.7.0. Chessmark is in beta, which is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.Ratings are rebuilt on Bradley-Terry, chat and decision models are ranked apart, and a model’s reasoning level is now part of what plays. The full changelog has every entry.
Ratings, rebuilt
Ratings are Bradley-Terry, and tables are ordered by proven strength. Every game is fitted at once, so the same games give the same ratings in any order, and a model that stops playing keeps what it proved. Tables are ordered by the rating less twice its ±, so a three-game streak no longer outranks a long record. (ADR-0060)
Every rating on the site is one stored run. The leaderboard, a pool’s standings and the matchmaker all read the same fingerprinted run, so what the matchmaker pairs on is what the site shows. The stored leaderboard is now actually stored: a warm read went from about 50 ms to 8 ms. (ADR-0061)
Chat models and decision models are ranked separately, and a game with a person, or between a chat and a decision model, is never ranked. (ADR-0063, ADR-0064)
How hard a model thinks is part of what plays
A contestant is now a model at a quantization and a reasoning level: the same model at high reasoning and at low are separate entrants, rated separately. Until now no request named a level, so every model reasoned at whatever its provider defaulted to, and nothing recorded it. Every game now records the level each seat played at, shown on the game page, the leaderboard and the model page. Starting a game offers the levels a model supports under think, with its default selected. Tournaments and pools can be set to a level; existing ones carry on at each model’s default. (ADR-0067)
Games already played are labelled from whether each seat actually reasoned, read through the level its model uses by default today, and marked inferred on the game page.
Choose who serves a model
Starting a game, against a model or between two, now offers a via menu listing every host for the model with its uptime. auto still picks the most reliable one. The quantization row is now always shown, even when there is only one. (ADR-0066)
Around them
- Download any game as a GIF, drawn in your browser, one frame per move. The game page’s buttons are now icons. (ADR-0065)
- Decision models with a limit on options play in heats, then a final between the heat winners, so Tev and Solar can play a middlegame. (ADR-0059)
- Standings show abandoned pairings, as W/D/L/A.
- A failed call is on record, with the provider’s error body, in the turn inspector. (ADR-0062)
- A game interrupted by a deploy resumes within a minute, not fifteen, and a turn longer than fifteen minutes is no longer run twice.
- A new game appears on the site as soon as it starts, not at its first move.
- On a phone, the leaderboard and standings scroll sideways instead of hiding columns, and the play page no longer scrolls sideways.
- Fix: “sign in” no longer sometimes shows “That did not load.”
Deploying
Three migrations, all additive:
8eac4a8e7efa(model_registry.decisions_max_choices),9a854789833f(leaderboard_snapshots.scope) and8ddbdc635007(the reasoning columns onmodel_registryandmodel_endpoints).Deploy by hand with
./chessmark deployonce Publish images has finished. Then expect:- every rating to change once, on the first read after the deploy, because the method changed. It is not a regression;
- the leaderboard to reshape once more when the catalogue refresh labels past games with their reasoning level. Until it finishes, new games play with no level and are labelled with the rest;
- no decision model to be paired until the same refresh re-checks each one under harness
d2.1.
24 commits since v0.6.0.
24 commits since
v0.6.0. Chessmark is in beta, which is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.Credit can be bought in any amount, and tournaments are paid for by Chessmark’s own house account. The full changelog has every entry.
Buy any amount of credit
/creditsells any amount from $5 to $100, with $5, $10 and $25 as quick picks. The amount you choose is what you pay, tax included. Each purchase is shown as one sum: tax, the payment processor’s fee, 5% for running Chessmark, the AI provider fee (OpenRouter’s 5.5% on the credit we buy from it), and the credit left. Where there is no tax, $5 buys $3.79, $10 buys $8.05 and $25 buys $20.85. The checkout opens in a dialog inside the credit page and closes itself once the payment goes through. (AUTH-22, ADR-0056)Credit is sold only while OpenRouter can pay for it. A checkout reserves its credit against OpenRouter’s remaining balance, less what users already hold and a $10 reserve. If an amount does not fit, the page offers the largest one that does. (AUTH-23)
Sales are a switch, and it starts closed.
./chessmark sales open | pause "reason"opens and pauses sales without a restart. While sales are paused,/creditshows the breakdown and “not on sale right now”. Anything already paid for is still credited. (AUTH-25, ADR-0057)The house pays for tournaments
Every game no person started is now charged to Chessmark’s house account, turn by turn. When the house runs out of credit, those games pause, showing “waiting for Chessmark to add credit”. A tournament with paid models waits rather than starting games it can’t fund. Credit in the house counts against the credit for sale, so tournaments can no longer spend the OpenRouter balance that backs users’ credit. (AUTH-26, ADR-0058)
Move sounds
A move, a capture, castling, a check, a promotion and the end of a game each have their own sound, live and in replays. Sound is on by default. “sound on / off” beside “copy link” turns it off, and your browser remembers the setting. Sounds by Kenney.
Around them
./chessmark purchases <email>lists an account’s purchases and whether each can be refunded. (AUTH-24)- Credit unused for 12 months may expire, because OpenRouter can expire the credit we hold with it after a year.
- Fix: a tournament whose last game is paused is no longer marked finished.
Deploying
Two migrations, both additive:
credit_reservations, and the house account’s user row.Deploy by hand with
./chessmark deployonce Publish images has finished. Then:- fund the house with
./chessmark credits house <usd>. It starts empty, and tournaments wait until it has credit; - open sales with
./chessmark sales openwhen ready. They start paused.
24 commits since v0.5.0.
24 commits since
v0.5.0. Chessmark is in beta, which is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.Credit is money now. It is spent at what each turn actually cost, settled to what OpenRouter billed, and ready to be bought. The full changelog has every entry with its evidence.
Credit is dollars, spent at actual cost
A balance is US dollars. Each model turn is charged its real cost as it is played, from the provider’s own token counts, with no estimate and no hold. When credit runs out, a paid game pauses before its next turn and resumes when credit is added. Playing a free model needs no credit at all. You set a game’s spending limit yourself, or none, and you can pause and resume the model-vs-model games you pay for. Existing balances were reset to zero. (AUTH-10–17, ADR-0052)
Every game is settled to what OpenRouter billed
Each game is checked against OpenRouter’s own bill for its session, about 5 minutes, an hour and a day after it ends or is held. Its payer is then settled to that figure, as a charge or a refund. The page shows Billed, with a tooltip that explains any difference from the running total. On production’s copy, 168 games reconciled to $0.4332, against the key’s own $0.4334 for the month. (OPS-25, AUTH-18, ADR-0054)
Finding the gap fixed its main cause. A failed turn now keeps the answers it paid for, where it used to roll them back and pay for them again: about 3.5% of successful answers since mid-September. A turn that crashes spends one of its attempts instead of looping. (ADR-0053)
Buying credit
/creditsells packs of $5, $10 and $25 that grant $4.00, $8.50 and $22.00. Each is shown as a sum: the price, the payment processor’s fee, 5% for running the site, and the credit that is left. Tax is added at checkout. Paddle sells the credit as merchant of record. Only its signed webhook moves a balance: once per purchase, for the amount the server maps the paid price to. Refunds and chargebacks take the credit back. The terms of service, privacy policy and refund policy are linked from every page. (AUTH-19–21, ADR-0055)Buying is off until Paddle approves the live account. Until then the packs show “not on sale yet”.
Around them
- Starting a game has its settings on one row: spending limit, ply cap, and Talk. For a game between two models, Talk is trash talk, which the form now lets you turn off.
- Your profile lists the games you started, with what each cost.
- Decision models choose what to do with their turn, and are checked before they are offered (harness
d2). (ADR-0051) ./chessmark statuslists turns that crashed, and the worker keeps running rather than dying with them.- Fixes:
- a game ended by its spending limit now says so live;
- balances show enough decimal places to change and to add up;
- pauses fold only into the one directly above them;
- a resumed turn no longer reuses a tool call’s sequence number;
- a tournament’s entrant count now matches its table.
Deploying
Four migrations, all already applied on production. Three are additive. One is not:
1b65e05c166cmade credit dollars and reset every balance to zero.Deploy by hand with
./chessmark deployonce Publish images has finished. Reconciliation needsOPENROUTER_MANAGEMENT_KEYon the server. Buying needs the Paddle settings in DEPLOYMENT.md.39 commits since v0.4.0.
39 commits since
v0.4.0. Chessmark is in beta — that is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.Three things a reader can now do that they could not: watch a decision model play, find any game in an archive, and see a pool that has finished its work stop. The full changelog has every entry with its evidence.
Decision models play
OpenRouter’s decision models — TypeSafe’s Jev 1.13 and Kev 4B today — play through the Decisions API: one request per turn with the position and every legal move described, and a probability back for each. They resign, offer, accept and claim draws as a chat model can, share the leaderboard behind a badge, and get tournaments of their own. The timeline draws each decision as the moves it weighed, withheld from a person mid-game like reasoning. The harness is versioned separately (
d1), and a game is held only to the versions of the harnesses that played in it. (AGENT-23..25, BENCH-13, ADR-0049)Every game, at
/gamesFilter by status, result, ending, ranked or not, model against model or a person at the board, a model, a matchup or an event; search either seat’s name or its OpenRouter id. Every filtered view is a link, and it unfurls as that filter. Paged by keyset, so a game starting mid-read never repeats a row, and
GET /gamesstays two statements whatever it is asked. (UI-12, ADR-0048)A pool can stop
--games-per-pair Ngives a pool a target per era; once every pair has played it, the pool idles until a new model is admitted and then plays only the newcomer’s pairs. (BENCH-14, ADR-0050)Around them
- Pages arrive whole, and the skeletons are gone. Every public read is cached and tagged, and the worker invalidates the tags when a game moves (ADR-0046). The lobby went from complete-at-43ms to 14ms.
- Our own sign-in, sign-up and profile, and Clerk off every page that does not need it: 619 KiB → 258 KiB for a signed-out reader on
/leaderboard. Two live bugs were found by writing the tests — nobody could sign up, and no display name could be saved. - A rebuilt lobby: a podium for the top three, one real turn from a finished game, what a tournament is, and the human-versus-model record with what the opponent has been caught doing.
- Accessibility at 100 on every public page against production’s data, with Lighthouse budgets in CI.
- Fixes that changed a result or a reading: a reopened game now tells both players it reopened (a model had been forfeited for correctly saying the game was over); colours alternate even when games don’t finish; a halted game says when the halt lifts; “retrying shortly” no longer says so forever; links to a model no longer prefetch ~90 times in twelve seconds.
Deploying
Three migrations, all additive:
runtime/decision_versioncolumns, a nullabletournaments.games_per_pair, and a GIN trigram index onplayers.display_name, which createspg_trgm— a trusted extension since Postgres 13, so the database owner can create it without a superuser.Five commits since v0.3.0.
Five commits since
v0.3.0. Chessmark is in beta — that is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.Two halves, unrelated in the code and identical in the working habit that produced them: both were things nobody could see from where they were standing. The full changelog has every entry with its evidence.
A harness ceiling stopped counting as a result
db/tournaments.settlesaid in its own docstring that a game the harness stopped “is marked abandoned rather than scored”, and then asked a question that could not answer it: it readGameStatus.ABORTED, and onlyABANDONEDarrives that way. Aply_cap, abudget_exceededor anadjudicationends a gameFINISHEDcarrying a realGameResult, so all three fell through and were scored like any other draw.pool-freeround 175 is what that cost.ling-3.0-flash-santereached8/6P1/1k5P/5K2/5p2/8/8/8 w— a pawn on g7,g8=Qon the move, the black king stranded on b6 — and the 300-ply cap drew it. The rating rules excluded the game correctly the whole time, which is exactly why nothing showed: the two numbers agreed everywhere except one column, and there the page read0.5besideunrated.settlenow asksHARNESS_TERMINATIONS. That set was already the answer and nothing linked it —test_classification.pyexists because three sets classifying terminations had drifted apart once, and this was the fourth, one module away and unchecked.It is also what makes the ply cap legitimate at all. The cap is not stated in the system prompt, and invariant 12 says a rule that decides a game must be. A ceiling that never decides a scored game satisfies that by construction.
The site works on a phone
Seven pages measured at 390px. Every failure had the same shape — a flex row splitting a width that does not exist — and none of it was visible from a desk.
was now a pairing’s two model names 37px each, against the 310px the longest needs stacked, a full row each the pool’s standings column 37px 173px a player’s nameplate 157px of a 209px name the whole row, captures below the leaderboard’s rating behind a horizontal swipe on screen the replay transport 28×28 44×44 on touch, 28 with a cursor the conversation panel as tall as the game was long 58svh, scrolling inside itselfthe stats on a game page a whole conversation below the board a tab Held by a new
mobilePlaywright project that CI runs besidepublic. Eight of its nine assertions fail without these changes.And one the phone work introduced
GameLayoutorders its slots twice, andlg:order-nonelet DOM order stand: the grid was still three columns, the three tops still aligned, and themin()middle column that exists to size the board went to the conversation while the board sat in a 323px rail.replay.spec.tsnow asks which child is in which column, because everything short of that passed.Twenty-nine commits since v0.2.0.
Twenty-nine commits since
v0.2.0. Chessmark is in beta — that is what0.xmeans under SemVer: anything may change, and the public API should not yet be considered stable.One thread runs through this release. A provider that stops answering no longer destroys the work it interrupted — and everything downstream of that discard, from the cooldown ladder to the way a pause is drawn on the page, was built around it. The full changelog has every entry with the evidence behind it.
A turn keeps the rounds it completed
A turn ran inside one transaction and a provider failure raised out of it, so a turn refused on its third call discarded the two that had already been answered and billed. The retry then paid for them again. In
f129b600the rolled-back turns are the gaps in the id sequence — 7854-7856, 7859 — each a fresh attempt redoing the board read the one before it had finished. A model that could not complete a whole turn inside one provider window never banked a step, and therefore never moved.The retry now continues the turn (ADR-0045). Most of that was free: the transcript is rebuilt in
seqorder, so committed rounds are simply there and the model carries on mid-conversation. What needed writing is what must not repeat — the turn prompt andturn_startedare per turn, not per attempt.Which failures keep their rounds follows one rule: commit when the next attempt sends the same request again. A rate limit, a timeout and a 5xx do.
NoRoomToAnswerError,HarnessCeilingError,ProviderAccountingErrorandProviderMangledErrorstill roll back, because each needs the next request to be different.The rollback was avoiding something real — a half-written turn can leave an assistant message whose
tool_callsnothing answered, and that shape corrupted 242 rows across 14 seats — but a refusal comes out ofcomplete(), before the round’s assistant message is appended, so the transcript is already at a clean boundary.The ladder measures the endpoint, not the turn
A turn is many calls against a growing transcript, so an endpoint that answered the board read and was refused on the move never reached the reset: 60s, 300s, 900s, up to the hour cap, against an endpoint that had never gone away. The direction was the perverse part — the longer the game, the less likely a turn completes, so the ladder was harshest on exactly the endpoint a long game most needs. It resets on an answered call now (ADR-0044).
The endpoint being credited was also the wrong one: OpenRouter’s
providerfield is absent for several models — every call inf129b600came backprovider: None— so the clear landed onmodel|*while the strikes sat undermodel|BaseTen. The seat’s pin is the answer when the response has none.A pause is drawn where it happened
The move divider closes its turn instead of opening it, so a reader meets the thinking, the tool calls, then the move — above, a pause belonging to the next seat appeared under the previous seat’s block, which is how a deepseek rate limit read as GLM’s problem. A pause names the seat it is waiting on. And a pause inside an open turn is a step of it, in sequence under the step counter, so unrolling the steps shows what survived the interruption and what followed it.
Then the panel had to be taught that a turn can be committed and still unfinished: an interrupted turn and the retry continuing it were drawn as two headers for one seat, and a turn that talked after it moved was split in half by its own move divider. Both are one turn again, and the frames that predicted a round no longer linger beside the record of it.
Four kinds of event —
game_paused,game_resumed,outputandcompacted— were never reaching the browser at all. The SSE listener list was written by hand and named nine of thirteen types; every one of those events was published, delivered and discarded, which is why a pause appeared only after a reload. The list is keyed by the type union now, so the next omission does not compile.Also
A game already paused when the free allowance ran out could not say so —
9b4bced5sat fifteen hours advertising a reason that had stopped being true within the hour. The reconciler now publishes the events it writes, instead of committing agame_resumedthat reached nobody. And CI can run its jobs again:setup-uv@v10does not exist, and the browser job now stops the servers it starts.v0.2.0Breaking
Seventy-seven commits. The benchmark measures something harder now, and a pool no longer has to be reset by hand.
The task changed
get_legal_movesno longer says which move is mate. Thecheckandcheckmateflags were a one-ply search with terminal evaluation — run by us, handed to every seat, on every move of every turn. No model playing Chessmark had ever had to find mate in one. The same facts were then stripped out of the SAN string, where they had survived as#.- A third version of the prompt. It states the silence forfeit it has always
enforced, describes the turn loop it actually has rather than one that ended
at
make_move, and names no tool the model cannot see. Three rules that decided games were not in it. - A pool balances its pairings. The matchmaker optimised for information and had no fairness term at all — 44% pair coverage, one entrant on 25 pairings and another on 1. Simulated over 19 entrants and 800 pairings, a greedy incremental round robin takes coverage to 100%.
Harness bounds stopped becoming findings about models
- A turn ends when the model stops, not when it moves — and a turn could previously end between a tool call and its result, leaving a transcript every provider refuses for the rest of the game. That corrupted 242 rows across 14 seats with the test suite green throughout.
- The window is sized for the request going out, not the previous one.
- A halt does not spend a game’s patience. The daily free allowance runs out most days and the halt holds to UTC midnight — about 8.3 hours, a third of the abandonment window, charged to games we had chosen not to play.
Upgrading
Four migrations, all safe to apply with the old code still running. There is no backfill. A running pool opens a new era on its next tick and starts a fresh table; games played under earlier prompt and tool versions keep their results and drop out of the rated set, which is what an era is for.
The first tagged release, and three fixes found by reading the live pool rather than by testing it.
0.xmeans what it says: Chessmark is in beta, and anything may change.Fixed
- The pool no longer re-pairs a fixture it can never play. One pairing was scheduled seven times over five days without a move: an abandoned pairing carries no score, deliberately, and the matchmaker read that absence as “these two have never met.” Attempted pairings now count as meetings.
- An endpoint’s output ceiling is no longer a finding about a model. We asked for 64,000 output tokens against an endpoint that stops at 32,768, so every truncation stopped short of our own request — the exact signature the harness read as the model’s failure. It cost one model a game it had already won.
- A forfeit flag follows the game’s ending, not the turn’s status. Two games were budget-stopped, reopened, and played on to a real checkmate and a real threefold draw while still carrying a forfeit on the leaderboard.
Added
./chessmark repair-forfeits, which reconciles forfeit flags against the game record. It reports by default and changes no result.