Chess Tracker App
Marko Petrović / Oct 14, 2026
A web app I built for my chess group to record our over-the-board games. It calculates Elo ratings, runs leagues and playoffs with automatic pairings and tiebreaks, names openings from a position lookup, and runs depth-20 Stockfish analysis in the browser instead of on the server.
I play chess with a group of friends, over the board, pretty regularly. At some point the question of who is actually the best among us needed a better answer than memory and arguing, so I built an app for it. Chess Tracker is a self-hosted web app where we record our games, with full moves most of the time (sometimes a game gets too heated for anyone to write moves down), and it handles everything we would never track by hand: ratings, leagues and playoffs, opening names, and engine analysis.
It's private, and the user base is small and known. I'm the admin and I enter the games, everyone else logs in to see their results. That matters for a lot of the decisions below, because some things that would be wrong in a bigger app are completely fine here. Under the hood it's a TypeScript app: React Router 8 in framework mode with server-side rendering, PostgreSQL on Neon with Drizzle, and chess.js for the chess itself (reading games, checking that moves are legal, positions). One pattern goes through the whole codebase: the chess logic is written as functions that get data in and return a result, and they never touch the database. The database only stores what they return (In Programming jargon they are called pure functions).

What we do with it
Day to day, after a game I enter who played white and who played black, the result, the date, and the moves if we wrote them down. The app checks that the moves are legal and stores the game as PGN, the standard format for chess games.

Every player has a page with their stats: wins, losses, draws, current rating and a chart of how it changed over time, and trophies. You get a trophy for winning a league or a playoff tournament. We're a small group, so they're taken more seriously than they should be.

Ratings as derived data
Elo is the rating system chess uses. If you beat someone rated higher than you, you gain a lot of points, if you beat someone rated lower, you gain only a few, and the loser drops by the same amount. The math is small. This is the whole rating calculation in the app:
const K_FACTOR = 32;
export const DEFAULT_RATING = 1200;
export function calculateEloChange(
ratingA: number,
ratingB: number,
scoreA: number,
K: number = K_FACTOR,
): { newRatingA: number; newRatingB: number } {
const expectedA = 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
const expectedB = 1 - expectedA;
const scoreB = 1 - scoreA;
return {
newRatingA: Math.round(ratingA + K * (scoreA - expectedA)),
newRatingB: Math.round(ratingB + K * (scoreB - expectedB)),
};
}The function doesn't know the database exists. Two ratings and a result go in, two new ratings come out.
A player's rating isn't stored as the truth, it's more like a cache. A rating is whatever you get when everyone starts at 1200 and every game goes through that function in order. The rating column just keeps the last result.
So why build it like this? It's probably a bit of over-engineering for us right now, but I thought it would be easier to do it this way from the start than to add it later. The main reason is that games could get corrected.
For example lets say we notice that an earlier game has the wrong result. If ratings were just stored numbers, fixing that one game wouldn't be enough. Its point exchange was wrong, so both players went into their next games with wrong ratings, so those games also gave out wrong points, and so on. You can't easily fix it. So the app doesn't try. When something changes that can affect the order or the results (a game edited, deleted, or added with an older date), it recalculates everything. Every player goes back to 1200, the rating history is cleared, and all games run through the function again in date order. It all happens in one database transaction, so anyone looking at the ratings sees either the old state or the new one, never something in between. A new game that really is the latest one doesn't need any of that, only the two players get updated. The app checks this on its own: if any game in the database is dated after the new one, the new game is backdated and everything gets recalculated.
This has a cost. The recalculation isn't one smart database query, it's a loop that writes one history row per game and one update per player, one by one, while the request waits. There's no background job, so editing an old game blocks the page until it's done. We have around a hundred games right now, so that's a couple of hundred small queries and it's done in well under a second. For our size that's completely fine, and I knew our size when I built it. If we ever get anywhere near tens of thousands of games, then this would definitely need re-write.
Tournaments
The app also runs our leagues and playoffs: pairings, standings, tiebreaks and brackets. Same rule as with ratings. The logic is pure functions that get players and results and return a schedule or a table, and a separate layer loads from the database, calls them and saves what they return.
Pairing by rotation
A league is a round-robin, everyone plays everyone. The schedule uses the circle method. One player stays in
place, the rest sit around them, and every round everybody except the fixed player moves one spot. Then you pair them from the outside in. If the number of players is odd, the app adds a fake player to make the math work, and whoever gets paired with them sits out that round. Colours switch every round so nobody plays white five times in a row.

