Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  • Game owns the raylib window and a LayerStack
  • ImGuiLayer brings in a docking workspace and routes ImGui frames
  • GameLayer renders the actual scene into a RenderTexture2D that 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

opeffectuse case
Switchempty the stack, push one new scenemenu navigation
Pushoverlay a new scene on toppause / dialog
Popdrop the topmost sceneresume / dismiss

Render() walks the stack from the lowest opaque scene up, so an overlay (IsOverlay() == true) keeps the scene below visible.

Scenes shipped

sceneroleexits via
MainMenuSceneroot menu (Play / Settings / …)only the Quit item terminates
GameplaySceneactual puzzle (placeholder grid)ESC / P → push PauseMenuScene
PauseMenuSceneoverlay over gameplayESC / P or Resume = pop, Quit = switch to menu
SettingsSceneplaceholderESC → menu (or pop if pushed from pause)
GallerySceneplaceholderESC → menu
AchievementsSceneplaceholderESC → menu
CreditsScenestatic credits listESC → menu
EndScenegame-over / winESC / 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:

  1. InitWindow → GL context ready
  2. Game::ShowSplashFrame paints assets/textures/jl.png to the backbuffer. The OS shows it immediately.
  3. Heavy init runs (engine::LoadFonts(AsciiPlusCJK), ImGui context, layer attach) — the user keeps seeing the splash.
  4. Main loop starts, GameLayer::OnAttach does scenes_.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:

  1. No font cache. DrawTextEx takes a Font value; you have to own and pass it everywhere yourself.
  2. 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.
  3. stb_truetype rounding artifacts at small bake sizes. Descenders of j / g get clipped by 1px, the digit 1 floats 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(...) after InitWindow(...) — at that point raylib has a GL context.
  • Call engine::UnloadFonts() before CloseWindow(...) — once the GL context is gone every cached Font is 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_POINT here 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:

  1. We pass physical_size, not the caller’s logical size. This is what makes the 1:1 sampling possible — DrawTextEx ends up issuing draws at exactly the resolution the atlas was baked at, divided by the super-sample factor.
  2. Spacing scales with g_ui_scale. Without this, kerning looks visibly tighter on HiDPI than on a 100% display.
  3. We do std::string s(text) because string_view isn’t guaranteed to be NUL-terminated and DrawTextEx wants const 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:

familycovers
NotoSansSCSimplified Chinese + Latin
NotoSansTCTraditional Chinese + Latin
NotoSansJPJapanese (kana + kanji)
NotoSansKRKorean (hangul + hanja)
NotoSansCJKUnified 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:

  1. 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.
  2. 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:

setglyphssuper-sampleatlas (rough)
ASCII95~1 MB
ASCII+CJK21 200~30 MB
ASCII+CJK21 200~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:

  1. 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.
  2. 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.