Skip to content
alzy.kr
← Devlog

A 54% win rate against a 25% baseline — and 5% of that data was invisible

A hunch is not evidence, so I went and looked at production data. In solo matches, one human against three bots, the human won 54% of the time. With four seats the balance point is 25%.

Written: 2026-08-01

Last updated: 2026-08-21

It started as "these feel a bit soft" 💭

Solo bot matches defaulted to medium difficulty.

That was a value I set once and left alone, assuming it was fine.

But for anyone playing seriously, the resistance felt light.

A feeling is only a feeling, though. It is not grounds for changing a spec.

So I went to get numbers first.

The data: 54% against 25% 📊

The production audit came back like this.

In solo matches, one human against three bots, the human won 54% of the time.

At a four-seat table the balance point is 25%.

More than double.

That is where it stopped being a feeling: default medium really was too weak for engaged players.

The hunch was the hypothesis. The audit was the evidence. 📌

Pencil sketch bar chart with a tall bar beside a short one, a dashed line crossing at the height of the short bar
The dashed line is the balance point. The measurement came in at more than double.

Except 5% of that data was invisible ⚠️

Turning the audit into code surfaced an awkward fact.

The select policy on the games table is is_room_member(room_id).

That checks whether you are still a member of the room right now.

Leave the room after a match, or let the room get recycled by rematching, and finished games disappear from view.

Measured directly: 9 of 174 solo matches were invisible under the current-state check.

Five percent.

The conclusion would not have flipped, but shipping it unaware would have meant judging players on only the games that happened to still be visible.

Pencil sketch of a tally grid with one corner covered by a heavily hatched sheet, hiding the squares beneath it
The tally itself had a corner I could not see from where I was standing.

So I changed where the count reads from 🔑

The workaround was to read the tally from somewhere else.

The game_participants snapshot is fixed at the moment a match starts.

Leaving the room later does not move that record.

Reading it directly with the service role picked the hidden 5% back up.

Before trusting data, check how the data got collected. 🧐

Two wins as the threshold 🎯

The upgrade condition is two or more wins in past solo matches.

Below two wins, the default stays at medium.

The reason is written straight into the code comment: protecting first-timers and beginners from bouncing.

Meeting a hard bot on your very first match is not difficulty tuning. It is a door slammed shut.

Matches abandoned early were excluded from the tally. A loose count makes the threshold meaningless.

Pencil sketch of a small stack before a low step and a taller stack on top of it, with an arrow crossing over
Below the step, nothing changes. The upgrade only happens after you cross it.

A deliberate choice is never overwritten 🙅

This logic only applies to the default when nobody picked a difficulty.

In quick play or in the host UI, an explicit choice is respected exactly as made.

If you chose low and keep winning, nothing silently promotes you to high.

What I wanted to adapt was the default, not a person intention.

No new column, no migration 📦

The implementation is 79 lines in lib/bot-difficulty.ts.

The tests are another 79 lines, six cases.

No new column was added. No migration was run.

Querying the existing tables on demand is enough, since match counts per user are small.

The difficulty constants that drive how the bots actually think were left untouched.

Wrapping up 🏁

What I kept from this one is a two-step check.

When a hunch shows up, go get numbers.

When numbers show up, question how they were collected.

Had I stopped at 54 against 25, the implementation would have sat on a tally missing 5% of its input.

Look at where the evidence came from before you look at what it says. Same drill next time! 📈

TAMNA