It's not perfect. The fixed player's colours don't balance as well as everyone else's. That's a known weakness of the simple version of the method, and nobody in our group has complained so far.
One number, two formats
Playoff series can be played two ways. One is the classic knockout: best of three, first to two wins, and it can end early. The other is the chess way, a fixed mini-match: play all four games, count points, higher score goes through. I didn't want two settings for this, one for format and one for length, and it turned out one was enough:
/**
* Even numbers (2, 4) → fixed-length match (chess mini-match style)
* Odd numbers (1, 3, 5) → best-of (first to ceil(N/2) wins)
*/
export function isFixedLengthFormat(seriesFormat: number): boolean {
return seriesFormat % 2 === 0;
}When I set up a series, I choose one number. Odd means best-of, even means all games get played and points decide. The database stores just that number, and the rule comes from whether it's odd or even. If a series that uses chess match style ends tied, it goes to sudden death, and if the sudden-death game is a draw, you play another one.

Handling ties
League standings use only one tiebreaker: head-to-head results. Players with the same points form a group, and only their games against each other count, like a small tournament inside the league. If that sorts them out, done. If it only sorts out some of them, the same thing is repeated for whoever is still tied. No Sonneborn-Berger, no Buchholz. In a round-robin everyone plays everyone, so who beat whom is the rule that feels fair to us. My favourite part is what happens when even that doesn't work, a circular tie where everyone beat someone. The app doesn't make up an order. It returns the players who are still tied, and they get a new mini round-robin added to the league as tiebreaker rounds. A league can't be finished while a tie like that exists. The tie gets settled by playing more chess, which is how it should be.
Because all of this is pure, the two tournament engines are covered by 66 tests with no database, no mocks, and no test data beyond lists of names. The part that saves to the database needed a real Postgres for testing. The engines needed nothing.
Naming the opening
After a game is saved, the app names the opening, like Ruy Lopez: Closed or the Caro-Kann Defense. It does that with a simple lookup. I took the Lichess openings dataset (public domain), played the moves of every opening once through chess.js, and saved the result as a file that maps positions to opening names. That's 3,641 entries in 484 KB, loaded into memory when the server starts. There's no database table or external service behind it.
The important part is using the position as the key, not the order of moves. A chess position can be written as a short line of text: where every piece is, who's to move, castling rights and en passant. Drop the move counters from the end, and two games that reached the same position through different move orders get the same key. Transpositions, which are a real pain if you match on move order, just work. The lookup is then a simple
loop:
function fenKey(fen: string): string {
return fen.split(" ").slice(0, 4).join(" ");
}
// Replay move-by-move to find the deepest matching position
const replay = new Chess();
let bestMatch: EcoEntry | null = null;
for (const move of moves) {
replay.move(move);
const match = ecoLookup[fenKey(replay.fen())];
if (match) bestMatch = match;
}The game is replayed move by move, every position is looked up, and the last match is kept. In practice that's the deepest named position before the game left known theory. The whole feature is under fifty lines. If the moves of a game can't be read, it just doesn't get an opening name. The game itself is what matters, the name is a bonus.
Analysis without a server engine
Every game can be analyzed with Stockfish at depth 20: move classifications, blunder counts and the engine's best lines. The main decision behind this feature was that the server never runs a chess engine. The analysis runs in the visitor's browser, with a WebAssembly build of Stockfish in a web worker. No engine on the server, no job queue, nothing new to host. It was decision from the start to simplify server side of application. In practice, I open a game, click analyse, and a progress bar goes through every position while the engine works. A normal game takes a couple of minutes. When it's done, the browser sends the analysis to the server, and the server checks it before saving. It reads the game's moves itself and compares them move by move with what was sent, so analysis that doesn't belong to the real game gets rejected. The saved analysis is tied to a fingerprint of the exact move list. If the game is edited later, the old analysis is marked as stale and hidden instead of being shown for moves that no longer match.

The engine is bundled with the app, in the public folder next to its GPL licence, since there was no package to install. I went with the single-threaded build on purpose. The multi-threaded one needs extra browser security headers that would have affected the rest of the site, and it wasn't worth it for one feature.
Running it
The app runs in Docker on a Hetzner VPS, like most of my projects. The only exception is Postgres, which is on Neon. Login is Better Auth with TOTP two-factor. There's no sign-up, I create accounts by hand, which for a small group and a non-commercial app is all that's needed. One note on the stack:
Drizzle is on the 1.0 release candidate, not a stable version, because the older version had a security issue in a dependency that was never going to be fixed there.
What's next is already planned but not built: UI design overhaul/improvement is something that would be nice, but for our use its not really needed. Interactive analysis, where you can play through variations on the board instead of only reading the evaluations is new feature. It's fully described in the roadmap, but I haven't started on it. The app does what our group needs, and I'm trying not to keep adding features to something that's probably already over-engineered for what we use it for. If I find some spare time, I'll probably build it anyway :D