enigmash
This book is a working journal for the enigmash clone — a C++23 + raylib remake of JackLance’s PuzzleScript original. It documents engineering decisions that are tedious to rediscover by reading the diff later.
The runtime is structured as a layered application:
Gameowns the raylib window and aLayerStackImGuiLayerbrings in a docking workspace and routes ImGui framesGameLayerrenders the actual scene into aRenderTexture2Dthat the ImGui Viewport panel displays
The chapters that follow drill into the parts that are easy to get wrong on the first try. Right now that means text rendering — picking a font, keeping raylib + ImGui visually consistent, and adding CJK without melting VRAM.
Scenes
Scenes are the unit the game switches between — logo splash, menu, gameplay,
pause overlay, etc. engine::SceneManager (src/engine/scene_manager.{h,cpp})
holds a stack and routes the per-frame hooks; scenes call back via
Manager()->Switch<T>() / Push<T>() / Pop() to navigate.
Transitions are queued and applied at the top of Update(), so a scene
can safely call Manager()->Switch<...>() from inside its own OnUpdate
without invalidating this.
Operations
| op | effect | use case |
|---|---|---|
Switch | empty the stack, push one new scene | menu navigation |
Push | overlay a new scene on top | pause / dialog |
Pop | drop the topmost scene | resume / dismiss |
Render() walks the stack from the lowest opaque scene up, so an overlay
(IsOverlay() == true) keeps the scene below visible.
Scenes shipped
| scene | role | exits via |
|---|---|---|
MainMenuScene | root menu (Play / Settings / …) | only the Quit item terminates |
GameplayScene | actual puzzle (placeholder grid) | ESC / P → push PauseMenuScene |
PauseMenuScene | overlay over gameplay | ESC / P or Resume = pop, Quit = switch to menu |
SettingsScene | placeholder | ESC → menu (or pop if pushed from pause) |
GalleryScene | placeholder | ESC → menu |
AchievementsScene | placeholder | ESC → menu |
CreditsScene | static credits list | ESC → menu |
EndScene | game-over / win | ESC / Enter → menu |
PauseMenuScene is the only IsOverlay() == true scene right now — the
gameplay underneath stays rendered so the dim panel reads as a true pause.
The shared placeholder chrome (centered title + bottom hint) lives in
scenes::ui::DrawPlaceholderFrame (src/scenes/placeholder.{h,cpp}); the
five “🔲 placeholder” scenes call into it. Delete placeholder.{h,cpp} once
every placeholder has been replaced with real content.
Boot flow
There’s no LogoScene — the .exe launch path paints the logo directly:
InitWindow→ GL context readyGame::ShowSplashFramepaintsassets/textures/jl.pngto the backbuffer. The OS shows it immediately.- Heavy init runs (
engine::LoadFonts(AsciiPlusCJK), ImGui context, layer attach) — the user keeps seeing the splash. - Main loop starts,
GameLayer::OnAttachdoesscenes_.Switch<MainMenuScene>().
This sidesteps the 1-2 s “frozen window” feel that the CJK font atlas bake would otherwise produce.
Quitting
A scene asks to terminate via Manager()->RequestQuit(), which sets a
flag. Game::Run polls it after each Tick:
while (!WindowShouldClose() && !game_layer_->QuitRequested()) {
Tick();
}
Shutdown();
The flag is honoured at a frame boundary, so Shutdown() always runs on
the clean teardown path (font atlas freed, window.state saved, GL
context closed in order). Scenes never call exit() or CloseWindow()
themselves.
Font wrapper (engine::text)
raylib’s stock text APIs (DrawText, DrawTextEx) are fine for one-off
demos but they leave a few problems on your plate when you ship a real
game:
- No font cache.
DrawTextExtakes aFontvalue; you have to own and pass it everywhere yourself. - No HiDPI story. The font you bake at 18px stays 18 physical px
on a 200% display, while ImGui scales its UI by
dpi_scale. Result: raylib HUD text looks half the size of ImGui chrome on a 4K laptop. - stb_truetype rounding artifacts at small bake sizes. Descenders of
j/gget clipped by 1px, the digit1floats above its baseline, and so on.
engine::text (in src/engine/text.{h,cpp}) is the wrapper layer that
solves all three. The public surface looks like this:
namespace engine {
enum class CodepointSet { AsciiOnly, AsciiPlusCJK };
void LoadFonts(CodepointSet cps = CodepointSet::AsciiOnly,
float ui_scale = -1.0f);
void UnloadFonts();
const Font& GetFont(int logical_size);
float UiScale();
CodepointSet GetCodepointSet();
void DrawText(std::string_view text, Vector2 pos, int size, Color color);
Vector2 MeasureText(std::string_view text, int size);
} // namespace engine
The rest of this chapter walks through why each piece exists.
Lifecycle: where to call LoadFonts / UnloadFonts
LoadFontEx uploads atlas pixels to a GL texture, and UnloadFont
expects that texture to still exist. So:
- Call
engine::LoadFonts(...)afterInitWindow(...)— at that point raylib has a GL context. - Call
engine::UnloadFonts()beforeCloseWindow(...)— once the GL context is gone every cachedFontis dangling.
In Game::Init we sandwich it between InitAudioDevice() and the
push_layer calls. In Game::Shutdown it goes after layers_.clear()
(which detaches GameLayer and ImGuiLayer, both of which may still
reference Font handles) and before CloseWindow().
void Game::Init() {
// ... InitWindow, SetTargetFPS, InitAudioDevice ...
engine::LoadFonts(engine::CodepointSet::AsciiPlusCJK);
layers_.push_layer(std::move(imgui_layer));
layers_.push_layer(std::move(game_layer));
}
void Game::Shutdown() {
// ...
layers_.clear();
engine::UnloadFonts();
CloseAudioDevice();
CloseWindow();
}
LoadFonts is idempotent — calling it twice is a no-op so layers can
defensively call it during OnAttach if they need to.
DPI-aware sizing
engine::DrawText takes a logical size in UI points. Internally we
scale it to physical pixels by the same DPI multiplier ImGui uses, so
engine::DrawText(..., 18, ...) and ImGui::Text at 18pt render at the
same visual height.
float DetectUiScale() {
Vector2 dpi = GetWindowScaleDPI();
return std::max(1.0f, std::max(dpi.x, dpi.y));
}
int Physical(int logical_size) {
return static_cast<int>(std::round(logical_size * g_ui_scale));
}
The scale is captured once at LoadFonts time. If the window moves
between monitors at different DPIs you can pass an explicit value:
engine::LoadFonts(CodepointSet::AsciiOnly, /*ui_scale=*/2.0f);
ImGuiLayer::GetDpiScale uses the exact same formula — that’s the trick
that keeps the two text systems aligned.
Super-sampled atlas, bilinear downsample
stb_truetype’s signed-distance metrics get rounded harshly when you bake
glyphs at small target sizes. The fix: bake the atlas at N× the
rendered size, sample with TEXTURE_FILTER_BILINEAR, and let the GPU
downsample at draw time.
constexpr int kSuperSampleAscii = 2;
constexpr int kSuperSampleCjk = 1; // see CJK chapter for why
FontEntry LoadOne(const char* path, int logical_size) {
FontEntry entry{};
entry.physical_size = Physical(logical_size);
const int bake_size = entry.physical_size * SuperSampleFor(g_cps);
entry.font = LoadFontEx(path, bake_size, ...);
SetTextureFilter(entry.font.texture, TEXTURE_FILTER_BILINEAR);
return entry;
}
When the user calls DrawText("hi", pos, 18, ...) we look up the entry
for size 18, then pass physical_size (not bake_size) into
DrawTextEx. raylib divides UVs by the bake size, so the GPU samples
the high-res atlas and the result is crisp.
TEXTURE_FILTER_POINThere would alias hard. The whole reason we bake 2× is so the bilinear tap blurs sub-pixel positioning errors away.
Per-size cache
We can’t cache one atlas and rescale — the cost of TEXTURE_FILTER_BILINEAR
between very different sizes is visible blur. So the cache is keyed by
logical size, with lazy load on miss:
std::unordered_map<int, FontEntry> g_cache;
const FontEntry& GetOrLoad(int logical_size) {
if (auto it = g_cache.find(logical_size); it != g_cache.end()) {
return it->second;
}
FontEntry entry = LoadOne(PathFor(g_cps), logical_size);
auto [it, _] = g_cache.emplace(logical_size, entry);
return it->second;
}
LoadFonts warms the cache for a few common sizes so the first frame
doesn’t pause to rasterise. For ASCII we preload {16, 18, 20, 24, 32}
— with kAtlasSuperSample = 2 and g_ui_scale = 2 that’s 5 atlases
of about a couple hundred glyphs each. Fine.
constexpr int kPreloadAsciiSizes[] = {16, 18, 20, 24, 32};
If a layer asks for size 22 we lazy-load on demand; subsequent calls hit the cache.
What DrawText actually does
void DrawText(std::string_view text, Vector2 pos, int size, Color color) {
std::string s(text);
const FontEntry& e = GetOrLoad(size);
DrawTextEx(e.font, s.c_str(), pos,
static_cast<float>(e.physical_size),
kDefaultSpacing * g_ui_scale,
color);
}
Three things to notice:
- We pass
physical_size, not the caller’s logicalsize. This is what makes the 1:1 sampling possible —DrawTextExends up issuing draws at exactly the resolution the atlas was baked at, divided by the super-sample factor. - Spacing scales with
g_ui_scale. Without this, kerning looks visibly tighter on HiDPI than on a 100% display. - We do
std::string s(text)becausestring_viewisn’t guaranteed to be NUL-terminated andDrawTextExwantsconst char*.
Mirroring the choice in ImGui
ImGuiLayer::LoadFonts reads engine::GetCodepointSet() and picks the
matching face, so a Chinese string renders identically whether it goes
through engine::DrawText or ImGui::Text. The next chapter covers
the CJK case in detail.
Adding Simplified Chinese
The base engine::text setup ships Noto Sans (Latin-only). Adding
Chinese is more involved than it looks — you need to (a) get a font
that actually contains Han glyphs, (b) be careful about which TTF you
pick, and (c) keep the atlas from melting your VRAM.
This chapter records what worked and the dead-ends I hit on the way.
TL;DR
// game.cpp
engine::LoadFonts(engine::CodepointSet::AsciiPlusCJK);
Then ship assets/fonts/noto/NotoSansSC-Regular.ttf next to the
existing NotoSans-Regular.ttf. Both engine::text and ImGuiLayer
read engine::GetCodepointSet() and pick the SC face automatically.
Why Noto Sans alone is not enough
NotoSans-Regular.ttf (the Latin Noto) doesn’t contain CJK Unified
Ideographs. If you DrawText("你好") with it you get tofu (◻◻).
The CJK glyphs live in a separate font family:
| family | covers |
|---|---|
NotoSansSC | Simplified Chinese + Latin |
NotoSansTC | Traditional Chinese + Latin |
NotoSansJP | Japanese (kana + kanji) |
NotoSansKR | Korean (hangul + hanja) |
NotoSansCJK | Unified pan-CJK |
We ship NotoSansSC because the game is shipping in zh-CN. Switching
to JP/KR is a one-line change in engine::text.cpp’s kCjkPath.
Trap #1: don’t use the Variable TTF
The canonical noto-cjk repo distributes its TTFs as variable fonts
(NotoSansSC-VF.ttf). VFs pack every weight from Thin through Black
into one file, with the actual rendered weight selected via the fvar
axis at draw time.
stb_truetype — used by both raylib and ImGui — does not parse the
fvar axis. It just renders the file’s default instance, which for
the upstream Noto CJK VFs is wght=100 (Thin). The result is text
that looks like it’s about to evaporate.
Confirming the effect on a real machine:
Before fix: thin, hairline strokes
After fix: normal Regular weight
There are two ways out:
- Use a static TTF at the weight you want. Both Google Fonts and the noto-cjk repo unfortunately only distribute VF TTFs publicly. The static OTFs they ship are CFF-based, which raylib’s stb_truetype doesn’t reliably handle either.
- Instance the VF down to a static TTF yourself. This is the approach we use.
Trap #2: don’t use the OTF static distribution
The static-weight files at noto-cjk/Sans/OTF/SimplifiedChinese/ are
in the right weight (Regular = wght 400) — but they’re CFF-based OTF
(.otf with the CFF table, PostScript Type 2 outlines). Newer
stb_truetype builds added partial CFF support, but coverage is uneven
and at the time of writing the version raylib ships with renders these
files badly.
Conclusion: use TrueType outlines (glyf table) only. That means
either a TTF that’s already static, or a VF that we statically instance
ourselves.
Static-instance the VF with fonttools
fonttools.varLib.instancer takes a VF and pins one or more axes to a
constant, producing a static TTF as output. It also drops fvar /
gvar / STAT / HVAR so downstream consumers don’t get confused.
# 1. Get the upstream variable font (~17 MB for the SC subset).
curl -L -o /tmp/NotoSansSC-VF.ttf \
https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/Variable/TTF/Subset/NotoSansSC-VF.ttf
# 2. Pin the wght axis to 400 (Regular) and write out a static TTF.
python -m pip install --user fonttools
python -m fontTools.varLib.instancer \
/tmp/NotoSansSC-VF.ttf wght=400 \
-o assets/fonts/noto/NotoSansSC-Regular.ttf
The resulting file is ~10.6 MB, contains only glyf outlines at
wght=400, and renders identically through stb_truetype, FreeType, and
the OS shaper. This is what we commit into the repo.
If you ever need a Bold variant, run the instancer again with
wght=700 and update the kCjkPath switch — that’s it.
Codepoint coverage
engine::text.cpp::BuildCodepoints decides which characters are baked
into the atlas:
void BuildCodepoints(CodepointSet cps) {
g_codepoints.clear();
for (int c = 0x20; c <= 0x7E; ++c) g_codepoints.push_back(c); // ASCII
if (cps == CodepointSet::AsciiPlusCJK) {
for (int c = 0x3000; c <= 0x303F; ++c) g_codepoints.push_back(c); // CJK punct
for (int c = 0x4E00; c <= 0x9FFF; ++c) g_codepoints.push_back(c); // Han
for (int c = 0xFF00; c <= 0xFFEF; ++c) g_codepoints.push_back(c); // Half/Full
}
}
The Han range alone is ~21 000 codepoints. That’s not free.
VRAM budget: super-sampling and preload list
For ASCII we bake the atlas at 2× the render size to dodge stb_truetype’s small-size rounding artifacts (see the previous chapter). With CJK that doubles the atlas footprint:
| set | glyphs | super-sample | atlas (rough) |
|---|---|---|---|
| ASCII | 95 | 2× | ~1 MB |
| ASCII+CJK | 21 200 | 2× | ~30 MB |
| ASCII+CJK | 21 200 | 1× | ~7 MB |
Multiply by the number of preloaded sizes and the cost adds up fast.
So engine::text makes two adjustments when CJK is on:
- Drop super-sampling to 1×. CJK strokes are thicker than Latin ones — the half-pixel rounding artefacts that 2× was hiding aren’t visible on Han to begin with.
- Preload only one size (18pt). Lazy-load any other size on
first use. ASCII still preloads
{16, 18, 20, 24, 32}because the per-atlas cost is negligible.
constexpr int kPreloadAsciiSizes[] = {16, 18, 20, 24, 32};
constexpr int kPreloadCjkSizes[] = {18};
constexpr int kSuperSampleAscii = 2;
constexpr int kSuperSampleCjk = 1;
Wiring it up: raylib side
const char* PathFor(CodepointSet cps) {
return cps == CodepointSet::AsciiPlusCJK ? kCjkPath : kRegularPath;
}
void LoadFonts(CodepointSet cps, float ui_scale) {
if (!g_cache.empty()) return; // idempotent
g_cps = cps;
g_ui_scale = (ui_scale > 0.0f) ? ui_scale : DetectUiScale();
BuildCodepoints(cps);
const char* path = PathFor(cps);
if (cps == CodepointSet::AsciiPlusCJK) {
for (int s : kPreloadCjkSizes) g_cache[s] = LoadOne(path, s);
} else {
for (int s : kPreloadAsciiSizes) g_cache[s] = LoadOne(path, s);
}
}
g_cps is exposed via engine::GetCodepointSet() so other layers can
mirror our choice without a separate config.
Wiring it up: ImGui side
ImGuiLayer::LoadFonts reads the same global and picks both the font
file and the glyph range to load:
void ImGuiLayer::LoadFonts(float dpi_scale) {
ImGuiIO& io = ImGui::GetIO();
const float font_size = kImGuiBaseFontSize * dpi_scale;
const bool cjk = (engine::GetCodepointSet()
== engine::CodepointSet::AsciiPlusCJK);
const std::filesystem::path path =
cjk ? std::filesystem::path{"assets/fonts/noto/NotoSansSC-Regular.ttf"}
: std::filesystem::path{"assets/fonts/noto/NotoSans-Regular.ttf"};
const ImWchar* ranges =
cjk ? io.Fonts->GetGlyphRangesChineseFull()
: io.Fonts->GetGlyphRangesDefault();
io.Fonts->Clear();
if (std::filesystem::exists(path)) {
io.FontDefault = io.Fonts->AddFontFromFileTTF(
path.string().c_str(), font_size, nullptr, ranges);
}
if (io.FontDefault == nullptr) {
io.FontDefault = io.Fonts->AddFontDefault();
}
}
GetGlyphRangesChineseFull() covers Latin + Hiragana + Katakana +
Half/Fullwidth + CJK Unified Ideographs — same set as our raylib-side
codepoint list, give or take. The atlas ImGui builds is independent
from the raylib atlas, but both end up with the same glyph repertoire.
For tighter VRAM, swap in GetGlyphRangesChineseSimplifiedCommon() —
that ships ~2 500 of the most common simplified characters instead of
the full ~21 000.
Testing
The placeholder scene writes a Chinese line so the regression is visible at a glance:
engine::DrawText("应无所住,而生其心。", Vector2{16, 48}, 24,
Color{220, 220, 240, 255});
If that line renders as tofu, your assets/fonts/noto/NotoSansSC-Regular.ttf
is missing or the wrong file. If it renders as Thin (visibly skinny
strokes), you grabbed the upstream VF directly without instancing it.