DeviceBench
DeviceBench answers “how fast is this device” during the loading screen so quality stops being one-size. Three time-boxed Luau workloads measure the client on three axes — Compute, Churn, Resume — each expressed as a continuous 0.25..4 multiplier against a mid-range reference (1.0). Decimals are valid quality values; there are no tier cliffs. The defining decision: budgets are funded per axis — each knob scales by the workload that pays its cost, so a device that benched 3.4× on churn but 1.2× on compute gets near-max particle budgets while bone chains stay modest.
Everything here is a client measurement. Results drive cosmetics only and never touch 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 in a time box and reports operations per second:
| Axis | Workload | What it funds |
|---|---|---|
Compute |
4,000-iteration Vector3/math loop per chunk — simulation-ish throughput |
Bone chains, bone cull distance, projectile visuals |
Churn |
400 four-field table allocations per chunk — allocation pressure and GC cost | Particle budgets, VFX density — instance/VFX spawning |
Resume |
200 coroutine create/resume pairs per chunk — how quickly the client’s threads actually switch | Audio channels — scheduling, mixing, concurrent fades |
Time-boxed, not fixed-work: 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 longer — the bench costs ~90ms on every device.
Each measured rate divides by its reference rate to give the axis multiplier, clamped to 0.25..4. The Score is the raw geometric mean of the three ratios (unclamped); Quality is the score clamped into the band. The geometric mean tempers a lopsided device: 8× on one axis with 1× on the other two scores ∛8 = 2, not 3.3.
Tiers are a convenience, not the model
Section titled “Tiers are a convenience, not the model”Low/Medium/High/Ultra remain as a coarse ladder over the continuous scale, mainly for pick():
| Tier | Quality threshold | As a uniform quality (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 module-wide; every later call returns it (Force = true re-measures). That is what lets consumers call DeviceBench.profile() lazily without re-benching — and why the bench should run once, deliberately, during the loading screen rather than implicitly at first use.
Bench during the loading screen
Section titled “Bench during the loading screen”Pair with Preload: asset warming is network-bound, the bench uses the idle CPU.
-- Client, during the loading screenlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Kernel = require(ReplicatedStorage.ChloeKernel).boot()local DeviceBench = require(ReplicatedStorage.ChloeKernel.DeviceBench)
local Bench = DeviceBench.run({ BudgetMs = 90, Bus = Kernel.Bus })print(Bench.Quality) -- continuous 0.25..4, decimals allowed (1 = mid-range reference)print(Bench.Axes) -- e.g. { Compute = 2.1, Churn = 3.4, Resume = 1.6 }print(Bench.Tier, Bench.Platform) -- "High", "Desktop"run is synchronous for BudgetMs — it never yields unless you also pass Frames = N, which additionally samples N Heartbeats for a median FrameMs.
Consume the profile
Section titled “Consume the profile”profile() turns axes into concrete budgets. Pass nothing for the benched device, a Result, a plain quality number (profile(1.35) is a real point, not a rounded tier), or a tier name:
local Quality = DeviceBench.profile() -- computed 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-- device quality buys the same slice of the rangelocal ViewDistance = DeviceBench.scale(80, 500)
-- Tier-keyed choice; sparse tables fall 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”DeviceBench.governor({ TargetFps = 60, Bus = Kernel.Bus, -- publishes Device.QualityChanged(quality, profile) OnChange = function(quality, profile) applyQuality(profile) end,})The governor connects to Heartbeat and watches p95 frame times in 1-second windows. Sustained misses (p95 above 1.05 × the frame target for 3s) multiply the effective quality by 0.8; sustained headroom (p95 below 0.7 × the target for 10s) multiplies it by 1.15 — granular nudges, not tier jumps. One hitch is not a quality change, and the asymmetric timers prevent flapping: the band between breach and headroom resets both. The effective quality is clamped between the floor (0.25) and the benched quality — a lucky frame window can never promote past measured capability. Governor profiles scale the benched axes together, so axis ratios survive: a churn-strong device dialed down still favors its particles.
What consumes the profile
Section titled “What consumes the profile”Framework client modules default from the profile when unconfigured. Every default is an option you can override:
| Consumer | Knob | Default from |
|---|---|---|
| BonePhysics | MaxDistance (cull) |
profile().BoneDistance |
| BonePhysics | MaxChains |
profile().BoneChains |
| AudioKit | MaxChannels |
profile().AudioChannels |
| ProjectileClient | MaxVisuals |
profile().ProjectileVisuals |
| Dissolve | MaxPoints |
scale(500, 5000) |
The caps are purely 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 only decides who gets the full visual (trails, particles); overflow renders as a minimal pooled tracer, and a shot on course to pass within ThreatRadius (default 15 studs) of the local character always renders full. No quality level ever hides an incoming round from its target.
API reference
Section titled “API reference”Benchmarking
Section titled “Benchmarking”| Member | Description |
|---|---|
DeviceBench.run(options?) → Result |
Runs (or returns the cached) benchmark. Synchronous for BudgetMs; Frames > 0 additionally yields that many Heartbeats for a median frame-time sample |
DeviceBench.axes(measures) → Axes |
Pure: per-axis multipliers from raw rates, clamped 0.25..4 |
DeviceBench.score(measures) → (score, tier) |
Pure: unclamped geometric mean of the reference ratios, plus 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 |
Re-measure instead of returning the cached result |
Clock |
os.clock |
Injectable for specs |
Bus |
nil |
Publishes Device.Benchmarked(result) |
Result: Score (raw, unclamped), Quality (clamped 0.25..4), Axes { Compute, Churn, Resume }, Tier, ComputePerSecond, ChurnPerSecond, ResumePerSecond, Platform ("Desktop" | "Mobile" | "Console" | "Unknown" — console via ten-foot interface, mobile via touch-without-keyboard), FrameMs? (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 (benched device — runs the bench if never run). Returns a fresh table every call |
DeviceBench.quality(result?) → number |
The overall clamped multiplier for the benched (or given) device |
DeviceBench.scale(min, max, result?) → number |
Log2-mapped knob: 0.25 → min, 1.0 → midpoint, 4.0 → max; every doubling buys the same slice |
DeviceBench.pick(byTier, result?) → T? |
Tier-keyed choice; sparse tables fall to the nearest tier below, then above |
DeviceBench.tier(result?) → string |
The coarse tier name |
Profile — the 1.0 base each axis multiplies, and the formula per knob:
| Field | Base at 1.0 | Funded by | Notes |
|---|---|---|---|
Quality |
— | 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 |
Max concurrent particles |
ShadowsEnabled |
— | overall | true at overall >= 0.9 |
PostFx |
— | overall | Bloom/DoF/atmosphere extras; true at overall >= 1.4 |
BoneChains |
10 |
Compute |
Minimum 1 |
BoneDistance |
80 studs |
Compute |
BonePhysics cull default |
AudioChannels |
24 |
Resume |
Minimum 4 |
ProjectileVisuals |
40 |
Compute |
Minimum 8; caps full cosmetics only (fairness rule above) |
Governor
Section titled “Governor”| Member | Description |
|---|---|
DeviceBench.governor(options?) → Governor |
Starts holding the frame target (Heartbeat-connected unless Manual) |
governor.Quality / governor.Tier |
Current effective quality and its coarse tier |
governor:profile() → Profile |
Budgets at the effective quality — benched axes scaled together, ratios preserved |
governor:sample(dt) |
Manual mode: feed frame deltas yourself |
governor:stop() |
Disconnects the Heartbeat connection |
GovernorOptions:
| Option | Default | Description |
|---|---|---|
TargetFps |
60 |
The frame target the governor holds |
Result |
cached/auto-run bench | The capability ceiling |
Manual |
false |
No Heartbeat connection; drive sample(dt) yourself |
Clock |
os.clock |
Injectable for specs |
Bus |
nil |
Publishes Device.QualityChanged(quality, profile) |
OnChange |
nil |
(quality, profile) on every shift |
Pacing constants: 1s evaluation windows on p95; breach at > 1.05 × frame target sustained 3s → × 0.8; headroom at < 0.7 × target sustained 10s → × 1.15; clamped to [0.25, benched quality].
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Device.Benchmarked |
result: Result |
run() completed, when a Bus was passed |
Device.QualityChanged |
quality: number, profile: Profile |
The governor shifted the effective quality, when a Bus was passed |