Skip to main content

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 callYou get
matches:startthe real start time, and the match moves to LIVE
matches:endthe real end time, the outcome, and the match duration
matches:reportwho 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.

Call them from your backend, not from the game server

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.

Not an environment variable

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
}),
});
FieldDescription
game_idYour GameFlow game id.
match_idThe match id from the payload.
started_atUnix 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

  1. On startup, call the SDK's payload(), parse it, and keep match_id.
  2. When play begins, have the game server tell your backend, which calls matches:start.
  3. When play ends, the same way, and your backend calls matches:end with a real result.
  4. Rated queues only: add match_id to your existing matches:report call.

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.

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.