# EL3AB multiplayer: what was broken, and what I changed

**1 September 2026 — championship in 5 days.**

Chess was not "mostly working with a few bugs". Five independent defects each broke
competitive play on their own, and they compounded. Everything below was confirmed
against live production before a line was changed.

**This is deployed and rehearsed against production.** Nothing is outstanding on my side.

---

## Status

Deployed to `el3ab-player` via the app's own CapRover push webhook, and verified by a full
rehearsal against the live deployment:

```bash
node tests/rehearsal.mjs
```

That creates four throwaway accounts through the real auth endpoint, drives them through
matchmaking, a complete 33-move game to checkmate, spectating, and a full Swiss tournament
round — all against production — then deletes everything it created. It passes end to end.
Run it after any future deploy; production is left exactly as it was found.

Nothing new is required in the environment — every new setting has a safe default. Roll
back from the CapRover version list if anything ever looks wrong; the app was on version
262 before this work.

---

## What was actually wrong

### A. Every decisive game was rejected by the server

*No ratings, no coins, matches stuck "in progress" forever, tournament results lost.*

The board sends `win` / `loss` / `draw`. The server's list of permitted results contained
neither `win` nor `loss`, so it answered *"Invalid result value"* to every game that wasn't
a draw. That list was also wrong in its own right — half its entries are not values the
database accepts. And the code ignored whether the write succeeded, so it reported success
either way.

The winner's screen showed a *fabricated* "+12 rating". That is why nobody reported this:
it looked like it worked. Production has 78 completed matches and 61 abandoned ones, and
**not one match in the database has ever had a rating recorded.**

**Now:** the client reports only *how* the game ended. The server works out who won — from
the final position, from who resigned, from whose clock expired — checks the write landed,
and returns a real error if it didn't.

### B. Both players could be assigned White

*"People see themselves playing against themselves."*

When a screen opened a game without saying which colour you were, the code defaulted to
White — for *both* players. And a client only accepts the opponent's move while it is not
its own turn, so two Whites never saw each other. Each played a private board against nobody.

Four routes into a game supplied no colour at all, including reconnecting after a refresh.
A group invite additionally forgot to say the game was live, so it quietly started a match
against the computer.

**Now:** the server computes your colour and tells you. The board stays inert until it
answers — a wrong guess is far worse than a half-second wait — and it refuses a move from
whoever is not on turn.

### C. Finished games were never let go

*Players dragged back into dead matches on every app start.*

Leaving a game never shut down its polling or cleared the "resume this match" marker.
Combined with A — where matches never actually finished — every app start found a "still
running" game and pushed the player back into it, with no colour, which lands straight in B.

**Now:** leaving a game tears the session down. The resume window also went from 5 minutes
to 4 hours, so a long tournament game can genuinely be rejoined after a refresh.

### D. Hand-offs between two players were destroyed

*"Players don't get connected to the opponent in front of them."*

When you queued, the server deleted *all* your queue rows — including the one that had
already been matched and was carrying your match ID. You never learned about your own game,
and your opponent sat in it alone. The queue screen retries every 60 seconds, so it hit this
constantly.

Inside tournaments the same thing happened differently: both paired players checked for a
match and then created one, with no lock between. Two matches for one pairing, each player
alone on their own.

**Now:** a matched row survives, and is checked for staleness before it is trusted.
Tournament match IDs are derived from the pairing itself, so both players compute the same
one and the database guarantees exactly one game exists.

### E. There was no tournament engine at all

*Nothing could start a tournament, pair a round, or advance one.*

No code anywhere in the repository created a tournament, acted on `auto_start`, generated a
pairing, advanced a round, or computed standings. The newest live tournament has been sitting
in "registration" with `auto_start` set since July.

Tournament data was being fetched from a separate pairing service that requires an
`Authorization` header — and the platform has **never sent one**. Every request it has ever
made returned 401, and each one was swallowed and turned into an empty list. That is the
whole of "tournaments are not correct": standings and pairings have always rendered blank.

**Now:** EL3AB has its own Swiss engine, with no external dependency — pairing that never
repeats an opponent, FIDE colour allocation, byes, Buchholz and Sonneborn-Berger tiebreaks,
automatic round advance, no-show forfeits and final placings.

---

## Also fixed along the way

Smaller things, each of which would have shown up on the day.

- **Ratings never moved.** 272 of 290 players were still on the starting 1200. Swiss seeding
  orders players by rating, so round one was effectively random. Elo is now computed and
  recorded per game.
- **Disconnect detection was dead.** Two separate bugs meant a player whose opponent walked
  away waited forever — which stalls the whole round. Heartbeats are now recorded per player
  and the clock runs server-side, so an absent player loses on time like anyone else.
