- Published on
Lantern v0.1: reference-counted asset scoping for a small raylib framework
- Authors

- Name
- Ryan Flores
Context
Spooky Run was a weekend game jam made in raylib (C++, web build on itch.io). I extracted the reusable parts into Lantern so a second game can start from a working base. That base now includes a game/window/audio lifetime wrapper, a scene manager, an asset store, and a tool that generates asset-path constants from the assets/ folder.
The problem
Assets in the jam game were loaded and unloaded per scene, through a static asset manager and later per-scene stores. Two things bothered me:
- Which store holds which asset was something a developer had to remember.
- Moving between scenes produced an audible pause in the music (about half a second). My hypothesis was that unloading and reloading assets caused it.
Design
Ownership at the top
Game owns the window, the audio device, one AssetStore, and the SceneManager. Members are destroyed in reverse declaration order, so the scene manager (and the scenes' asset handles) go first, and the audio device and window close last. Anything that calls raylib is released while they are still open. Copy and move are deleted on the classes that own a resource.
private:
Window window;
Audio audio;
AssetStore assets;
SceneManager scene_manager;
One reference-counted store
AssetStore::Get<T>(path) returns a shared_ptr<T>. The store keeps only weak_ptrs, so it never keeps an asset alive. If the cached entry has expired, Get loads the asset again. Loading and unloading per type live in a traits class, AssetTraits<T>, specialized for Texture2D, Sound, Music and Font; the store keeps one map per type in a tuple.
template <>
struct AssetTraits<Texture2D> {
static constexpr const char* Name = "Texture2D";
static Texture2D Load(const std::string& path) { return LoadTexture(path.c_str()); }
static bool Valid(const Texture2D& asset) { return IsTextureValid(asset); }
static void Unload(const Texture2D& asset) { UnloadTexture(asset); }
};
The deleter attached to each shared_ptr does two jobs, in order: unload the GPU/audio resource, then free the heap memory that held the raylib struct. A failed load throws with the type and path in the message. Because the store owns nothing, it needs no destructor (rule of zero which I learned going through this project too :)).
template <typename T>
std::shared_ptr<T> AssetStore::Get(const std::string_view file_path) {
auto& map = std::get<std::unordered_map<std::string, std::weak_ptr<T>>>(maps);
const std::string path(file_path);
if (const auto it = map.find(path); it != map.end()) {
if (std::shared_ptr<T> ptr = it->second.lock()) return ptr;
}
const T loaded = AssetTraits<T>::Load(path);
if (!AssetTraits<T>::Valid(loaded)) {
AssetError(std::string("AssetStore: failed to load ") + AssetTraits<T>::Name + " '" + path + "'");
}
std::shared_ptr<T> asset(new T(loaded), [](T* a) {
AssetTraits<T>::Unload(*a);
delete a;
});
map[path] = asset;
return asset;
}
Scoped handles
Each scene owns an AssetScope, which is a wrapper for a map from path to a type-erased shared_ptr<void> plus the std::type_index of what was stored. A scene loads everything it needs in its constructor and reads it back by reference in Draw. The void-erased pointer still destroys the real type, because the deleter lives in the control block. Asking for a path that was never loaded, or for the wrong type, fails with the path in the message.
template <typename T>
T& Get(const std::string_view asset_path) const {
const auto it = assets.find(std::string(asset_path));
if (it == assets.end()) {
AssetError(std::string("AssetScope::Get: ") + AssetTraits<T>::Name + " '" + std::string(asset_path) +
"' was not loaded in this scope. Load it (or call the helper's Preload) first.");
}
// ...
return *std::static_pointer_cast<T>(it->second.ptr);
}
template <typename T>
void Load(const std::string_view asset_path) {
assets.insert_or_assign(std::string(asset_path), Entry{asset_store.Get<T>(asset_path), std::type_index(typeid(T))});
}
Swap order
In SceneManager::Run(), pending() builds the new scene (which acquires its handles in its constructor) before the assignment releases the old one, so an asset used by both scenes is never down to zero owners.
if (pending && (!transitioning || transition_progress >= swap_time)) {
current = pending();
pending = nullptr;
current->Enter();
current->scene_manager = this;
}
Path constants
assetgen (a separate C# tool, released under its own assetgen-v... tags) turns the assets/ folder structure into nested namespaces of inline constexpr std::string_view constants, so a typo in a path is a compile error.
Decisions and what I rejected
- A static/global asset manager: lifetime became implicit and hard to test. I usually go for static singletons because they are so easy to use and set up, but I wanted to challenge myself and create a system that didn't rely on a singleton class. I still only have 1 AssetStore, and that is because of the rule of 0, where I can delete those ctors and dtors.
- A persistent store that is filled as the game runs, or several stores. This would devolve to "which store holds this asset?" which becomes a thing the developer has to remember (not what I want).
- Lazy loading with a flag: loading happens mid-frame on first use, and it undoes the loud failure on a missing path.
- Injecting the store through a setter: the store would not exist during the scene constructor, which is where handles are acquired, and a reference member cannot be reseated.
- A hand-written wrapper class vs
shared_ptr<void>for mixed types: I learned ofshared_ptr<void>and that I could usestatic_pointer_cast<T>()on it to get the shared pointer back out and first accepted that casting to the wrong type is undefined behavior; a later commit added thetype_indexcheck so a mismatch fails loudly.
Mistakes that taught me something
- Dereferencing a handle returned by
GetinsideDrawand keeping a reference. The temporary owner died at the end of the statement, the deleter ran, and the reference pointed at freed memory. Symptom: MAYHEM! Lots of crazy stuff going on there
const Texture2D& background = *assets.Get<Texture2D>(Assets::UI::BUTTON_BG); // The shared_ptr dies at the ;
- Assigning a freshly created
shared_ptrinto aweak_ptrand callinglock(): the only owner was a temporary, so the asset was gone before it was returned.
map[normalizedPath] = std::make_shared<T>(Asset<T>::Load(normalizedPath), [](Asset<T> asset){Asset<T>::Unload(asset);});
(make_shared also takes no deleter, and the deleter receives a pointer, not a value.)
- Declaring deleted copy/move operations removed the implicit default constructor.
- My first timer wrapped only the scene constructor. The lazy loads in the first
Draw(including a 96 px font atlas) happened after it, so the number did not match what I heard.
Results
What I heard
Same scene-change route, music playing.
| Build | Debug | Release |
|---|---|---|
Before: Lantern before-shared-ptr (ec8c745) | audible pause, about 0.5 s | no pause |
After: Lantern lantern-v0.1.0 | no pause | no pause |
The pause was a Debug-build cost. Release never had it, so the refactor did not fix a Release problem, however...
What the log shows
Raylib's own File loaded / Unloaded lines, same scripted route on both builds. I went through the main menu, and completed 2 levels.

| Before | After | Change | |
|---|---|---|---|
| Asset files loaded | 93 | 42 | -55% |
| Unload events | 76 | 25 | -67% |
| 96 px font rasterized | 7 times | once | -86% |
| Each wall/floor texture loaded | 7 times | 2-3 times |
The font atlas (95 glyphs into a 1024x1024 texture) is the most expensive single load in the log. Before, every scene rebuilt it; after, it loads once.
Why my first timer disagreed with my ears
The instrumented baseline's swap timer read 44-58 ms, but the log showed a second batch of loads (walls, floors, pieces, icons, and the font) in the new scene's first Draw, after the timer stopped.
This is not the ceiling
I think I can push it further. Looking at the new log, leaving a level unloads the piece and floor textures (the win or lost scene in between does not hold them), and the next level loads the same files again. The idea is to keep those handles alive across the intermediate scenes, for example in a longer-lived scope, and then re-run the same scripted route and add a third bar to the chart above. My initial thought is sub-scenes, where a scene can have multiple sub-scenes. Level Menu hosts the level scenes. Level Menu owns the AssetStore, and when we go to the levels, that reference doesn't die, and is re-used between each sub-scene/level. That's just right outa my noggin, and I have no idea how it will work. I'll play around with that when I want to optimize Lantern a bit more.
Limitations and next steps
- Preload lists in each scene must match what
Drawuses; helper functions that fetch their own assets hide dependencies. I really really really don't like this. This is the one thing I'm going to fix right away somehow. It's up to the developer to keep track of what they'll use, and load it in the ctor of the scene. Not a good plan. - Assets shared by non-adjacent scenes (level, win screen, next level) are still reloaded; see "This is not the ceiling".
SceneManager::Run()assignsscene_managerafterEnter(), so it is not set duringEnter().- Expired
weak_ptrentries stay in the cache maps until the path is requested again. - No fullscreen/scaling support yet.
- Ideas I parked on purpose: lazy loading, frame-sliced loading with a progress bar, a data-driven manifest per scene, async image decode.
