Files
skills/docs/gaming/games/controller/src/merger/merger.md
2026-05-29 08:20:56 +01:00

6.3 KiB

merger — ControllerMerger

(no submodules)

Purpose

ControllerMerger fuses an ordered list of BaseController children into a single virtual controller whose ControllerInput snapshot is the per-field maximum of every attached child. It is the layer that lets a game treat keyboard, gamepad, touch, and remote network controllers as one unified action source without re-binding consumers when a player adds or removes a device mid-session.

The merger extends BaseController so it transparently inherits the typed InputEventEmitter, threshold/deadzone normalisation, OpenTelemetry counters, and lifecycle guards. Children are observed through their public onAnyInput and onCombo subscriptions only — the merger never peeks at protected state, so any subclass of BaseController (current or future) plugs in without an adapter shim.

Features

  • Per-field Math.max reduction across N children, walked in stable insertion order over the canonical CONTROLLER_INPUT_KEYS.
  • Subscription-based wiring — child state-change events are funnelled into per-child snapshots, then merged on update().
  • Combo events from any child are re-emitted through the merger's own eventEmitter so consumers only listen in one place.
  • Duplicate-detection by both instance identity and child.id, raising ControllerError(CONTROLLER.DUPLICATE_CHILD) early.
  • removeController() validates membership and throws ControllerError(CONTROLLER.UNKNOWN_CHILD) for stale references.
  • Lifecycle propagation — destroy() disposes every subscription and destroys every child before clearing local state.
  • pollRate gating inherited from BaseController, driven by the same injected Clock so tests can use a FakeClock without globals.

Usage: Basic

import {
    BaseController,
    ControllerMerger,
} from "@suaveplan/controller";

// Two concrete children produced by Chunk B's adapters.
declare const keyboard: BaseController;
declare const gamepad: BaseController;

const merger = new ControllerMerger({ children: [keyboard, gamepad] });

merger.onAnyInput((state) => {
    // Single subscription regardless of how many devices are connected.
    render(state);
});

// Drive every tick of the game loop.
function tick(): void {
    merger.update();
    requestAnimationFrame(tick);
}

Usage: Advanced

import {
    BaseController,
    CONTROLLER_CODES,
    ControllerError,
    ControllerMerger,
} from "@suaveplan/controller";
import type { Clock, IdGenerator } from "@suaveplan/types";

const fakeClock: Clock = (() => {
    let t = 0;
    return () => (t += 16);
})();
let n = 0;
const ids: IdGenerator = () => `m-${++n}`;

const merger = new ControllerMerger({
    clock: fakeClock,
    idGenerator: ids,
    pollRate: 120,
    strict: true,
});

declare const padA: BaseController;
declare const padB: BaseController;
merger.addController(padA);

try {
    merger.addController(padA);
} catch (err) {
    if (
        err instanceof ControllerError &&
        err.code === CONTROLLER_CODES.DUPLICATE_CHILD
    ) {
        console.warn("already attached:", err.context);
    }
}

merger.addController(padB);
merger.setThreshold("lx", { deadzone: 8, threshold: 32 });
merger.update();

merger.removeController(padA);
merger.destroy(); // destroys padB as well

API Reference

  • class ControllerMerger extends BaseController
    • constructor(options?: ControllerMergerOptions) — accepts every BaseControllerOptions field plus children?: readonly BaseController[] seeded eagerly via addController().
    • addController(child: BaseController): void — subscribes to onAnyInput + onCombo, captures the current snapshot, marks the merger dirty. Throws CONTROLLER.DUPLICATE_CHILD on identity or id collision.
    • removeController(child: BaseController): void — disposes subscriptions, drops the snapshot, marks dirty. Throws CONTROLLER.UNKNOWN_CHILD when the child is not attached.
    • getChildren(): MergerChildren — read-only snapshot of children in insertion order.
    • get childCount(): number — current attachment count.
    • update(): void — polls every child, then runs the per-field max reduction over CONTROLLER_INPUT_KEYS and routes diffs through updateInput so threshold/deadzone gating + event emission stay consistent.
    • destroy(): void — disposes all subscriptions, destroys every child, clears local state, then defers to BaseController.destroy.
  • interface ControllerMergerOptions extends BaseControllerOptions
    • children?: readonly BaseController[] — initial seed.
  • type MergerChildren = readonly BaseController[]

Implementation Notes

The merger captures child snapshots eagerly on subscription so the first update() after construction reflects whatever state the child already has, even if no further input events fire between addController and update. Snapshots are stored keyed by child.id rather than by identity reference so the lookup remains O(1) and the duplicate guard shares the same key space.

update() is overridden in full rather than using the base updateState() template. The base update() short-circuits when needsUpdate is false, which would skip child polling entirely — a visible bug in a tick-driven game loop where children must always be polled regardless of whether the merger itself observed prior changes. The override polls children first, then applies the same pollRate gating the base class would have applied before mutating local state.

Per-field reduction uses Math.max because every input value lives in [0, 255] and the desired semantic is "any source wins" — a pressed button on the keyboard plus a released button on the gamepad should read as pressed, and a partially deflected stick on one pad should not be cancelled by a centred stick on another. This matches the upstream contract and avoids subtle precedence rules between devices.

destroy() propagates to children because the merger owns the wiring; leaving children alive after the merger goes away leaves dangling subscriptions on the children's emitters and is the failure mode removed-without-destroy would otherwise create. Callers that need independent child lifecycles call removeController() before destroy().