- **Draws could be taken unilaterally** — a way out of a lost position. They now require an
  offer from the opponent.
- **Three time controls in the picker** (5\|5, 20\|0, 45\|45) are not values the database
  accepts. Choosing one created no game at all.
- **Authentication called the auth service twice on every request**, including the
  twice-a-second poll during a game. A 64-board tournament would have generated roughly 100
  auth requests a second before a single move was processed.
- **A bot outage froze the board** on "thinking…" forever, retrying in a loop with no way out.
- **Tournament results were reported over HTTP from the server back to itself** — a deadlock
  risk that silently lost the result whenever it failed. It now runs in-process.

---

## How it's verified

```bash
./tests/run.sh
```

It creates a disposable local Postgres carrying a copy of the production schema — real enums,
real jsonb, real primary keys — runs the real API against it, and destroys it afterwards.
No production data is touched.

| Suite | What it proves | Scale | |
|---|---|---|---|
| Pairing engine | No repeat opponents, one bye each, colours balanced, points conserved, always completes | 2–100 players | PASS |
| Move validator | Real master games replayed move by move — *no legal move is ever rejected* | 167 moves | PASS |
| Engine vs Postgres | jsonb round-trips, every enum value accepted, the pairing race produces exactly one game | 8 players, 3 rounds | PASS |
| Token verification | Wrong secret, expired, tampered and `alg=none` tokens all rejected | 8 cases | PASS |
| Full API | Two clients through matchmaking, a 33-move game to mate, and a tournament — over HTTP | 20 sections | PASS |

And against the live deployment, `node tests/rehearsal.mjs` — 34 checks covering
matchmaking, a full game, spectating, ratings and a complete Swiss round, on real accounts
with real tokens, cleaning up after itself.

The checks that matter most: a losing player who reports themselves the winner *does not get
the win*; both paired players always land on the same board with opposite colours; a round
advances by itself when its last game finishes; and a player who never shows up is forfeited
so the round cannot stall.

---

## Before Saturday

1. **Play one game on a real phone, with a real person.** The API is proven against
   production; the interface is not — I never opened the app in a browser. Check the board
   orientation, that each side sees the opponent's name, and the result screen.

2. **Create the championship itself** through `api/tournament-admin.php`
   (`action: "create"`, then players register normally). Set `starts_at` and leave
   `auto_start` on — it will begin on its own.

3. **Optional, recommended:** set `CRON_SECRET` on the app and call
   `/api/cron.php?secret=…` once a minute. Tournaments now advance off ordinary player
   traffic without it, but the scheduler is a useful safety net when nobody is looking.

4. **Also optional:** set `SUPABASE_JWT_SECRET` to remove the remaining auth round-trip
   entirely. Without it the result is already cached; with it there is no upstream call at all.

---

## What I did not do, and what to watch

- **I did not open the app in a browser.** The API and the engine are proven against
  production; the interface changes are not. Colour, board orientation and the result screen
  are the things to look at first, on a real device.
- **I could not set environment variables** — the push webhook triggers builds but does not
  grant access to app configuration, and I did not have the CapRover password. `CRON_SECRET`
  and `SUPABASE_JWT_SECRET` are therefore still unset. Neither is required; both are
  improvements.
- **27 matches remain stuck in a non-final state** from May and July. They no longer cause
  harm — spectator lookups and queue hand-offs both ignore anything older than 12 hours —
  but they will sit there until someone clears them.
- **Move legality is checked, not proved.** The server verifies turn ownership, that the
  position could plausibly follow the previous one, and that the move counters advance
  correctly — enough to stop the tampering that matters. A full move generator in PHP was a
  worse risk than the one it removes: a subtly wrong one would reject legal moves mid-game.
- **The external Swiss service is now unused.** Its credentials are not in the repository and
  I would not guess at them. The native engine replaces it; if you want that service back in
  the loop, set `SWISS_API_KEY` and it will be called again.
- **Knockout brackets are not implemented.** Swiss, which is what the championship uses, is.
  The bracket endpoint now says so plainly instead of returning an empty list that renders as
  "no games".
- **The service-role key and Supabase credentials are committed in the Dockerfile.** I left
  that as I found it rather than change how the app is configured days before the event — but
  it is worth moving to CapRover environment variables once this is over.

---

Commits on `main`: `33fd4d4` → HEAD.
Full technical diagnosis in [FIX_PLAN.md](FIX_PLAN.md); test details in [tests/README.md](tests/README.md).

Investigated against live Supabase, the Swiss service, the Stockfish engine and the CapRover
deployment on 1 September 2026.
