<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Isometric on Worlds of the Next Realm - Dev Blog</title><link>https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/tags/isometric/</link><description>Recent content in Isometric on Worlds of the Next Realm - Dev Blog</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 19 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/tags/isometric/index.xml" rel="self" type="application/rss+xml"/><item><title>A Mini-Map, Bigger Chunks, and the Bugs They Surfaced</title><link>https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/p/a-mini-map-bigger-chunks-and-the-bugs-they-surfaced/</link><pubDate>Thu, 19 Feb 2026 00:00:00 +0000</pubDate><guid>https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/p/a-mini-map-bigger-chunks-and-the-bugs-they-surfaced/</guid><description>&lt;p&gt;I&amp;rsquo;m building a browser-based strategy game called Worlds of the Next Realm with Claude Code as my AI pair-programmer. The world map is a 600x600 isometric tile grid — forests, deserts, mountains, volcanoes — loaded as chunks from CloudFront. This post covers two sessions that improved the map experience and the cascade of fixes each one triggered.&lt;/p&gt;
&lt;h2 id="the-mini-map"&gt;&lt;a href="#the-mini-map" class="header-anchor"&gt;&lt;/a&gt;The Mini-Map
&lt;/h2&gt;&lt;p&gt;The world is big. At normal zoom, you see maybe 30 tiles in each direction. Pan around for a while and you&amp;rsquo;ve lost all sense of where you are on the 600x600 grid. The solution is the oldest trick in game UI: a mini-map.&lt;/p&gt;
&lt;h3 id="v1-client-side-chunk-sampling"&gt;&lt;a href="#v1-client-side-chunk-sampling" class="header-anchor"&gt;&lt;/a&gt;v1: Client-Side Chunk Sampling
&lt;/h3&gt;&lt;p&gt;The first version (PR #167, FrontEndClient) is a Flutter widget overlaid on the world map screen — not a Flame engine component. This was a deliberate choice. The mini-map needs device-pixel rendering and Flutter gesture handling (tap to navigate, drag to pan), which are awkward to implement inside the game engine&amp;rsquo;s coordinate system.&lt;/p&gt;
&lt;p&gt;Each loaded chunk contributes one colored block to the mini-map based on its center tile&amp;rsquo;s biome: green for grass, dark green for forest, tan for desert, white for snow, dark red for volcanic, olive for swamp, blue for water, gray for mountain. Unloaded chunks render as dark fog.&lt;/p&gt;
&lt;p&gt;That fog-of-war effect wasn&amp;rsquo;t designed — it fell out of the architecture. The world map uses lazy chunk loading from CloudFront. You only have the chunks around your viewport on the client. The mini-map can only render what&amp;rsquo;s been fetched, so unexplored regions are naturally dark. Sometimes constraints produce good game features.&lt;/p&gt;
&lt;p&gt;The killer feature is tap-to-navigate. Tap anywhere on the mini-map and the main camera pans to that world position. Drag to continuously update. On a 600x600 world, this changes the map from something you slowly scroll through to something you can jump around instantly.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/p/a-mini-map-bigger-chunks-and-the-bugs-they-surfaced/mini-map-initial-imple.png"
	width="1562"
	height="1596"
	loading="lazy"
	
		alt="The v1 mini-map in the top-right corner, showing chunky 30x30 biome blocks. The dark green void bleeding through at the bottom-left is the edge problem that PR #168 fixes."
	
 
 title="Mini-map v1 with visible edge problem"
 data-title-escaped="Mini-map v1 with visible edge problem"
 
	
		class="gallery-image" 
		data-flex-grow="97"
		data-flex-basis="234px"
	
&gt;&lt;/p&gt;
&lt;h3 id="v2-pre-rendered-png"&gt;&lt;a href="#v2-pre-rendered-png" class="header-anchor"&gt;&lt;/a&gt;v2: Pre-Rendered PNG
&lt;/h3&gt;&lt;p&gt;The chunk-sampled mini-map was functional but crude — 30x30 effective pixels (one per chunk) stretched to 180x180 screen pixels. We doubled the widget to 360px and moved terrain rendering to the deployment pipeline.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;publish-s3&lt;/code&gt; command in the Documentation repo now generates a 600x600 PNG (one pixel per tile) using ImageSharp during deployment. Pure managed .NET, no native dependencies, runs anywhere including CI. The resulting PNG is tiny — biome regions produce large blocks of identical color that PNG&amp;rsquo;s deflate compression handles efficiently. It gets uploaded alongside chunk data and the client fetches it on map load.&lt;/p&gt;
&lt;p&gt;The mini-map painter now has three layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Base terrain&lt;/strong&gt; — the pre-rendered PNG at full tile resolution, or chunk-based fallback if the fetch fails&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Features&lt;/strong&gt; — plumbed in for future dynamic markers (mines, monsters, quests) but currently empty&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Overlays&lt;/strong&gt; — viewport rectangle and city dot&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The fallback is important. Old deployments that predate the PNG generator still work — the fetch 404s silently and the chunk-based rendering continues. No feature flags, no version checks. Try the better path, fall back to the existing one.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/p/a-mini-map-bigger-chunks-and-the-bugs-they-surfaced/mini-map-improvement.png"
	width="1558"
	height="1674"
	loading="lazy"
	
		alt="The v2 mini-map at 360px with full tile-resolution PNG rendering. The dark region is fog of war — chunks the player hasn’t explored yet. Compare the biome detail to the chunky v1 above."
	
 
 title="Mini-map v2 with pre-rendered PNG and fog of war"
 data-title-escaped="Mini-map v2 with pre-rendered PNG and fog of war"
 
	
		class="gallery-image" 
		data-flex-grow="93"
		data-flex-basis="223px"
	
&gt;&lt;/p&gt;
&lt;h3 id="the-hash-versioning-gotcha-again"&gt;&lt;a href="#the-hash-versioning-gotcha-again" class="header-anchor"&gt;&lt;/a&gt;The Hash Versioning Gotcha (Again)
&lt;/h3&gt;&lt;p&gt;The deployment pipeline uses content hashing for change detection. SHA-256 the input data, compare to the previous hash, skip upload if unchanged. Efficient — most CI runs finish in seconds.&lt;/p&gt;
&lt;p&gt;But adding a PNG generator doesn&amp;rsquo;t change the input data. The hash matched the previous deployment. The pipeline said &amp;ldquo;nothing changed&amp;rdquo; and skipped everything. The PNG was never uploaded.&lt;/p&gt;
&lt;p&gt;This is a pattern we&amp;rsquo;ve hit before. Content-addressable systems track &lt;em&gt;what&lt;/em&gt; is stored, not &lt;em&gt;what artifacts you produce from it&lt;/em&gt;. We&amp;rsquo;d already solved this once by adding a format version prefix to the hash. This time we bumped it from &lt;code&gt;v3&lt;/code&gt; to &lt;code&gt;v4&lt;/code&gt; — same fix, same lesson, different day.&lt;/p&gt;
&lt;p&gt;Worth noting: we added a code comment this time. &amp;ldquo;Bump this when changing the set of uploaded artifacts.&amp;rdquo; Future us will thank present us.&lt;/p&gt;
&lt;h2 id="edge-treatment"&gt;&lt;a href="#edge-treatment" class="header-anchor"&gt;&lt;/a&gt;Edge Treatment
&lt;/h2&gt;&lt;p&gt;With the mini-map encouraging players to jump to any part of the world, the map edges became visible for the first time. At full zoom-out, panning to a corner revealed &lt;code&gt;backgroundColor: 0xFF1A3A1A&lt;/code&gt; — a flat dark green void past the tile grid. Not a great look.&lt;/p&gt;
&lt;p&gt;The city map already solved this problem. &lt;code&gt;IsometricGround&lt;/code&gt; has an &lt;code&gt;overflow&lt;/code&gt; parameter that renders extra tiles beyond the grid boundary, and &lt;code&gt;StormCloudOverlay&lt;/code&gt; draws animated storm clouds with lightning over the edges. The city map uses 40 base + 20 detail puffs for the clouds, punching a diamond-shaped hole through the cloud layer with &lt;code&gt;saveLayer&lt;/code&gt; + &lt;code&gt;BlendMode.dstOut&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the world map (PR #168), the fix was three small changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;overflow: 20&lt;/code&gt; on the world map&amp;rsquo;s ground component&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;StormCloudOverlay&lt;/code&gt; with 200 base + 100 detail puffs (proportional to the larger area)&lt;/li&gt;
&lt;li&gt;Lower &lt;code&gt;minZoom&lt;/code&gt; from 0.625 to 0.5 for one extra zoom-out step&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The overflow tiles render as default grass via &lt;code&gt;MapChunkCache.getTileAt&lt;/code&gt; returning the fallback for out-of-bounds coordinates. The storm clouds cover everything beyond the tile grid. Three changes, zero special-casing.&lt;/p&gt;
&lt;p&gt;The lesson here is that reusable components pay off quietly. The city map&amp;rsquo;s edge treatment was designed for one context. Months later, it applied to a completely different map with only parameterization changes.&lt;/p&gt;
&lt;h2 id="variable-chunk-size"&gt;&lt;a href="#variable-chunk-size" class="header-anchor"&gt;&lt;/a&gt;Variable Chunk Size
&lt;/h2&gt;&lt;p&gt;The world map was divided into 20x20 tile chunks — 30 chunks per axis, 900 chunks total per world. This was hardcoded everywhere: backend, frontend, deployment pipeline, operational tools. The number 20 appeared as constants in four repositories.&lt;/p&gt;
&lt;p&gt;We changed it to 50. Here&amp;rsquo;s why: 600 / 50 = 12 chunks per axis = 144 chunks per world instead of 900. Fewer chunks means fewer HTTP requests during loading, fewer S3 objects, and faster manifest processing. The tradeoff is larger individual chunk files, but gzipped JSON for a 50x50 tile grid is still small.&lt;/p&gt;
&lt;p&gt;The change touched every repo in the project:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BackendCommon&lt;/strong&gt;: Added &lt;code&gt;ChunkSize&lt;/code&gt; (default 50) to &lt;code&gt;WorldDefInput&lt;/code&gt; and &lt;code&gt;WorldDefinitionData&lt;/code&gt;. The field flows through serialization as &lt;code&gt;&amp;quot;chunkSize&amp;quot;&lt;/code&gt; / &lt;code&gt;&amp;quot;cks&amp;quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Documentation&lt;/strong&gt;: Removed the hardcoded constant from &lt;code&gt;PublishS3Command&lt;/code&gt;, added &lt;code&gt;&amp;quot;chunkSize&amp;quot;: 50&lt;/code&gt; to all five world entries in &lt;code&gt;world-definitions.json&lt;/code&gt;, bumped the hash version to &lt;code&gt;v4&lt;/code&gt; (including chunk size in the hash so changes force map regeneration).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OperationalTools&lt;/strong&gt;: Removed constants from &lt;code&gt;GenerateMapCommand&lt;/code&gt;, &lt;code&gt;BootstrapWorldCommand&lt;/code&gt;, and &lt;code&gt;PublishGameDataCommand&lt;/code&gt;. Added a &lt;code&gt;--chunk-size&lt;/code&gt; CLI option.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FrontEndClient&lt;/strong&gt;: This was the most involved. Removed &lt;code&gt;chunkSize&lt;/code&gt;, &lt;code&gt;worldSize&lt;/code&gt;, &lt;code&gt;chunksPerAxis&lt;/code&gt;, &lt;code&gt;worldGridWidth&lt;/code&gt;, and &lt;code&gt;worldGridHeight&lt;/code&gt; from &lt;code&gt;IsoConfig&lt;/code&gt;. Made &lt;code&gt;WorldMapScreen._initGame()&lt;/code&gt; async to load the manifest first and extract map dimensions before creating the game. &lt;code&gt;MapChunkCache&lt;/code&gt; now takes &lt;code&gt;chunkSize&lt;/code&gt; as a constructor parameter. The mini-map reads dimensions from the game instance instead of constants.&lt;/p&gt;
&lt;p&gt;The key design decision: chunk size is per-world, defined in the world definitions file and carried through the manifest. The client reads it dynamically. Old manifests with &lt;code&gt;chunkSize: 20&lt;/code&gt; still work. No hardcoded world dimensions remain in the client.&lt;/p&gt;
&lt;h2 id="the-viewport-bug"&gt;&lt;a href="#the-viewport-bug" class="header-anchor"&gt;&lt;/a&gt;The Viewport Bug
&lt;/h2&gt;&lt;p&gt;Lowering &lt;code&gt;minZoom&lt;/code&gt; from 0.625 to 0.5 during the edge treatment work exposed a rendering bug that took two PRs to fix.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://ipjohnson-org.github.io/WorldsOfTheNextRealm.Blog/p/a-mini-map-bigger-chunks-and-the-bugs-they-surfaced/zoom-clip-issue.png"
	width="3016"
	height="1114"
	loading="lazy"
	
		alt="The viewport culling bug at 0.5x zoom. Tiles cut off in a harsh diagonal line across the top of the screen — the isometric math wasn’t accounting for the rotated grid at this zoom level."
	
 
 title="Viewport culling bug at maximum zoom-out"
 data-title-escaped="Viewport culling bug at maximum zoom-out"
 
	
		class="gallery-image" 
		data-flex-grow="270"
		data-flex-basis="649px"
	
&gt;&lt;/p&gt;
&lt;p&gt;At 0.5x zoom, tiles disappeared from the upper-left and lower-right corners of the screen. The first fix (PR #171) was straightforward: &lt;code&gt;updateVisibleTiles&lt;/code&gt; was receiving the raw screen &lt;code&gt;size&lt;/code&gt; to calculate which tiles to render, but at 0.5x zoom the viewport in world coordinates is &lt;code&gt;size / 0.5 = size * 2&lt;/code&gt;. Passing &lt;code&gt;size / zoom&lt;/code&gt; instead of bare &lt;code&gt;size&lt;/code&gt; was the obvious correction.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t sufficient. Tiles were still missing at the corners.&lt;/p&gt;
&lt;p&gt;The deeper issue (PR #172) was in the isometric math. The tile range calculation treated X and Y independently:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tilesX = viewportWidth / tileWidth / 2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tilesY = viewportHeight / tileHeight / 2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;But isometric projection rotates the grid 45 degrees. A screen rectangle maps to a rotated diamond in grid space. The screen&amp;rsquo;s top-right corner needs tiles far in the +X direction, the bottom-left needs tiles far in -X, and &lt;em&gt;both corners contribute to the Y range&lt;/em&gt;. Independent axis calculations don&amp;rsquo;t account for this rotation.&lt;/p&gt;
&lt;p&gt;The correct formula uses the inverse isometric transform to find the maximum grid extent needed to cover all four screen corners:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gridRange = (halfW / halfTileWidth + halfH / halfTileHeight) / 2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This produces a symmetric range for both grid axes. On a portrait phone at 0.5x zoom, the old formula gave ±14 tiles (barely covering the ±13.35 needed). The new formula gives ±18 tiles — a comfortable buffer.&lt;/p&gt;
&lt;p&gt;The lesson: isometric rendering bugs are subtle because the relationship between screen space and grid space is rotated. Calculations that work fine at high zoom (where the viewport is small enough that the rotation doesn&amp;rsquo;t matter) break at low zoom where the aspect ratio stretches the diamond. Always think in terms of the inverse transform.&lt;/p&gt;
&lt;h2 id="features-expose-problems"&gt;&lt;a href="#features-expose-problems" class="header-anchor"&gt;&lt;/a&gt;Features Expose Problems
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a pattern across both sessions worth calling out. Every feature we added exposed a pre-existing issue:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;mini-map&amp;rsquo;s tap-to-navigate&lt;/strong&gt; sent players to map corners they&amp;rsquo;d never visited → exposed the ugly edge background&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;edge treatment&amp;rsquo;s extra zoom level&lt;/strong&gt; made the viewport larger → exposed the tile culling bug&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;pre-rendered PNG&lt;/strong&gt; added a new artifact to the deployment → exposed the content-hash blind spot (again)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;variable chunk size&lt;/strong&gt; change touched hardcoded constants in four repos → exposed how tightly coupled the repos were to a single magic number&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these problems were new. They&amp;rsquo;d been there since the code was written. They were invisible because nothing exercised those code paths until the new features did.&lt;/p&gt;
&lt;p&gt;This is worth internalizing for any development project, AI-assisted or not: polish work and UX improvements are stress tests. When you make a system more usable, you make more of its surface area visible, and some of that surface area has rough edges you never noticed.&lt;/p&gt;
&lt;h2 id="the-honest-scorecard"&gt;&lt;a href="#the-honest-scorecard" class="header-anchor"&gt;&lt;/a&gt;The Honest Scorecard
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Session&lt;/th&gt;
 &lt;th&gt;Planned PRs&lt;/th&gt;
 &lt;th&gt;Fix-Up PRs&lt;/th&gt;
 &lt;th&gt;Repos Touched&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Mini-map + edge treatment&lt;/td&gt;
 &lt;td&gt;2&lt;/td&gt;
 &lt;td&gt;6&lt;/td&gt;
 &lt;td&gt;4 (FrontEndClient, Documentation, BackendCommon, BlogNotes)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Variable chunk size&lt;/td&gt;
 &lt;td&gt;4&lt;/td&gt;
 &lt;td&gt;0&lt;/td&gt;
 &lt;td&gt;4 (BackendCommon, Documentation, OperationalTools, FrontEndClient)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The mini-map session ballooned because each feature revealed something downstream. The variable chunk size session was clean — the plan was scoped correctly and all callers were identified upfront. The difference? The chunk size change was a known, bounded transformation (find every hardcoded 20, make it configurable). The mini-map was exploratory — we didn&amp;rsquo;t know what &amp;ldquo;add a mini-map&amp;rdquo; would surface until we deployed it.&lt;/p&gt;
&lt;p&gt;Both outcomes are fine. What matters is recognizing which type of task you&amp;rsquo;re starting so you can calibrate your expectations.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This post is part of a series about building Worlds of the Next Realm with Claude Code. Code is real, mistakes are real, the map finally has proper edges.&lt;/em&gt;&lt;/p&gt;</description></item></channel></rss>