Analytics events for multiplayer games
Which game analytics events and metrics to track in a round-based multiplayer game, including Roblox games, with exact definitions.
Updated
Game analytics events are timestamped records of things that happen in your game, such as a match starting or a player leaving. A round-based multiplayer game can answer its first questions about matches and returning players with five server-side events: match_started, player_joined, round_ended, player_left, and match_ended. Two more, search_started and search_ended, cover matchmaking. This guide covers the events and properties to log, exact definitions for completion rate, match duration, DAU, MAU, retention, and peak concurrent users, and matchmaking health metrics, with examples that use invented sample data.
Status. BirdHouse Analytics is in development and is not available yet. It does not collect events or provide reports today. This guide is vendor-neutral: everything in it applies to whichever analytics tool or data store you use. See the Analytics page for current status, or join the Analytics waitlist.
Start from decisions, not events
List the questions you expect to act on after launch, then log only what they need.
- Are matches starting and finishing? Matches started and completion rate.
- Where do players leave? Early leaves by round, map, and reason.
- Which maps or modes underperform? Completion rate and match duration by map and mode.
- Do players come back? DAU, MAU, and retention.
- Is matchmaking keeping up? Queue wait time and match rate, from the two search events.
Which events should a round-based multiplayer game track?
| Event | Fires when | Key properties |
|---|---|---|
match_started |
A match begins | player_count |
player_joined |
A player enters the match | player_id, party_size |
round_ended |
A round finishes | round_number, duration_seconds, winning_side |
player_left |
A player leaves before the match ends | player_id, round_number, seconds_in_match, reason |
match_ended |
The match finishes or is abandoned | duration_seconds, outcome, players_at_end |
Put match_id, mode, and map on each of these events so any of them can be filtered without a join. Add the same envelope to every event: event name, unique event ID, schema version, UTC timestamp, build, and environment.
{
"event": "player_left",
"event_id": "5a0f7c1e-3a4b-4a0e-9a57-1f2f0c9f6d11",
"schema_version": 1,
"occurred_at": "2026-10-10T18:42:07Z",
"environment": "prod",
"build": "1.8.2",
"match_id": "m-20261010-000417",
"mode": "duel",
"map": "harbor",
"player_id": "p_7f3a9c2e",
"party_size": 2,
"round_number": 3,
"seconds_in_match": 271,
"reason": "disconnected"
}
Naming conventions
- Use one case and one tense:
snake_case, object first, past-tense verb (match_started, notStartMatch). - Never rename an event or reuse a name for a new meaning.
- Increase
schema_versionwhen a property changes type or meaning. - Put units in names (
duration_seconds), and keepoutcomeandreasonto a short documented list.
Why log from the server instead of the client?
The game server sees every player, uses one clock, and is still running when a player disconnects, which is when player_left has to fire. A client can be modified and can disconnect before it sends anything.
Identity and data minimization
Use a pseudonymous player identifier: a stable value that counts the same player across days without recording who they are. Keep the mapping to a platform account in a separate store with tighter access. You need it to find and delete one player’s events when a deletion request names their account.
Collect only what a decision uses. Leave out names, chat text, and contact details, and decide before launch how long you keep raw events. Follow the rules of your platform and jurisdiction; this guide is not legal advice. For Roblox experiences, start with Roblox’s page on the right to be forgotten and creators, which covers deletion requests.
Metric definitions with worked examples
Write each definition down before launch, and use UTC days throughout.
How do you calculate completion rate and average match duration?
A join is one player_joined event. A finish is a join still in the match when match_ended fires with the outcome completed. Completion rate is total finishes divided by total joins. Average match duration is total match time divided by matches. Compute both from totals, not by averaging per-map figures.
| Map | Matches | Joins | Finishes | Completion rate | Average duration |
|---|---|---|---|---|---|
| Harbor | 400 | 4,000 | 3,400 | 85.0% | 9.0 min |
| Foundry | 150 | 1,500 | 1,050 | 70.0% | 12.0 min |
| Rooftops | 50 | 500 | 250 | 50.0% | 15.0 min |
| All maps | 600 | 6,000 | 4,700 | 78.3% | 10.25 min |
Overall completion is 4,700 / 6,000 = 78.3%. Averaging the three map percentages gives (85.0 + 70.0 + 50.0) / 3 = 68.3%, ten points lower, because the smallest map counts as much as the largest.
Total match time is (400 × 9.0) + (150 × 12.0) + (50 × 15.0) = 6,150 minutes, so the match-weighted average is 6,150 / 600 = 10.25 minutes, not the 12.0-minute mean of the three map averages.
DAU, MAU, and stickiness
DAU is the number of distinct players active on one UTC day. MAU is the number of distinct players active at least once in a 30-day window. Define “active” once, for example one player_joined event.
MAU is not the sum of daily counts, because a returning player is counted on every day they play. If A and B play on day 1, A and C on day 2, and only A on day 3, the daily counts sum to 5, but 3 distinct players were active.
Stickiness is average DAU divided by MAU for the same window. An average DAU of 2,400 and an MAU of 12,000 give 2,400 / 12,000 = 20%.
Day-1, day-7, and day-30 retention
Day-N retention is the share of a cohort that is active again exactly N days after its first day (day 0). A cohort is every player first seen on the same day.
Of 1,000 players first seen on 1 October, 300 are active on 2 October, 120 on 8 October, and 50 on 31 October: day-1 retention is 300 / 1,000 = 30.0%, day-7 is 12.0%, and day-30 is 5.0%. A cohort has no day-30 figure until 30 days have passed, so leave younger cohorts out instead of counting them as zero.
Peak concurrent users
Concurrent users (CCU) is the number of players connected at one instant across all game servers. Peak CCU is the highest sample in a window: samples of 1,180, 1,420, and 1,310 give a peak of 1,420. Record the sampling interval, because a longer one can miss short spikes.
Matchmaking health metrics you can instrument yourself
Record search_started when a party begins searching and search_ended, with the same search_id plus an outcome and wait_seconds, when the search finishes.
- Queue wait time: seconds from
search_startedto a matchedsearch_ended. Report the median and 90th percentile, not only the mean. - Match rate: matched searches divided by all searches that ended in the window.
- Cancellation rate: canceled searches divided by the same total.
- Party-size mix: share of searches, and of players, at each party size.
If 1,000 searches end in a day as 820 matched, 150 canceled, and 30 failed, the match rate is 82.0% and the cancellation rate is 15.0%. Report waits for canceled searches separately: an abandoned queue can still show a short wait for the searches that matched.
If you use BirdHouse Matchmaking, which is in early access, a session’s TicketId can serve as the search_id, and its StateChanged event reports the terminal states matched, canceled, and failed (see the Roblox SDK reference). Your game code records the events:
-- Server Script. `MatchmakingClient` is the required SDK module, `client` is
-- your client, and `recordEvent` is your own function that sends one event to
-- your analytics tool.
local function startTrackedSearch(options: MatchmakingClient.CreateOptions, mode: string)
local partySize = #options.players
local startedAt = os.clock()
local session = client:CreateSession(options)
-- Connect before anything can yield. The SDK does not replay events to
-- late listeners.
session.StateChanged:Connect(function(state)
-- Record the search once, when it reaches a terminal state.
if state == "matched" or state == "canceled" or state == "failed" then
recordEvent("search_ended", {
search_id = session.TicketId,
mode = mode,
party_size = partySize,
outcome = state,
wait_seconds = math.round(os.clock() - startedAt),
})
end
end)
-- task.spawn returns without waiting for recordEvent, so a slow or
-- failing analytics call cannot delay or interrupt the search.
task.spawn(recordEvent, "search_started", {
search_id = session.TicketId,
mode = mode,
party_size = partySize,
})
-- Connect your own Matched and Failed listeners as soon as this returns.
return session
end
The service-side match duration in monitoring runs from ticket creation to match publication. A wait measured in your game starts before the SDK sends the create request, which goes out on its next heartbeat, and ends when a later heartbeat reports the match, so the two numbers differ.
Roblox: the built-in analytics option
Roblox provides an analytics dashboard in Creator Hub with retention, engagement, acquisition, demographics, feedback, and monetization charts. That page lists the eligibility requirements. Its retention page defines day-1, day-7, and day-30 retention from a player’s first visit.
For game-specific events, AnalyticsService has LogCustomEvent, LogFunnelStepEvent, and LogEconomyEvent, which feed dashboard charts, and LogProgressionEvent, which the reference says does not currently display in any Roblox-provided chart. Custom events can be sent only from the server and in published games, not from the client or Studio, and, as of October 2026, are aggregated daily, so charts can take up to 24 hours to populate.
The documented limits, as of October 2026, are 100 custom event names, three custom fields per event, and 120 + (20 × CCU) AnalyticsService requests per minute. Custom field values must be strings. As of October 2026, that page allows up to 8,000 unique combinations of values across the three fields, while the limits table groups values as “Other” after 8,000 combined values. Stay under both: use low-cardinality fields such as mode, map, and outcome, and keep match IDs out of them.
local AnalyticsService = game:GetService("AnalyticsService")
local Fields = Enum.AnalyticsCustomFieldKeys
-- Roblox custom events are logged for a player. Call this once for each
-- player who was in the round when it ended. `outcome` is that player's
-- result, such as "win" or "loss".
local function logPlayerRoundEnded(player: Player, mode: string, map: string, outcome: string, durationSeconds: number)
-- pcall keeps a logging error from interrupting the round.
local ok, problem = pcall(function()
AnalyticsService:LogCustomEvent(player, "player_round_ended", durationSeconds, {
[Fields.CustomField01.Name] = "Mode - " .. mode,
[Fields.CustomField02.Name] = "Map - " .. map,
[Fields.CustomField03.Name] = "Outcome - " .. outcome,
})
end)
if not ok then
warn("player_round_ended was not logged:", problem)
end
end
Logged once per player, player_round_ended counts player-rounds, not rounds, and its average value weights each round by its player count. It is separate from the per-round round_ended event.
Pre-launch checklist
- Events fire from the game server: a forced disconnect produces
player_left, and an abandoned match still producesmatch_ended. - A test match reconciles: joins equal early leaves plus
players_at_end. - Development traffic is kept apart from production data.
- The player identifier is pseudonymous.
Next steps
- Roblox matchmaking options compares built-in server matching, a MemoryStore queue, and an external ticket service.
- Skill-based matchmaking explains expanding skill windows.
- BirdHouse Analytics is in development. The plan is event tracking, retention, and player behavior analysis. See the Analytics page for current status.