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

Source: https://birdhouse.tech/docs/game-analytics-events/

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](https://birdhouse.tech/products/analytics/) for current status, or [join the Analytics waitlist](https://birdhouse.tech/?product=analytics#signup).

## 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.

```json
{
  "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`, not `StartMatch`).
- Never rename an event or reuse a name for a new meaning.
- Increase `schema_version` when a property changes type or meaning.
- Put units in names (`duration_seconds`), and keep `outcome` and `reason` to 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](https://create.roblox.com/docs/production/publishing/RTBF-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_started` to a matched `search_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](https://birdhouse.tech/docs/roblox-sdk/)). Your game code records the events:

```luau
-- 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](https://birdhouse.tech/docs/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](https://birdhouse.tech/docs/lifecycle/), 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](https://create.roblox.com/docs/production/analytics/analytics-dashboard) in Creator Hub with retention, engagement, acquisition, demographics, feedback, and monetization charts. That page lists the eligibility requirements. Its [retention page](https://create.roblox.com/docs/production/analytics/retention) defines day-1, day-7, and day-30 retention from a player's first visit.

For game-specific events, [`AnalyticsService`](https://create.roblox.com/docs/reference/engine/classes/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](https://create.roblox.com/docs/production/analytics/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](https://create.roblox.com/docs/production/analytics/event-types), as of October 2026, are 100 custom event names, three custom fields per event, and 120 + (20 × CCU) `AnalyticsService` requests per minute. [Custom field](https://create.roblox.com/docs/production/analytics/custom-fields) 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.

```luau
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 produces `match_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](https://birdhouse.tech/docs/roblox-matchmaking/) compares built-in server matching, a MemoryStore queue, and an external ticket service.
- [Skill-based matchmaking](https://birdhouse.tech/docs/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](https://birdhouse.tech/products/analytics/) for current status.
