12 KiB
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: <secret from settings>.
Goals
- Collect entry fees into a per-match escrow only after both players are connected.
- Pay the winner (and house fee) only from that escrow.
- 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": "<ISO-8601 UTC>" }
3. Blue connects → PATCH /internal/match/:matchId { "blue_joined_at": "<ISO-8601 UTC>" }
→ 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", "red_connected": true | false, "blue_connected": true | false,
"red_score": 0-3, "blue_score": 0-3, "forfeit": true | false }
Join PATCH
PATCH /internal/match/:matchId
Example bodies:
{ "red_joined_at": "2026-08-12T18:00:00.000Z" }
{ "blue_joined_at": "2026-08-12T18:00:01.000Z" }
Success when both have joined (collect ran)
{
"ok": true,
"id": 123,
"entries_collected": true,
"already_collected": false,
"escrow_user_id": 456
}
Success when only one side joined yet
{
"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
participant_cc (100 CC) is not a join/participation prize for everyone in the match. Award it only to players who finish the match still connected and did not forfeit. Players who leave, disconnect, skip-forfeit (auto-forfeit), or forfeit in any other way must not receive it. The opponent who stayed and did not forfeit still gets CC if they were connected.
RC prize / escrow payout is unchanged: still paid to the reported winner.
{
"winner": "red",
"red_connected": true,
"blue_connected": true,
"red_score": 3,
"blue_score": 1,
"forfeit": false
}
| Field | Type | Meaning |
|---|---|---|
winner |
"red" | "blue" |
Match winner (unchanged). RC prize goes to this side. |
red_connected |
bool | Dedicated server: red still had a live connection at settle. |
blue_connected |
bool | Dedicated server: blue still had a live connection at settle. |
red_score |
int | Rounds/sets won by red (first-to-3). Send on every settle. |
blue_score |
int | Rounds/sets won by blue. Send on every settle. |
forfeit |
bool | true for leave, disconnect, last-disconnect-wins, and skip/auto-forfeit (two skipped turns). false only for a normal score win. |
Set a side to false when:
- Mirror reports that client disconnected and the reconnect grace expired (or both left)
- That player pressed Leave (voluntary forfeit) — even if the TCP connection is still up for a moment
true means that player was still in the match when the outcome was reported (score win, opponent forfeit, or skip-forfeit while they stayed connected). Skip-forfeit still sends forfeit: true even if that side is *_connected: true.
Hidden MMR (admin-only, not shown to players) uses score and forfeit:
| Result | MMR % of the Clash-style base |
|---|---|
forfeit: true and losing side disconnected, or both disconnected |
20% |
forfeit: true and both still connected (skip/auto-forfeit) |
score table below |
| Loser scored 0 (3–0) | 100% |
| Loser scored 1 (3–1) | 90% |
| Loser scored 2+ (3–2) | 75% |
Skip-forfeit (two skipped turns, both still connected) sends forfeit: true. Hidden MMR still uses the score table, not 20%. Old dedicated builds that omit scores and connected flags are treated as 3–0 (100%). 3–1 / 3–2 multipliers apply only once this build sends scores.
Award rules
Let amount = existing participant_cc setting (100). Do not infer connectedness from winner. The winner can be a disconnected player (last-disconnect-wins); that player must not get CC.
Skip-forfeit / auto-forfeit (two skipped turns): send forfeit: true. The skipper is the loser and does not get CC, even if they are still connected. The opponent gets CC if they were connected.
On any forfeit (forfeit: true or a disconnected loser), CC is never paid to the losing side.
red_connected |
blue_connected |
forfeit |
CC credit |
|---|---|---|---|
| true | true | false | +amount to both users |
| true | true | true | +amount to the winner only (skip/auto-forfeit) |
| true | false | true | +amount to red only |
| false | true | true | +amount to blue only |
| false | false | true | nobody gets participation CC |
Backward compatibility
New dedicated servers always send both bools.
If red_connected / blue_connected are omitted (old dedicated builds):
- Keep awarding CC to both players (previous behaviour)
Do not treat omitted fields as false; that would stop CC for every match until the dedicated build is deployed.
Success response
Requires entry fees already collected.
{
"ok": true,
"id": 123,
"winner": "red",
"winner_id": 456,
"already_settled": false,
"economy": {
"entry_fee_rc": 21,
"rc_prize": 34,
"participant_cc": 100,
"cc_awarded_red": true,
"cc_awarded_blue": false
},
"mmr_delta": 27,
"forfeit": false
}
| Field | Meaning |
|---|---|
winner |
"red" or "blue" for the stored outcome. Honor this, not the winner you sent. |
winner_id |
User id of that side. |
already_settled |
true if this call did not settle (retry or second forfeit). |
participant_cc |
Amount per finisher (still 100). Not “total CC paid”. |
cc_awarded_red |
Red’s user received participant_cc on this settle. |
cc_awarded_blue |
Blue’s user received participant_cc on this settle. |
mmr_delta |
Hidden MMR the winner gained (loser lost). Do not show to players. |
forfeit |
Whether the match ended by forfeit (leave, disconnect, last-disconnect, or skip/auto-forfeit). |
| HTTP | Meaning |
|---|---|
| 200 | Settled, already settled for the same winner, or a second forfeit: true that lost the race (already_settled: true; use response winner / winner_id) |
| 409 | Entries not collected yet, or winner already set to the other side on a non-forfeit conflict (score win vs forfeit, or two score wins that disagree) |
| 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: they must not pay CC twice, and they repeat the same cc_awarded_* flags from the first settle.
Simultaneous Leave / skip from both sides may fire two forfeit: true PATCHes with opposite winner values. That is also safe: the first settle stands; the second is 200 already_settled with the stored winner. Apply the response winner, not the side you requested. Do not treat both players as losers.
Examples
Both finish (score 3–1):
{ "winner": "red", "red_connected": true, "blue_connected": true, "red_score": 3, "blue_score": 1, "forfeit": false }
→ RC to red, 100 CC to red and blue. Hidden MMR uses 90% (3–1).
Blue left / disconnected, red wins:
{ "winner": "red", "red_connected": true, "blue_connected": false, "red_score": 1, "blue_score": 0, "forfeit": true }
→ RC to red, 100 CC to red only. Hidden MMR uses 20% (forfeit).
Both disconnected, last disconnect was blue:
{ "winner": "blue", "red_connected": false, "blue_connected": false, "red_score": 0, "blue_score": 0, "forfeit": true }
→ RC to blue, no participation CC. Hidden MMR uses 20% (forfeit).
Blue skip-forfeit / auto-forfeit (two skipped turns), still connected:
{ "winner": "red", "red_connected": true, "blue_connected": true, "red_score": 1, "blue_score": 0, "forfeit": true }
→ RC to red, 100 CC to red only (blue forfeited). Hidden MMR uses the score table (100% here, blue scored 0), not 20%.
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).
-
One player disconnects mid-match
Remaining player wins. Set the disconnected side tofalseandforfeit: true.
Example: blue disconnects →{ "winner": "red", "red_connected": true, "blue_connected": false, "forfeit": true }. -
Both players disconnect
The last player who disconnected is the winner. Both sides are disconnected, so neither gets participation CC.
Example: red disconnects first, then blue → blue was last to leave →{ "winner": "blue", "red_connected": false, "blue_connected": false, "forfeit": true }. -
Process crash / forced shutdown
If fees were collected, choose a winner with the same rules (remaining player, or last disconnect), send accurate*_connectedflags, and call winner before exit whenever possible. -
Normal score / time win
Report the actual winning side, scores,forfeit: false, andtruefor any player still connected. -
Skip-forfeit / auto-forfeit (two skipped turns)
Not a disconnect. Both*_connectedstaytrueif they are still in the match. Sendforfeit: true. The skipper is the loser and does not get CC. The opponent gets CC if connected. Hidden MMR uses the score table, not 20%. -
Simultaneous forfeits (both Leave / skip at once)
Each handler may PATCH with the opponent aswinnerandforfeit: true. The API keeps the first settle (first forfeiter loses). The second returns 200already_settled: truewith the realwinner/winner_id— not 409. Apply the response winner. Do not show both players as losers. Do not invent a draw. Score-win vs forfeit disagreement is still 409.
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_atin 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)
- Send
red_connected/blue_connected(do not infer fromwinner; last-disconnect winner with bothfalsestill gets RC, not CC) - Send
red_score/blue_scoreon every settle (3–1 / 3–2 MMR multipliers need these) - Send
forfeit: trueon leave, disconnect, last-disconnect, and skip/auto-forfeit - Persist disconnect order until winner is acknowledged
- Dual Leave/skip: honor 200
already_settledwinnereven if it differs from the side you sent