Wintersalmon | Blog

The AI opponents players couldn't see, and the one line that hid them

6 min read

Part of this project is training AI opponents for the board games I build, then putting them in front of real players. This week I found a quiet bug: I had trained and deployed six opponents for YINSH, but a player opening the game could only ever pick three of them. The newest and strongest bots were running, healthy, and completely invisible. The cause was a single missing line in a startup script, and the real fix was to stop hand-wiring each new opponent in the first place.

  • YINSH has a menu where you invite an AI opponent into your game. Six were deployed; only three appeared.
  • A startup script that hands settings to the website was never updated for the last three opponents, so the site didn’t know they existed. No error, just a short menu.
  • I replaced the per-opponent hand-wiring with one list the website reads and checks. Adding an opponent is now one line, not edits across six files.
  • The newest opponent now sits at the top of the menu, numbered, so the strongest one is the obvious default.
  • Verified on the live test site, without pausing a 12-hour training run on the same machine.

What a player actually saw

YINSH is a two-player strategy game played on a hexagonal board. When you start a game on the site, there’s a waiting room with an “invite an AI opponent” dropdown. Each opponent in that list is a separate small program running on the server, with one trained AI model inside it. Pick one, click invite, and it joins your game.

The symptom was simple: three options where there should have been six. The three missing ones were the most recent champions, the bots I had spent the most training time producing. They were live and answering, but no player could choose them.

The cause was a startup script that never got the memo

The same website is shipped once and runs in two places: a test copy and the real one. When it boots, a small startup script writes that environment’s settings into a file the browser reads. That is where each opponent’s web address was supposed to be listed.

To add one opponent, the old design needed edits in roughly six places: a settings field in the site’s code, an entry in the menu list, a line in the startup script, a line in the deployment file, plus the opponent’s own server. The startup script had only ever received the lines for the first three opponents. So even though the deployment file correctly listed all six addresses, the script silently dropped half of them on the floor, and the website faithfully rendered the short list it was handed.

Nothing crashed. That’s what made it quiet: the missing opponents looked exactly like opponents that had never been added.

The real bug was “add it in six places”

The missing line was the symptom. The disease was that every new opponent required editing six files in lockstep, and forgetting any one of them failed silently. A process that needs six correct edits to do one thing will eventually get five.

The fix: one list the website trusts and checks

I replaced all of it with a single setting: a list of opponents, each just a name and a web address. The website reads that list, runs it through a validator, and builds the menu from it. Adding an opponent is now one entry in one place.

The validator matters because the website now trusts outside data. It throws away any entry that’s malformed or points at an unsafe address, so a typo can never break the menu or send a player to the wrong place. And the menu numbers the opponents oldest-to-newest and shows them newest-first, so the latest, strongest bot lands at the top as the default choice.

Verified live, training untouched

I merged the change and let the cluster’s automatic deployment system rebuild and roll it out on its own. Within minutes the live test site served all six opponents, in order, and each one passed a health check. The whole time, a 12-hour AI training run was using the same GPU; I never had to touch it, because none of this lived in the training system.

Keywords

  • YINSH — a two-player abstract strategy game played with rings and markers on a hexagonal board.
  • Bot / AI opponent — a small always-on program that runs one trained model and plays moves like a human would.
  • Champion model — the current best version of a bot, chosen by tournaments during training.
  • Client / website — the page players open in their browser; it builds the menus they see.
  • Startup (entrypoint) script — a few lines that run the moment a server program boots, here used to write per-environment settings.
  • Environment variable — a named setting handed to a program from outside, so one program behaves differently on the test site vs the real one.
  • Config-driven — behavior controlled by a data setting you can change, rather than hard-coded into the program.
  • Validator — code that checks incoming data and rejects anything malformed or unsafe before it’s used.
  • GitOps (FluxCD) — a deployment method where the running system automatically updates itself to match what’s written in the project’s Git history.

References

  • #1234 — the fix: replaced the per-bot wiring with one validated config list and the numbered menu.
  • #1233 — the sixth opponent (an 8th-session champion) that landed mid-fix and was folded into the new list.
  • apps/board-game-client/src/shared/config/ai-bots.ts — the parser, validator, and newest-first menu ordering.
  • apps/board-game-client/build-config/docker-entrypoint.sh — the startup script that had been missing the lines.
  • YINSH overview — the game’s rules, if you’ve never played.
  • FluxCD image automation — how the deploy rebuilt and rolled out on its own.

AI workflow note

Claude Code drove this end to end inside the cluster repo. The diagnostic win was cheap and came first: before changing any code, I had it fetch the settings file the live site actually serves, which proved the three opponents were missing at the website layer, not the server layer. I asked it to write the parser and validator as a pure function with unit tests before wiring anything in. The one place it needed steering was the deploy: a sixth opponent had merged to the main branch mid-task, so it rebased onto that and folded the new bot into the list instead of overwriting someone else’s change.

#yinsh #game-ai #deployment


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.