Skip to content

tilemap: engine Tilemap component + .tmx loader + post-sprite render pass (T2 Phase 2) - #703

Merged
apotema merged 5 commits into
mainfrom
feat/t2-tilemap-component
Jul 7, 2026
Merged

apotema merged 5 commits into
mainfrom
feat/t2-tilemap-component

Conversation

@apotema

@apotema apotema commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Phase 2 of the minimal T2 tilemap epic: a real, immutable Tilemap component that references an embedded .tmx asset by name, decodes it through the gfx 1.21.0 tilemap API, and renders it as a POST-SPRITE pass. The engine owns pass ordering (entities first, tilemaps after); gfx's RetainedEngine is untouched.

Load-bearing design decision (the render-orchestration unknown)

The engine module takes NO labelle-gfx dependency — the existing invariant (game.zig: "The engine does NOT depend on labelle-gfx — it accesses component types through RenderImpl"). The design brief assumed a direct gfx.TileMap / gfx pin on the module; grounding against origin/main showed that would break the invariant and the "purely additive" bar.

Instead, the engine reaches gfx's TileMap decoder + TileMapRenderer through the renderer plugin — RenderImpl.TileMapRendererType, loadTextureFromMemory, getTextureInfo — exactly as it reaches Sprite/Shape and the drawMesh seam. All the type reflection (naming TileMap, Tileset, the backend Texture, the resolver) is centralised in src/tilemap_runtime.zig. The whole feature is comptime-gated on tilemap_supported, so stub renderers get a void side table + no-op methods — zero cost, no behaviour change for existing consumers.

Texture crux: the engine catalog stores u32 texture ids, but the gfx resolver seam needs a Backend.Texture. Bridged via the SAME path sprites use — RenderImpl.loadTextureFromMemory (upload) → getTextureInfo(id).backend_texture (resolve) — so tileset images share the sprite texture registry and are unowned by the tilemap renderer (load_unresolved_from_filesystem = false).

Asset lifecycle: a LoaderKind.tilemap catalog variant would touch ~13 files / ~86 exhaustive switch sites AND can't hold a gfx TileMap in the gfx-agnostic DecodedPayload union — that fails "minimal/additive". Used a dedicated embedded-source registry (embedded_tilemap_sources + addEmbeddedTilemapAsset), same idiom as embedded_scene_sources.

Save: Tilemap.asset_name is []const u8, which the registry-driven serde can't round-trip — so it lives in the built-in save channel alongside Position/PrefabInstance, persisting only asset_name. Load rehydrates the runtime by re-decoding. Tilemap-only entities are swept into the save set; ECS reset frees stale runtimes.

The gfx pin (build.zig.zon) is lazy + test-only: consumed solely by the tilemap test to drive the real shipped gfx 1.21.0 tilemap sub-package headless.

Files

  • New: src/tilemap.zig, src/tilemap_runtime.zig, src/game/tilemap_mixin.zig, test/tilemap_test.zig
  • Wiring: game.zig, game_init.zig, lifecycle_mixin.zig, world_mixin.zig, entity_mixin.zig, loop_mixin.zig, jsonc/component_apply.zig, editor_api.zig, save_load/{save,load}.zig, root.zig, build.zig, build.zig.zon

Tests

745/745 engine tests pass (zig build test), incl. 5 new: seam detection, .tmx decode, post-sprite ordering + world offset, save→load round-trip of asset_name, digest presence. zig fmt clean; ReleaseSafe build clean.

Not in scope (deferred)

  • Camera-transform alignment (T2 runs the pass in screen space, camera_* = 0) + Z-interleaving with sprite layers → T3.
  • Assembler-side .tmx + tileset-image embedding → Phase 4.

Do not merge — no engine release/version bump (release choreography is handled post-review).

https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw

apotema added 2 commits July 7, 2026 17:10
The engine module itself takes NO labelle-gfx dependency — it reaches
tilemap types through the renderer plugin (`RenderImpl.TileMapRendererType`),
exactly as it reaches Sprite/Shape/`drawMesh`. This lazy pin is consumed
only by the T2 tilemap test to decode a real `.tmx` through gfx's std-only
`tilemap` sub-package and drive `TileMapRendererWith(MockBackend)`
post-sprite, proving the whole path against the shipped gfx 1.21.0 API.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
…pass (T2 Phase 2)

