Skip to content
CppCooperProgramming Portfolio

Article

From input polls to controllers and editable menus in Cheryl Engine 4

ArchitectureGames

A keyboard key can move an actor, edit a name, or operate a menu. Each use needs a different view of the same observations. A controller wants semantic actions. A text field needs committed characters and editing keys in order. Both need a clear answer about which owner may inspect the native device.

In Cheryl Engine 4, the platform collects input and publishes immutable polls. Simulation consumes those polls through its tick. Capture requests, keyboard routing and action contexts determine what crosses that boundary. The lower-level Gainput mapping work supplies part of the native foundation; this article starts where controls become application input.

One poll, several useful views

The GLFW adapter begins a poll before pumping native events. At that boundary, Engine copies the current keyboard-focus request, latches the requested capture channels and applies pending action-context changes. Callbacks then record observations and update physical state. Publication joins semantic state and ordered records in one complete poll. The shared input implementation applies the same focus snapshot to state gating and record routing.

State is always available. The optional Events channel retains physical records, including key repeats, pointer positions and scroll. Text retains committed Unicode scalars from the operating system. A key identity describes a control; it cannot supply the character produced by a keyboard layout. When both channels are active, text and physical editing controls share one ordered record stream.

Simulation may consume several completed polls in one update. Suppose a button is pressed in one poll and released in the next, before simulation runs. Its final held() state is false, while pressed() and released() are both true. Counts preserve multiple transitions. Relative axes sum their deltas; absolute axes retain their final value. The tick contract makes those choices explicit.

Observed input time and simulation time also remain separate. A fixed update can have a selected simulation delta that differs from the elapsed observation interval. TickContext::button_simulation_seconds() offers a derived policy for direct actions: apply the observed down-time fraction to that simulation delta. It cannot recover events the backend never observed.

A controller owns its action namespace

Game-wide bindings can live for the whole runtime. A controller that joins or leaves during play needs a smaller lifetime. The consumer retains a move-only context lease for an owned definition. Local action IDs can be reused by another controller without sharing state.

The following fragment follows the runtime-consumer example. The game saves the keyboard identity and submission endpoint during platform initialization. The retained endpoint can then accept this owned definition from simulation. Here controls_ is a retained std::optional<CE::Input::Actions::Context> game member:

CE::Input::Actions::ContextDefinition definition;
definition.buttons.push_back({
    CE::Input::Actions::Chord{{{keyboard_, CE::Input::KeyboardKey::Left}}},
    left_
});
controls_.emplace(context_requests_->attach(std::move(definition)));

A later update reads that retained context after a poll has applied its mappings:

const auto actions = tick.input.actions(*controls_);
const bool moving_left = actions.button(left_).held();

Attachment accepts a request for the next poll boundary; owner-side application or publication can still fail. Resetting the lease requests removal at a later poll, without changing an older tick. Attachment seeds an existing physical hold, while previous relative motion stays in the past. This avoids native binding mutation from the simulation worker, but the application must allow for the boundary between requesting and observing a change. Context membership establishes a namespace and lifetime; keyboard focus remains a separate decision.

An editable menu makes routing visible

Consider a settings menu with a player-name field and Escape assigned to quit gameplay. An RmlUi session lives on the owner that updates the UI. Its constructor requests Events. When the field starts editing, request_keyboard_focus() requests Text and publishes an exclusive focus lease. The session implementation checks both the recipient target and focus epoch before feeding keyboard records to RmlUi.

Exclusive focus gates keyboard participation in gameplay actions at the next poll. Mouse and controller policy still belongs to the application. Capture only asks for records to exist; it grants no exclusive queue. Each consumer can inspect the same immutable batch, and selecting records never drains them for another consumer.

The target identifies the recipient. The epoch identifies this particular focus request, including a later request that reuses the same target. Releasing a superseded lease cannot clear a newer request. Already collected records keep their original target and epoch; moving focus during a batch does not send earlier text to a different recipient. Releasing current focus clears ownership. Restoring an earlier recipient requires another request.

If the game opens this menu during an update, the current tick still contains input published before that request. The game must use its new menu state when deciding whether to update gameplay in that same tick. The next poll applies the routing request.

Escape introduces a less obvious case. Releasing UI focus while Escape remains physically held can make the next poll publish a gameplay press. The GUI tutorial keeps a guard until that physical key is released. With an existing session and ignore_quit_until_release_ member, its update begins with:

if (tick.input.button(quit_).pressed() && !ignore_quit_until_release_) {
    tick.request_stop();
    return;
}

It then feeds the whole record batch. This shortened version keeps the tutorial’s Escape handling:

ui_->handle_input(tick.input.records(), true,
    [&](const CE::Input::Events::Record& record) {
        const auto* button =
            std::get_if<CE::Input::Events::Button>(&record.data);
        if (button &&
            record.device_kind == CE::Input::DeviceKind::Keyboard &&
            button->key == CE::Input::KeyboardKey::Escape) {
            if (button->phase == CE::Input::Events::ButtonPhase::Press &&
                ui_->owns_keyboard_focus()) {
                ignore_quit_until_release_ = true;
                ui_->release_keyboard_focus();
            } else if (button->phase ==
                       CE::Input::Events::ButtonPhase::Release) {
                ignore_quit_until_release_ = false;
            }
        }
        return true;
    });

The quit check stays before record handling so the physical release cannot clear the guard before that update checks its accumulated semantic press. The true selects pointer delivery for this interface; keyboard focus does not perform pointer hit testing. The full example also restores editing with F2 and applies the field’s value to game state.

Keep ownership through consumption

The update borrows its tick. Action views and record spans must stay within that tick’s lifetime, while copied states or retained snapshot handles can outlive it. UI callbacks run during UI consumption, away from the native callback stack. The runtime lifecycle supplies those owners; later UI drawing crosses the prepared-frame boundary as owned rendering data.

The current native path supplies committed text and editing controls. Composition/preedit and clipboard integration require additional work. The routing contract defines where delivered observations belong; application policy still decides when a menu opens, whether gameplay pauses and how pointer input is shared.

Cheryl Engine 4 reading path

  1. Runtime ownership and lifecycle
  2. Prepared frames and rendering handoff
  3. Retained resources and cleanup
  4. Input routing and editable menus (this article)
  5. Unicode layout and retained text
  6. Optional modules and the installed SDK