# Unity dedicated server — match lifecycle (escrow + always finish) Contract for the Kick Kings dedicated server build against the matchmaker API. Header on all `/internal/*` calls: `X-Dedicated-Server-Secret: `. ## Goals 1. Collect entry fees into a **per-match escrow** only after **both** players are connected. 2. Pay the winner (and house fee) **only from that escrow**. 3. **Never leave a match unfinished** — always report a winner, including disconnect forfeits. ## Sequence ``` 1. Match row exists (matchmaking or rematch) with match_id, user_red, user_blue, entry_fee 2. Red connects → PATCH /internal/match/:matchId { "red_joined_at": "" } 3. Blue connects → PATCH /internal/match/:matchId { "blue_joined_at": "" } → When both join timestamps are set, the API collects entry fees into escrow 4. Start the match only after collect succeeds (see responses below) 5. Match ends (score / disconnect / crash path) → PATCH /internal/match/:matchId/winner { "winner": "red" | "blue" } ``` ## Join PATCH `PATCH /internal/match/:matchId` Example bodies: ```json { "red_joined_at": "2026-08-12T18:00:00.000Z" } ``` ```json { "blue_joined_at": "2026-08-12T18:00:01.000Z" } ``` ### Success when both have joined (collect ran) ```json { "ok": true, "id": 123, "entries_collected": true, "already_collected": false, "escrow_user_id": 456 } ``` ### Success when only one side joined yet ```json { "ok": true, "id": 123, "entries_collected": false } ``` ### Collect failure (fatal — do not start the match) Typical cases: | HTTP | Meaning | |------|---------| | 402 | One or both players lack enough RC for `entry_fee` | | 409 | Players/joins incomplete (should not happen if you set both joins) | | 503 | DB / economy RPCs not installed on the server | On any non-2xx after the second join, **abort kickoff**, disconnect both clients, and do **not** call winner (fees were not escrowed). Idempotent: repeating the join PATCH after a successful collect returns `entries_collected: true` (or `already_collected: true`). ## Winner PATCH `PATCH /internal/match/:matchId/winner` ```json { "winner": "red" } ``` or ```json { "winner": "blue" } ``` Requires entry fees already collected. Response includes economy summary (`entry_fee_rc`, `rc_prize`, `participant_cc`). | HTTP | Meaning | |------|---------| | 200 | Settled (or already settled for the same winner) | | 409 | Entries not collected yet, or winner already set to the other side | | 500 | Escrow inconsistency (should be rare; escalate) | Call winner **exactly once** when the match outcome is known. Retries with the **same** side are safe if the first call succeeded. ## Always finish — disconnect / forfeit rules Matches must **never** end without a winner call after fees were collected. Track disconnect order on the dedicated server (timestamps or a queue). 1. **One player disconnects mid-match** Remaining player wins. Example: blue disconnects → `{ "winner": "red" }`. 2. **Both players disconnect** The **last player who disconnected is the winner**. Example: red disconnects first, then blue → blue was last to leave → `{ "winner": "blue" }`. 3. **Process crash / forced shutdown** If fees were collected, choose a winner with the same rules (remaining player, or last disconnect) and call winner **before** exit whenever possible. 4. **Normal score / time win** Report the actual winning side as today. Do **not** leave escrow locked with no winner. Do **not** invent a draw path that skips the winner endpoint. ## Rematch `POST /internal/rematch` still only creates the room/match row. Entry fees are **not** taken there. Collect still happens when both `*_joined_at` are set on the new `match_id`. ## Checklist for the Unity build - [ ] Set `red_joined_at` / `blue_joined_at` in UTC ISO when each client is fully connected - [ ] Treat 402/4xx/5xx on the second join as fatal — no kickoff - [ ] Gate gameplay start on `entries_collected: true` - [ ] Always call winner after collect (score, forfeit, or last-disconnect-wins) - [ ] Persist disconnect order until winner is acknowledged