Create a PlayerController

A PlayerController turns a player’s scoped input actions into an actor command. Start with First Game and the actor-controller command contract, then build this controller before the Character. The maintained tutorial game implements the complete sequence.

The application defines what a command means. Cheryl supplies action contexts, copied tick input and lifecycle boundaries; it does not require a particular controller class or inheritance hierarchy.

Once their contexts have been published by a poll, separate instances can read the same local IDs without sharing action state:

        flowchart TB
    accTitle: Player controllers have separate action namespaces
    accDescr: Two player controllers query their own contexts, each using local action IDs one and two. Each produces its own CharacterCommand value.
    first["Context A: actions 1 and 2"] -->|tick view| player_a["PlayerController A"]
    second["Context B: actions 1 and 2"] -->|tick view| player_b["PlayerController B"]
    player_a --> command_a["Command for actor A"]
    player_b --> command_b["Command for actor B"]
    

Define a command

Use character-command.h from Actor controllers and commands. Its horizontal value requests left, right, or no movement. The Character applies speed and elapsed time; an AIController will produce the same command from game observations.

Translate a player’s actions

Create player-controller.h:

#pragma once

#include "character-command.h"

#include <cheryl/core/controls/action-context.h>
#include <cheryl/core/controls/button-codes.h>
#include <cheryl/core/controls/tick-input.h>

#include <utility>

namespace Tutorial {
    class PlayerController final {
        static constexpr CE::Input::Actions::Id left_{1};
        static constexpr CE::Input::Actions::Id right_{2};
        CE::Input::Actions::Context input_;

    public:
        explicit PlayerController(CE::Input::Actions::Context input)
        : input_(std::move(input)) {}

        [[nodiscard]] static CE::Input::Actions::ContextDefinition keyboard(
            CE::Input::DeviceId device,
            CE::Input::KeyboardKey left = CE::Input::KeyboardKey::Left,
            CE::Input::KeyboardKey right = CE::Input::KeyboardKey::Right
        ) {
            CE::Input::Actions::ContextDefinition definition;
            definition.buttons.push_back({{{{device, left}}}, left_});
            definition.buttons.push_back({{{{device, right}}}, right_});
            return definition;
        }

        [[nodiscard]] CharacterCommand read(const CE::Input::Tick& tick) const {
            const auto actions = tick.actions(input_);
            return {(actions.button(right_).held() ? 1.0f : 0.0f) - (actions.button(left_).held() ? 1.0f : 0.0f)};
        }
    };
}

keyboard() creates a binding definition from a saved device identity and chosen controls. It performs no native device query. Every controller uses local action IDs 1 and 2; tick.actions(input_) selects that controller’s own action namespace. Another player may reuse those IDs with different keys without sharing its action state or colliding with First Game’s direct quit binding.

read() combines the held states into a command. Both directions held cancel. It does not move a sprite or draw; those are the Character’s responsibilities.

Initialize the first controller

In the game header, include player-controller.h, <cheryl/core/controls/input-interface.h> and <optional>. Retain these members:

CE::Input::DeviceId keyboard_ = 0;
std::optional<CE::Input::Actions::ContextSubmission> player_input_;
std::optional<Tutorial::PlayerController> controller_;

Keep First Game’s input/binding references, native keyboard acquisition, and game-wide quit/Space registrations. Remove only its direct left/right binding registrations. After the existing const auto keyboard = input.keyboard_id(); line in platform-owned init_platform(), insert:

keyboard_ = keyboard;
player_input_.emplace(input.action_contexts());
controller_.emplace(player_input_->attach(Tutorial::PlayerController::keyboard(keyboard_)));

Acquire the endpoint and native device ID on platform while input is initialized. Retaining them lets simulation request a later player without calling keyboard_id() or changing the platform-owned binding collection itself. attach() validates and accepts an owned definition; installation happens at the next input poll. Until that poll publishes the context, its tick view is empty.

Read during update

After quit handling in update(), obtain the command from copied tick input:

[[maybe_unused]] const auto command = controller_->read(tick.input);

At this intermediate checkpoint, inspect command.horizontal in a debugger: left yields -1, right yields 1, and neither or both yields 0, after the attachment’s first input poll. [[maybe_unused]] permits that inspection before the next lesson consumes the value. The old Actor will not respond to the removed direct movement bindings; the Character lesson makes this command visible as movement.

The Character tutorial applies this command. Keep control translation in the controller so character movement can also accept a patrol or other application-selected source.

This player controller needs no work in frame preparation. Read it during simulation updates, then let frame preparation copy the Character’s resulting visible state. Input capture, routed keyboard focus and controller action namespaces are separate policies; see input contexts.

Join, leave and clean up

Each additional player attaches another copy of the definition, choosing its own controls. The complete Character integration uses F2 to join a J/L player and F3 to remove it. Both controller objects use the same class and local action IDs.

Destroying/resetting a controller releases its context lease. That removes its mappings at the next poll; previously published ticks retain their state. During deinit_platform(), after simulation stops, release controller handles and the saved endpoint:

controller_.reset();
player_input_.reset();

Keep the existing cleanup of the game’s direct bindings and graphics resources. The input system remains alive during game cleanup. Continue with Create a Character to give these commands visible behavior.