← All examples
Rendering · ECS · Assets
Sprite Animation
Spritesheets via a shared frame table
Showcase for components::Animation and systems::AnimationSystem/RenderPipeline: playing back a spritesheet through per-entity frame state, with the frame list itself loaded as a shared asset::FrameTable (walk.toml) rather than embedded per-entity.
Controls
- Left Click Spawn another animated sprite at the cursor
- SPACE Play/pause every sprite's animation
- R Reset

Run it yourself
build just this example
cmake --build --preset windows-debug --target animation_demo
# binary lands under bin/ (e.g. bin/Debug/animation_demo.exe on Windows)
See Installation for the full build setup.
Source
examples/animation_demo/Game.hpp
#pragma once
/**
* @brief Showcase for components::Animation and systems::AnimationSystem/
* RenderPipeline: playing back a spritesheet through per-entity
* frame state, with the frame list itself loaded as a shared
* asset::FrameTable rather than embedded per-entity.
*
* A 6-frame spritesheet, described by a small `walk.toml` FrameTable
* meta-file (see assets/), drives a row of pre-spawned sprites, each
* started on a different frame so they visibly play out of phase with each
* other -- Animation is per-entity playback state even though every sprite
* here shares the one loaded FrameTable asset. Left-click spawns another
* animated sprite at the cursor, with its own randomized frame duration.
* Space toggles every sprite's Animation between playing and paused
* (components::PlayAnimation/StopAnimation); R resets back to the initial
* row.
*
* Every Render() call runs asset::AssetManager::ResolveAssets first --
* deferred-loading any still-unresolved Sprite::m_Texture/Animation::m_Clip
* in one pass, entities spawned this frame included -- then
* systems::RenderPipeline (AnimationSystem then RenderSystem). Spawning
* itself just sets Sprite::m_VirtualPath/Animation::m_ClipPath; unlike
* earlier examples (see ecs_demo), there's no hand-rolled "is the texture
* attached yet" bookkeeping to write, since ResolveAssets already re-checks
* per-entity every call.
*/
#include <ASGE/ASGE.hpp>
#include <random>
#include <vector>
class AnimationDemoState final : public asge::game::state::IGameState<int>
{
asge::ecs::Registry& m_Registry;
asge::game::asset::AssetManager& m_Assets;
std::vector<asge::ecs::Entity> m_SpriteEntities; // every entity spawned so far, in spawn order
std::mt19937 m_Rng{ std::random_device{}() };
bool m_Playing{ true };
void SpawnInitialRow();
void SpawnSprite( asge::math::Float2 inPosition, std::size_t inStartFrame );
void Reset();
void HandleInput( asge::input::InputState const& inInput );
public:
AnimationDemoState(asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets);
[[nodiscard]] std::optional<asge::game::state::Transition<int>>
Update(float inDeltaTime, asge::input::InputState const& inInput) override;
void Render(asge::video::IRenderer& inRenderer) override;
[[nodiscard]] asge::graphics::RGBA_Color ClearColor() const noexcept override { return { 15, 15, 20, 255 }; }
void OnSystemEvent(asge::event::SystemEvent const& inSysEvent) override;
};
class AnimationDemoGame final : public asge::game::Game<int>
{
public:
explicit AnimationDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev);
protected:
[[nodiscard]] std::unique_ptr<StateType> CreateState(int inId) override;
};
examples/animation_demo/Game.cpp
#include "Game.hpp"
namespace
{
using asge::game::components::Animation;
using asge::game::components::PlayAnimation;
using asge::game::components::Sprite;
using asge::game::components::StopAnimation;
using asge::game::components::Transform;
constexpr float kWindowWidth = 800.0f;
constexpr std::size_t kFrameCount = 6; // must match assets/walk.toml's "count"
constexpr float kCellSize = 32.0f;
constexpr float kSpriteScale = 2.5f; // draws each 32x32 cell at 80x80 on screen
constexpr float kRowY = 250.0f;
constexpr float kRowSpacing = kWindowWidth / static_cast<float>(kFrameCount + 1);
constexpr float kMinFrameDuration = 0.05f;
constexpr float kMaxFrameDuration = 0.25f;
constexpr char const* kSheetPath = "textures/spritesheet.bmp";
constexpr char const* kClipPath = "textures/walk.toml"; // asset::FrameTable meta-file -- see assets/
}
AnimationDemoState::AnimationDemoState(
asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets)
: m_Registry(inRegistry), m_Assets(inAssets)
{
SpawnInitialRow();
}
void AnimationDemoState::SpawnInitialRow()
{
// Every sprite plays the same clip, but each starts on a different
// frame -- makes it visually obvious that Animation is per-entity
// playback state, not one shared clock, even though every sprite here
// shares the one loaded FrameTable asset (see Animation::m_Clip).
for ( std::size_t ii = 0; ii < kFrameCount; ++ii )
{
float const x = kRowSpacing * static_cast<float>(ii + 1) - (kCellSize * kSpriteScale * 0.5f);
SpawnSprite( asge::math::Float2{ x, kRowY }, ii );
}
}
void AnimationDemoState::SpawnSprite( asge::math::Float2 inPosition, std::size_t inStartFrame )
{
auto entity = m_Registry.CreateEntity();
if ( !entity ) { entity.LogError(); return; }
m_Registry.AddComponent<Transform>( entity.Value(), Transform{
.m_LocalCoordinates = inPosition, .m_LocalScale = {kSpriteScale, kSpriteScale},
.m_WorldCoordinates = inPosition, .m_WorldScale = {kSpriteScale, kSpriteScale}
} );
std::uniform_real_distribution<float> durationDist( kMinFrameDuration, kMaxFrameDuration );
m_Registry.AddComponent<Animation>( entity.Value(), Animation{
.m_ClipPath = kClipPath,
.m_FrameDuration = durationDist( m_Rng ),
.m_CurrentFrame = inStartFrame,
.m_Playing = m_Playing
} );
// m_Texture stays null and m_Clip (Animation, above) stays unresolved
// until Render()'s AssetManager::ResolveAssets call -- no "is the
// renderer ready yet" bookkeeping needed here, unlike earlier examples.
m_Registry.AddComponent<Sprite>( entity.Value(), Sprite{ .m_VirtualPath = kSheetPath } );
m_SpriteEntities.push_back( entity.Value() );
}
void AnimationDemoState::Reset()
{
for ( auto entity : m_SpriteEntities )
{
if ( auto result = m_Registry.DestroyEntity( entity ); !result ) result.LogError();
}
m_SpriteEntities.clear();
m_Playing = true;
SpawnInitialRow();
}
void AnimationDemoState::HandleInput(asge::input::InputState const &inInput)
{
using asge::input::Keycode;
using asge::input::MouseButton;
if ( inInput.IsMouseButtonPressed( MouseButton::LEFT ) )
{
auto const mouse = inInput.GetMousePosition();
SpawnSprite(
asge::math::Float2{ mouse.x() - kCellSize * kSpriteScale * 0.5f,
mouse.y() - kCellSize * kSpriteScale * 0.5f },
0 );
}
if ( inInput.IsKeyPressed( Keycode::SPACE ) )
{
m_Playing = !m_Playing;
for ( auto entity : m_SpriteEntities )
{
auto result = m_Registry.GetComponent<Animation>( entity );
if ( !result ) continue;
if ( m_Playing ) PlayAnimation( result.Value().get() );
else StopAnimation( result.Value().get() );
}
}
if ( inInput.IsKeyPressed( Keycode::R ) )
{
Reset();
}
}
std::optional<asge::game::state::Transition<int>>
AnimationDemoState::Update([[maybe_unused]] float inDeltaTime, asge::input::InputState const &inInput)
{
HandleInput( inInput );
return std::nullopt;
}
void AnimationDemoState::Render([[maybe_unused]] asge::video::IRenderer &inRenderer)
{
}
void AnimationDemoState::OnSystemEvent([[maybe_unused]] asge::event::SystemEvent const &inSysEvent)
{
// Everything here is driven by polling InputState in Update() instead.
}
AnimationDemoGame::AnimationDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev)
: Game(inRenderer, inAudioDev)
{
// ASGE_ANIMATION_DEMO_ASSET_DIR is injected by CMakeLists.txt; mounted
// once so both the sheet and its FrameTable meta-file are loaded by
// virtual path (see Render()'s ResolveAssets call) instead of a
// hardcoded OS path baked into this demo.
auto mountResult = m_Vfs.Mount("textures", ASGE_ANIMATION_DEMO_ASSET_DIR);
if ( !mountResult ) mountResult.LogError();
SetInitialState(0);
}
std::unique_ptr<AnimationDemoGame::StateType> AnimationDemoGame::CreateState([[maybe_unused]] int inId)
{
return std::make_unique<AnimationDemoState>( m_SceneManager.GetRegistry(), m_Assets );
}