Adds a minimal, immutable `Tilemap` component that references an embedded
`.tmx` asset by name, decodes it through the gfx 1.21.0 tilemap API, and
renders it as a POST-SPRITE pass — the engine owns pass ordering (entities
first, tilemaps after); gfx's RetainedEngine is untouched.

The engine module stays gfx-free: it reaches gfx's `TileMap` decoder +
`TileMapRenderer` through the renderer plugin (`RenderImpl.TileMapRendererType`,
`loadTextureFromMemory`, `getTextureInfo`), exactly as it reaches
Sprite/Shape and the drawMesh seam. All the reflection is centralised in
`src/tilemap_runtime.zig`; the whole feature is comptime-gated on
`tilemap_supported` so stub renderers see a void side table + no-op methods
(purely additive).

- `Tilemap` component (`src/tilemap.zig`) — POD `{ asset_name }`; the
  decoded map + per-entity draw-pass renderer live in a Game-side side
  table (`tilemaps`), keyed by entity, heap-owned for pointer stability.
- `addTilemap` mixin (`src/game/tilemap_mixin.zig`) — decodes behind the
  embedded tilemap-asset registry (`addEmbeddedTilemapAsset`), resolves
  each tileset image through the SAME backend texture path sprites use
  (the gfx 1.21.0 resolver seam, load_unresolved_from_filesystem = false).
- Scene loader: `{"Tilemap": {"asset_name": "..."}}` → `addTilemap`.
- Render orchestration: `renderTilemaps()` after `renderer.render()` in the
  frame loop, gated by the post-load render gate.
- Save/load: built-in channel (like PrefabInstance — `[]const u8` can't
  round-trip via registry serde) persists ONLY `asset_name`; load
  rehydrates the runtime by re-decoding. Tilemap-only entities are swept
  into the save set. ECS reset frees stale runtimes (`clearTilemaps`).
- Editor digest: per-entity `"tilemap":"<asset_name>"` presence flag.

Test (`test/tilemap_test.zig`) drives the ACTUALLY-shipped gfx 1.21.0
tilemap sub-package + `TileMapRendererWith(MockBackend)` (lazy test-only
pin) headless: decode, post-sprite ordering + world offset, save→load
round-trip, digest. 745/745 engine tests pass; zig fmt clean.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@apotema, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fae2ff03-9243-4002-a658-af3a7c6a3f8d

📥 Commits

Reviewing files that changed from the base of the PR and between fdd9832 and 733d46c.

📒 Files selected for processing (8)
  • build.zig
  • src/game.zig
  • src/game/lifecycle_mixin.zig
  • src/game/tilemap_mixin.zig
  • src/jsonc/component_apply.zig
  • src/tilemap_runtime.zig
  • test/jsonc/tilemap_precedence_test.zig
  • test/tilemap_test.zig
📝 Walkthrough

Walkthrough

This PR adds a built-in Tilemap component and a renderer-agnostic tilemap_runtime decode/draw system, wiring them through Game (add/acquire/release/render/clear/deinit), lifecycle/destroy/reset paths, save/load persistence of asset_name, jsonc component application, editor scene digest, build/dependency wiring for a lazy labelle-gfx test dependency, and new end-to-end tests.

Changes

Tilemap Feature

