Kowloon Knockout — Phase 5: Tier Settings + Adaptive FPS Governor — Design Spec¶
Date: 2026-06-26. Final phase of the Kowloon Knockout graphics overhaul (see
2026-06-25-kowloon-knockout-graphics-overhaul-design.md). Builds on merged Phases 0–4. Adds the user-facing control + automatic scaling layer over the existing render tiers.
Goal¶
Let players control graphics quality and keep the game smooth on weak machines: a main-menu Graphics panel (Auto / Ultra / High / Medium / Low + live FPS), a downscale-only adaptive FPS governor (Auto mode only), persistence of the choice, and validation of the mobile / WebGL2 fallback path. No new render features — this is the control/scaling layer over the tiers shipped in Phases 0–4. Combat sim, net, input, and HUD are untouched.
Current state¶
RenderTierProvider(inarena/RenderTierContext.tsx) lives inside the R3F Canvas (it needsglfor backend detection) and computes the tier once viauseMemo. Eight render components consumeuseRenderTier()({ tier, flags }) and gate on it.detectTier(caps)andTIER_FLAGS(inrender/tier.ts) are pure and unit-tested. Detection routes mobile →low, WebGL2 →medium/low(no compute).No settings UI, no localStorage.
zustand5 is a dependency. The game shell (KowloonKnockout.tsx) is aphasestate machine: menu → select → lobby → match;MainMenu.tsxholds the menu buttons.
Components & boundaries¶
Pure, headlessly-testable core — lib/kowloon-knockout/render/¶
governor.ts— no React/three:a
FrametimeMonitor(push frame deltas, expose a rolling average over a fixed window);shouldDownscale(avgMs: number, budgetMs: number): boolean(avg over budget = struggling);nextLowerTier(tier: RenderTier): RenderTier(ultra→high→medium→low, floors at low).
The governor’s “sustained for N frames” hysteresis is part of
FrametimeMonitor/the decision (a downscale only triggers after the average stays over budget across the whole window, and a cooldown prevents immediate re-trigger). Unit-tested.
Store — lib/kowloon-knockout/render/graphicsStore.ts¶
A zustand store (with
persistmiddleware → localStorage keykk-graphics):preference: 'auto' | RenderTier,setPreference(p),fps: number,setFps(n). Onlypreferenceis persisted;fpsis ephemeral and excluded from persistence.
Render layer — components/kowloon-knockout/¶
arena/RenderTierContext.tsx(refactor) — tier becomes state. The provider ownsgovernorTier(auseState, init = detected tier) as the single source of tier authority, and readspreferencefrom the store. The context value is{ tier, flags, detectedTier, preference, downscale }wheretier = preference === 'auto' ? governorTier : preference,flags = TIER_FLAGS[tier], anddownscale()lowersgovernorTierby one step (nextLowerTier). Existing consumers still read{ tier, flags }(added fields are additive). Keep the detection logic; just make the output mutable.arena/Governor.tsx(new, in-Canvas, mounted inArena3D) — reads{ preference, downscale }fromuseRenderTier(). Whenpreference === 'auto', each frame push the delta into theFrametimeMonitorand publish the smoothed FPS to the store; ifshouldDownscaleand not at floor, calldownscale()(with a cooldown so it steps at most once per window). Renders nothing. (governorTierresets to the detected tier when the provider remounts — i.e. per match/session — so Auto re-evaluates fresh.)GraphicsSettings.tsx(new, DOM) — the panel: a 5-way preset selector bound tostore.preference, and a live FPS readout fromstore.fps. Styled with the existing neon menu CSS; touch-friendly (large tap targets).MainMenu.tsx(modify) — add a “Graphics” button that toggles theGraphicsSettingspanel (same expand pattern as the existing Controls/Combos panels).
Data flow¶
Panel writes
preference→ store → persisted to localStorage.In-Canvas
RenderTierProviderreadspreference+ detected tier → effective tier as context state → the eight consumers re-render and re-gate automatically.In Auto,
Governorsamples frametime each frame, lowersgovernorTieron sustained low FPS (never raises), and publishes FPS for the readout.Manual pick (
Ultra…Low) → effective tier = that pick, governor inert (no sampling-driven changes).On next load, the persisted
preferenceis restored; Auto re-evaluates from the freshly detected tier.
Mobile / WebGL2 validation¶
Detection already routes mobile → low and WebGL2 → medium/low (compute layers gated off). Scope here is small + RUN-OBSERVE:
GraphicsSettingsmust be touch-usable (large targets, no hover-only affordances).Confirm: mobile renders
lowand the panel works by touch; forced WebGL2 stays on the CPU/non-compute paths with no crash; the governor no-ops atlow(nextLowerTier('low') === 'low').
Testing¶
UNIT (Vitest, node):
governor.ts—FrametimeMonitorrolling average;shouldDownscaletrue only when the average exceeds budget across the window (with the cooldown/hysteresis);nextLowerTiersteps down and floors atlow. The store’ssetPreferencereducer (state updates;fpsexcluded from persisted shape). Runnode_modules/.bin/vitest run lib/kowloon-knockout/render.RUN-OBSERVE (user): the panel changes quality live (post pipeline rebuilds, particle layers remount, fighters re-dispatch); Auto visibly downscales on a struggling machine and never oscillates (downscale-only); preference persists across reload; mobile + forced-WebGL2 paths render and the panel is usable.
Per project workflow: a
senior-swe-reviewerpass before the PR.
Risks & mitigations¶
Runtime tier change cost / pop. Changing the effective tier rebuilds the post pipeline (
PostFx), remounts particle layers, and re-dispatches fighters. This is the same machinery that runs at mount, but mid-session it causes a visible one-frame pop. Mitigation: the governor steps at most one level with a cooldown, so pops are rare and isolated; manual changes are user-initiated.Governor oscillation. Avoided by design (downscale-only). The only state that ever raises tier is an explicit manual pick or a new session.
Hook-order / context-shape change.
RenderTierProvidergains state; all consumers read the same{ tier, flags }shape (plus new optional fields), so they are unaffected. Mitigation: keep the existing return keys; add fields, don’t rename.Persistence SSR/JSON safety. zustand
persistreads localStorage on the client only; the store is created client-side (the game is'use client'). Mitigation: guard fortypeof windowper zustand’s standard persist setup; persist onlypreference.
Out of scope¶
Per-setting toggles (individual bloom/shadow/particle switches) — preset tiers only.
In-match pause/settings overlay (menu-only this phase; a possible later add).
Any new render technique or change to
TIER_FLAGSvalues /detectTierlogic.Changes to sim, net, input, HUD.