Building a Tournament Platform for My Local Pool Club
Every Wednesday, Frank's Poolhouse in Odense runs a tournament, and the club kept its pools, brackets, season standings and handicaps in an Excel workbook. I built a web app to replace it. It now runs the tournaments in production at turneringsportal.kjaer-thomsen.dk, on a server I host myself.
I built it alone over about five weeks in the autumn of 2026. The club's product owner wrote a functional spec and made wireframes in Figma, and the old workbook served as the reference implementation: when the spec didn't cover a rule, the app does what the workbook did.
An evening with the app
Setting up. The tournament director creates a tournament, picks the format and adds tonight's players with a search that matches the start of any word in a name, so a known player is a couple of letters and Enter away. The app shows how the players will be split into pools as the list grows.
The players are drawn into pools at random. The director can then drag a player onto another to swap them, move a player to another pool, or start a new pool, and choose how many frames each pool's matches are played to. The join code for players is shown in the admin panel on the right.
Playing. Players enter their own results on their phones.
They open /play, type the six-digit code from the director,
and tap a match to pick the score. The pool table, standings and "Up next"
update for everyone as soon as a result is in.
/play: joining with the code, the pool table, and entering a score.
On the TV. A flatscreen in the club shows
/live: the pool tables and "Up next" during pool play, the
bracket during the knockout, a podium when the tournament is concluded,
and the season leaderboard between tournaments.
/live view during pool play, at TV resolution.
After the pools, the app qualifies players for the knockout and seeds them, keeping players from the same pool apart in the first round. When the final is played, concluding the tournament gives every player a placement and season points. Members get a bonus, and handicaps are reviewed the way the workbook did it.
Architecture
The app is a TypeScript monorepo with three packages:
- core: the tournament rules as pure functions with no database or HTTP code: pool splits and draws, round-robin schedules, standings, qualification, seeding, the bracket, placements, points, seasons and handicaps
- server: a Fastify REST API on PostgreSQL with Drizzle ORM. It also serves the built frontend, so the whole app is one container
- web: a React app built with Vite, using TanStack Query for data fetching. The interface is in Danish and English
Both the server and the frontend use the core package, so a rule exists in one place. Randomness, such as the pool draw, is passed into the functions, so tests can use a seeded generator and get the same draw every time.
Design decisions
Ties are flagged, not guessed. Players in a pool are ranked by matches won, then frame difference, then head-to-head, with a mini-league between them when three or more are level. Some ties can't be broken that way, such as A beating B, B beating C and C beating A. The app flags those, and the director decides the order.
Live updates without state on the wire. When something changes, the server sends a Server-Sent Event that only says which tournament changed, and each open page refetches what it shows. Pages can't drift out of sync, and the event bus can stay in memory because the app runs as one server process. The first event also carries a version hash of the frontend, so the TV in the club reloads itself after a deploy.
A screen nobody can scroll. The TV has no mouse or
keyboard, so /live is exactly one screen high. The pools or
the bracket are laid out at their natural size and scaled to fill the
screen. It uses a dark theme, which is easier to read in a dim bar.
A code instead of player accounts. Asking every player to
sign up would make self-scoring a hassle. Instead, each tournament gets a
six-digit code that only admins see. It is never shown on
/live, because people outside the tournament are in the bar
too. A player can only enter a result in a match that doesn't have one,
after confirming it. If two people enter the same match, the first result
counts, and corrections are left to the director. Join attempts are rate
limited.
Rules enforced by the database too. Only one tournament can be in progress at a time, and a partial unique index in PostgreSQL guarantees that, not just the application code. Admin accounts can only be created with a command-line tool on the server, so there is no sign-up route to protect.
Testing
The rules in the core package are unit tested, using results from the club's own workbook as fixtures. The API is covered by integration tests that run against a real PostgreSQL database rather than mocks. That's more than 260 test cases, run with Vitest.
Deployment
The app is built into a single Docker image with a multi-stage build. On a
Linux server, Docker Compose runs the app, PostgreSQL, Caddy as a reverse
proxy with automatic HTTPS, and a backup container that takes a nightly
database dump. Only Caddy is reachable from the internet. The server
applies database migrations when it starts, so an update is a
git pull and a rebuild.
Related project
Pool Tournament Platform
A web platform that runs the weekly tournaments at my local pool hall, from pool play and knockout brackets to season standings and handicaps, with live updates on players' phones and the club's TV screen.