Gameplay

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:

MethodTriggerWhat it does
initialize(ecd)Called once before first useCreates entity builders via makeEntity(); transitions state to Ready
startup()Called when its state is enteredCalls .build(ecd) on every registered entity; transitions to Running
shutdown()Called when its state is leftCalls .destroy() on every built entity; transitions back to Ready
dispose()Called when the context is no longer neededCleans 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.

initialize(ecd)startup()shutdown()dispose()InitialReadyRunningDisposed

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:

  • InputMap for 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 set order and occlusion so an in-menu context takes keys away from the on-foot context underneath rather than racing it.
  • InputController for pointer gestures - tap, drag, dragStart, dragEnd, move, pinch. It is not deprecated, and it is the only way to bind those: PointerDevice synthesizes 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.