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

init()

Bind controls and create initial resources after the platform and graphics adapters start.

update(tick)

Advance game state using the supplied input and simulation delta.

prepare_render_frame(frame) const

Resolve that state into complete render submissions.

quiesce()

Stop external producers before final cleanup when your game adds them.

deinit()

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

first-game:images

Triangle strip

Each sprite cell uses a four-vertex strip, with nearest sampling for the small image.

first-game:text

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.