Joe Burdickengineering

Why We Built Cue Quest's Match Tracker

Every other pool app I tried treated each game like it was unrelated to the previous one. That's not how anyone actually plays.

Every other pool app I tried treated each game like it was unrelated to the previous one. That's not how anyone actually plays.

A few months back, a friend and I were running a casual best-of-7 on a Tuesday at Society Billiards. Nothing serious — a few dollars on the line, the usual trash talk. I was down 3-1 and clawed back to win 4-3. The next day I opened the app I'd been using to log it and there it was: seven separate single-game entries, sorted by timestamp. No series. No score. No comeback. The most fun I'd had at a table in a month, flattened into a list.

That bothered me more than it probably should have. It's also why we built the match tracker the way we did.

Single game vs. race-to

There are really only two units of play in pool, and most software only models one of them.

A single game is one rack. You break, somebody runs out or doesn't, the nine drops, the eight goes in, whatever — one winner, one loser, done. That's the unit if you're playing pickup at a bar and never seeing the other person again.

A race to N is the unit everybody who actually plays uses. League nights are races. Money games are races. Tournaments are nested races. The whole shape of the night is "first to five" or "first to seven," and the loser of the first three racks can absolutely still take the set. Which means the story is never the rack — it's the arc.

If your data model only stores racks, the arc is gone. You can reconstruct it with enough joins and timestamps, but the system doesn't know a series happened. The comeback is invisible. The pivot rack is invisible. The rivalry is invisible. You're left with a bag of games and a hope that the user will mentally re-stitch them.

What the schema looks like

So we made the series the first-class thing.

type Match = {
  id: string;
  format: "single" | "race_to";
  race_to: number | null;     // null for single-game matches
  player_a_id: string;
  player_b_id: string;
  winner_index: 0 | 1 | null; // null while in progress
  started_at: string;
};

type MatchGame = {
  id: string;
  match_id: string;           // FK back to Match
  rack_number: number;        // 1, 2, 3...
  winner_index: 0 | 1;
};

One row in matches per series. One row in match_games per rack. A single-game match is just a Match with format: "single" and exactly one MatchGame underneath it — the same shape, no special case.

We considered the obvious shortcut: a single match_results table with a JSON array of racks stuffed into a column. It would have been faster to ship. It also would have made every interesting thing we wanted to do later either painful or impossible. With racks as their own rows, you can edit one rack without rewriting the series. You can comment on a specific rack. You can ask "which rack does this player most often lose on the break?" and get an answer in SQL instead of in application code that has to unpack JSON for every match in the database. The series narrative becomes queryable, not just displayable.

What changes when the UX knows about the series

Once the series exists in the data, the product starts pulling on threads we didn't fully see at the start.

Rivalry tracking is the obvious one — head-to-head records aren't "player A has won 14 racks against player B," they're "player A leads the rivalry 4-2 in sets, but player B has won the last three." That second sentence is the one that actually means something at a pool hall.

The pivot rack is the one I'm most excited about. In any series longer than a race-to-3, there's almost always a single rack where momentum flips — the leader misses a shot they normally make, the trailer gets the table back, and the run ends. We can find that rack now, because we have racks. It becomes a UI affordance: a marker on the timeline, a card in the post-match recap, eventually a notification for the opponent. None of that exists if your atomic unit is the rack and you threw the series away.

What's next

A couple of things on the horizon, both downstream of this same choice.

RFC #697 on the app repo proposes tournament mode — brackets are just nested races, so the schema mostly already supports it; what's missing is the bracket state machine and the seeding UI. Deferred for now, but cheap to pick up later because the foundation is right.

Live match streaming is the other one. Once a series is a first-class object, "watch this match in progress" becomes a question of subscribing to one row's children, not stitching events together on the client.

The series is the unit

The reason any of this matters: pool isn't a sequence of independent racks any more than a tennis match is a sequence of independent points. The story is the set. The rivalry is the set. The comeback is the set. If the data layer doesn't believe that, nothing on top of it ever will.

We built the match tracker around the series because that's the unit people actually play. Everything else — the rivalries, the pivot racks, the tournaments, the live view — is just what falls out once the schema gets that part right.