Quick answer: Use a fixed-bucket histogram to count frame times each frame with negligible overhead. Ship the histogram at session end. Compute p50, p95, and p99 server-side by interpolating within buckets. Set targets like p50 < 14ms and p99 < 25ms for a 60 FPS game, and alert when a new build regresses.
Average frame rate is a lie. A game can report 60 FPS on average while delivering a miserable experience because five percent of frames take three times longer than the budget. Players do not perceive averages — they perceive stutters, hitches, and inconsistency. Frame time percentiles reveal what players actually feel. Tracking them in production, across real hardware and real play sessions, gives you the ground truth that internal QA on developer machines cannot provide. The challenge is collecting this data efficiently enough that the measurement itself does not become a performance problem.
Why Percentiles Beat Averages
Consider a game running at a steady 60 FPS with an occasional garbage collection spike. The average frame time across a ten-second window might be 16.2ms — well within budget. But if 1% of frames spike to 80ms, the player sees a visible hitch every second or two. The average told you nothing was wrong. The p99 would have told you immediately.
Percentiles work by ordering all frame time samples and finding the value below which a given percentage of samples fall. The p50 (median) represents the typical frame. The p95 represents the worst 1-in-20 frame. The p99 represents the worst 1-in-100 frame. For a game running at 60 FPS, one hundred frames elapse in under two seconds, so a p99 stutter happens frequently enough for players to notice and complain about.
Tracking p50 alone is insufficient. You need all three tiers: p50 tells you whether your baseline performance is healthy, p95 tells you whether occasional spikes are within tolerance, and p99 tells you whether your worst-case outliers are destroying the player experience.
Collecting Frame Times With a Histogram
Storing every individual frame time is impractical. A 60 FPS game generates 216,000 samples per hour. Storing them as 32-bit floats would consume nearly a megabyte per hour per player. Multiply that by thousands of concurrent players and you have a data pipeline problem.
The solution is a fixed-bucket histogram. Define a set of buckets that span your expected frame time range, and increment the appropriate bucket counter each frame. The histogram uses constant memory regardless of session length and requires only a single integer increment per frame — an operation that takes nanoseconds.
# Frame time histogram with fixed buckets (in milliseconds)
var buckets = [0, 4, 8, 12, 14, 16, 18, 20, 25, 33, 50, 75, 100, 200]
var counts = [] # One counter per bucket
func _ready():
counts.resize(buckets.size())
counts.fill(0)
func _process(delta):
var frame_ms = delta * 1000.0
for i in range(buckets.size() - 1, -1, -1):
if frame_ms >= buckets[i]:
counts[i] += 1
break
Choose bucket boundaries that give you resolution where it matters. For a 60 FPS target, you need fine granularity around 16.67ms. For a 30 FPS target, you need it around 33.33ms. The buckets above provide sub-2ms resolution in the 12–20ms range, which is exactly the zone where a 60 FPS game crosses from “smooth” to “stuttering.”
Shipping Data Without Hurting Performance
The histogram itself is tiny — fourteen integers in the example above. You can ship it as a JSON payload under 200 bytes. The question is when and how to send it.
The simplest approach is to submit the histogram at session end. When the player quits the game or the session exceeds a timeout, serialize the histogram and send it as part of the session telemetry payload. This adds zero network overhead during gameplay because no data leaves the client until the session is over.
For longer sessions, you can also submit on a timer — every five or ten minutes. This gives you time-segmented data that reveals whether performance degrades over the course of a session, which is a strong signal for memory leaks or resource accumulation bugs. When submitting on a timer, reset the histogram after each submission so each payload represents a distinct time window.
Tag every submission with the game version, platform, GPU model, driver version, and the current scene or level. Without these tags, aggregate percentiles are meaningless because they blend together a player on a high-end desktop and a player on an integrated GPU laptop. You need to slice the data to draw conclusions.
Computing Percentiles Server-Side
On the backend, you receive histograms from thousands of sessions. To compute aggregate percentiles, merge the histograms by summing corresponding bucket counts. Then walk the merged histogram from the lowest bucket to the highest, accumulating the total count until you reach the desired percentile threshold.
func compute_percentile(histogram, buckets, percentile):
var total = 0
for count in histogram:
total += count
var target = total * percentile / 100.0
var accumulated = 0
for i in range(histogram.size()):
accumulated += histogram[i]
if accumulated >= target:
# Linear interpolation within the bucket
var lower = buckets[i]
var upper = buckets[i + 1] if i + 1 < buckets.size() else lower * 2
var fraction = (target - (accumulated - histogram[i])) / histogram[i]
return lower + fraction * (upper - lower)
return buckets[buckets.size() - 1]
Linear interpolation within buckets gives you a more accurate estimate than simply reporting the bucket boundary. The narrower your buckets in the critical range, the more precise this estimate becomes.
Setting Quality Targets and Catching Regressions
With production percentile data flowing in, define target thresholds for each platform tier. A reasonable starting point for a 60 FPS game: p50 under 14ms, p95 under 18ms, p99 under 25ms. For a 30 FPS game, target p50 under 28ms, p95 under 36ms, p99 under 50ms. These are not universal constants — adjust them based on your game’s genre and your audience’s hardware profile.
Track these percentiles across builds. When a new build ships, compare its percentile values against the previous build for the same platform and scene. A p99 regression of more than five milliseconds should trigger an alert. A p50 regression of more than two milliseconds warrants investigation even if no one has complained yet, because it means baseline performance has degraded for every player.
Segment by scene or level to pinpoint exactly where regressions occur. An overall p95 increase from 17ms to 19ms might seem minor, but if it is entirely concentrated in one scene, that scene has a serious problem that the aggregate number obscures. Scene-level tracking turns vague performance complaints into actionable work items.
“We tracked frame time percentiles for three months before our 1.0 launch. The p99 data showed us that our particle system was causing 40ms spikes on integrated GPUs every time an explosion happened. Average FPS looked fine. Percentiles caught what averages hid, and we shipped a fix before launch day.”
Related Issues
For a broader look at collecting performance data from players, see how to measure input latency in your game. To learn about tracking stability alongside performance, read how to set up automated performance regression detection. For guidance on acting on the data you collect, check out bug reporting metrics every game studio should track.
Add a frame time histogram to your game this week. Ship it at session end with the scene name attached. After one weekend of player data, you will know exactly which scenes need optimization work.