Skip to content

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.

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.

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

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.

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

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 WindowFocusReleased the governor disconnects its Heartbeat sampler and clears its window and timers. On WindowFocused it reconnects and starts a fresh window.
  • The player’s graphics setting, when RespectSavedQuality = true. While UserGameSettings.SavedQualityLevel is 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 (with OnChange and the Bus topic, except at construction). When it lifts, quality climbs back through sustained headroom like any other recovery.

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 attached
local Bench = DeviceBench.run({ BudgetMs = 90, Bus = kernel.Bus })
print(Bench.Quality) -- 0.25-4, decimals allowed; 1 is the mid-range reference
print(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.

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 axes
Emitter.Rate = BaseRate * Quality.VfxDensity
Lighting.GlobalShadows = Quality.ShadowsEnabled
local Handcrafted = DeviceBench.profile(1.35) -- exact quality point
local Coarse = DeviceBench.profile("High") -- uniform 1.5 on every axis
-- 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 above
local MaxRagdolls = DeviceBench.pick({ Low = 4, High = 16 })
src/Client/Game/Controllers/QualityController.luau
--!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.ShadowsEnabled
end
function QualityController.init(self: any, kernel: any)
self.Kernel = kernel
end
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()
end
end
return QualityController

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.

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

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

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.