Layer / File(s) Summary
Tilemap component and runtime contracts
src/tilemap.zig, src/root.zig, src/tilemap_runtime.zig
Defines the Tilemap component (asset_name only) and the tilemap_runtime module with supported(), ImageProvider, and Runtime(RenderImpl) decoding TMX bytes, uploading tileset textures, and drawing layers.
Tilemap mixin and Game wiring
src/game/tilemap_mixin.zig, src/game.zig, src/game/game_init.zig, src/game/lifecycle_mixin.zig, src/game/loop_mixin.zig, src/game/entity_mixin.zig, src/game/world_mixin.zig
Implements the tilemap mixin (add/acquire/release/render/clear/deinit/runtime lookup) and integrates it into Game's state, init, render loop (post-sprite pass), teardown, entity destruction, and ECS reset.
Save/load and jsonc apply integration
src/game/save_load/save.zig, src/game/save_load/load.zig, src/jsonc/component_apply.zig, src/editor_api.zig
Persists only asset_name on save, auto-collects tilemap-only entities, rehydrates the runtime on load, applies "Tilemap" via jsonc special-casing, and adds "tilemap" to editor scene digest output.
Build wiring and end-to-end tests
build.zig, build.zig.zon, test/tilemap_test.zig
Adds a lazy, test-only labelle_gfx dependency, wires a new tilemap_test.zig test target, and adds tests covering decoding, render ordering, save/load payload shape, and digest output.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Loader as loadGameState
  participant Game
  participant TilemapMixin
  participant Runtime as tilemap_runtime.Runtime

  Loader->>Game: addTilemap(entity, .{asset_name})
  Game->>TilemapMixin: addTilemap
  TilemapMixin->>TilemapMixin: acquireTilemap
  TilemapMixin->>Runtime: initInPlace(tmx_bytes, images)
  Runtime-->>TilemapMixin: decoded map stored
  Game->>TilemapMixin: renderTilemaps (post-sprite)
  TilemapMixin->>Runtime: draw(camera, position)
  Game->>TilemapMixin: releaseTilemap(entity)
  TilemapMixin->>Runtime: deinit
Loading

Possibly related PRs

Poem

A tile, a map, a hop, a bound,
Asset names saved safe and sound.
No gid leaks in the JSON glade,
Just sprites then tiles, in order laid.
This bunny cheers with twitching nose —
🐇 the tilemap runtime grows and grows!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main tilemap component, loader, and post-sprite render-pass changes.
Description check ✅ Passed The description is directly related to the tilemap feature work and matches the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/t2-tilemap-component

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the built-in "Tilemap" component (T2 Phase 2), integrating ".tmx" decoding, a post-sprite rendering pass, and save/load persistence into the engine. The feedback highlights a critical issue in "src/tilemap_runtime.zig" where a stack-allocated resolver context creates a dangling pointer, which should be resolved by storing it as a field in the "Runtime" struct. Additionally, it is recommended to convert file extensions to lowercase in "fileTypeZ" to ensure compatibility with image decoders.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/tilemap_runtime.zig
/// Per-tileset catalog texture id (null = image unresolved →
/// that tileset draws nothing). Read by the resolver trampoline.
tileset_ids: []?TextureId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Add the resolver_ctx field to the Runtime struct to prevent the dangling stack pointer issue in initInPlace.

        resolver_ctx: ResolverCtx,

Comment thread src/tilemap_runtime.zig
Comment on lines +132 to +146
self.* = .{
.allocator = allocator,
.renderer = renderer,
.map = map,
.tm = undefined,
.tileset_ids = ids,
};

