2.3 Matches
Section 2 — Games
2.3 Matches
2.3.1 Match Lifecycle
Overview
A match progresses through up to five discrete steps, each a separate endpoint. Steps are not strictly sequential — results can be submitted before or without an end record — but the typical flow is:
1. Create → 2. Join (optional, repeated) → 3. Leave (optional, repeated)
→ 4. End → 5. ResultsMatch create, join, leave, end, and results are authenticated via both X-Game-Key and a
player bearer JWT. POST /api/game/matches/players/resolve is the exception: it is
game-key-only and does not require a player JWT.
Ledger Tables
Every match write operation appends to one or more append-only ledger tables. Nothing is ever updated or deleted.
| Table | Written by | Rows |
|---|---|---|
game_app_match_sessions | Create | 1 per match |
game_app_match_players | Create (initial players), Join | 1 per player participation |
game_app_match_player_events | Create, Join, Leave | 1+ per player action |
game_app_match_ends | End | 1 per match |
game_app_player_results | Results | 1 per player result |
Step 1 — Create Match
POST /api/game/matches
X-Game-Key: <key>
Authorization: Bearer <player_jwt>Creates the match session. Optionally accepts an initial player list.
Request:
| Field | Required | Notes |
|---|---|---|
idempotencyKey | Yes | 1–64 chars |
levelName | No | Map, level, or scene name; max 64 chars |
isMultiplayer | No | Boolean; defaults to false |
startedAt | Yes | Domain time the match started (game server clock) |
metadata | No | Arbitrary key-value pairs; max 64 keys, max 32 KB total |
players[] | No | Initial player list; max 1,000 players |
Per player (MatchPlayerRequest):
| Field | Required | Notes |
|---|---|---|
playerId | Yes | Player profile ID |
loginSessionId | Yes | Login session ID; must be active within 2-hour window |
joinedAt | No | Defaults to startedAt if omitted |
teamId | No | Short integer team identifier |
teamLabel | No | Display label; max 64 chars |
isHost | No | Boolean; marks the player as match host |
Session Validation: Each player's login session must exist and have last_seen_at within
the past 2 hours with no ended_at. Failed players are listed in errors[] with status
410 Gone; the match is still created if at least one player is accepted.
Response — Partial Success: Returns 201 Created if the match session was written.
200 OK on idempotency replay.
alreadyProcessed bool
matchId Guid?
players[]
playerId Guid
loginSessionId Guid
matchPlayerId Guid?
errors[]
playerId Guid
loginSessionId Guid?
statusCode int
error stringWhat Gets Written:
- 1 row →
game_app_match_sessions - 1 row per accepted player →
game_app_match_players - 1 row per accepted player →
game_app_match_player_events(type:joined)
Step 2 — Join Match
POST /api/game/matches/join
X-Game-Key: <key>
Authorization: Bearer <player_jwt>Adds a single player to an existing match after creation.
Request:
| Field | Required | Notes |
|---|---|---|
idempotencyKey | Yes | |
matchSessionId | Yes | Must reference an existing, non-ended match |
playerId | Yes | |
loginSessionId | Yes | Session validated same as create |
joinedAt | No | Defaults to server receive time |
teamId | No | |
teamLabel | No | Max 64 chars |
isHost | No |
If the player has previously left this match, the join is recorded as a rejoined event.
Response:
alreadyProcessed bool
matchSessionId Guid
playerId Guid
matchPlayerId Guid
loginSessionId Guid
joinedAt DateTime
eventType string — "joined" or "rejoined"What Gets Written:
- 1 row →
game_app_match_players - 1 row →
game_app_match_player_events(type:joinedorrejoined)
Step 3 — Leave Match
POST /api/game/matches/leave
X-Game-Key: <key>
Authorization: Bearer <player_jwt>Records a player leaving the match. Does not validate the player's login session.
Request:
| Field | Required | Notes |
|---|---|---|
idempotencyKey | Yes | |
matchSessionId | Yes | |
playerId | Yes | |
occurredAt | Yes | Domain time the leave occurred |
reason | No | Free-text reason; max 64 chars |
Response:
alreadyProcessed bool
matchSessionId Guid
playerId GuidWhat Gets Written: 1 row → game_app_match_player_events (type: left)
Step 4 — End Match
POST /api/game/matches/end
X-Game-Key: <key>
Authorization: Bearer <player_jwt>Records the match as ended. A match can only be ended once.
Request:
| Field | Required | Notes |
|---|---|---|
idempotencyKey | Yes | |
matchSessionId | Yes | |
endReason | Yes | See end reason values below |
endedAt | Yes | Domain time; max 30 days after startedAt |
metadata | No | Max 64 keys, max 32 KB |
End Reason Values:
| Value | Meaning |
|---|---|
completed | Match ran to natural conclusion |
abandoned | Match abandoned before completion |
timeout | Match exceeded a time limit |
server_error | Server-side failure terminated the match |
host_disconnect | Host player disconnected |
Response:
alreadyProcessed bool
matchSessionId Guid
endId GuidWhat Gets Written: 1 row → game_app_match_ends
Step 5 — Submit Results
POST /api/game/matches/results
X-Game-Key: <key>
Authorization: Bearer <player_jwt>Submits outcome data for the authenticated player only. Does not require an end record to exist first.
Requires both X-Game-Key and a bearer token, and enforces request.playerId == authenticated playerId.
Request:
| Field | Required | Notes |
|---|---|---|
idempotencyKey | Yes | |
matchSessionId | Yes | |
playerId | Yes | Must have a match player record in this match |
result | Yes | See result values below |
finalScore | No | Long integer |
matchPosition | No | Integer finish position |
recordedAt | Yes | Domain time the result was determined |
Result Values:
| Value | Meaning |
|---|---|
win | Player won |
loss | Player lost |
draw | Match ended without a winner |
forfeit | Player forfeited |
disconnect | Player disconnected before conclusion |
incomplete | Match did not reach a conclusive result for this player |
continue | Player continues to a next stage or round |
Response: 201 Created on first success, 200 OK on idempotency replay.
alreadyProcessed bool
matchSessionId Guid?
playerId Guid
matchPlayerId Guid
resultId GuidWhat Gets Written: 1 row per accepted result → game_app_player_results
Match Player Events
Every player presence action appends a row to game_app_match_player_events. These are
structural events about a player's lifecycle within the match — distinct from game event
records (game_app_event_records).
| Event type | Written by | Trigger |
|---|---|---|
joined | Create, Join | Player accepted into match for the first time |
rejoined | Join | Player re-joins after a previous leave |
left | Leave | Player leaves voluntarily or is recorded as leaving |
kicked | Mid-match logout | Player's session ends while in an active match |
timeout | — | Reserved; not currently written by any endpoint |
game_app_match_player_events columns:
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | Composite PK |
id | uuid | Composite PK |
match_session_id | uuid | FK → match session |
match_player_id | uuid | FK → match player record |
login_session_id | uuid? | Login session at time of event; null if session unavailable |
event_type | text | joined, rejoined, left, kicked, timeout |
occurred_at | timestamptz | Domain event time |
created_at_gl | timestamptz | Server receive time |
reason | text? | Optional context |
metadata | jsonb | Arbitrary metadata |
idempotency_key | text | Required; used for deduplication |
row_hash | text | Application-computed integrity hash |
Mid-Match Logout
When a player calls POST /api/player-auth/logout while they have active match player records,
AppendLogoutEventsAsync runs automatically. It:
- Queries
game_app_match_playersfor all records linked to the player'sloginSessionIdwhere noleftorkickedevent has been written yet. - Appends a
kickedevent togame_app_match_player_eventsfor each active match.
This keeps the match ledger consistent. The match itself is not affected; it continues for other players.
Field Limits Reference
| Field | Limit |
|---|---|
levelName | 64 chars |
teamLabel | 64 chars |
Leave reason | 64 chars |
Match metadata keys | 64 keys |
Match metadata size | 32 KB |
endedAt − startedAt | Max 30 days |
| Players per create request | 1,000 |
| Results per request | 1,000 |
| Idempotency key | 64 chars |
Error Codes
| Code | Meaning |
|---|---|
| 201 | Created — write accepted |
| 200 | OK — idempotency replay |
| 400 | Invalid request |
| 409 | Conflict — same idempotency key with different payload |
| 410 | Gone — player login session expired in create-match per-player validation |
| 422 | Unprocessable — results submission with zero accepted rows |
| 429 | Rate limit exceeded |
Code References
- Controller:
GamersLabRestAPI/Game/Matches/Controllers/GameMatchesController.cs - Service interface:
GamersLabRestAPI/Game/Matches/Services/IMatchService.cs - Service:
GamersLabRestAPI/Game/Matches/Services/MatchService.cs - Repository interface:
GamersLabRestAPI/Game/Matches/Repositories/IMatchRepository.cs - Repository:
GamersLabRestAPI/Game/Matches/Repositories/NpgsqlMatchRepository.cs - DTOs:
GamersLabRestAPI.Contracts/Game/Matches/DTOs/MatchDTOs.cs - Constants:
GamersLabRestAPI/Game/Matches/Models/MatchConstants.cs
2.3.2 Player Resolution
POST /api/game/matches/players/resolve
X-Game-Key: <key>Resolves a matchPlayerId from a matchSessionId + playerId pair. Used by game servers
that need the platform-assigned matchPlayerId for downstream event writes without having
cached it from the join/create response.
Request:
| Field | Required |
|---|---|
matchSessionId | Yes |
playerId | Yes |
Response:
matchSessionId Guid
playerId Guid
matchPlayerId Guid
loginSessionId Guid?