Files
kickkingsapi/docs/unity_match_lifecycle.md
T
2026-09-04 19:11:49 +00:00

12 KiB
Raw Blame History

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

  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": "<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 (30) 100%
Loser scored 1 (31) 90%
Loser scored 2+ (32) 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 30 (100%). 31 / 32 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 Reds user received participant_cc on this settle.
cc_awarded_blue Blues 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 31):

{ "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% (31).

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

  1. One player disconnects mid-match
    Remaining player wins. Set the disconnected side to false and forfeit: true.
    Example: blue disconnects → { "winner": "red", "red_connected": true, "blue_connected": false, "forfeit": true }.

  2. 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 }.

  3. Process crash / forced shutdown
    If fees were collected, choose a winner with the same rules (remaining player, or last disconnect), send accurate *_connected flags, and call winner before exit whenever possible.

  4. Normal score / time win
    Report the actual winning side, scores, forfeit: false, and true for any player still connected.

  5. Skip-forfeit / auto-forfeit (two skipped turns)
    Not a disconnect. Both *_connected stay true if they are still in the match. Send forfeit: 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%.

  6. Simultaneous forfeits (both Leave / skip at once)
    Each handler may PATCH with the opponent as winner and forfeit: true. The API keeps the first settle (first forfeiter loses). The second returns 200 already_settled: true with the real winner / 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_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)
  • Send red_connected / blue_connected (do not infer from winner; last-disconnect winner with both false still gets RC, not CC)
  • Send red_score / blue_score on every settle (31 / 32 MMR multipliers need these)
  • Send forfeit: true on leave, disconnect, last-disconnect, and skip/auto-forfeit
  • Persist disconnect order until winner is acknowledged
  • Dual Leave/skip: honor 200 already_settled winner even if it differs from the side you sent