Create a Character
A Character owns its position, movement settings and retained appearance. It consumes the command from Create a PlayerController, so the same Character class can be driven by a player or by autonomous patrol behavior. Continue in First Game’s initialization, update, frame and cleanup hooks.
flowchart LR
accTitle: Character state and retained appearance
accDescr: A Character applies an actor command to its own position. It retains shared appearance and resolves packets during frame preparation; those packets retain their selected resources.
command["Actor command"] -->|update| character["Character"]
character -->|changes| position["Own position"]
character -->|retains| appearance["Shared appearance"]
character -->|append| packet["Draw packet"]
packet -->|retains resources| appearance
Compose state and appearance
Create character.h:
#pragma once
#include "character-command.h"
#include <cheryl/assets/submission/draw2d.h>
#include <cheryl/core/rendering/render-frame.h>
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <stdexcept>
#include <utility>
namespace Tutorial {
class Character final {
std::shared_ptr<const CE::Assets::Sprites::Asset> sprite_;
std::shared_ptr<const CE::Rendering::Resources::Material> material_;
glm::vec2 position_;
CE::Assets::Grids::CellIndex cell_;
float speed_;
public:
Character(
std::shared_ptr<const CE::Assets::Sprites::Asset> sprite,
std::shared_ptr<const CE::Rendering::Resources::Material> material,
glm::vec2 position,
CE::Assets::Grids::CellIndex cell = 0,
float speed = 160
)
: sprite_(std::move(sprite)), material_(std::move(material)), position_(position), cell_(cell), speed_(speed) {
if (!sprite_ || !material_ || cell_ >= sprite_->definition().grid.cell_count() || !std::isfinite(position.x) ||
!std::isfinite(position.y) || !std::isfinite(speed_) || speed_ <= 0)
throw std::invalid_argument("Character requires resources, a valid cell, finite position and positive speed");
}
void update(CharacterCommand command, double seconds) {
if (!std::isfinite(command.horizontal) || !std::isfinite(seconds) || seconds < 0)
throw std::invalid_argument("Character movement requires a finite command and nonnegative time");
const double next = position_.x + std::clamp(command.horizontal, -1.0, 1.0) * speed_ * seconds;
if (!std::isfinite(next) || std::abs(next) > std::numeric_limits<float>::max())
throw std::invalid_argument("Character movement exceeds the position range");
position_.x = static_cast<float>(next);
}
void append(CE::Rendering::PassWriter& pass) const {
const CE::Rendering::Submission::Context2D context{pass.semantics(), pass.parameters(), pass.constraints()};
CE::Rendering::DrawStyle2D style;
style.material = material_;
style.scale = 4;
style.model_matrix = glm::translate(glm::mat4(1), glm::vec3(position_, 0));
pass.add(CE::Rendering::Submission::resolve_sprite(*sprite_, cell_, style, context));
}
[[nodiscard]] glm::vec2 position() const { return position_; }
[[nodiscard]] float speed() const { return speed_; }
};
}
The Character retains a sprite and material, together with its own position, cell and speed. Many Characters can share the same immutable resources. The sprite already retains a compatible default material; an object can retain that handle or select another compatible material, such as First Game’s nearest-sampled appearance. No application material builder or asset subclass is needed.
update() applies a command and elapsed simulation seconds to authoritative
position. append() prepares a drawing instruction from the current state and
adds it to a borrowed pass. It does not call a controller, advance behavior or
issue graphics operations. The renderer later consumes retained frame packets.
This example clamps horizontal command strength to [-1, 1] and validates its
inputs. It supplies no collision detection, gravity, world storage or boundary
policy. Its speed uses local pixels per simulation second, independently of the
draw style’s visual scale.
Construct after loading
Include character.h in the game and retain a Character alongside the controller:
std::shared_ptr<const CE::Assets::Sprites::Asset> sprite_;
std::optional<Tutorial::Character> player_;
Keep First Game’s asset loading and images_ appearance handle. Instead of
constructing its actor_, retain the loaded sprite and create the Character:
sprite_ = std::move(sprite);
player_.emplace(sprite_, images_, glm::vec2(160.0f, 100.0f));
These calls belong in platform-owned init_platform() after loading and validation. The
controller is already attached as described in the previous tutorial. The
maintained game retains the
same resources for later player joins.
Update Characters from commands
Replace First Game’s Actor movement/cell block during update() with:
player_->update(controller_->read(tick.input), tick.delta_seconds);
Keep quit handling and camera resize observation. Position changes only in simulation; concurrent rendering can skip a visual snapshot without slowing movement. Both direction keys held request no movement.
Prepare the Character’s appearance
During prepare_render_frame(), replace First Game’s pass/context declarations
and old Actor draw with:
auto world = frame.begin_pass(camera_.projection_matrix(), camera_.view_matrix());
player_->append(world);
const CE::Rendering::Submission::Context2D context{
world.semantics(), world.parameters(), world.constraints()
};
The pass copies the camera matrices and retains each packet’s resources and draw
values. Keep First Game’s score afterward, changing its pass.add(...) to
world.add(...) to use this pass and the context above, until the HUD tutorial
replaces its presentation. Add animation or
camera behavior as separate application state.
Add an autonomous controller
Create an AIController supplies the complete patrol extension:
behavior state, Character observations, construction, update, drawing, and cleanup.
It produces the same command as the player controller while retaining independent
direction state. Follow that lesson to add patrol_ and enemy_, or continue here
with player input alone.
Join and leave during play
The complete update block below also includes the optional AI extension. Add
std::optional<Tutorial::PlayerController> second_controller_; and
std::optional<Tutorial::Character> second_player_; beside the first player’s
members. Declare game-wide action IDs join_{7} and leave_{8} with type
CE::Input::Actions::Id. The IDs remain separate from each controller’s local IDs.
During platform-owned init_platform(), use the now-unused left/right registration slots
in First Game’s bindings_ array:
bindings_[1] = bindings.bind_button({keyboard_, CE::Input::KeyboardKey::F2}, join_);
bindings_[2] = bindings.bind_button({keyboard_, CE::Input::KeyboardKey::F3}, leave_);
Here bindings is the existing platform binding reference. Replace the single
player update call and any patrol update with the maintained game’s complete player block.
The first line imports its controller name into your update() scope:
using Tutorial::PlayerController;
if (tick.input.button(join_).pressed() && !second_controller_) {
second_controller_.emplace(
player_input_->attach(PlayerController::keyboard(keyboard_, CE::Input::KeyboardKey::J, CE::Input::KeyboardKey::L))
);
second_player_.emplace(sprite_, images_, glm::vec2(320, 100), 1);
}
if (tick.input.button(leave_).pressed()) {
second_controller_.reset();
second_player_.reset();
}
player_->update(controller_->read(tick.input), tick.delta_seconds);
if (second_player_)
second_player_->update(second_controller_->read(tick.input), tick.delta_seconds);
enemy_->update(patrol_.read(*enemy_, tick.delta_seconds), tick.delta_seconds);
F2 requests a separate action context using the device ID saved during init_platform();
its mappings become visible after the next poll. F3 releases that controller and
Character. Releasing one context does not remove another player’s mappings.
If both players are present, each tick reads their own action namespace before
applying commands. Keep the simple single-player update above or this extended
block, according to the application you are building. If omitting the patrol
extension, omit the final enemy_->update(...) line from this block too.
In prepare_render_frame(), after player_->append(world), add:
if (second_player_)
second_player_->append(world);
The maintained Game declaration and implementation contain the complete fields, direct binding registrations, loading and rendering order.
Release and observe
Replace actor_.reset() during deinit_platform() with controller/Character cleanup:
controller_.reset();
player_input_.reset();
player_.reset();
sprite_.reset();
If added, reset the second controller, second Character and enemy_ too. Keep
First Game’s direct binding removal, text and material cleanup. Reset the optional
controller before dropping its endpoint, and release resources while the graphics
backend remains alive. Published frames retain their own handles until recycling.
Observe one arrow-controlled Character and, when enabled, a second J/L player and a patrol. Moving one player must not move the other; F3 must leave the first controller working. Repeat in both runtime execution modes. Continue with Create an AIController for autonomous behavior, then Create a UIController and a HUD for shared-session widgets.