Screen shake, particle bursts, chromatic aberration - the "juice" that makes a game feel alive is the sensory layer designers chase hardest. For players with vestibular sensitivity, ADHD, or migraine triggers, that same layer is what makes a game unplayable.
Game accessibility usually gets bolted on as a settings toggle: a global reducedMotion boolean that flips somewhere in a preferences screen, and a hope that every particle system, camera shake, and postfx pass remembers to check it. That check never gets remembered everywhere. Five effects ship clean, and the sixth one - added three sprints later, by someone who never read the settings doc - ignores the flag entirely. The toggle exists. It does almost nothing.
In my React Native engine, reduced motion is not a settings-screen afterthought. It is a first-class mechanic: one manager owns a single source of truth, a duck-typed bridge pushes a derived motion scale into every subscribed system, and two pure functions encode the actual design decision - kill the non-essential motion outright, or keep it but soften it. That is the thesis this post defends, and it ships with an honest limit built in: this is a policy layer, not enforcement.
Game accessibility: the toggle that does nothing
The naive fix looks reasonable on a whiteboard. Add a reducedMotion flag to app settings. Sprinkle if (settings.reducedMotion) return at every shake call, every particle burst, every chromatic-aberration pass. Ship it.
It fails for a boring reason: there is no single place that check lives. It lives at every call site, independently, written by whoever touched that system that week. Miss one - a new boss adds its own screen-shake call, a postfx pass gets refactored and the guard gets dropped in the diff - and the player who turned the setting on still gets slammed with the exact motion they opted out of. The bug is invisible in code review, because the missing line isn't there to review.
This is not a hypothetical concern. Xbox's Accessibility Guideline 117 names camera shake, camera bobbing, and motion blur explicitly as barriers - not implementation details, but named failure modes in Microsoft's published best-practice guidance for developers. That guidance has already moved past "add a checkbox" toward per-effect granularity: Halo Infinite sets radial blur, screen shake, full-screen effects, and speed lines on independent 0-100% scales. What it does not specify is how an engine actually delivers that granularity without the scattered-guard problem. That gap is what this engine's @flare-engine/a11y package targets: a single, tiny (269 LOC, 47 tests), tier-1 package with exactly two dependencies: the engine's event bus, and its math package for OKLab color.
One source of truth: the A11yManager
The fix for "the check lives everywhere" is: it doesn't. One A11yManager owns the settings object, and everything downstream reads from it instead of re-deriving its own opinion.
// packages/a11y/src/manager.ts
export interface A11ySettings {
/** When true, animations and heavy postfx should be minimized. */
reducedMotion: boolean;
/** Current contrast preference - UI tokens swap accordingly. */
contrast: ContrastLevel;
/** Global UI scale multiplier (`1` = default). */
uiScale: number;
}Three axes, not one. reducedMotion is this post's subject; contrast and uiScale are named here because they live in the same manager, but they are a separate story (contrast gets its own section under Limits). A11yManager.update() is idempotent - setting reducedMotion to the value it already holds doesn't fire a change event, so downstream systems never re-run work for a no-op toggle. An onChange: Signal<A11ySettings> is the one hook everything else attaches to.
The manager owns no platform wiring. It does not read AccessibilityInfo.isReduceMotionEnabled() itself, and it never will - that would mean every consumer of @flare-engine/a11y pulls in a React Native dependency, even the ones running headless in a test or on a web canvas. The host app is responsible for bridging the OS preference in, typically once, at startup:
// pseudocode - host app responsibility, not engine code
const reduced = await AccessibilityInfo.isReduceMotionEnabled();
a11yManager.setReducedMotion(reduced);
AccessibilityInfo.addEventListener('reduceMotionChanged', (v) =>
a11yManager.setReducedMotion(v),
);That's the whole platform-integration contract: one function call in, on boot and on change. Everything after that point is plain TypeScript.
Kill or soften: two pure functions
The manager is plumbing. The actual design decision - the thing worth a blog post - lives in two pure functions in a 29-line file.
// packages/a11y/src/helpers.ts
/**
* Strength multiplier used by animation + postfx systems. Caller chooses a
* base intensity; this function clamps it for reduced-motion users.
*/
export function motionScale(settings: { reducedMotion: boolean }, base = 1): number {
return settings.reducedMotion ? 0 : base;
}
/**
* Returns a second, dampened intensity. Useful for effects you want to keep
* but soften (camera shake, chromatic aberration). `reducedFactor` controls
* how much survives when reduced-motion is on (default 20%).
*/
export function dampenedMotion(
settings: { reducedMotion: boolean },
base: number,
reducedFactor = 0.2,
): number {
return settings.reducedMotion ? base * reducedFactor : base;
}motionScale is the blunt instrument: reduced motion on means zero, full stop. Use it for anything genuinely non-essential - decorative particle drift, a screen-wide flash, an idle-animation wobble. Nothing about the game is lost if it disappears entirely.
dampenedMotion is the more interesting function, because it refuses the false choice between "keep everything" and "kill everything." Camera shake on a boss hit, or the chromatic-aberration pulse on a critical parry, carries part of the game's identity - remove it completely and the game feels flat, not accessible. dampenedMotion keeps 20% of the base intensity by default (reducedFactor = 0.2), tunable per call site. The player still feels the hit register. They just don't get slammed by it.
That kill-versus-dampen split, not the boolean, is the real content of this package. A single if (reducedMotion) guard is one line; deciding which effects deserve motionScale's zero and which deserve dampenedMotion's 20% is the design work.
The bridge: no effect site hard-codes the check
Two pure functions solve nothing on their own if every camera and every postfx pass still has to remember to call them. The bridge is the piece that removes that remembering. It duck-types a minimal contract:
// packages/a11y/src/bridge.ts
/** Minimal structural contract. `Camera2D` and `PostFxController` both match. */
export interface MotionScaleTarget {
setMotionScale(k: number): void;
}Any object with a setMotionScale(k: number): void method qualifies - no interface to implement, no import of @flare-engine/a11y required on the consumer's side. That's deliberate: Camera2D and PostFxController both match this shape without ever depending on the a11y package. createA11yBridge() is what pushes the current motion scale into every attached target, on construction and on every subsequent change:
// packages/a11y/src/bridge.ts
export function createA11yBridge(options: A11yBridgeOptions = {}): A11yBridge {
const manager = options.manager ?? new A11yManager({ initial: options.initial });
function attach(target: MotionScaleTarget): () => void {
const apply = (): void => {
target.setMotionScale(motionScale(manager.settings));
};
apply();
manager.onChange.add(apply);
return () => manager.onChange.remove(apply);
}
return {
manager,
attach,
attachCamera: attach,
attachPostfx: attach,
};
}attach() runs apply() immediately (so a newly-created camera picks up whatever the current setting is, not just future changes), subscribes to onChange, and returns an unsubscribe function. attachCamera and attachPostfx are the same function under two names - the bridge doesn't care what kind of target it's pushing into, only that the target exposes setMotionScale.
The dangling node at the bottom is deliberate, not an afterthought - it is the diagram's honesty caveat, and it earns its own section below.
The payoff shows up at the real consumer. Camera2D never imports @flare-engine/a11y. It just exposes setMotionScale, and the bridge does the rest:
// packages/render/src/camera2d.ts
/**
* Global motion multiplier applied to subsequent `shake()` calls. Scales
* both amplitude and duration. Default 1 (no-op). 0 fully suppresses
* shake. Intended to be driven by `@flare-engine/a11y` reduced-motion so
* callers don't have to guard every shake site.
*
* Only affects future `shake()` calls - does not retroactively modify an
* in-flight shake.
*
* Clamped to `>= 0`.
*/
setMotionScale(scale: number): void {
this._motionScale = scale < 0 ? 0 : scale;
}
/** Current motion scale (default 1). */
get motionScale(): number {
return this._motionScale;
}
/** Start camera shake. */
shake(intensity: number, duration: number): void {
const k = this._motionScale;
if (k <= 0) {
this._shake = null;
return;
}
this._shake = {
intensity: intensity * k,
duration: duration * k,
elapsed: 0,
offsetX: 0,
offsetY: 0,
};
this._shakeSeed++;
}shake() reads k once, at call time. If k <= 0, the shake never starts - no half-built shake object gets created, no guard clause has to be repeated at every place shake() is called elsewhere in the codebase. That's the same "don't pay the cost, don't branch on the hot path" discipline I wrote about in zero-alloc game loops: one guard, once, at the one place that matters. PostFxController.setMotionScale follows the identical shape, scaling chain.setGlobalIntensity(k) instead of a shake amplitude - same contract, same bridge, different system.
Limits
The strongest trust signal a post like this can give is naming exactly where the design stops covering you.
Policy, not enforcement. A system honors reduced motion only if it reads motionScale/dampenedMotion or attaches to the bridge. Nothing forces a rogue effect to comply, and there is no automated "every system complied" test - the two real consumers (Camera2D, PostFxController) opted in because they were built to; a third-party plugin or a hastily-added new effect could ignore the bridge entirely and nothing in this package would catch it. This is the load-bearing honesty caveat, and it's the reason the mermaid diagram above ends with a dangling "not covered" node instead of a clean loop back to "done."
Single boolean, not per-category. reducedMotion is one flag. iOS Accessibility actually exposes finer categories - prefer-cross-fade, dim-flashing-lights, and others - and the host app has to map those down to this one boolean. The engine deliberately doesn't model the finer granularity; that's a host-side decision, not an engine one.
No platform wiring in the engine. Worth restating: the manager and bridge own zero OS integration. The host app bridges AccessibilityInfo.isReduceMotionEnabled() in. This package provides plumbing, not detection.
Contrast is a separate axis. A11ySettings.contrast and the audit.ts module are the sibling half of the same package - a WCAG contrast checker, not a motion-dampening one:
// packages/a11y/src/audit.ts
export function wcagLevel(ratio: number, largeText = false): WcagLevel {
if (largeText) {
if (ratio >= 4.5) return 'AAA';
if (ratio >= 3.0) return 'AA';
return 'fail';
}
if (ratio >= 7.0) return 'AAA';
if (ratio >= 4.5) return 'AA';
return 'fail';
}wcagLevel grades a contrast ratio against WCAG 2.x thresholds; the package also carries an OKLab deltaE perceptual-distance check for "can a player actually tell these two colors apart," independent of raw contrast ratio. Landed 2026-07-01, it's a real, tested part of the package (82 LOC of the 269 total) - but it is not deep-dived here, and it does not solve everything either: Skia-rendered text can still be invisible to a screen reader, and that gap isn't addressed by either half of this package.
Per-suite test attribution. The a11y package's 47 tests break down as audit 30, bridge 6, helpers 6, manager 5. That is this package's own count - a subset of the engine's whole-repo suite rather than a figure to add on top of it, and not part of Pan Tvardowski's separate test suite.
Restricted registry, Apache-2.0 now. The engine relicensed from MIT to Apache-2.0 on 2026-07-12. That's the license today - not a future promise. What's still pending is the public npm flip, targeted for October 2026; until then @flare-engine/* publishes to a restricted, private registry. Nothing here is "open source on npm" yet.
The ethics of the 20 percent
Game juice - screen shake, particle density, chromatic aberration - is tuned for immediate visceral payoff. In Yu-kai Chou's Octalysis terms that is Core Drive 3, Empowerment of Creativity and Feedback: the instant, legible response to a player's action. That pull is a real part of why games feel good to play. But its intensity is calibrated for the median player's nervous system, and nothing in the framework says whose nervous system that ought to be.
dampenedMotion's surviving 20% is where that gets decided. Killing every effect outright (motionScale's zero) satisfies the letter of "reduced motion" while quietly deleting the feel the designer built. Keeping 20% says: your preference is respected, and the game still feels like the game. The mechanism is the engine's job. Deciding where the line sits between essential feel and skippable flourish is the designer's - the engine just makes sure that decision, once made, actually reaches every player who asked for it.
Close
Reduced motion belongs in the engine's plumbing, not in a settings-menu checkbox that nothing downstream reads. One manager owns the setting, a duck-typed bridge pushes it to every system that opts in, and two pure functions - kill or soften - encode the only design decision that matters. The honesty is part of the design: this is a policy layer that covers what attaches to it, not a guarantee that covers everything.
Nothing about A11yManager or the MotionScaleTarget contract is game-specific. Any React Native app that animates - a Reanimated shared value, a Lottie playback speed, a screen-transition duration - can implement the same setMotionScale(k) shape or read motionScale(settings) directly, and subscribe to the same onChange signal. The pattern that keeps a game's camera shake honest is the same pattern that keeps a checkout-flow's page transitions honest. If you're building a reduced-motion toggle for a non-game RN app and it currently flips a flag nothing reads, this is the fix: one source of truth, one bridge, systems that opt in instead of hard-coding a check.
The stack this package lives in is the same 40-package Turborepo/Bun/Biome monorepo covered in the Turborepo, Bun, and Biome stack behind a 40-package monorepo - @flare-engine/a11y is one tier-1 leaf of that graph, depending on nothing but the event bus and the math package.