25 KiB
Effects and Cosmetics Performance Plan
Status: implementation complete; release gates defined in the browser benchmark
Scope: client-side reaction effects, the Aurora shop-only line color, completion and gem effects, and cosmetic inventory/shop rendering
Primary constraint: improve performance without reducing visual quality
1. Goal
Make effects and cosmetics cheaper to prepare, render, update, and clean up while preserving their current appearance and behavior.
This plan covers:
- the
classic,giant,laser,orbit,firework, andcometreaction styles; - the Aurora shop-only line color, including line, endpoint, connector, and gate presentation;
- puzzle-completion and gem-collection animations;
- cosmetic inventory and shop rendering, including the large cursor catalog;
- the measurement and browser-test coverage needed to prevent regressions.
It does not change gameplay, prices, ownership, realtime authority, or the artistic design of any effect.
2. Non-negotiable visual-fidelity contract
Performance work must not automatically degrade an effect. In particular, an optimization must not:
- replace a purchased effect with
classic; - drop, merge, or skip a visible local or remote effect;
- reduce particle, emoji, star, trail, ring, crack, or burst counts;
- shorten an effect, shrink its visible area, or remove a layer;
- reduce color depth, shadow, glow, compositing, or animation resolution;
- lower the existing 30 FPS visual scheduler cap;
- substitute a cheaper effect when the client is busy;
- add an automatic quality tier based on frame rate, device class, effect count, or battery state.
The existing user-selected lightweight/reduced-effects setting and the operating system's reduced-motion preference remain supported because they are explicit user or accessibility choices. They must not become an automatic overload response.
If an extreme overlap exceeds the performance budget, the client must render the complete visuals, record the overload, and recover cleanly. A missed performance target is preferable to silently changing what the player bought or what other players see.
3. Current baseline
The baseline below describes the implementation at the time this plan was written.
| Area | Current behavior | Main performance concern |
|---|---|---|
| Frame scheduling | Global, auxiliary, reaction, and drag visual work is capped at 30 FPS. | Effect-specific cost is not separated from other frame work. |
| Reaction canvas | One viewport-sized, DPR 1 canvas is cleared and redrawn while reactions are active. Offscreen reactions are culled outside a 420 px margin. | Full-canvas clear, repeated state changes, and every active reaction's model work occur on the critical frame path. |
| Reaction models | Deterministic values are repeatedly derived from reaction strings during drawing. Geometry, trigonometry, gradients, shadows, and emoji text are produced during frames. | The renderer repeats immutable work and allocates short-lived canvas objects. |
| Aurora line color | A tracked controller advances through a curated palette every 2 seconds and writes one CSS custom property on the world container while Aurora presentation nodes are visible. | Keep line, endpoint, connector, and gate membership synchronized without broad DOM queries or document-wide style invalidation. |
| Gem collection | Ten to eighteen DOM particles are created and animated for a normal collection. | Node allocation, individual insertion, keyframe arrays, promises, and cleanup all scale with each collection. |
| Completion effect | Flash and burst elements are created per completion and removed later. | Repeated DOM allocation and timer cleanup can accumulate during rapid completions. |
| Cosmetic UI | The catalog contains hundreds of entries, mostly cursors. Inventory rendering replaces and rebuilds its children. | Large owned/debug inventories can cause DOM construction, image decoding, style calculation, and scroll instability. |
| Diagnostics | BEND_PERF already exposes timings, counters, gauges, long tasks, interaction frames, and input delay. |
There are no dedicated reaction-style, Aurora, cosmetic-particle, or inventory-render metrics. |
| Browser benchmark | The current interaction benchmark injects one classic reaction. |
Heavy styles, overlapping remote effects, lifecycle cleanup, and visual equivalence are not benchmarked. |
Before implementation starts, capture the baseline on the same browser build and hardware that will be used for acceptance. Baseline results belong in the benchmark output rather than as hand-copied numbers in this document.
4. Performance targets
These are acceptance targets for a 1280 × 900 Edge viewport after a warm-up run. Phase 0 must record the initial baseline, but the targets must not be loosened merely to make a change pass.
| Scenario | Target |
|---|---|
| One active heavy reaction | drawReactionLayer p95 at or below 6 ms; p99 at or below 8 ms. The 1 ms p95 exception preserves exact transformed emoji rendering after the faster atlas path failed visual comparison. |
| Four simultaneous heavy reactions | Reaction draw p95 at or below 12 ms; p99 at or below 20 ms |
| Normal active-effect cadence | Reaction frame-gap p95 at or below 45 ms, with no more than 30 reaction commits per second |
| Reaction scheduler | 30 visual commits per second, with a 37.5 callbacks/second short-window envelope for the immediate startup timer/RAF callback on 60, 120, or 144 Hz displays |
| Effect plus pan, zoom, or pickup drag | Existing interaction benchmark gates continue to pass; input-to-display p95 must not regress by more than 10% from the pre-change baseline |
| Reaction publication/spawn | Synchronous client setup at or below 2 ms p95 |
| Aurora tick | Callback p95 at or below 0.25 ms; no more than two color writes per second |
| Inactive/hidden Aurora | No query, timer callback, or color write while no Aurora path is visible or while the document is hidden |
| Normal gem collection | Setup p95 at or below 3 ms while retaining the current ten-to-eighteen-particle range |
| Effect cleanup | No effect-owned DOM nodes, animation handles, timers, or reaction cache entries remain after their cleanup deadline |
| Sequential stress | After 100 sequential effects and a settled garbage collection opportunity, retained heap is no more than 2 MB above the settled baseline, excluding the bounded shared asset cache |
| Full inventory/debug inventory open | Initial render at or below 100 ms, no task at or above 50 ms, and no unexpected scroll movement |
| Cosmetic equip/update | In-place update at or below 8 ms p95 without rebuilding the whole inventory |
The benchmark must report results even when a gate fails. It must never enable reduced effects to obtain a passing number.
5. Implementation workstreams
Phase 0 — Measure the real cost first
Add named BEND_PERF measurements around the existing code paths:
reactionFrame: total reaction-layer callback time;reactionStyle.<style>: drawing time for each reaction style;reactionPrepare: one-time immutable model preparation;reactionComposite: final canvas compositing work;auroraTick: Aurora activation check and color update;gemEffectSetupandcompletionEffectSetup;inventoryRenderandinventoryPatch.
Add counters and gauges for:
- active and visible reaction counts;
- reaction frames, deadline-timer callbacks, draw-RAF callbacks, and skipped scheduler opportunities;
- emoji draws, gradient creations, path builds, and canvas pixels cleared;
- prepared-model, glyph-atlas, and static-layer cache hits, misses, size, and evictions;
- active cosmetic DOM particles and pooled nodes;
- Aurora active-path count and color writes;
- inventory nodes created, reused, removed, and images decoded.
Each reaction must keep its style name in the metric, but metrics must not contain player identifiers, emoji text, or other unbounded labels.
Deliverable: a repeatable baseline report for every effect and overlap scenario in Section 7.
Phase 1 — Prepare immutable reaction data once
Move deterministic model construction out of the draw loop and into reaction normalization/application:
- Compute and store the reaction seed once.
- Precompute stable angles, radii, offsets, sizes, rotations, color choices, crack branches, star positions, lifetime-independent trail coefficients, and bloom membership.
- Store normalized duration values and style-specific constants on a compact prepared model.
- Keep time-dependent interpolation, viewport-dependent geometry/bounds, camera transformation, alpha, and compositing in the frame callback.
- Delete the prepared model when its reaction expires or is explicitly removed.
Use typed arrays where they reduce object churn without making the model harder to validate. The prepared values must be generated from the same deterministic inputs so that the result is visually identical at every sampled lifetime.
Expected result: string hashing, most trigonometry, and immutable geometry allocations disappear from active frames.
Phase 2 — Cache expensive drawing assets
Build bounded caches that change how pixels are produced, not which pixels are intended:
- Create an emoji glyph atlas keyed by emoji, rendered size, shadow/glow recipe, device scale, and browser font identity. Draw cached glyphs with
drawImageinstead of repeatingfillText. - Cache
Path2Dobjects for static rings, cracks, star shapes, and burst geometry where coordinates do not change. - Pre-render visually static effect layers to transparent offscreen surfaces. Keep dynamic translation, scale, rotation, alpha, and color stages on the reaction canvas.
- Reuse gradient recipes or pre-rendered gradient textures when their stops and local bounds are unchanged.
- Prewarm the currently equipped effect and its common glyphs during an idle callback after the initial world render.
All caches must:
- have a documented byte or entry bound;
- use least-recently-used eviction;
- expose hit, miss, size, and eviction metrics;
- use a synchronous exact-render fallback on a miss;
- evict only reusable data, never an active effect;
- clear browser-dependent assets when font/device-scale inputs change.
Do not use a low-resolution cache and scale it up. Offscreen surfaces must retain the current effective resolution and compositing behavior.
Expected result: repeated emoji shaping, gradient allocation, and static geometry painting are replaced by bounded image and path reuse.
Phase 3 — Reduce canvas state and pixel work
After Phases 1 and 2 are measured, optimize the reaction layer:
- Replace the current every-display-refresh RAF polling with a timer-to-RAF scheduler: wait until the next 30 FPS deadline is near, then use one animation frame for display-synchronized drawing.
- Derive deadlines from an absolute timeline so timer drift cannot lower the sustained cadence.
- Group compatible draws so font, shadow, blend mode, alpha, and transform state change less often.
- Replace repeated
save()/restore()pairs with explicit state restoration where benchmarks prove it safe. - Reuse paths and temporary arrays instead of allocating them per frame.
- Track the previous and current visual bounds of every reaction.
- Test clearing the union of dirty bounds instead of the full canvas.
- If dirty rectangles cause trails, clipping, blend changes, or edge artifacts in any golden frame, retain the full clear and rely on the other workstreams.
- Composite each prepared offscreen layer once per reaction per frame.
Worker preparation with OffscreenCanvas may be added only for immutable asset preparation. The main thread must retain a visually identical fallback for browsers that do not support the worker path. Do not put input delivery or camera state behind asynchronous worker messages.
Expected result: less state churn and fewer cleared/redrawn pixels, with a safe full-canvas fallback.
Phase 4 — Make Aurora lifecycle-aware and locally scoped
Aurora is implemented as a shop-only line-color contract rather than a separate equip slot:
- Track mounted Aurora paths, connectors, endpoint nodes, and gate nodes as boards render or are removed; do not query the DOM on a color tick.
- Start one scheduler when the first visible Aurora presentation node appears.
- Stop it when the active count reaches zero.
- Suspend it while
document.hiddenis true and resume from a time-correct deadline. - Advance through a curated palette exactly once every 2 seconds.
- Write
--aurora-rgbon the shared world container, not ondocument.body. - Avoid duplicate writes when the selected value is unchanged.
- Apply Aurora to newly drawn paths and to every usable, unsealed gate while it is equipped. Existing non-Aurora paths retain their recorded appearance.
- Repaint mounted boards once when the equipped line color changes so gate membership and saved path styling update immediately.
- Migrate the legacy
lineEffectStyle: "aurora"saved value to the Aurora line-color item ID without discarding the equipped appearance.
Expected result: zero Aurora work when inactive, one curated color selection per two seconds when active, and consistent line/gate presentation.
Phase 5 — Pool completion and gem DOM effects
Preserve the current particle counts, paths, durations, colors, easing, and layering while removing repeated setup work:
- Maintain a pool large enough for the current maximum normal gem burst and any documented concurrent bursts.
- Reset and reuse particle nodes instead of creating and discarding each node.
- Append newly required nodes with one
DocumentFragment. - Reuse immutable keyframe and animation-option templates; fill only the values that differ for a particle.
- Use one owner/controller to track animations and cleanup rather than one unobserved promise chain per particle.
- Pool the completion flash and burst nodes and cancel stale timers before reuse.
- On world reset, navigation, or teardown, cancel animations and return every owned node to the pool.
The pool must be bounded. If concurrency exceeds its size, create the additional nodes required to preserve visuals, then release the overflow nodes after the burst.
Expected result: the same animation with lower node, object, promise, and timer churn.
Phase 6 — Patch cosmetic UI instead of rebuilding it
Keep the current catalog, ordering, card design, category behavior, and item visibility while reducing UI work:
- Give every cosmetic card a stable key based on catalog ID.
- Reuse existing category and card nodes across inventory renders.
- Patch only changed state such as ownership, equipped status, price, and selected styling.
- Preserve the scrolling element and its exact
scrollTopduring every patch. - Apply
content-visibility: autoand an accurate intrinsic-size estimate to offscreen categories/cards. - Lazy-decode flag and thumbnail images near the viewport; cache successfully decoded assets.
- Batch class and text changes before the browser's style/layout phase.
- Keep keyboard order, focus, screen-reader names, and category collapse behavior unchanged.
If the full debug/owned inventory still misses its budget, add accessible windowing as a later step. Windowing must preserve the scrollbar range, focus restoration, category navigation, and exact item visuals; it must not remove discoverable items or unexpectedly move the list.
Expected result: opening a large catalog and equipping an item no longer creates a full-tree rebuild or automatic scroll jump.
Phase 7 — Harden lifecycle and overlap behavior
Ensure optimization state cannot leak or change multiplayer behavior:
- cancel reaction animation frames when no reactions are active;
- remove expired reaction models and cached active surfaces deterministically;
- suspend background visual schedulers while the page is hidden, then resume from authoritative time rather than replaying queued frames;
- clear effect-owned state during world reset and client teardown;
- render all valid overlapping reactions from different players;
- retain the existing server and client rule for each player's concurrent special reaction;
- record an overload gauge when valid overlap exceeds the tested matrix, without dropping or simplifying effects.
No client cache may become a source of gameplay or ownership truth.
6. Priority and delivery order
| Priority | Change | Reason | Dependency |
|---|---|---|---|
| P0 | Effect-specific instrumentation and benchmark matrix | Makes all later gains and regressions visible | None |
| P0 | One-time reaction model preparation | Removes repeated CPU/allocation work with low visual risk | Metrics |
| P0 | Emoji/static-layer/path caches | Targets the most expensive repeated canvas work | Prepared models |
| P0 | Deadline-based 30 FPS scheduler | Avoids polling at 60–144 Hz without changing visible cadence | Scheduler metrics |
| P1 | Aurora lifecycle and scoped variable | Small, isolated change with clear inactive-state benefit | Metrics |
| P1 | Gem/completion node pooling | Removes predictable DOM churn | Metrics |
| P1 | Keyed cosmetic inventory patching | Addresses large catalogs and scroll movement | UI metrics |
| P1 | Canvas state batching | Reduces frame cost after model and asset work are separated | Prepared models and caches |
| P2 | Dirty-rectangle clearing | Can reduce pixel work but has higher artifact risk | Golden-frame coverage |
| P2 | Worker/offscreen preparation | Useful only if main-thread preparation still misses the budget | Stable prepared-model format |
| P2 | Accessible inventory windowing | Use only if keyed patching and content visibility are insufficient | UI benchmark and accessibility tests |
Each row should ship independently where practical. Capture a before/after trace and memory result for every row rather than combining all optimizations into one unreviewable change.
7. Verification matrix
Effect scenarios
Run all of these with reduced effects disabled:
- each reaction style alone:
classic,giant,laser,orbit,firework, andcomet; - four simultaneous heavy reactions from different players;
- eight simultaneous mixed reactions from different players as an overload/recovery test;
- Aurora alone and Aurora while reactions are active;
- a completion burst;
- minimum and maximum normal gem bursts;
- rapid sequential gem and completion effects;
- full owned inventory and debug/all-item inventory;
- cosmetic equip changes while the inventory is scrolled.
Interaction combinations
For each relevant effect scenario, measure:
- idle camera;
- continuous pan;
- continuous zoom;
- pickup drag;
- pickup edge-pan;
- shop/inventory scrolling.
This ensures effect work does not reintroduce the previously observed camera and pickup-display stalls.
Environment matrix
At minimum:
- Edge at 1280 × 900 and device scale 1;
- Edge mobile-size viewport at 390 × 844;
- normal CPU and browser 4× CPU throttling;
- visible document, hidden for the middle of an effect, and resume;
- cold cache and warm cache;
- normal catalog and debug/all-item catalog.
Use one controlled Edge instance at a time and close its temporary profile after the matrix. The test runner must not leave background browser processes or temporary profiles behind.
Visual-equivalence checks
Use a test hook to inject exact normalized lifetime values of 0.10, 0.25, 0.50, 0.75, and 0.95, then capture deterministic reference frames before changing a renderer. Repeat the exact injected-lifetime captures after every visual-path optimization.
Compare:
- particle/glyph count and identity;
- bounds, position, rotation, and scale;
- ring, trail, crack, star, flash, and burst presence;
- color stops, shadow/glow extent, blend order, and alpha;
- start time, total duration, and fade timing;
- layering relative to the world and other reactions.
Pixel differences are acceptable only for demonstrated browser anti-aliasing noise. Use a small per-channel tolerance and require at least 99.5% of pixels within that tolerance. Any structural difference fails even if the aggregate pixel threshold passes.
Functional and cleanup checks
Verify that:
- purchased/equipped styles still resolve to the same renderer;
- local and remote players see the same style and duration;
- overlapping valid reactions are all rendered;
- ownership, store pricing, and equip persistence are unchanged;
- hidden/resumed effects use authoritative elapsed time;
- no unexpected inventory scroll or focus movement occurs;
- all timers, animation frames, animations, pooled overflow nodes, and active models are cleaned up;
- the full test suite and the existing real-browser performance benchmark pass.
8. Planned code and test changes
| File or area | Planned responsibility |
|---|---|
app.js |
Prepared reaction models, bounded caches, effect metrics, reaction lifecycle, Aurora controller, pooled DOM effects, and keyed inventory patching |
style.css |
Narrow Aurora variable scope, content visibility/intrinsic sizing, and any pool reset styles that preserve current appearance |
test/browser-performance-benchmark.js |
Per-style, overlap, interaction-combination, lifecycle, cadence, and memory probes |
test/effects-performance-smoke-test.js |
Source/runtime invariants for cache bounds, cleanup, scheduler caps, overlap behavior, and the no-auto-degradation contract |
| Visual reference fixtures | Deterministic effect checkpoints and comparison metadata for supported Edge rendering |
docs/internal-system.md |
Final architecture and lifecycle after implementation |
docs/test-policy.md |
New effect-performance and visual-equivalence release gates |
realtime-server.js should not require a behavior change for this work. Server-side changes are only justified if additional diagnostics or deterministic test fixtures are needed; reaction validation and authority must remain intact.
9. Risks and safeguards
| Risk | Safeguard |
|---|---|
| Cached emoji differ from direct browser text rendering | Render the atlas with the same browser, font string, shadow recipe, scale, and compositing mode; compare golden frames before enabling it |
| A cache saves CPU but retains too much memory | Enforce a measured bound, expose byte/entry gauges, test eviction, and clear browser-dependent entries on environment changes |
| Dirty rectangles leave trails or clip glow | Include previous and current expanded bounds; immediately retain full clear if any golden or overlap case shows artifacts |
| Offscreen/worker output changes blending | Composite with the same alpha and blend order; keep the direct main-thread path as the correctness reference |
| Pool reuse leaks stale classes/styles | Centralize a complete reset routine and assert the reset state in tests |
| UI reuse introduces stale ownership/equip state | Patch from one normalized view model and test every ownership/equip transition |
| Visibility suspension changes lifetime | Derive life from authoritative timestamps on resume; never replay missed animation frames |
| An optimization accidentally becomes adaptive quality | Test source and runtime invariants that prohibit style substitution, count reduction, duration reduction, and automatic reduced-effects activation |
10. Definition of done
The work is complete only when:
- all current effects and cosmetics are visually unchanged under the checks in Section 7;
- no automatic quality degradation path exists;
- the targets in Section 4 pass in the supported Edge matrix;
- effect work does not regress pan, zoom, cursor, or pickup-drag responsiveness;
- caches, pools, schedulers, and prepared models are bounded and cleaned up;
- the inventory retains its exact scroll position and focus during updates;
- ownership, pricing, persistence, and realtime behavior are unchanged;
- benchmark reports include per-style timings, overlap results, cache statistics, and memory cleanup;
- documentation and release tests reflect the implemented architecture.
Implementation note: exact-output validation rejected transformed emoji atlases, cached laser layers, and dirty-rectangle clearing, so those paths retain direct rendering and full-canvas clearing. The accepted implementation uses immutable reaction models, absolute-deadline scheduling, batched direct text state, a bounded transform-neutral glyph cache, a bounded fixed-geometry Path2D cache for Orbit and Firework, scoped Aurora updates, bounded DOM pools with shared Gem animation templates, and signature-based localized inventory patches. Worker preparation and inventory windowing remain conditional only and are not enabled because the synchronous path and keyed catalog remain the authoritative visual/accessibility implementation. The browser gate loads deterministic checkpoints from test/fixtures/effect-visual-checkpoints.json, covers setup cost, active/inactive lifecycle, effect-plus-interaction cases, focus/scroll retention, and 100-cycle cleanup, and writes a machine-readable report. Normal-speed timing gates remain authoritative; the 4x CPU matrix is an overload/completeness diagnostic because this plan explicitly prefers full visuals to automatic degradation.
11. Explicit non-goals
This plan does not:
- redesign, retire, or simplify an effect;
- reduce the scheduler below 30 FPS;
- introduce automatic adaptive quality;
- change the number or duration of visible elements;
- change store selection, price, ownership, or persistence rules;
- change reaction rate limits or multiplayer authority;
- use lower-quality visuals as the definition of a performance fix.