Your first game
Start with a small application that opens a window, loads its own sprite and shaders, and draws the sprite and a score label. The Left and Right arrow keys move the sprite, Space selects its other image cell, and Escape closes the application. The score stays at zero while you establish the application and resource flow.
The maintained source lives in projects/apps/first-game. The excerpts below come directly from that example, so you can inspect the complete application as you work through each step.
1. Set up the checkout and build the application
Install Git, CMake 3.28 or newer, Ninja, and a C++23 compiler and standard library
with std::format. Follow the
system dependency table for
FreeType, HarfBuzz, ICU, and the native GLFW/OpenGL requirements. On Linux, this
example uses X11 and needs a desktop driver supporting an OpenGL 3.3 core context.
Python and Jinja2 are required when GLAD is generated during the source build.
Choose a checkout location and a revision matching the documentation you are
reading. Use main for stable documentation, develop for latest documentation,
or the corresponding release tag for a versioned manual. Replace
/path/to/cheryl-engine with your desired checkout location:
(
set -e
cheryl_version=main
git clone --branch "$cheryl_version" https://github.com/cppcooper/cheryl-engine.git /path/to/cheryl-engine
cd /path/to/cheryl-engine
git submodule update --init --recursive
)
The example’s PNG, shader sources, and manifests are checked in under its own
assets directory. The Engine’s embedded fallback font comes from the pinned
assets submodule. This walkthrough needs no optional artwork downloads or asset
initialization script.
Run the following block from anywhere inside the checkout. It selects only the native graphics/input composition and this application:
(
set -e
cd "$(git rev-parse --show-toplevel)"
cmake -S . -B build/first-game -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCHERYL_BUILD_FIRST_GAME=ON -DCHERYL_BUILD_DEMO=OFF \
-DCHERYL_BUILD_NATIVE_GLFW=ON -DCHERYL_BUILD_OPENGL=ON \
-DCHERYL_NATIVE_INPUT=ON -DCHERYL_NATIVE_NULL_PLATFORM=OFF \
-DCHERYL_BUILD_UI_TGUI=OFF -DCHERYL_BUILD_UI_RMLUI=OFF \
-DCHERYL_BUILD_AUDIO_MINIAUDIO=OFF -DCHERYL_BUILD_DEBUG_TERMINAL_LINUX=OFF \
-DCHERYL_BUILD_WORKER_TOPOLOGY_HWLOC=OFF -DCHERYL_BUILD_WORKER_TOPOLOGY_JSON=OFF \
-DCHERYL_BUILD_TESTS=OFF -DCHERYL_BUILD_CONSUMER_TESTS=OFF \
-DCHERYL_BUILD_SDK=OFF -DCHERYL_BUILD_DOCS=OFF \
-DGLFW_BUILD_WAYLAND=OFF -DGAINPUT_ENABLE_HID=OFF
cmake --build build/first-game --target first-game --parallel
./build/first-game/first-game
)
CMAKE_POLICY_VERSION_MINIMUM=3.5 accommodates the pinned legacy dependency
projects on CMake 4 hosts. GLFW_BUILD_WAYLAND=OFF selects the Linux X11 route;
GAINPUT_ENABLE_HID=OFF avoids the separate HID dependency download. See the
build reference for other platform and toolchain
choices. Windows executables have an .exe suffix.
The expected result is a 640 × 360 window containing a cyan sprite and “Score: 0”. Space changes the sprite to amber. Check movement, cell selection, resizing, and Escape on the desktop; compilation and command-line help alone do not establish those graphics and input observations.
2. Start the engine and keep its owners alive
The application’s entry point creates
CE::Application::Startup through
CE::Application::GLFWOpenGL::make_startup(). That factory selects the native
GLFW window/input adapter and the OpenGL renderer and resource provider.
std::string asset_root = CHERYL_FIRST_GAME_ASSETS;
auto startup = CE::Application::GLFWOpenGL::make_startup(
"Cheryl first game", {.width = 640, .height = 360, .title = "First game: arrows move, Space changes cell, Escape quits"}
);
startup.add_application_options([&asset_root](CLI::App& options) {
options.add_option("--assets", asset_root, "Game asset directory")->default_str(asset_root);
});
auto result = startup.initialize(argc, argv);
if (!result.should_start())
return result.exit_code;
FirstGame::Game game(*result.context, std::filesystem::path(asset_root));
auto runtime = result.make_runtime(game);
runtime.run();
return result.exit_code;
Register your own options before calling initialize(). Here, --assets binds a
string that stays alive until parsing completes. Its default is an absolute path
supplied by the example’s CMake target, so launching the executable from another
directory still selects the same assets. An explicit relative override resolves
from the application’s working directory.
Help and argument errors return a CE::Application::StartupResult with no
context; return its exit code when should_start() is false. On success, construct
your game from the owned context and use make_runtime(game) to apply the parsed
execution, input polling, and timing options together.
Declare the result, game, and runtime in that order. The game borrows the
CE::Runtime::Context, and the runtime borrows both. Their
declaration order keeps those owners alive through runtime destruction. Add
--concurrent when launching to place simulation on a separate thread; the same
game hooks and resource ownership rules apply.
3. Give the game its hooks and controls
Implement CE::Game::AbstractGame and keep the state your game
needs in that object. The example owns its camera, bindings, resources, and actor:
class Game final : public CE::Game::AbstractGame {
static constexpr CE::Input::Actions::Id quit_{1};
static constexpr CE::Input::Actions::Id left_{2};
static constexpr CE::Input::Actions::Id right_{3};
static constexpr CE::Input::Actions::Id next_cell_{4};
static constexpr float actor_scale_ = 4.0f;
CE::Runtime::Context& engine_;
std::filesystem::path asset_root_;
CE::Rendering::Camera2D camera_;
std::array<CE::Input::Actions::BindingId, 4> bindings_{};
std::optional<Actor> actor_;
std::optional<CE::Text::Rendering::Generation> score_;
std::shared_ptr<const CE::Rendering::Resources::Material> images_;
std::shared_ptr<const CE::Rendering::Resources::Material> text_;
public:
Game(CE::Runtime::Context& engine, std::filesystem::path asset_root);
void init() override;
void update(const CE::Game::TickContext& tick) override;
void prepare_render_frame(CE::Rendering::FrameWriter& frame) const override;
void deinit() override;
};
Hook |
What belongs here |
|---|---|
Bind controls and create initial resources after the platform and graphics adapters start. |
|
|
Advance game state using the supplied input and simulation delta. |
|
Resolve that state into complete render submissions. |
Stop external producers before final cleanup when your game adds them. |
|
Release game-owned resources and registrations after simulation and accepted work settle. |
init() and deinit() run on the platform/graphics owner. In concurrent mode,
update() and frame preparation run on the simulation thread. Bindings belong to
the platform owner, so define them during initialization:
auto& input = engine_.input();
auto& bindings = input.bindings();
const auto keyboard = input.keyboard_id();
bindings_[0] = bindings.bind_button({keyboard, gainput::KeyEscape}, quit_);
bindings_[1] = bindings.bind_button({keyboard, gainput::KeyLeft}, left_);
bindings_[2] = bindings.bind_button({keyboard, gainput::KeyRight}, right_);
bindings_[3] = bindings.bind_button({keyboard, gainput::KeySpace}, next_cell_);
The native key codes appear at this boundary. Gameplay reads the game’s semantic
CE::Input::Actions::Id values instead. Retaining each returned binding
ID lets the game remove its own mappings during cleanup. The
input guide covers additional actions, events,
and focus handling.
4. Load a controlled game asset directory
Put the resources required by this game under one root. The example uses:
assets/
└── graphics/
├── graphics-manifests.json
├── definitions/sprites.json
├── shaders/materials.json
├── shaders/first-game.vert
├── shaders/first-game.frag
└── textures/actor.png
The index selects the sprite and shader/material definitions:
{
"$schema": "https://raw.githubusercontent.com/cppcooper/assets/5603c1974bff4054459edb23d36a52869cba863d/schemas/graphics-manifest-index-2.0.schema.json",
"version": "2.0",
"manifests": ["definitions/sprites.json", "shaders/materials.json"]
}
Only files named graphics-manifests.json select definitions automatically.
Unlisted JSON files are ignored. The listed paths, texture references, and shader
source references resolve from the enclosing graphics directory. The $schema
URLs support editor validation; runtime loading uses local files.
Create an owned CE::Assets::Loading::Loader and load the root during init():
auto& resources = engine_.resources();
CE::Assets::Loading::Loader loader(asset_root_);
loader.load_assets(resources);
auto sprite = CE::Assets::Sprites::Manager::get().get_asset("first-game", "actor");
images_ = CE::Assets::Shaders::Manager::get().find_material("first-game:images");
text_ = CE::Assets::Shaders::Manager::get().find_material("first-game:text");
if (!sprite || !images_ || !text_)
throw std::runtime_error("First-game assets require actor, images and text resources");
const auto size = camera_.framebuffer_size();
actor_.emplace(std::move(sprite), glm::vec2(static_cast<float>(size.width) * 0.5f, static_cast<float>(size.height) * 0.5f));
load_assets() performs CPU preparation followed by upload on the active
provider’s loading owner. It processes indexed programs/materials, images,
sprites, and tilesets, along with standalone PNGs below the root. Fonts and audio
use separate loading paths. Preparation validates definitions and image bounds
before publication; an upload failure can leave already completed cache entries.
Use a controlled root containing just this game’s required assets. Pointing the
general loader at the repository’s larger asset collection also selects its
optional downloaded artwork. register_files() only registers paths for manual
loading; it does not parse definitions, decode images, or upload resources.
The asset-loading guide explains publication and
retry behavior.
Resolve required named resources during initialization and check each result.
CE::Assets::Sprites::Manager returns a retained sprite handle or null;
CE::Assets::Shaders::Manager supplies the materials used for drawing.
For this manifest, get_asset("first-game", "actor") identifies
first-game:actor exactly. get_assets("first-game") provides a namespace
collection keyed by local names when your game needs several sprites.
5. Pass retained resources into game-owned state
The sprite is shared resource data. The actor owns its position and selected cell, while retaining the sprite passed into its constructor:
struct Actor {
std::shared_ptr<const CE::Assets::Sprites::Asset> sprite;
glm::vec2 position;
CE::Assets::Grids::CellIndex cell = 0;
Actor(std::shared_ptr<const CE::Assets::Sprites::Asset> resource, glm::vec2 initial_position)
: sprite(std::move(resource)), position(initial_position) {}
};
The actor_.emplace(...) call above transfers a retained handle into this small
game-owned structure. Use the same approach when an enemy manager or another
game system needs a sprite collection: resolve the required names once, then
pass the handles into that system. Keep per-entity transforms, counters, and
playback state with their application owners. A namespace collection’s iteration
order supplies no gameplay identity.
The loader can go out of scope after initialization. Cache lookups and the actor’s retained handles have their own lifetimes. The resources still belong to their original backend domain; retaining a handle does not make it usable after that backend has been destroyed. See the resource ownership guide.
6. Select a font and prepare the score label
Font selection chooses immutable font bytes and faces. It does not draw text or upload a glyph atlas. Select a preferred installed family, lay out the message, prepare its pixels and geometry, then upload that complete value:
CE::Text::FontSelection selection;
selection.preferred.emplace_back(CE::Text::SystemFontFamily{"DejaVu Sans"});
selection.automatic_system_fonts = false;
auto fonts = CE::Text::FontCollection::load(selection);
auto layout = CE::Text::layout_text(fonts, "Score: 0", {.pixel_height = 24});
score_.emplace(CE::Text::Rendering::upload(CE::Text::Rendering::prepare(std::move(layout)), resources));
CE::Text::FontCollection tries the preferred DejaVu Sans family
and appends the embedded fallback. automatic_system_fonts=false disables the
additional automatic family choices; the explicitly preferred installed family
is still considered. A missing family is skipped, so the score remains usable
without that system installation.
CE::Text::layout_text() shapes the UTF-8 message using the selected
font data. CE::Text::Rendering::prepare() creates owned CPU atlas pages and
glyph geometry. CE::Text::Rendering::upload() uses the resource provider to
create a CE::Text::Rendering::Generation generation. Initial upload is safe
here because init() runs on the graphics owner.
The retained text owns the layout and matching resources it needs after the local font and preparation variables leave scope. Keep this static label as one generation. When gameplay starts changing the score, prepare a replacement and adopt it only when upload succeeds. The Unicode text guide covers fallback, wrapping, and replacement ownership.
7. Update simulation and select image cells
Use CE::Game::TickContext during each update. It supplies the
simulation delta, accumulated semantic input, and copied window dimensions:
void Game::update(const CE::Game::TickContext& tick) {
camera_.set_framebuffer_size(tick.framebuffer_size);
if (tick.input.button(quit_).pressed()) {
tick.request_stop();
return;
}
const float direction = (tick.input.button(right_).held() ? 1.0f : 0.0f) - (tick.input.button(left_).held() ? 1.0f : 0.0f);
const float width = static_cast<float>(tick.framebuffer_size.width);
const float half_actor = static_cast<float>(actor_->sprite->definition().grid.frame.width) * actor_scale_ * 0.5f;
const float margin = std::min(half_actor, width * 0.5f);
actor_->position.x =
std::clamp(actor_->position.x + direction * 160.0f * static_cast<float>(tick.delta_seconds), margin, width - margin);
actor_->position.y = static_cast<float>(tick.framebuffer_size.height) * 0.5f;
if (tick.input.button(next_cell_).pressed())
actor_->cell = (actor_->cell + 1) % actor_->sprite->definition().grid.cell_count();
}
Movement uses delta_seconds, and the camera follows the tick’s framebuffer
size. This supports concurrent mode without querying a live window from the
simulation thread. Escape requests a stop through the tick; the runtime performs
its normal cleanup after the update returns.
The sprite definition divides the 32 × 16 PNG into two 16 × 16 cells:
{
"$schema": "https://raw.githubusercontent.com/cppcooper/assets/5603c1974bff4054459edb23d36a52869cba863d/schemas/graphics/sprite-tileset-manifest-2.0.schema.json",
"asset_class": "sprite-tileset",
"version": "2.0",
"namespace": "first-game",
"texture": "textures/actor.png",
"defaults": {
"sprite": {"pivot": {"x": 0.5, "y": 0.5}},
"tileset": {"pivot": {"x": 0.0, "y": 1.0}}
},
"sprites": {
"actor": {
"description": "Two self-authored cells selected by the game; no timed animation clip.",
"grid": {
"origin": {"x": 0, "y": 0},
"frame": {"width": 16, "height": 16},
"spacing": {"x": 0, "y": 0},
"rows": 1,
"columns": 2,
"cell_order": "row-major"
}
}
}
}
A grid describes which image rectangle each cell selects. Space advances the
actor’s cell index explicitly. This manifest supplies no timed animation clip.
For a sprite with authored timed clips, keep a separate
CE::Assets::Sprites::Animation cursor per entity and advance it using
simulation time. The shared cached sprite holds definitions and resources;
its entities choose playback. See
asset values and playback.
8. Submit the sprite and text as a complete frame
Frame preparation copies the simulation’s current transforms and selects retained resources. The renderer consumes those resolved packets later:
void Game::prepare_render_frame(CE::Rendering::FrameWriter& frame) const {
auto pass = frame.begin_pass(camera_.projection_matrix(), camera_.view_matrix());
const CE::Rendering::Submission::Context2D context{
pass.semantics(), pass.parameters(), pass.constraints(), CE::Rendering::Submission::ImageParameter2D{"image", 0}
};
CE::Rendering::DrawStyle2D image_style;
image_style.material = images_;
image_style.scale = actor_scale_;
image_style.model_matrix = glm::translate(glm::mat4(1.0f), glm::vec3(actor_->position, 0.0f));
pass.add(CE::Rendering::Submission::resolve_sprite(*actor_->sprite, actor_->cell, image_style, context));
CE::Rendering::DrawStyle2D text_style;
text_style.material = text_;
text_style.model_matrix =
glm::translate(glm::mat4(1.0f), glm::vec3(24.0f, static_cast<float>(camera_.framebuffer_size().height) - 48.0f, 0.0f));
pass.add(CE::Rendering::Submission::resolve_text(*score_, text_style, context));
}
CE::Rendering::Camera2D uses Y-up pixel coordinates. Opening a pass copies its
projection and view matrices. The
CE::Rendering::Submission::Context2D carries those pass values and
selects the public image parameter at texture unit zero. Each
CE::Rendering::DrawStyle2D chooses a material and supplies the
draw’s model transform, scale, and alpha.
The checked-in material manifest gives the shared shader program two compatible draw recipes:
Material |
Geometry topology |
Why it is separate |
|---|---|---|
|
Triangle strip |
Each sprite cell uses a four-vertex strip, with nearest sampling for the small image. |
|
Triangles |
Each uploaded glyph uses a six-vertex quad, with linear sampling for its alpha atlas. |
The vertex shader applies the copied pass and draw transforms:
#version 330 core
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform float in_Scale;
layout(location = 0) in vec3 in_Position;
layout(location = 1) in vec2 in_Texcoord;
out vec2 v_texcoord;
void main() {
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(in_Position * in_Scale, 1.0);
v_texcoord = in_Texcoord;
}
Material bindings map public parameter keys to the shader’s native uniform names.
Your common submission code uses image, while this OpenGL recipe maps it to
mytexture. CE::Rendering::Submission::resolve_sprite() and
CE::Rendering::Submission::resolve_text
resolve complete CPU packets without binding
graphics state or uploading resources. Adding those packets to the pass retains
their geometry, materials, and selected images and copies their parameter values.
The renderer receives no borrowed actor, camera, or playback cursor. The
pipeline and material guide explains
the contracts and additional recipes.
9. Release the game’s resources
The runtime joins simulation and recycles accepted frames before calling
deinit() on the platform/graphics owner. Remove the game’s mappings and release
its retained generations there:
void Game::deinit() {
for (auto& binding : bindings_) {
if (binding != 0) {
static_cast<void>(engine_.input().bindings().unbind(binding));
binding = 0;
}
}
score_.reset();
actor_.reset();
images_.reset();
text_.reset();
}
Cleanup also follows attempted initialization, so these resets tolerate resources
that were never created. The example uses the default quiesce() because it has
no external producers. Games that add listeners, audio producers, or background
services should stop those producers in quiesce() while their dependencies
remain alive. The runtime frame boundary
defines the complete shutdown ordering.
Continue with background loading
The synchronous path makes initial ownership clear. For larger loads, use an
Engine worker group for Loader::prepare() and submit the resulting owned
CE::Assets::Loading::Prepared to the platform dispatcher for upload(). Simulation checks
futures for readiness and adopts complete retained results during update. Keep
the loader alive through preparation and handoff, and keep the prior resource
generation while its replacement is pending. Avoid waiting on the thread that
must complete the upload.
Text follows the same boundary: font/layout preparation can run on workers; upload remains on the graphics owner. Follow the working sequences in asset loading, worker execution, and thread dispatch when you add that flow. This example keeps initialization synchronous and frame preparation CPU-only.
Use the example as an independent project
The example’s own CMake entry point supports source and installed SDK consumers. Its application target links the public owners explicitly:
add_executable(${TARGET_APP_FIRST_GAME} src/main.cpp src/first-game.cpp)
set_target_properties(${TARGET_APP_FIRST_GAME} PROPERTIES
OUTPUT_NAME "${OUTPUT_APP_FIRST_GAME}"
RUNTIME_OUTPUT_DIRECTORY "${first_game_output}")
target_compile_definitions(${TARGET_APP_FIRST_GAME} PRIVATE
CHERYL_FIRST_GAME_ASSETS="${CMAKE_CURRENT_SOURCE_DIR}/assets")
target_link_libraries(${TARGET_APP_FIRST_GAME} PRIVATE
Cheryl::Engine Cheryl::NativeGLFW Cheryl::OpenGL Cheryl::OpenGL::Startup)
To configure the example directly against the checkout, use a separate build directory. Its bootstrap selects the required Engine, Native GLFW, OpenGL, and startup targets and disables unrelated applications and modules:
(
set -e
cd "$(git rev-parse --show-toplevel)"
cmake -S projects/apps/first-game -B build/first-game-source -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCHERYL_FIRST_GAME_ENGINE_SOURCE="$PWD" \
-DGLFW_BUILD_WAYLAND=OFF -DGAINPUT_ENABLE_HID=OFF
cmake --build build/first-game-source --target first-game --parallel
./build/first-game-source/first-game
)
For an installed SDK, leave CHERYL_FIRST_GAME_ENGINE_SOURCE empty and supply a
compatible SDK through CMAKE_PREFIX_PATH. It must include Native GLFW with
native input, OpenGL, and OpenGL startup. The SDK contains libraries and headers;
keep this example’s source and assets directory alongside its CMake project:
(
set -e
cd "$(git rev-parse --show-toplevel)"
cmake -S projects/apps/first-game -B build/first-game-sdk -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCHERYL_FIRST_GAME_ENGINE_SOURCE= \
-DCMAKE_PREFIX_PATH=/absolute/path/to/extracted-sdk
cmake --build build/first-game-sdk --target first-game --parallel
./build/first-game-sdk/first-game
)
The application’s compiled default asset path names its source assets
directory. Use --assets when relocating those files with a packaged application.
For SDK ABI/configuration matching and Windows runtime DLL paths, follow
consuming the engine.