Match Analytics
GameFlow keeps a record of every match it forms. Most of it fills itself in. Three timestamps and the final outcome need your game server to say something.
What gets recorded
Every match produces two records, both keyed by the GameFlow match id.
Match history is the operational record: who played and on which team, which game, mode and matchmaker produced the match, which region and server ran it, when it was created, how long each player waited, when it started and ended, how long it lasted, how it finished, and when the server became ready or went away.
Match quality is the skill analytics: how many players were rated, their average μ and σ, a skill bucket for the match, how balanced the teams were, the pre-match win probability per team, and once a result is reported, who actually won and how far the match moved ratings.
Together they answer questions that a per-player rating alone cannot: how long high-skill players wait compared to low-skill ones, whether teams are actually balanced, how often the favourite wins, and how many matches are created but never played.
What you get without any integration
The roster, ticket ids, teams, region, server name, matchmaker, creation time, time to match, every skill and team-balance metric, and the server's own ready and shutdown timestamps. That is most of it. The rest needs three calls.
| You call | You get |
|---|---|
matches:start | the real start time, and the match moves to LIVE |
matches:end | the real end time, the outcome, and the match duration |
matches:report | who won, and rating changes linked to the match |
Without them a match stays at PENDING with no start, end or duration. Nothing breaks,
those columns are just empty.
The Game Server SDK does not make these calls for you. It covers the server's own lifecycle, not the platform API. These three are plain HTTP.
Your game server knows when play began and how the match ended, but it should not hold a
GameFlow API key: it is the process running in front of players, and a key that can mutate
ratings does not belong there. Have the game server tell your own backend, and let your
backend, which already holds the key, make the call. That is the same split most
integrations already use for matches:report.
First: get the match id
When GameFlow provisions a server for a match, it attaches the match context as a
GAMEFLOW_PAYLOAD annotation on the GameServer:
{
"match_id": "match-6f1e...",
"game_mode": "ranked",
"team_count": 2,
"teams": [
{ "team_index": 0, "players": [{ "player_id": "p1", "ticket_id": "t1", "slot_index": 0 }] }
]
}
Read it with the SDK's payload() call, which returns the raw string for you to parse.
If a server can be reallocated to a second match, subscribe with onPayloadChange
instead of reading once at startup. See
Reading the Payload.
In production this is a Kubernetes annotation, so reading GAMEFLOW_PAYLOAD from the
environment returns nothing. Local mode is the one exception: with no GameFlow runtime to
read from, the SDK falls back to the environment variable so you can simulate a match
(see Local Development). Reading the environment
yourself therefore works locally and comes back empty in production, which is the most
common way this integration fails silently.
match_id from that payload is what all three calls need.
1. Match started
Call once every player has connected and play has actually begun.
await fetch(`${GAMEFLOW_API_URL}/matchmaking/matches:start`, {
method: "POST",
headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({
game_id: gameId,
match_id: matchId, // from GAMEFLOW_PAYLOAD
started_at: Date.now(), // optional, epoch ms
}),
});
| Field | Description |
|---|---|
game_id | Your GameFlow game id. |
match_id | The match id from the payload. |
started_at | Unix epoch milliseconds. Optional; omit it and the time of the call is used. |
2. Match ended
Call when play concludes, whatever the outcome.
const res = await fetch(`${GAMEFLOW_API_URL}/matchmaking/matches:end`, {
method: "POST",
headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({
game_id: gameId,
match_id: matchId,
result: "MATCH_RESULT_V1_SUCCESS",
ended_at: Date.now(), // optional, epoch ms
external_match_id: yourOwnId, // optional
}),
});
const { duration_ms } = await res.json();
result is required, and an unset value is rejected rather than assumed to be a success:
MATCH_RESULT_V1_SUCCESS: the match played out.MATCH_RESULT_V1_FAILURE: it broke. Not enough players, a crash, an unrecoverable error.MATCH_RESULT_V1_CANCELED: it was abandoned deliberately.
external_match_id is your own id for the match, if you have one, so you can join our
records to yours.
The response carries duration_ms, but only if you also called matches:start. If you
did not, the field is absent rather than zero, because an unknown duration must never
look like an instant match.
3. Match result
If your queue uses skill rating you are probably already calling
matches:report. Adding match_id links the rating
result to the match's analytics, which is what makes "did the favourite win?" answerable.
It is one extra field on a call you already make.
Unlike the two above, this endpoint needs a skill model configured on the queue.
matches:start and matches:end deliberately do not: a FIFO queue has no ratings but
still has a match that starts and ends.
Things worth knowing
All three are safe to retry. The first call wins. A repeat is not an error, it
returns what was already recorded with already_recorded set, so you can retry on a
network blip without guarding.
None of them can break a match. Every one of these writes is best-effort on our side. If our storage is having a bad day you lose a row, not a game.
Auth is the X-Api-Key header, same as the rest of the API. The key's user must
belong to the organization that owns the game and needs at least the Engineer role.
Timestamps are Unix epoch milliseconds throughout and all are optional. Send them explicitly if your server buffers calls or retries later, so the recorded time is when the thing happened rather than when we heard about it.
Minimal integration
- On startup, call the SDK's
payload(), parse it, and keepmatch_id. - When play begins, have the game server tell your backend, which calls
matches:start. - When play ends, the same way, and your backend calls
matches:endwith a realresult. - Rated queues only: add
match_idto your existingmatches:reportcall.
Steps 3 and 4 happen at the same moment, so one message from the game server covers both: your backend ends the match, then reports the result if there was anything to rate.
Steps 2 and 3 turn match duration and outcome from empty columns into real data. Step 4 is a one-field change.
Related
- Implementing In Your Game: the ticket API that gets players into the match.
- Reporting Match Results: the rating call that step 4 extends.
- Environment Variables & Metadata: the payload the server reads.
- Game Server SDK: the server-side lifecycle these calls sit alongside.
The Colyseus multiplayer example implements all of this end to end, with the game server signalling its own backend and the backend holding the key.