var ctx = ResolverCtx{ .renderer = renderer, .ids = self.tileset_ids };
self.tm = try TmRenderer.initWithOptions(allocator, &self.map, .{
.resolver = Resolver{ .context = &ctx, .resolveFn = resolveTexture },
// Embedded env: never touch the filesystem for unresolved
// tilesets — the resolver is the only texture source.
.load_unresolved_from_filesystem = false,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The local variable ctx is allocated on the stack of initInPlace. Passing its address (&ctx) to TmRenderer.initWithOptions creates a dangling pointer once initInPlace returns. When draw is later called, the resolver will access this garbage stack memory, leading to undefined behavior or crashes. To fix this, store resolver_ctx as a field in the Runtime struct so it has a stable lifetime matching the heap-allocated Runtime instance.

            self.* = .{
                .allocator = allocator,
                .renderer = renderer,
                .map = map,
                .tm = undefined,
                .tileset_ids = ids,
                .resolver_ctx = .{
                    .renderer = renderer,
                    .ids = ids,
                },
            };

            self.tm = try TmRenderer.initWithOptions(allocator, &self.map, .{
                .resolver = Resolver{ .context = &self.resolver_ctx, .resolveFn = resolveTexture },
                // Embedded env: never touch the filesystem for unresolved
                // tilesets — the resolver is the only texture source.
                .load_unresolved_from_filesystem = false,
            });

Comment thread src/tilemap_runtime.zig
Comment on lines +182 to +187
fn fileTypeZ(allocator: std.mem.Allocator, image_source: []const u8) ![:0]const u8 {
const dot = std.mem.lastIndexOfScalar(u8, image_source, '.');
const ext = if (dot) |d| image_source[d + 1 ..] else "";
const chosen = if (ext.len == 0) "png" else ext;
return allocator.dupeZ(u8, chosen);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring states that fileTypeZ returns a 'lowercase-ish file-type token', but it currently duplicates the extension exactly as-is (e.g., .PNG remains PNG). Since many image decoders and texture loaders expect lowercase file types, convert the duplicated extension to lowercase using std.ascii.toLower to ensure robustness.

fn fileTypeZ(allocator: std.mem.Allocator, image_source: []const u8) ![:0]const u8 {
    const dot = std.mem.lastIndexOfScalar(u8, image_source, '.');
    const ext = if (dot) |d| image_source[d + 1 ..] else "";
    const chosen = if (ext.len == 0) "png" else ext;
    const duped = try allocator.dupeZ(u8, chosen);
    for (duped) |*c| {
        c.* = std.ascii.toLower(c.*);
    }
    return duped;
}

…view)

gemini flagged the resolver context (passed by &ctx to initWithOptions) as a
dangling stack pointer. Verified against gfx v1.21.0: initWithOptions resolves
tileset textures EAGERLY and does not retain the resolver, so &ctx was safe
today. But that made the engine silently depend on gfx's resolution timing
across the repo boundary — a future switch to lazy resolution would be a UAF
with no compile error. Store the ctx inline in the heap-stable Runtime so it's
correct-by-construction regardless. Also lowercase fileTypeZ's token so a .PNG
tileset resolves the same decoder as .png.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
@apotema

apotema commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in fdd9832.

Dangling-pointer finding (lines 88/146): verified against gfx v1.21.0 — TileMapRenderer.initWithOptions resolves tileset textures eagerly inside the call (loops map.tilesets, stores resolved Texture handles in self.textures) and does not retain the resolver. So &ctx was only dereferenced while ctx was still alive → not a live bug today. But relying on that made the engine silently depend on gfx's resolution timing across the repo boundary (a future switch to lazy/draw-time resolution would be a UAF with no compile error), so I stored the context inline in the heap-stable Runtime to be correct-by-construction regardless. Thanks — good catch on the latent coupling.

Lowercase token (line 187): applied std.ascii.toLower so a .PNG tileset resolves the same decoder as .png.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf32c1b301

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tilemap_runtime.zig
Comment on lines +149 to +152
pub fn deinit(self: *Self) void {
self.tm.deinit();
self.map.deinit();
self.allocator.free(self.tileset_ids);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unload tilemap texture ids on runtime teardown

When a scene is unloaded or a Tilemap entity is destroyed, releaseTilemap reaches this deinit path, but the tileset textures uploaded in initInPlace via renderer.loadTextureFromMemory are never released. Because those textures are handed to gfx as resolver-supplied/unowned entries, self.tm.deinit() leaves them in the renderer texture table until the renderer itself is destroyed, so repeated scene reloads or multiple tilemap entities keep allocating duplicate GPU textures for the same tileset images.

Useful? React with 👍 / 👎.

Comment thread src/game.zig
/// pointers so the gfx tilemap renderer's `*const TileMap` into the
/// runtime stays stable. `void` on renderers without the tilemap
/// seam. Owned here; freed in `deinitTilemaps` / `releaseTilemap`.
tilemaps: if (tilemap_supported) std.AutoHashMap(Entity, *TilemapRuntimeType) else void,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Store tilemap runtimes per world

In games that use createWorld/setActiveWorld, this single Game-level side table is not swapped with active_world even though the ECS and renderer are. After shelving a world that has tilemaps, renderTilemaps still iterates those old entity ids against the newly active ECS, so stale tilemaps can draw in the wrong world (or at default/unrelated positions), and destroyWorld/resetEcsBackend cannot clean up the correct world's tilemap state independently.

Useful? React with 👍 / 👎.

Comment thread src/game/tilemap_mixin.zig Outdated
Comment on lines +109 to +110
const pos = self.getWorldPosition(e.key_ptr.*);
e.value_ptr.*.draw(0, 0, pos.x, pos.y, null, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the game Y-axis before drawing tilemaps

For projects using the default GameConfig / .up Y-axis, the retained renderer flips sprite positions through the engine's core.toScreenY path, but the tilemap pass sends the logical world Position directly to the backend as a screen-space offset. Even with camera offsets intentionally deferred to T3, this makes tilemaps render vertically mirrored/misaligned relative to sprites, gizmos, and input picking in existing y-up games.

Useful? React with 👍 / 👎.

/// that lands.
pub fn renderTilemaps(self: *Game) void {
if (comptime !supported) return;
var it = self.tilemaps.iterator();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release tilemap runtime when removing the component

If gameplay removes Game.TilemapComp through the existing generic removeComponent API, the ECS component disappears but nothing calls releaseTilemap, and this pass keeps drawing from the side table anyway. Since there is no removeTilemap counterpart to addTilemap, that leaves a removed tilemap visible until the entity is destroyed or the ECS is reset.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
build.zig (1)

297-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

test/tilemap_test.zig isn't wired through the test_files array.

As per coding guidelines, "Wire each src/foo.zig + test/foo_test.zig pair explicitly in build.zig's test_files array." This test is instead wired ad hoc inside the lazyDependency block because it needs an extra "tilemap" import the generic loop doesn't support — same pattern already used for iosurface_test/assets_tests above, so it's not unprecedented in this file, but it does deviate from the stated convention.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build.zig` around lines 297 - 328, The tilemap test is being added outside
the standard test_files wiring, so make build.zig register test/tilemap_test.zig
explicitly in the test_files array like the other src/foo.zig +
test/foo_test.zig pairs. Keep the special tilemap_module import handling, but
move or mirror the test registration so Tilemap test coverage follows the
established convention rather than only being created inside the
lazyDependency("labelle_gfx") block.

Source: Coding guidelines

src/jsonc/component_apply.zig (1)

115-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing test coverage for the new JSONC "Tilemap" apply path.

The decode/render/save-load/digest tests in test/tilemap_test.zig all construct Tilemap via game.addTilemap directly; none exercise this applyComponent branch (i.e., a scene authored in .jsonc with a "Tilemap" component key). A regression here (dispatch ordering, deserializer field mismatch) wouldn't be caught by current tests.

Want me to draft a jsonc-scene-loading test reusing the existing MockRender/TilemapGame scaffolding from test/tilemap_test.zig?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsonc/component_apply.zig` around lines 115 - 123, Add a test that
exercises the new applyComponent "Tilemap" branch instead of only using
game.addTilemap directly. Reuse the existing MockRender and TilemapGame
scaffolding from test/tilemap_test.zig, load a scene/component payload through
the JSONC path, and verify that applyComponent dispatches the "Tilemap" case and
produces the same tilemap/render/save-load behavior. Cover the Tilemap-specific
path in src/jsonc/component_apply.zig so regressions in dispatch ordering or
GameType.TilemapComp deserialization are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/game/tilemap_mixin.zig`:
- Around line 60-83: The global Game.tilemaps side table is not being cleared or
migrated when worlds change, which can leave stale Runtime entries tied to the
old world’s RenderImpl. Update setActiveWorld and destroyWorld to purge
tilemaps, or move the tilemap cache onto World so each world owns its own
runtimes. Make sure renderTilemaps only sees runtimes for the currently active
world and that acquireTilemap/releaseTilemap continue to manage entries
consistently.

In `@src/root.zig`:
- Around line 61-66: The Tilemap subsystem is only exposing the component type,
while tilemap_runtime.zig’s public API remains hidden from the engine surface.
Update src/root.zig to add a tilemap_runtime_mod re-export alongside Tilemap,
using the same pattern as tween_mod/tween_tick_mod and animation_events_mod, so
supported, ImageProvider, and Runtime are available through engine. Keep the new
export grouped under the existing Tilemap section and reference the
tilemap_runtime.zig module import directly.

---

Nitpick comments:
In `@build.zig`:
- Around line 297-328: The tilemap test is being added outside the standard
test_files wiring, so make build.zig register test/tilemap_test.zig explicitly
in the test_files array like the other src/foo.zig + test/foo_test.zig pairs.
Keep the special tilemap_module import handling, but move or mirror the test
registration so Tilemap test coverage follows the established convention rather
than only being created inside the lazyDependency("labelle_gfx") block.

In `@src/jsonc/component_apply.zig`:
- Around line 115-123: Add a test that exercises the new applyComponent
"Tilemap" branch instead of only using game.addTilemap directly. Reuse the
existing MockRender and TilemapGame scaffolding from test/tilemap_test.zig, load
a scene/component payload through the JSONC path, and verify that applyComponent
dispatches the "Tilemap" case and produces the same tilemap/render/save-load
behavior. Cover the Tilemap-specific path in src/jsonc/component_apply.zig so
regressions in dispatch ordering or GameType.TilemapComp deserialization are
caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 80efe74f-cf62-41a0-8ecc-c1969eda1225

📥 Commits

Reviewing files that changed from the base of the PR and between 14b5c46 and fdd9832.

📒 Files selected for processing (17)
  • build.zig
  • build.zig.zon
  • src/editor_api.zig
  • src/game.zig
  • src/game/entity_mixin.zig
  • src/game/game_init.zig
  • src/game/lifecycle_mixin.zig
  • src/game/loop_mixin.zig
  • src/game/save_load/load.zig
  • src/game/save_load/save.zig
  • src/game/tilemap_mixin.zig
  • src/game/world_mixin.zig
  • src/jsonc/component_apply.zig
  • src/root.zig
  • src/tilemap.zig
  • src/tilemap_runtime.zig
  • test/tilemap_test.zig

Comment thread src/game/tilemap_mixin.zig
Comment thread src/root.zig
Comment on lines +61 to +66
// ── Tilemap (T2 Phase 2) ──
/// Engine built-in `Tilemap` component — references an embedded `.tmx`
/// asset by name. Reachable on a configured game as `Game.TilemapComp`.
/// See `src/tilemap.zig` / `src/tilemap_runtime.zig`.
pub const Tilemap = @import("tilemap.zig").Tilemap;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

tilemap_runtime.zig's public API isn't re-exported on engine.

Tilemap the component is correctly re-exported here, but tilemap_runtime.zig also declares pub fn supported, pub const ImageProvider, and pub fn Runtime that aren't surfaced anywhere in this file. Other similarly-shaped subsystems (e.g. tween_mod/tween_tick_mod, animation_events_mod) expose both the component-level type and the underlying module. Consider adding a tilemap_runtime_mod re-export alongside Tilemap for consistency.

As per coding guidelines, "Add every new public type or function to src/root.zig so it is re-exported on the public engine surface as engine.Foo, grouped under a // ── Section ── heading."

♻️ Proposed addition
 // ── Tilemap (T2 Phase 2) ──
 /// Engine built-in `Tilemap` component — references an embedded `.tmx`
 /// asset by name. Reachable on a configured game as `Game.TilemapComp`.
 /// See `src/tilemap.zig` / `src/tilemap_runtime.zig`.
 pub const Tilemap = `@import`("tilemap.zig").Tilemap;
+/// Renderer-agnostic tilemap decode/draw runtime module — the feature
+/// gate (`supported`), the bytes-provider seam (`ImageProvider`), and
+/// the per-renderer `Runtime(RenderImpl)` type. See
+/// `Game.tilemap_supported` / `Game.TilemapRuntimeType` for the
+/// game-bound aliases.
+pub const tilemap_runtime_mod = `@import`("tilemap_runtime.zig");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ── Tilemap (T2 Phase 2) ──
/// Engine built-in `Tilemap` component — references an embedded `.tmx`
/// asset by name. Reachable on a configured game as `Game.TilemapComp`.
/// See `src/tilemap.zig` / `src/tilemap_runtime.zig`.
pub const Tilemap = @import("tilemap.zig").Tilemap;
// ── Tilemap (T2 Phase 2) ──
/// Engine built-in `Tilemap` component — references an embedded `.tmx`
/// asset by name. Reachable on a configured game as `Game.TilemapComp`.
/// See `src/tilemap.zig` / `src/tilemap_runtime.zig`.
pub const Tilemap = `@import`("tilemap.zig").Tilemap;
/// Renderer-agnostic tilemap decode/draw runtime module — the feature
/// gate (`supported`), the bytes-provider seam (`ImageProvider`), and
/// the per-renderer `Runtime(RenderImpl)` type. See
/// `Game.tilemap_supported` / `Game.TilemapRuntimeType` for the
/// game-bound aliases.
pub const tilemap_runtime_mod = `@import`("tilemap_runtime.zig");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/root.zig` around lines 61 - 66, The Tilemap subsystem is only exposing
the component type, while tilemap_runtime.zig’s public API remains hidden from
the engine surface. Update src/root.zig to add a tilemap_runtime_mod re-export
alongside Tilemap, using the same pattern as tween_mod/tween_tick_mod and
animation_events_mod, so supported, ImageProvider, and Runtime are available
through engine. Keep the new export grouped under the existing Tilemap section
and reference the tilemap_runtime.zig module import directly.

Source: Coding guidelines

…move, F2 guard)

F1 — tileset textures leaked on teardown. The runtime uploads them via
renderer.loadTextureFromMemory and hands them to gfx as UNOWNED, so
tm.deinit never released them. Runtime.deinit now unloads each (deduped)
tileset id through renderer.unloadTexture (added to the `supported` gate),
and lifecycle deinit runs deinitTilemaps BEFORE the renderer teardown so
the unload seam is still alive.

F3 — tilemaps rendered vertically mismatched vs sprites in `.up` projects.
renderTilemaps now flips the map's world offset through the SAME
core.toScreenY sprites use (reading the renderer's logical screen_height),
raising by the map's pixel height under `.up` so a tilemap and a sprite at
the same Position.y align; identity under `.down`. Camera-transform
alignment stays deferred to T3.

F4 — no removeTilemap counterpart to addTilemap, so generic removeComponent
orphaned the side-table runtime (kept drawing). Added removeTilemap
(releaseTilemap + removeComponent) and made renderTilemaps reap any
side-table entry whose entity no longer carries Tilemap in the active ECS
(alloc-free, restart-on-remove).

F2 — deferred + guarded + filed (#704). The Game-scoped side table isn't
swapped with active_world; the render reap now skips ids not live in the
currently active ECS, resetEcsBackend already clears the table via
clearTilemaps, and the single-world limitation is documented at the field.

+5 regression tests (750/750 pass): y-axis alignment (.up matches the
sprite transform; .down identity), texture release/no-leak + re-acquire
stability, removeTilemap teardown, and generic-removeComponent ghost reap.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
@apotema

apotema commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Disposition of the two CodeRabbit findings (both on fdd9832, now on head 00fbf9d):

  • tilemap_mixin.zig:84 — clear tilemap runtimes on world swaps: same concern as codex's per-world finding. Handled in 00fbf9d — renderTilemaps now reaps side-table entries whose entity isn't live+tilemap-bearing in the currently active ECS (so a shelved world's runtimes don't draw), resetEcsBackend clears the table via clearTilemaps on scene swap / load, and proper per-world scoping (own on World, clean in destroyWorld) is filed as Tilemap side table is Game-scoped, not per-world (T2 F2 follow-up) #704. The residual colliding-id edge is multi-world-only and tracked there.
  • root.zig:66 — re-export tilemap_runtime_mod: Runtime/supported/ImageProvider are internal plumbing the engine drives itself; games use the Tilemap component + addTilemap (both re-exported). Leaving the module unexported to avoid widening the public surface for an internal mechanism; will revisit if Phase 4 (assembler) needs it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00fbf9d920

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +163 to +165
if (!self.ecs_backend.hasComponent(e.key_ptr.*, Tilemap)) {
const rt = e.value_ptr.*;
_ = self.tilemaps.remove(e.key_ptr.*);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope tilemap runtimes by world

In games that use createWorld/setActiveWorld with tilemaps in multiple worlds, this guard only checks whether the currently active ECS has a Tilemap at the same numeric entity id. If it does, a runtime that belongs to the shelved world is kept and drawn at the active world's position; if it doesn't, the shelved runtime is freed, so switching back leaves that world's entity with a Tilemap component but no runtime to draw. Fresh evidence beyond the prior per-world concern is this reap path: it makes the Game-wide table alias or destruct inactive-world tilemaps during ordinary world swaps instead of preserving them.

Useful? React with 👍 / 👎.

Comment thread src/jsonc/component_apply.zig Outdated
Comment on lines +117 to +119
if (std.mem.eql(u8, name, "Tilemap")) {
if (deserializer.deserialize(GameType.TilemapComp, value, comp_alloc)) |tilemap| {
game.addTilemap(entity, tilemap);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid shadowing registered Tilemap components

For existing projects that already have a registry component whose serialized name is Tilemap, this new built-in branch runs before the Components.names() dispatch and returns unconditionally. Since GameType.TilemapComp.asset_name has a default, custom Tilemap JSON that lacks asset_name still deserializes as the engine tilemap with an empty asset name, so the registered component is silently not added and scene loads lose that component data.

Useful? React with 👍 / 👎.

…t (C1)

C2 — a project-registered component named `Tilemap` was silently shadowed
by the engine built-in: the built-in scene-loader branch ran before the
registry dispatch and returned unconditionally, so custom `Tilemap` JSON
deserialized as an empty engine tilemap (asset_name defaults to "") and the
registered component never loaded — silent scene-data loss. The built-in
branch is now comptime-gated on `!Components.has("Tilemap")`, so a
registered `Tilemap` falls through to the registry dispatch and WINS.
Regression test: test/jsonc/tilemap_precedence_test.zig asserts a registered
`Tilemap` loads with its real fields and the built-in is not attached.

C1 — documented + deferred into #704. The F4 ghost-reap can't tell
"component removed" from "entity belongs to a shelved world" (the table is
Game-global), so a raw world swap would destruct a shelved world's runtime.
Added a caveat at reapGhostTilemaps noting the single-active-world
assumption + pointing at #704, and expanded the #704 body to call out this
reap-path destruction/aliasing (not just stale draws). Single-world
behavior is unchanged (correct for minimal-T2).

751/751 tests pass; zig fmt clean.

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
@apotema
apotema merged commit a959498 into main Jul 7, 2026
3 checks passed
@apotema
apotema deleted the feat/t2-tilemap-component branch July 7, 2026 21:20
apotema added a commit that referenced this pull request Jul 7, 2026
…st-sprite render pass (T2 Phase 2, #703) (#705)

Pins labelle-gfx v1.21.0. Adds the engine Tilemap component (asset_name,
Saveable), the .tmx embedded-asset loader, addTilemap/removeTilemap, scene-loader
support, the post-sprite render pass (y-axis-aligned with sprites), and editor
digest presence. Additive; renderer-agnostic (reaches gfx via the RenderImpl seam).

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 733d46cd88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +534 to +535
if (comptime !Common.isRegistered(TilemapT_load)) {
if (components.get("Tilemap")) |tm_val| blk: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip built-in Tilemap load when the name is registered

When a game registers its own component named Tilemap and that save data includes an asset_name string, the registry loop above restores the custom component, then this type-identity guard still lets the engine built-in branch run because Game.TilemapComp itself is not registered. That attaches a second engine Tilemap/runtime and a later save can emit duplicate "Tilemap" keys, so the C2 precedence fix is not preserved for save/load. Fresh evidence beyond the prior scene-loader finding is that loadGameState still checks Common.isRegistered(TilemapT_load) instead of the registry name.

Useful? React with 👍 / 👎.

Comment thread src/tilemap.zig
Comment on lines +16 to +20
//! **Engine built-in, NOT a `ComponentRegistry` component.** Like
//! `Position` and `PrefabInstance`, `Tilemap` is handled by dedicated
//! built-in channels in the scene loader, save/load, and digest — its
//! `asset_name` is a `[]const u8`, which the registry-driven `serde`
//! path cannot round-trip. Do not register it in a game's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route Tilemap through the comptime scene writer

This declares Tilemap as a built-in that should not be registered, but only the JSONC loader was taught about it; the comptime scene writer still special-cases only Sprite/Shape before falling through to Components.has (scene/src/entity_writer.zig:55-62). In projects still using .zon/comptime scenes, a .Tilemap = .{ .asset_name = ... } field therefore cannot be registered and is silently ignored, so those scenes load without the component or decoded runtime.

Useful? React with 👍 / 👎.

Comment thread src/tilemap_runtime.zig
defer allocator.free(ft);
// A missing/undecodable tileset image degrades to "this
// tileset draws nothing" rather than failing the whole map.
ids[i] = renderer.loadTextureFromMemory(ft, bytes) catch null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unload uploaded tilesets on init failure

If any error occurs after one or more tileset images have been uploaded—e.g. a later fileTypeZ allocation fails or TmRenderer.initWithOptions returns OOM while inserting resolver textures—the active errdefers only free ids and map. acquireTilemap then destroys the runtime without calling deinit, so the renderer keeps those uploaded texture ids even though the tilemap failed to acquire; add an error cleanup that unloads every non-null id recorded here.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant