# 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.

The code is written, tested and pushed. **The deploy is waiting on you.**

---

## Do this first

I could not deploy — pushing the build to the live app needs your approval. Five commits
are on `main` in GitLab. Trigger the CapRover build for `el3ab-player`, from the dashboard
or with the app's own push webhook, then confirm it landed:

```bash
# 403 means the new code is live. 404 means it is not.
curl -s -o /dev/null -w "%{http_code}\n" \
  https://el3ab-player.caprover.al-arcade.com/api/cron.php
```

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

---

## 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 | 19 sections | PASS |

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. **Deploy**, and confirm with the curl above.

2. **Run one full rehearsal tournament** with real accounts on real phones — four to eight
   people, three rounds, short time control. This is the only thing I could not do for you,
   and it is worth more than any further code review. Watch for: both players seeing the
   right colour, results appearing in the standings, and the next round pairing itself.

3. **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.

4. **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.

5. **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

- **Nothing has run against production.** Every test ran against a local copy of the schema.
  The deploy and the rehearsal are how this gets proven for real — please don't skip the
  rehearsal.
- **I did not open the app in a browser.** The API and the engine are covered thoroughly; the
  interface changes are not. Colour, board orientation and the result screen are the things to
  look at first.
- **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.

---

Five commits on `main`: `33fd4d4` → `231ba42`.
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.
