Control contexts
State-machine wrappers that group ECS entities under a named state and activate or deactivate them as one unit when the state is entered or left.
A ControlContext groups the entities that belong to one logical mode -
on-foot, in-vehicle, in-menu, cutscene - and activates or deactivates those
entities atomically when the game transitions between modes. A StatefulController
ties one or more contexts to a SimpleStateMachine so that entering a state starts
its context and leaving it shuts it down.
The problem
Input bindings, cameras, UI overlays, and other mode-specific entities are easy to
build but awkward to manage. Without a coordination layer you end up with ad-hoc
flags, mixed teardown code, and entities that forget to unlink when the player
climbs into a vehicle. ControlContext formalizes the lifecycle so activation and
deactivation happen as one step via the state machine.
ControlContext
ControlContext is a base class. Subclass it and set up your entities in
initialize:
import { ControlContext } from "@woosh/meep-engine/src/engine/control/ControlContext.js";
import { InputMap } from "@woosh/meep-engine/src/engine/input/ecs/ism/map/InputMap.js";
import { InputTriggerKey } from "@woosh/meep-engine/src/engine/input/ecs/ism/trigger/InputTriggerKey.js";
class OnFootContext extends ControlContext {
initialize(ecd) {
super.initialize(ecd);
const map = new InputMap();
map.bind("jump", InputTriggerKey.from("space"));
// ... the rest of the on-foot bindings ...
// makeEntity() registers the entity with the context.
// It will be built on startup() and destroyed on shutdown().
this.makeEntity()
.add(map)
.add(SerializationMetadata.Transient);
}
}
The lifecycle is:
| Method | Trigger | What it does |
|---|---|---|
initialize(ecd) | Called once before first use | Creates entity builders via makeEntity(); transitions state to Ready |
startup() | Called when its state is entered | Calls .build(ecd) on every registered entity; transitions to Running |
shutdown() | Called when its state is left | Calls .destroy() on every built entity; transitions back to Ready |
dispose() | Called when the context is no longer needed | Cleans up; transitions to Disposed |
States are Initial → Ready → Running → Ready → Disposed. Calling startup() or
shutdown() twice is safe (idempotent). The transientEntities flag (default
true) marks every managed entity as SerializationMetadata.Transient
automatically.
ControlContextState
The four states are integers from the ControlContextState enum:
import { ControlContextState } from "@woosh/meep-engine/src/engine/control/ControlContextState.js";
// ControlContextState.Initial = 0
// ControlContextState.Ready = 1
// ControlContextState.Running = 2
// ControlContextState.Disposed = 3
Read the current state with context.getState().
StatefulController
StatefulController binds contexts to a SimpleStateMachine so that state
transitions wire context activation automatically.
import { StatefulController } from "@woosh/meep-engine/src/engine/control/StatefulController.js";
import { SimpleStateMachine } from "@woosh/meep-engine/src/core/fsm/simple/SimpleStateMachine.js";
import { SimpleStateMachineDescription } from "@woosh/meep-engine/src/core/fsm/simple/SimpleStateMachineDescription.js";
const desc = new SimpleStateMachineDescription();
const STATE_ON_FOOT = desc.createState();
const STATE_VEHICLE = desc.createState();
desc.createEdge(STATE_ON_FOOT, STATE_VEHICLE);
desc.createEdge(STATE_VEHICLE, STATE_ON_FOOT);
const sm = new SimpleStateMachine(desc);
const controller = new StatefulController(sm);
const onFootCtx = new OnFootContext();
const vehicleCtx = new VehicleContext();
// Initialize before binding so makeEntity() has ecd access.
onFootCtx.initialize(ecd);
vehicleCtx.initialize(ecd);
// Register: entering STATE_ON_FOOT calls onFootCtx.startup(),
// leaving it calls onFootCtx.shutdown().
controller.addContext(STATE_ON_FOOT, onFootCtx);
controller.addContext(STATE_VEHICLE, vehicleCtx);
// Drive the machine.
sm.setState(STATE_ON_FOOT); // starts on-foot context
sm.setState(STATE_VEHICLE); // shuts down on-foot, starts vehicle context
removeContext reverses the binding if a context needs to be detached at runtime.
SimpleStateMachine and SimpleStateMachineDescription
The underlying state machine is generic and not specific to control. Its description defines the graph; the machine traverses it.
const desc = new SimpleStateMachineDescription();
// States are plain non-negative integers allocated by the description.
const stateA = desc.createState(); // returns e.g. 0
const stateB = desc.createState(); // returns e.g. 1
// Edges define legal transitions.
desc.createEdge(stateA, stateB);
desc.createEdge(stateB, stateA);
// Optionally assign a selector function that picks the next state given input.
desc.setAction(stateA, (input) => input.wantsVehicle ? stateB : stateA);
const sm = new SimpleStateMachine(desc);
// Explicit transition.
sm.setState(stateB);
// Advance - calls the selector for the current state and transitions.
sm.advance(myInput);
// Shortest-path navigation (BFS over the edge graph).
sm.navigateTo(stateB);
Entry and exit handlers can be registered directly on the machine without going
through StatefulController:
sm.addEventHandlerStateEntry(stateA, () => console.log("entered A"));
sm.addEventHandlerStateExit(stateA, () => console.log("left A"));
Relationship to input bindings
A control context is the clean way to manage the lifetime of an input entity.
Both binding components go live on link and stop on unlink, so a context that
owns the entity needs no enable/disable bookkeeping of its own: create the entity
inside initialize with makeEntity(), and the context builds it on startup()
and destroys it on shutdown(). Bindings activate and deactivate exactly when
the state changes.
Which component goes on the entity depends on what the mode binds:
InputMapfor keyboard keys, pointer buttons and gamepad buttons, and for anything needing a chord, a sequence, a hold or an analog channel. A context’s map can also setorderandocclusionso an in-menu context takes keys away from the on-foot context underneath rather than racing it.InputControllerfor pointer gestures -tap,drag,dragStart,dragEnd,move,pinch. It is not deprecated, and it is the only way to bind those:PointerDevicesynthesizes them on top of its buttons and the binding layer does not model them. A context that needs both puts both components on the entity.
The transientEntities default is worth keeping on for input entities. A map
built by a controller rather than authored into a scene should not end up in a
save file; makeEntity() marks it SerializationMetadata.Transient for you.
See Input devices for both APIs.