DeviceBench
Benchmarks the client on three time-boxed workloads during the loading screen, turns them into per-axis quality budgets, and runs a governor that holds the frame target.
- Realm
- Client
- Require
ReplicatedStorage.ChloeKernel .DeviceBench - Since
- v0.3.0
- Source
- DeviceBench.luau
DeviceBench measures how fast a device is during the loading screen so quality stops being one size for everyone. Three time-boxed Luau workloads measure the client on three axes (Compute, Churn and Resume), each a continuous multiplier from 0.25 to 4 against a mid-range reference of 1.0. There are no tier cliffs; 1.35 is a real quality. Budgets are funded per axis: each knob scales by the workload that pays its cost, so a device that benches 3.4x on churn and 1.2x on compute gets near-maximum particle budgets while its bone chains stay modest. BonePhysics, AudioKit, Dissolve, VfxSuite, Impact and ProjectileClient read their defaults from it.
Everything here is a client measurement of the client’s own machine. It drives cosmetics only and never server authority.
Mental model
Section titled “Mental model”Three axes, three workloads
Section titled “Three axes, three workloads”Each axis runs a small chunked workload inside a time box and reports operations per second:
| Axis | Workload per chunk | What it funds |
|---|---|---|
Compute |
A 4,000-iteration Vector3 and math loop |
Bone chains, bone cull distance, projectile visuals |
Churn |
400 four-field table allocations | Particle budgets and VFX density |
Resume |
200 coroutine create and resume pairs | Audio channels |
The bench runs for a fixed time on every device. run splits BudgetMs (default 90) evenly across the three workloads, and
each runs as many chunks as fit in its slice. A weak device runs fewer chunks in the same wall time instead of hitching
for longer, so the bench costs about 90 ms everywhere.
Each rate divided by its reference rate gives the axis multiplier, clamped to 0.25-4. Score is the unclamped geometric
mean of the three ratios and Quality is the score clamped into the band. The geometric mean tempers a lopsided device:
8x on one axis and 1x on the others scores the cube root of 8, which is 2, not 3.3.
Tiers are a convenience
Section titled “Tiers are a convenience”Low, Medium, High and Ultra remain as a coarse ladder over the continuous scale, mostly for pick():
| Tier | Quality from | As a uniform quality in profile("Tier") |
|---|---|---|
Low |
0 | 0.5 |
Medium |
0.6 | 1 |
High |
1.2 | 1.5 |
Ultra |
2 | 2.5 |
Result caching
Section titled “Result caching”The first run() caches its Result for the whole client, and every later call returns it (Force = true measures
again). Consumers can therefore call DeviceBench.profile() lazily. It is also why the bench should run once, on
purpose, during the loading screen: a consumer that runs it lazily in the middle of gameplay causes a 90 ms hitch.
Platform
Section titled “Platform”Result.Platform is "Console" when GuiService.ViewportDisplaySize is Large (a TV-sized viewport, the ten-foot
experience). It no longer reads the deprecated IsTenFootInterface, which reports console hardware rather than how the
game is shown. Otherwise UserInputService.PreferredInput decides: Touch is "Mobile", KeyboardAndMouse is
"Desktop". Gamepad players and clients without PreferredInput fall through to the capability flags (touch without a
keyboard is "Mobile", a keyboard or mouse is "Desktop"), and anything else is "Unknown".
The governor
Section titled “The governor”DeviceBench.governor(options) holds a frame-rate target at runtime by nudging a granular effective quality. It samples
frame times on Heartbeat and evaluates the 95th percentile (p95) of each 1 s window:
- A breach is p95 above 1.05x the frame target. Sustained for 3 s, it multiplies quality by 0.8.
- Headroom is p95 below 0.7x the target. Sustained for 10 s, it multiplies quality by 1.15.
- A window between the two resets both timers, so the governor does not flap. One hitch never changes quality.
- Quality stays between the floor (0.25) and the ceiling, which is the benched quality. A lucky window can never promote a device past what it measured.
Governor profiles scale the benched axes together, so a churn-strong device dialed down still favors its particles.
Three rules keep the governor from punishing a device that is not overloaded:
- Frame-rate caps. A device capped at 30 fps (a power saver, a player setting, the platform) runs evenly paced frames under the target, and those are not overload. A breach counts only when p95 is also at least 1.1x the median. Real overload is jittery; a cap is flat.
- An unfocused window. The engine and the OS throttle a backgrounded window, so its frame times say nothing about load.
On
WindowFocusReleasedthe governor disconnects its Heartbeat sampler and clears its window and timers. OnWindowFocusedit reconnects and starts a fresh window. - The player’s graphics setting, when
RespectSavedQuality = true. WhileUserGameSettings.SavedQualityLevelis a low manual level, the ceiling drops: level 1 caps quality at 0.25, level 2 at 0.35, level 3 at 0.5. Automatic and levels 4 and up leave the benched ceiling alone. A cap applies at once (withOnChangeand the Bus topic, except at construction). When it lifts, quality climbs back through sustained headroom like any other recovery.
Bench during the loading screen
Section titled “Bench during the loading screen”Pair it with Preload: asset warming waits on the network while the bench uses the idle CPU.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DeviceBench = require(ReplicatedStorage.ChloeKernel.DeviceBench)local Preload = require(ReplicatedStorage.ChloeKernel.Preload)
-- In the loading flow, before any framework client module is attachedlocal Bench = DeviceBench.run({ BudgetMs = 90, Bus = kernel.Bus })print(Bench.Quality) -- 0.25-4, decimals allowed; 1 is the mid-range referenceprint(Bench.Axes) -- for example { Compute = 2.1, Churn = 3.4, Resume = 1.6 }print(Bench.Tier, Bench.Platform) -- "High", "Desktop"
Preload.run(kernel, { Banks = { Audio, Anims } }):await()run is synchronous for BudgetMs and never yields unless Frames = N is passed, which also samples N Heartbeats for
a median FrameMs.
Read the profile
Section titled “Read the profile”profile() turns axes into concrete budgets. Pass nothing for the benched device, a Result, a plain quality number
(profile(1.35) is an exact point, not a rounded tier) or a tier name:
local Quality = DeviceBench.profile() -- from the benched axesEmitter.Rate = BaseRate * Quality.VfxDensityLighting.GlobalShadows = Quality.ShadowsEnabled
local Handcrafted = DeviceBench.profile(1.35) -- exact quality pointlocal Coarse = DeviceBench.profile("High") -- uniform 1.5 on every axisMap quality onto your own knobs
Section titled “Map quality onto your own knobs”-- Log-mapped: min at 0.25, midpoint at 1.0, max at 4.0. Every doubling of quality buys the same slice.local ViewDistance = DeviceBench.scale(80, 500)
-- Tier-keyed choice; a sparse table falls to the nearest tier below, then abovelocal MaxRagdolls = DeviceBench.pick({ Low = 4, High = 16 })Hold the frame rate with the governor
Section titled “Hold the frame rate with the governor”--!strict-- QualityController: holds 60 fps and applies each quality shift.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DeviceBench = require(ReplicatedStorage.ChloeKernel.DeviceBench)
local QualityController = { Name = "QualityController",}
local function apply(profile: DeviceBench.Profile) game:GetService("Lighting").GlobalShadows = profile.ShadowsEnabledend
function QualityController.init(self: any, kernel: any) self.Kernel = kernelend
function QualityController.start(self: any) self.Governor = DeviceBench.governor({ TargetFps = 60, RespectSavedQuality = true, -- a low Roblox graphics level caps the budgets too Bus = self.Kernel.Bus, -- publishes Device.QualityChanged(quality, profile) OnChange = function(_quality, profile) apply(profile) end, }) apply(self.Governor:profile())end
function QualityController.stop(self: any) if self.Governor then self.Governor:stop() endend
return QualityControllerWhat consumes the profile
Section titled “What consumes the profile”Framework client modules take their defaults from the bench when you leave the option unset. Every one is an option you can override:
| Consumer | Option | Default from |
|---|---|---|
| BonePhysics | MaxDistance |
profile().BoneDistance |
| BonePhysics | MaxChains |
profile().BoneChains |
| AudioKit | MaxChannels |
profile().AudioChannels |
| VfxSuite | VfxDensity |
profile().VfxDensity |
| ProjectileClient | MaxVisuals |
profile().ProjectileVisuals |
| Dissolve | MaxPoints |
scale(500, 5000) |
| Impact | Post effects on when | quality() >= PostQuality (default 1) |
GovernorClient (NetGovernor) |
Device hint sent once | quality() |
These caps are cosmetic. A culled bone chain freezes at its pose; an over-budget sound is the least important one
playing. Projectiles add a fairness rule on top: MaxVisuals decides only who gets the full visual (trails, particles).
Overflow renders as a plain pooled tracer, and a shot on course to pass within ThreatRadius (default 15 studs) of the
local character always renders in full, so no quality level hides an incoming round from its target.
API reference
Section titled “API reference”Benchmarking
Section titled “Benchmarking”| Member | Description |
|---|---|
DeviceBench.run(options?) -> Result |
Runs the benchmark, or returns the cached result. Synchronous for BudgetMs; Frames > 0 also yields that many Heartbeats for a median frame time. |
DeviceBench.axes(measures) -> Axes |
Pure: per-axis multipliers from raw rates, clamped to 0.25-4. |
DeviceBench.score(measures) -> (number, string) |
Pure: the unclamped geometric mean of the reference ratios, and its tier. |
RunOptions:
| Option | Default | Description |
|---|---|---|
BudgetMs |
90 | Total bench time, split evenly across the three workloads. |
Frames |
0 | Heartbeat frames to sample for FrameMs (yields). 0 skips. |
Force |
false | Measure again instead of returning the cached result. |
Clock |
os.clock |
Injectable for specs. |
Bus |
nil | Publishes Device.Benchmarked(result). |
Result has Score (unclamped), Quality (clamped to 0.25-4), Axes { Compute, Churn, Resume }, Tier,
ComputePerSecond, ChurnPerSecond, ResumePerSecond, Platform ("Desktop", "Mobile", "Console" or
"Unknown") and FrameMs? (the median, only when frames were sampled).
Quality mapping
Section titled “Quality mapping”| Member | Description |
|---|---|
DeviceBench.profile(value?) -> Profile |
Budgets from a Result (per axis), a 0.25-4 number (uniform), a tier name (uniform) or nil (the benched device; runs the bench if it never ran). A new table every call, safe to change. |
DeviceBench.quality(result?) -> number |
The overall clamped multiplier for the benched or given device. |
DeviceBench.scale(min, max, result?) -> number |
Log2 mapping: 0.25 gives min, 1.0 the midpoint, 4.0 max. |
DeviceBench.pick(byTier, result?) -> T? |
Tier-keyed choice; a sparse table falls to the nearest tier below, then above. |
DeviceBench.tier(result?) -> string |
The coarse tier name. |
Profile fields, the 1.0 base each axis multiplies, and the axis that funds each:
| Field | Base at 1.0 | Funded by | Notes |
|---|---|---|---|
Quality |
none | all | Geometric mean of the clamped axes. |
ViewDistance |
200 studs | overall | Rounded. |
VfxDensity |
0.6 | Churn |
0-1 emit-rate scalar, capped at 1. |
ParticleBudget |
150 | Churn |
Maximum concurrent particles. |
ShadowsEnabled |
none | overall | true at overall 0.9 or above. |
PostFx |
none | overall | Bloom, depth of field and atmosphere extras; true at overall 1.4 or above. |
BoneChains |
10 | Compute |
At least 1. |
BoneDistance |
80 studs | Compute |
BonePhysics cull default. |
AudioChannels |
24 | Resume |
At least 4. |
ProjectileVisuals |
40 | Compute |
At least 8; caps full cosmetics only. |
Governor
Section titled “Governor”| Member | Description |
|---|---|
DeviceBench.governor(options?) -> Governor |
Starts holding the frame target. Connects Heartbeat and window-focus events unless Manual. |
governor.Quality |
Current effective quality. |
governor.Tier |
The coarse tier of Quality. |
governor:profile() -> Profile |
Budgets at the effective quality, benched axes scaled together. |
governor:sample(dt) |
Feeds one frame time. Called for you unless Manual. Ignored while the window is unfocused. |
governor:stop() |
Disconnects Heartbeat, the focus events and the saved-quality listener. |
GovernorOptions:
| Option | Default | Description |
|---|---|---|
TargetFps |
60 | The frame rate to hold. |
Result |
cached or new bench | The capability ceiling. |
Manual |
false | No Heartbeat or focus connections; call sample(dt) yourself. |
Clock |
os.clock |
Injectable for specs. |
Bus |
nil | Publishes Device.QualityChanged(quality, profile). |
OnChange |
nil | (quality, profile) on every shift. |
RespectSavedQuality |
false | Cap the ceiling while the player’s saved graphics level is 1-3. |
GameSettings |
UserGameSettings |
Stand-in for specs. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Device.Benchmarked |
result: Result |
run() measured, when a Bus was passed. |
Device.QualityChanged |
quality: number, profile: Profile |
The governor shifted the effective quality, when a Bus was passed. |