<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title>Dekiro: Sanctuary of Shadows · Dev Blog</title>
<subtitle>A hack-and-slash ARPG in the style of a classic Korean MMO, made by one developer, built with AI.</subtitle>
<link rel="self" type="application/atom+xml" href="https://dekiro.d12r.io/feed.xml"/>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/"/>
<id>https://dekiro.d12r.io/</id>
<updated>2026-10-19T22:21:58+02:00</updated>
<author><name>The Dekiro developer</name></author>
<icon>https://dekiro.d12r.io/icon-192.png</icon>
<entry>
<title>Mastery Past the Cap: Ten Grandmaster Ranks, a Hidden Rank, and the Soul Jade</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/28-mastery-past-the-cap.html"/>
<id>https://dekiro.d12r.io/28-mastery-past-the-cap.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>A skill used to stop at Grandmaster. Now it climbs ten more ranks on a jade that drops from dungeon bosses by chance, and above the tenth waits a rank the game never mentions until you reach it. How a long tail after the level cap was built without a hard wall, and why one study item serves every skill.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;The bosses in dungeons should also drop cores and skill books, or even better: let&#x27;s add Grandmaster
skill levels up to 10. With level 10 it can flip to a level above Grandmaster that is not visible in the
skill overview yet. [...] Bosses should have a chance to drop an item that can be studied to eventually
level up any one skill (no specific item per skill).&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;where-the-ladder-used-to-end&quot;&gt;Where the ladder used to end &lt;a class=&quot;anchor&quot; href=&quot;#where-the-ladder-used-to-end&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A skill in&lt;/span&gt; Dekiro has always been a single number, its rank. The first fourteen ranks come from skill
points, one per character level. The fifteenth turns the skill into its Mastery version, M1, and from there
only skillbooks raise it: Shadows drop them, reading one is a gamble between 70% and 35%, and the tenth
successful read makes the skill Grandmaster. That is the ladder of a popular Korean MMO that this game grew
out of, and until this week it ended there. A Grandmaster skill was finished.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The request above arrived the evening of the first playtest (post 18), as one message that started small
(bosses should drop Shadow Cores and books) and grew a new layer on top of the skill ladder. The
coordinator offered names for the two new things; the developer picked &lt;strong&gt;Peerless&lt;/strong&gt; for the hidden rank and
&lt;strong&gt;Soul Jade&lt;/strong&gt; for the item, and added two rules before the agent started: &amp;quot;of course the item should not
be dropped every time from bosses, but with a chance&amp;quot;, and &amp;quot;skill books can also drop&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;ten-more-rungs&quot;&gt;Ten more rungs &lt;a class=&quot;anchor&quot; href=&quot;#ten-more-rungs&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Grandmaster became ten&lt;/span&gt; ranks, G1 to G10. Old characters needed no migration: their Grandmaster was stored
as rank 25, which is now simply G1.&lt;/p&gt;
&lt;p&gt;The scaling keeps the shape the lower ranks already had. Every rank adds 7% of the skill&#x27;s base damage and
3% of its heal or buff strength. Heals and buffs grow slower on purpose: after the playtest, a permanently
held Iron Skin at the old numbers would have given a Grandmaster 86% damage reduction, and ten more ranks
of that would break the game rather than reward it.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Skill damage (x base)&lt;/th&gt;
&lt;th&gt;Raised by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lv 14&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1.91&lt;/td&gt;
&lt;td&gt;skill points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1.98&lt;/td&gt;
&lt;td&gt;the 15th point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M10&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2.61&lt;/td&gt;
&lt;td&gt;skillbooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G1&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2.68&lt;/td&gt;
&lt;td&gt;the 10th successful read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G10&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;3.31&lt;/td&gt;
&lt;td&gt;Soul Jades&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peerless&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;3.63&lt;/td&gt;
&lt;td&gt;a Soul Jade at G10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;So the ten new ranks are worth about as much as the ten Mastery ranks before them: a quarter more damage
over many hours, not a doubling. None of the monster numbers from the balance passes (posts 15 and 22) had
to move, because no character gets there before the level cap.&lt;/p&gt;
&lt;h2 id=&quot;one-jade-for-every-skill&quot;&gt;One jade for every skill &lt;a class=&quot;anchor&quot; href=&quot;#one-jade-for-every-skill&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The most interesting&lt;/span&gt; decision in the request is in its last sentence: &amp;quot;no specific item per skill&amp;quot;. The
skillbooks are the opposite: a book teaches one skill, and since the drop picks the skill, many books are
useless to the player who finds them. The book system needs a whole support structure for that. Drops are
weighted three to one toward skills you&#x27;ve already mastered, books of other schools can be traded or sold,
and since the playtest your own grandmaster swaps a foreign book for a random one of yours, once an hour.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;Soul Jade&lt;/strong&gt; has none of that problem. In the item&#x27;s own words, it is &amp;quot;a sliver of the Sanctuary Stone&#x27;s
jade that drank a fallen master&#x27;s last insight&amp;quot;. The player decides which Grandmaster skill studies it:
right-click it in the bag, press Study in the skill window, or drag the jade onto the skill. A small dialog
lists every skill of the school with its rank, the chance and where it leads.&lt;/p&gt;
&lt;figure class=&quot;fig margin pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/28-soul-jade.png&quot; alt=&quot;The Soul Jade&quot; width=&quot;256&quot; height=&quot;256&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Soul Jade&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/28-study-dialog.jpg&quot; alt=&quot;The skill window and the study dialog: Whirlwind at G5 (32% to G6), Cleave at G10 (4% to &amp;quot;???&amp;quot;), Charge at G1, and the skills that aren&amp;#x27;t Grandmaster yet greyed out&quot; width=&quot;1600&quot; height=&quot;1020&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The skill window and the study dialog: Whirlwind at G5 (32% to G6), Cleave at G10 (4% to &amp;quot;???&amp;quot;), Charge at G1, and the skills that aren&amp;#x27;t Grandmaster yet greyed out&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Three things come out of that choice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every drop is good news.&lt;/strong&gt; A jade is never the wrong item. That makes it safe to keep it rare, because
rarity only hurts when the rare thing might be useless.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The player builds the character.&lt;/strong&gt; Push one skill to the top or spread the jades over five; both are
valid, and the dialog shows what each choice costs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One stack in the bag, not twenty-four.&lt;/strong&gt; The study item has one icon, one tooltip, one place in the
inventory, and one sell price for the players who&#x27;d rather have the gold.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The jade is also tradeable like any material. A friend who has finished their skills can hand theirs over.&lt;/p&gt;
&lt;h2 id=&quot;the-odds-and-why-a-failure-still-counts&quot;&gt;The odds, and why a failure still counts &lt;a class=&quot;anchor&quot; href=&quot;#the-odds-and-why-a-failure-still-counts&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Studying is a&lt;/span&gt; gamble, like reading. The chance falls from 45% at G1 to 18% at G9, and the step from G10 to
Peerless is 4%. The jade is used up either way. What softens it is the bad-luck protection the books
already had: every failure in a row on the same skill adds 4 points to its next chance (1 point at G10),
and a success resets the streak.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The protection matters more than the base numbers suggest. Worked out over the whole ladder:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Base chance&lt;/th&gt;
&lt;th&gt;Jades needed on average&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;G1 -&amp;gt; G2&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;45%&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G5 -&amp;gt; G6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;32%&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G9 -&amp;gt; G10&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;18%&lt;/td&gt;
&lt;td&gt;3.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G1 -&amp;gt; G10, all nine steps&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;&lt;/td&gt;
&lt;td&gt;about 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G10 -&amp;gt; Peerless&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;4%&lt;/td&gt;
&lt;td&gt;about 10 (median 9)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;On the early steps the streak bonus hardly matters: G1 to G2 would take 2.2 jades without it instead of
2.1. On the long shots it decides everything. Without it, Peerless would average 25 jades, and one player in
ten would still be trying after 56. With it, the average is about 10, and the chance reaches 100% at the
97th try at the very worst. Nobody will get there, but the ceiling exists. That is what bad-luck protection
is for: the lucky and the typical player barely notice it, and the unlucky tail is cut off.&lt;/p&gt;
&lt;p&gt;This seems to break a rule from the Fullmoon post (17), &amp;quot;gamble on progress, never on rare rewards&amp;quot;. It
doesn&#x27;t, because of what is gambled. The Shard of the Sanctuary Stone drops once at the end of the final
raid and can&#x27;t be gambled away. A Soul Jade is a repeatable drop from every dungeon boss past level 25. Losing
one costs you a dungeon run, not the best moment of the game.&lt;/p&gt;
&lt;h2 id=&quot;rest-for-the-mind&quot;&gt;Rest for the mind &lt;a class=&quot;anchor&quot; href=&quot;#rest-for-the-mind&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Between two studies&lt;/span&gt;, the mind needs five minutes of play to rest. The study dialog counts the wait down,
and the refusal says what helps: &amp;quot;Your mind needs rest: 4:12 (destroying Shadows clears it faster)&amp;quot;. Each
Shadow you destroy takes a minute off.&lt;/p&gt;
&lt;p&gt;The cooldown exists so that a stack of jades isn&#x27;t one click session: twenty jades saved over a week of
dungeons would otherwise go in twenty seconds, failure on top of failure. Spread over play, each study is
its own small moment.&lt;/p&gt;
&lt;p&gt;Why Shadows? They are the game&#x27;s core loop. The Shadow stones are what the hunting grounds are built
around, they drop the skillbooks, and reading a book already requires one destroyed Shadow between two
reads. The jade&#x27;s rest follows the same idea: progress on your skills comes from going out and fighting,
not from waiting in town. A player who hunts Shadows between dungeon runs will rarely see the cooldown at
all; a player who logs in only to study waits five minutes. The timer counts play time, not wall-clock
time, so logging out doesn&#x27;t skip it either.&lt;/p&gt;
&lt;h2 id=&quot;bosses-drop-it-by-chance&quot;&gt;Bosses drop it, by chance &lt;a class=&quot;anchor&quot; href=&quot;#bosses-drop-it-by-chance&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Soul Jades come&lt;/span&gt; only from dungeon bosses, and never every time. The chance grows with the boss&#x27;s level,
so the late dungeons pay better:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Who&lt;/th&gt;
&lt;th&gt;Soul Jade&lt;/th&gt;
&lt;th&gt;Skillbook&lt;/th&gt;
&lt;th&gt;Shadow Cores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A dungeon&#x27;s final boss&lt;/td&gt;
&lt;td&gt;4% + 0.18% per boss level (max 20%): about 9% at level 25, 18% at 80&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;60%, 1-2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, in the Demon Tower and the Chamber of Seals&lt;/td&gt;
&lt;td&gt;a second roll at 15%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A boss within a dungeon&lt;/td&gt;
&lt;td&gt;2% + 0.12% per level (max 12%)&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A mini-boss&lt;/td&gt;
&lt;td&gt;1% + 0.05% per level (max 5%)&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The First Shadow (final raid)&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;always, 1-2&lt;/td&gt;
&lt;td&gt;always, 3-5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Jades start at boss level 25, because nobody has a Grandmaster skill that early. Chests and Elites in the
wild drop neither jades nor books: those entries are marked as boss-only in the loot tables.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The skillbooks from bosses answer the first half of the request. Unlike a Shadow&#x27;s book, a boss&#x27;s book can
be from any school, with your own school&#x27;s skills twice as likely (on top of the three-to-one weight toward
skills you&#x27;ve already mastered). Books from other schools aren&#x27;t wasted: they can be traded, sold, or
swapped at your grandmaster.&lt;/p&gt;
&lt;p&gt;A test rolls a level 80 final boss 400 times and checks the result lands in a window: a jade now and
then, never one per kill, and a skillbook more often. It rolls a chest and a wild Elite the same number of
times and expects zero of either.&lt;/p&gt;
&lt;h2 id=&quot;peerless-the-rank-nobody-tells-you-about&quot;&gt;Peerless: the rank nobody tells you about &lt;a class=&quot;anchor&quot; href=&quot;#peerless-the-rank-nobody-tells-you-about&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The part of&lt;/span&gt; the request that needed the most care was &amp;quot;not visible in the skill overview yet&amp;quot;. A hidden
rank is easy to hide and easy to spoil. Every place in the UI that talks about the next step had to agree.&lt;/p&gt;
&lt;p&gt;Up to G9, the tooltip shows the next rank&#x27;s numbers (&amp;quot;At G6: Damage 485% of your attack&amp;quot;). At G10 it shows
only this: &amp;quot;Next: ??? (a Soul Jade may show the way)&amp;quot;. The study dialog says &amp;quot;Chance 4% -&amp;gt; ???&amp;quot;. The jade&#x27;s
description ends with a rumour, &amp;quot;Some say there is a step beyond the tenth.&amp;quot; Nothing else names it. Once
one of the character&#x27;s skills is Peerless, the word appears everywhere at once: the other G10 skills show
&amp;quot;-&amp;gt; Peerless&amp;quot; and the real numbers. A test walks through exactly these states and checks every string.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/28-hidden-rank.jpg&quot; alt=&quot;Before and after the first Peerless. Above, Cleave at G10 only promises &amp;quot;???&amp;quot;. Below, Whirlwind has become Peerless (&amp;quot;without equal&amp;quot;) and Cleave&amp;#x27;s tooltip now shows what its own Peerless would do&quot; width=&quot;1600&quot; height=&quot;945&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Before and after the first Peerless. Above, Cleave at G10 only promises &amp;quot;???&amp;quot;. Below, Whirlwind has become Peerless (&amp;quot;without equal&amp;quot;) and Cleave&amp;#x27;s tooltip now shows what its own Peerless would do&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The name comes from the wuxia vocabulary the game&#x27;s lore already uses. &lt;em&gt;Peerless&lt;/em&gt; translates 无双,
&amp;quot;without equal&amp;quot;, the word for a martial artist nobody can match. The skill window gives it a line of its own,
&amp;quot;Peerless: without equal&amp;quot;, in jade.&lt;/p&gt;
&lt;h3 id=&quot;a-clear-step-not-a-bigger-number&quot;&gt;A clear step, not a bigger number &lt;a class=&quot;anchor&quot; href=&quot;#a-clear-step-not-a-bigger-number&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;A hidden rank that feels like G11 would be a letdown. So Peerless is built to be noticed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;More damage and support:&lt;/strong&gt; +25% of the skill&#x27;s base damage and +15% of its heal or buff strength on top
of the rank scaling. Against G10 that is about a tenth more damage (Whirlwind&#x27;s hit goes from 1,282 to
1,406 in the screenshots).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A bigger area:&lt;/strong&gt; every radius, length and width grows by a fifth. Whirlwind reaches 4.6 m instead of
3.8, enough to catch the stragglers at the edge of a pack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Its own look.&lt;/strong&gt; The effect tiers had been normal, Master and Grandmaster, with Grandmaster adding a
golden second shockwave, sparks and a pillar of light. Peerless is a fourth tier: the same, a bit bigger,
with a jade ring further out, jade sparks and a jade pillar, and a jade crown on buffs.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/28-whirlwind.jpg&quot; alt=&quot;Whirlwind at G10 and at Peerless on the test map&amp;#x27;s dummies: the wider swing, the jade ring inside the gold, and the bigger numbers&quot; width=&quot;1600&quot; height=&quot;367&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Whirlwind at G10 and at Peerless on the test map&amp;#x27;s dummies: the wider swing, the jade ring inside the gold, and the bigger numbers&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;A skill&#x27;s data can also replace the Peerless step with something of its own (an extra hit, a longer dash),
the same way skills already change shape at M1 and G1.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; None does yet. That&#x27;s a natural next pass
once players have Peerless skills and can say which ones feel flat.&lt;/p&gt;
&lt;h2 id=&quot;how-long-it-takes&quot;&gt;How long it takes &lt;a class=&quot;anchor&quot; href=&quot;#how-long-it-takes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The balance model&lt;/span&gt; from post 15 got a new phase for this. Its characters still play from level 1 to 90 (now
in about 21 hours, after the playtest halved the target), and then keep going: 60% of the time re-running
the dungeons that still drop jades at level 90, the rest hunting Shadows. They study every jade on the
highest Grandmaster skill first, so one skill goes to G10 and Peerless before the next one starts.&lt;/p&gt;
&lt;p&gt;The first finding was that &lt;strong&gt;nobody reaches G1 before level 90.&lt;/strong&gt; Skill points spread over several damage
skills, and the best skill at the cap is about M5. The four jades a character finds while levelling just
wait in the bag. That&#x27;s deliberate: the jade ladder is the long tail after the story, not a race inside it,
and it doesn&#x27;t move any of the levelling numbers.&lt;/p&gt;
&lt;p&gt;After 90, the median of 60 simulated Blade characters looks like this:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hours after level 90&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;G levels gained&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Skills at G10&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Peerless skills&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Characters with a Peerless skill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;at 90&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+25 h&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;9&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+50 h&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;13&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+100 h&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;19&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;78%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+200 h&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;30&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/28-endgame.png&quot; alt=&quot;The endgame in the model: Grandmaster levels gained over all skills (left) and the share of characters with a skill at G10 or Peerless (right), at the model&amp;#x27;s checkpoints&quot; width=&quot;1596&quot; height=&quot;592&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The endgame in the model: Grandmaster levels gained over all skills (left) and the share of characters with a skill at G10 or Peerless (right), at the model&amp;#x27;s checkpoints&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The median character&#x27;s first skill reaches G10 about 53 hours after the cap, about 74 hours into the game
in total, and its first Peerless skill arrives about 74 hours after the cap. At 200 hours there are two
Peerless skills and about thirty Grandmaster levels: a long way from all six skills at the top. The
ladder has no wall at any point. Every hour adds a little, and at no point does the next step need
something you can&#x27;t get.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-were-not-sure-about&quot;&gt;What we&#x27;re not sure about &lt;a class=&quot;anchor&quot; href=&quot;#what-were-not-sure-about&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The pace is a model.&lt;/strong&gt; &amp;quot;0.66 jades per dungeon hour&amp;quot; assumes clears as fast as the bots manage. Real
runs with puzzles, walking and loot breaks will be slower, and the hours above longer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Focus versus spread.&lt;/strong&gt; The model pushes one skill. A player who spreads jades sees more G levels and
later Peerless; whether that feels better or worse is a playtest question.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cooldown.&lt;/strong&gt; Five minutes, a minute per Shadow, is a guess at a rhythm. If jades pile up anyway,
it&#x27;s in the way; if players never see it, it does nothing. Either answer is one number.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The secret.&lt;/strong&gt; A hidden rank lasts until the first player posts about it, and this post doesn&#x27;t help.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Extend the ladder you have.&lt;/strong&gt; Ten more ranks with the same per-rank scaling fit everything that was
balanced before; a new system on top would have had to be balanced from scratch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the rare item useful to everyone.&lt;/strong&gt; One study item for every skill means no drop is ever wasted,
so it can be rare without feeling cruel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cut off the bad-luck tail, keep the gamble.&lt;/strong&gt; A few points per failure barely touch the easy steps,
cut the cost of the long shot by more than half, and make the worst case finite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tie the pacing to the core loop.&lt;/strong&gt; A cooldown that Shadows shorten rewards playing, not waiting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hide the top consistently.&lt;/strong&gt; A secret rank only works if every tooltip, dialog and item text agrees on
what it doesn&#x27;t say, so a test checks all of them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The skills now&lt;/span&gt; have somewhere to go after level 90. The next question is the same one the Fullmoon weapons
raised: whether a hundred hours of dungeons stay interesting, and what to add to them so they do.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The ladder lives in one pure script, &lt;code&gt;scripts/skills/skill_rules.gd&lt;/code&gt;. Ranks 1-14 are Lv 1-14,
15-24 are M1-M10, 25-34 are now G1-G10 and 35 is the new top rank. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;The failure streaks are per skill and saved with the character, together with the study cooldown.
A forged save file can&#x27;t skip either: loading clamps the cooldown to five minutes and drops junk. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;&lt;code&gt;data/items.json&lt;/code&gt;, the loot tables &lt;code&gt;dungeon_boss&lt;/code&gt;, &lt;code&gt;boss&lt;/code&gt;, &lt;code&gt;elite&lt;/code&gt; and &lt;code&gt;first_shadow&lt;/code&gt;. A material
entry can scale with the victim&#x27;s level (&lt;code&gt;chance_per_level&lt;/code&gt;, &lt;code&gt;max_chance&lt;/code&gt;), and an item id of
&lt;code&gt;@skillbook&lt;/code&gt; drops a skillbook picked for whoever made the kill. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;&lt;code&gt;test_peerless_stays_hidden_until_reached&lt;/code&gt; in &lt;code&gt;tests/run_tests.gd&lt;/code&gt;, against the skill tooltips
(&lt;code&gt;SkillTips&lt;/code&gt;) and the study dialog (&lt;code&gt;SoulJadeDialog&lt;/code&gt;). A tooltip drawn without a character keeps the
secret too. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;A &lt;code&gt;&amp;quot;peerless&amp;quot;&lt;/code&gt; block in a skill&#x27;s entry in &lt;code&gt;data/skills.json&lt;/code&gt;, applied after the bigger area
(&lt;code&gt;SkillData.params_for_rank&lt;/code&gt;). The effect tier is in &lt;code&gt;scripts/skills/skill_fx.gd&lt;/code&gt;. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;&lt;code&gt;tools/balance/progression.py&lt;/code&gt;, the &lt;code&gt;endgame()&lt;/code&gt; phase, about 0.66 jades per hour of dungeon running.
The numbers are in &lt;code&gt;docs/balance/model.json&lt;/code&gt; under &lt;code&gt;skills&lt;/code&gt;; the design doc keeps the summary. &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>A Dev Blog in the Game&#x27;s Style</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/27-a-dev-blog-in-the-games-style.html"/>
<id>https://dekiro.d12r.io/27-a-dev-blog-in-the-games-style.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>How the website you&#x27;re reading is made: a Python script that turns the Markdown drafts in the game&#x27;s repository into static pages, framed with the game&#x27;s own 9-slice UI textures, set in its pixel fonts, and checked by a robot before anything goes online.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;Build a website for the blog articles we have in a style fitting the game&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;drafts-in-the-repository&quot;&gt;Drafts in the repository &lt;a class=&quot;anchor&quot; href=&quot;#drafts-in-the-repository&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;These articles started&lt;/span&gt; as Markdown files in &lt;code&gt;docs/blog/&lt;/code&gt; of the game&#x27;s repository, written from the git
history, the docs and the code, with screenshots from the development review folders. They sat there as
drafts for three days. When the human asked for a website, two constraints shaped it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The drafts stay the source.&lt;/strong&gt; Articles keep being added and corrected next to the code they describe.
The site has to be rebuilt from them at any time, never edited by hand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It should look like the game.&lt;/strong&gt; The game&#x27;s UI is lacquer panels, gold double frames, crimson title bars
and pixel fonts. A generic blog theme would have been faster and wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is one script, &lt;code&gt;tools/blog/build_site.py&lt;/code&gt;, about 830 lines, and a 400-line stylesheet. It writes
plain HTML and CSS with a little JavaScript into &lt;code&gt;website/&lt;/code&gt;, and it is now online at
&lt;a href=&quot;https://dekiro.d12r.io/&quot; rel=&quot;noopener&quot;&gt;dekiro.d12r.io&lt;/a&gt;.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/27-home.jpg&quot; alt=&quot;The home page: a golden-hour screenshot of the Vale&amp;#x27;s town square in a gold frame, the seal and title in the game&amp;#x27;s pixel font, and the first windows of the article list&quot; width=&quot;1440&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The home page: a golden-hour screenshot of the Vale&amp;#x27;s town square in a gold frame, the seal and title in the game&amp;#x27;s pixel font, and the first windows of the article list&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;one-script-no-framework&quot;&gt;One script, no framework &lt;a class=&quot;anchor&quot; href=&quot;#one-script-no-framework&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A static site&lt;/span&gt; generator would have worked. The choice of a single script came from how the rest of the
project works: every generated thing (maps, dungeons, monster models, music, sound effects, the minimap
pictures) is a rerunnable Python tool in &lt;code&gt;tools/&lt;/code&gt;, whose output is never edited by hand. The blog follows the
same rule. The dependencies are small and ordinary: markdown-it-py for Markdown, Pygments for code
highlighting, Pillow for images, fontTools for fonts.&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/blog/build_site.py&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# incremental, ~2 s; first run or --clean ~30 s&lt;/span&gt;
python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/blog/check_site.py&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# link check&lt;/span&gt;
python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/blog/check_site.py&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--shots&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;review/website&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# + screenshots at 1440x900 and 390x844&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Everything the site knows comes from files that already existed or are short:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Articles, their order, teasers, the 8-post route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docs/blog/NN-*.md&lt;/code&gt; and the table in &lt;code&gt;docs/blog/README.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title and subtitle&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;# Title&lt;/code&gt; line and the italic paragraph under it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image captions&lt;/td&gt;
&lt;td&gt;the Markdown alt text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;About page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools/blog/about.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address, title, tagline, author, dates, hero image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools/blog/site.json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Look&lt;/td&gt;
&lt;td&gt;&lt;code&gt;site.css&lt;/code&gt; + &lt;code&gt;site.js&lt;/code&gt;, the game&#x27;s &lt;code&gt;assets/ui&lt;/code&gt; textures and &lt;code&gt;assets/fonts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;An article that isn&#x27;t in the README&#x27;s table isn&#x27;t published; the build warns. That makes the README the
editorial switch: drafts can live in the folder until they&#x27;re listed.&lt;/p&gt;
&lt;p&gt;The build is incremental (images and fonts are only re-encoded when their source is newer) and
deterministic: building twice gives byte-identical output, so the site&#x27;s diff in git shows only what
really changed.&lt;/p&gt;
&lt;h2 id=&quot;the-games-ui-in-css&quot;&gt;The game&#x27;s UI, in CSS &lt;a class=&quot;anchor&quot; href=&quot;#the-games-ui-in-css&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The game draws&lt;/span&gt; its windows from &lt;strong&gt;9-slice textures&lt;/strong&gt;: small pixel-art images whose corners stay fixed while
the edges and middle stretch. The window panel is a 40 x 40 pixel PNG with 14-pixel corners; the crimson
title bar is 32 x 18.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;CSS has the same idea built in: &lt;code&gt;border-image&lt;/code&gt;. So the site doesn&#x27;t imitate the game&#x27;s frames; it uses the
same files, copied from &lt;code&gt;assets/ui&lt;/code&gt;, drawn at 2x with nearest-neighbour scaling:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;css&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;panel&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;border-style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;solid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;border-width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;28&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;border-image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;ui/panel.png&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;14&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;28&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;px&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;round&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;image-rendering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;pixelated&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;14 fill&lt;/code&gt; slices the panel at 14 pixels and keeps the middle as the background; &lt;code&gt;/ 28px&lt;/code&gt; draws the corners
at twice their size; &lt;code&gt;round&lt;/code&gt; repeats the edges in whole tiles, so the gold fretwork never squashes;
&lt;code&gt;image-rendering: pixelated&lt;/code&gt; keeps the pixels square. The title bars, the tabs in the header, the jade and
red buttons (with their hover and pressed states), the dividers and the tooltip frame all work the same
way, from 22 textures that weigh 30 KB together. The page background is a 48-pixel tile of lacquer noise,
generated deterministically by the build.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/27-nine-slice.png&quot; alt=&quot;The window panel (40 x 40) and the title bar (32 x 18) from the game, at 8x with their slice lines, and the panel stretched to a window the way border-image draws it at 2x&quot; width=&quot;1600&quot; height=&quot;420&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The window panel (40 x 40) and the title bar (32 x 18) from the game, at 8x with their slice lines, and the panel stretched to a window the way border-image draws it at 2x&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;fonts-2-mb-down-to-6-kb&quot;&gt;Fonts: 2 MB down to 6 KB &lt;a class=&quot;anchor&quot; href=&quot;#fonts-2-mb-down-to-6-kb&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The game uses&lt;/span&gt; three free (OFL) pixel fonts: DotGothic16 for headings, Pixelify Sans for UI text and
Silkscreen for the tiny labels. On the web, a font file is a download on every first visit, and
DotGothic16 is large because it covers Japanese: its TTF is 2 MB.&lt;/p&gt;
&lt;p&gt;The site needs only Latin and a few symbols, so the build subsets each font with fontTools to a fixed
list of Unicode ranges and saves it as WOFF2:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Font&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;TTF&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;WOFF2 subset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DotGothic16&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2,069 KB&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pixelify Sans&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;79 KB&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;10 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silkscreen&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;32 KB&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;8 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;One detail sits in a code comment: Pixelify Sans has a &amp;quot;fi&amp;quot; ligature that, at blog sizes, reads like an
&amp;quot;A&amp;quot;. The subset keeps kerning and drops ligatures. The licences are copied next to the fonts.&lt;/p&gt;
&lt;p&gt;Pixel fonts are made for short text. The articles are long, so the body text is set in a system serif at
19 pixels with lines of about 690 pixels; the pixel fonts are for titles, headings, navigation and labels.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/27-article.png&quot; alt=&quot;An article page on a desktop: a sticky table of contents in the game&amp;#x27;s window style, the crimson title bar with the part number, and the body in a readable serif&quot; width=&quot;1440&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;An article page on a desktop: a sticky table of contents in the game&amp;#x27;s window style, the crimson title bar with the part number, and the body in a readable serif&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;images-webp-with-fallbacks&quot;&gt;Images: WebP with fallbacks &lt;a class=&quot;anchor&quot; href=&quot;#images-webp-with-fallbacks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The articles carry&lt;/span&gt; over a hundred screenshots, many of them 4K captures cut down to 1600 pixels. For each
one the build writes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;WebP&lt;/strong&gt; at up to 1600 pixels, plus an &lt;strong&gt;800-pixel WebP&lt;/strong&gt; for phones;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;JPG or PNG fallback&lt;/strong&gt; for browsers without WebP;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element with &lt;code&gt;srcset&lt;/code&gt;, &lt;code&gt;loading=&amp;quot;lazy&amp;quot;&lt;/code&gt; and the width and height set, so the page doesn&#x27;t
jump while images load.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pixel art and UI shots need different treatment from photos. The build counts an image&#x27;s colours on a
thumbnail: few colours means pixel art, a chart or UI, which is saved &lt;strong&gt;lossless&lt;/strong&gt; and scaled with nearest
neighbour; everything else is lossy at quality 80. Thumbnails for the home page are 480 x 270 crops.&lt;/p&gt;
&lt;p&gt;All formats together are 33 MB on disk, most of it images. A visitor downloads only one variant of each,
and only the ones they scroll to.&lt;/p&gt;
&lt;h2 id=&quot;social-cards-a-feed-and-the-boring-parts&quot;&gt;Social cards, a feed and the boring parts &lt;a class=&quot;anchor&quot; href=&quot;#social-cards-a-feed-and-the-boring-parts&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A link to&lt;/span&gt; an article shared on Discord or a social network shows a preview image. The build draws one per
article, 1200 x 630: the article&#x27;s first picture, darkened towards the bottom, the game&#x27;s gold double frame,
the 影 seal, a crimson label with the part number, and the title in DotGothic16. The seal comes from the
same file as the iOS app icon.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/27-social-card.jpg&quot; alt=&quot;A generated social card for the post about HD-2D sprites: the article&amp;#x27;s first screenshot behind an ink gradient, the seal, the part label and the title in the game&amp;#x27;s heading font&quot; width=&quot;1200&quot; height=&quot;630&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A generated social card for the post about HD-2D sprites: the article&amp;#x27;s first screenshot behind an ink gradient, the seal, the part label and the title in the game&amp;#x27;s heading font&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The rest is the list that makes a site a site: an Atom feed with full articles, &lt;code&gt;sitemap.xml&lt;/code&gt;,
&lt;code&gt;robots.txt&lt;/code&gt;, a 404 page, a favicon in four sizes, canonical links, &lt;code&gt;.nojekyll&lt;/code&gt; for GitHub Pages, and a
&lt;code&gt;.gdignore&lt;/code&gt;.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Most of the site works without knowing where it lives: every link between pages, images and styles is
relative, so &lt;code&gt;website/index.html&lt;/code&gt; opens straight from disk. Only the feed, the sitemap and the social cards
need absolute addresses. When the human said &amp;quot;The domain will be dekiro.d12r.io&amp;quot;, the change was one line
in &lt;code&gt;site.json&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;base_url&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://dekiro.d12r.io/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;and a rebuild.&lt;/p&gt;
&lt;h2 id=&quot;no-tracking&quot;&gt;No tracking &lt;a class=&quot;anchor&quot; href=&quot;#no-tracking&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The footer says&lt;/span&gt; &amp;quot;No cookies, no tracking&amp;quot;, and the build makes it true by construction: no analytics, no
web fonts from a CDN, no embedded players, no external requests at all. Everything a page loads is in
&lt;code&gt;website/&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;checked-by-a-robot&quot;&gt;Checked by a robot &lt;a class=&quot;anchor&quot; href=&quot;#checked-by-a-robot&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;check_site.py&lt;/code&gt; serves &lt;code&gt;website/&lt;/code&gt; on localhost, fetches every page and everything each page links to, and
reports broken links, missing images or fonts, &lt;code&gt;#anchor&lt;/code&gt; links to headings that don&#x27;t exist, and images
without alt text or size. It exits with an error if anything is broken, so it can gate a deploy.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;--shots&lt;/code&gt;, it also drives a headless browser (Playwright, using the Edge already installed on the
machine, so no browser download) and saves screenshots at 1440 x 900 and at 390 x 844, a phone. Those
screenshots were the design review. The first round found three things: the text in the crimson title
bars sat on the top edge instead of the middle, an empty column next to the article title on desktop, and
body text that looked huge on phones. That last one turned out to be the 2x screenshot, not the page; the
phone layout got narrower window frames anyway, which left more room per line.&lt;/p&gt;
&lt;h2 id=&quot;a-tufte-inspired-layout&quot;&gt;A Tufte-inspired layout &lt;a class=&quot;anchor&quot; href=&quot;#a-tufte-inspired-layout&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Wide screens leave&lt;/span&gt; room beside a 690-pixel text column, and a second pass on the site uses it the way Edward
Tufte&#x27;s books do: side facts sit next to the line that needs them, not at the bottom of the page. All of it
still comes from plain Markdown, and an article that uses none of it renders as before.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sidenotes.&lt;/strong&gt; Cross-references, file paths and small facts are ordinary Markdown footnotes in the
drafts (&lt;code&gt;[^1]&lt;/code&gt;, with markdown-it&#x27;s footnote plugin). The build turns each one into a numbered note in the
margin beside its paragraph. A label starting with &lt;code&gt;m-&lt;/code&gt; makes an unnumbered margin note instead. On
narrow screens a note collapses behind its number and opens on a tap, done with a checkbox and a label,
no JavaScript. The Atom feed, where there is no margin, gets them as numbered endnotes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Margin and full-width figures.&lt;/strong&gt; The image title picks the kind: &lt;code&gt;![caption](x.png &amp;quot;margin&amp;quot;)&lt;/code&gt; puts a
small image (an icon, a sprite, a phone screenshot) in the margin, &lt;code&gt;&amp;quot;full&amp;quot;&lt;/code&gt; lets a panorama, a before/after
pair or a chart span the text column and the margin. The &lt;code&gt;srcset&lt;/code&gt; sizes follow, so a margin figure doesn&#x27;t
download its 1600-pixel version. An unknown title stops the build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Epigraphs.&lt;/strong&gt; A blockquote whose last line starts with an em dash becomes an epigraph with that line as
its attribution. Most of the newer posts open with the developer&#x27;s request that started the work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small-caps openings.&lt;/strong&gt; The first few words after each section heading are set in small caps, cut at the
first punctuation mark so a phrase is never split oddly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Booktabs tables,&lt;/strong&gt; in the spirit of the LaTeX package: a few horizontal rules instead of a grid, and
columns whose cells are all numbers are right-aligned automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The phone screenshot below is a margin figure: on a wide screen it sits in the margin, not in the text column.&lt;/p&gt;
&lt;figure class=&quot;fig margin&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/27-phone.jpg&quot; alt=&quot;The same article on a 390-pixel phone screen: the title window, the folded table of contents, and the body text&quot; width=&quot;390&quot; height=&quot;844&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same article on a 390-pixel phone screen: the title window, the folded table of contents, and the body text&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-it-took&quot;&gt;What it took &lt;a class=&quot;anchor&quot; href=&quot;#what-it-took&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The website was&lt;/span&gt; one request and one agent run, with one round of fixes after the screenshots, and the
domain a week later. The home page&#x27;s hero image and the About page&#x27;s gallery are Steam store screenshots,
which live in a git-ignored folder; so that the site stays rebuildable from a fresh clone, those seven shots
were copied into &lt;code&gt;tools/blog/media/&lt;/code&gt; as JPGs (1.5 MB).&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keep the drafts as the source,&lt;/strong&gt; next to the code, and generate the site from them. Corrections stay in
one place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reuse the game&#x27;s art as-is.&lt;/strong&gt; 9-slice textures map directly onto CSS &lt;code&gt;border-image&lt;/code&gt;; pixel art needs
&lt;code&gt;image-rendering: pixelated&lt;/code&gt; and whole-number scales.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subset fonts.&lt;/strong&gt; A CJK pixel font went from 2 MB to 6 KB for Latin-only use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make links relative&lt;/strong&gt; and keep the absolute address in one setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the built site, not the source,&lt;/strong&gt; and take screenshots at phone size: that&#x27;s where layouts
break.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Back to the&lt;/span&gt; game. The next posts return to what players will actually do in Dekiro.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The UI textures are generated too: &lt;code&gt;tools/ui/gen_ui_textures.js&lt;/code&gt; draws every 9-slice, ornament and
icon at 1x, and the game&#x27;s theme sets the slice margins in code. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;code&gt;website/&lt;/code&gt; sits inside the Godot project folder. Without a &lt;code&gt;.gdignore&lt;/code&gt;, the editor would import
every website image and font as a game asset. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Baking the Minimap: Top-Down Renders of the Real World</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/26-baking-the-minimap.html"/>
<id>https://dekiro.d12r.io/26-baking-the-minimap.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>The minimap was drawn from the map&#x27;s data: zones as coloured discs, roads as lines. It knew where the hunting grounds were and nothing about what you could see. Now Godot photographs each map from above and a Python script turns the photo into something that fits the UI.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;The minimap (and map) do not take trees or buildings into consideration for the top down view - hard to
orient with that.&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;hard-to-orient-with-that&quot;&gt;&amp;quot;Hard to orient with that&amp;quot; &lt;a class=&quot;anchor&quot; href=&quot;#hard-to-orient-with-that&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The minimap is&lt;/span&gt; older than most of the world. It came on day two: a corner disc and a big map on M, drawn
from the map&#x27;s JSON. Zones were coloured by level, the town was a stone-coloured circle, roads were lines,
water was blue, and portals, NPCs, party members and nearby monsters were dots on top. It rendered on a
background thread, once per map, and it was cheap.&lt;/p&gt;
&lt;p&gt;The world it described kept growing after that. The maps became generated, 1.2 to 1.5 km across, each with
one walled town of East Asian buildings,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; forests of tens of thousands of trees, lakes, ruins, camps
and landmarks. The minimap still showed a beige disc where the town was and an even green where the
forest was.&lt;/p&gt;
&lt;p&gt;In the first playtest, the human summed it up in the line at the top of this post: hard to orient.&lt;/p&gt;
&lt;p&gt;That&#x27;s the whole point of a minimap. You glance at it to find the gap in the treeline, the road out of town,
the building with the smith. A map that only shows the &lt;em&gt;data&lt;/em&gt; behind the world can&#x27;t do that, because the
data is &amp;quot;a hunting ground of level 5-9 here&amp;quot;, not &amp;quot;a dense pine wood with a clearing in the middle&amp;quot;.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/26-minimap-before-after.jpg&quot; alt=&quot;The corner minimap on three maps, before (left: drawn from the map data) and after (right: the baked picture). Sanctuary Vale&amp;#x27;s town, Duskmoor&amp;#x27;s grey village grid, the Ember Wastes&amp;#x27; ash plain&quot; width=&quot;608&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The corner minimap on three maps, before (left: drawn from the map data) and after (right: the baked picture). Sanctuary Vale&amp;#x27;s town, Duskmoor&amp;#x27;s grey village grid, the Ember Wastes&amp;#x27; ash plain&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;draw-it-or-photograph-it&quot;&gt;Draw it, or photograph it? &lt;a class=&quot;anchor&quot; href=&quot;#draw-it-or-photograph-it&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;There were two&lt;/span&gt; ways to fix it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Draw more from the data.&lt;/strong&gt; Every tree, house and road is in the map JSON, so the old renderer could
paint trees as dots and buildings as rectangles. That&#x27;s a lot of drawing code, and it would still be a
diagram: it can&#x27;t know that a forest&#x27;s crowns overlap, that a roof is red, or what the Shadow Marsh&#x27;s water
actually looks like.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Photograph it.&lt;/strong&gt; The game can already render each map. Put an orthographic camera straight above it,
render it once, and the picture shows exactly what stands in the world, because it &lt;em&gt;is&lt;/em&gt; the world. The
cost moves from runtime to a bake step, and the picture has to be kept in sync with the map.&lt;/p&gt;
&lt;p&gt;The second won, for a reason that runs through this project: the maps are generated by Python scripts and
rebuilt often.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; Anything derived from them has to be rerunnable, so the bake became a tool next to
the generators, with a &lt;code&gt;--stale&lt;/code&gt; flag for &amp;quot;only what changed&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;step-1-godot-renders-the-map-from-above&quot;&gt;Step 1: Godot renders the map from above &lt;a class=&quot;anchor&quot; href=&quot;#step-1-godot-renders-the-map-from-above&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tests/bake_map_images.gd&lt;/code&gt; is a script that starts the real game on a map, waits until the map has finished
building, and takes over the camera:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;orthographic camera looks straight down&lt;/strong&gt;, north up, at noon. No fog, no clouds, no weather, no
night, no sun glint on the water.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Everything that moves is hidden&lt;/strong&gt;: monsters, NPCs, townsfolk, animals, the caravan, loot, particles and
your own character. So is the grass, which from above is just noise. What remains is the ground, flora,
clutter, buildings, mountains and water.&lt;/li&gt;
&lt;li&gt;The sea isn&#x27;t drawn at all. It stays transparent, and the next step paints it flat, so the picture has a
clean coastline instead of sun reflections.&lt;/li&gt;
&lt;li&gt;The map is rendered in &lt;strong&gt;1024 px tiles at 2 pixels per meter&lt;/strong&gt; and stitched together. A 1.5 km map is
about 3,000 x 3,700 pixels at this stage.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It also renders two extra passes as &lt;strong&gt;masks&lt;/strong&gt;. Trees are moved to their own render layer, and so are
structures: houses, halls, walls, palisades, towers, tents, bridges, ships. A structure is recognised by
matching each prop&#x27;s position to its entry in the map JSON, so the map code itself didn&#x27;t change for the
bake. The three images go to &lt;code&gt;build/map_bake/&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&amp;lt;id&amp;gt;_color.png       the lit picture (alpha 0 = sea)
&amp;lt;id&amp;gt;_trees.png       alpha = tree crowns
&amp;lt;id&amp;gt;_structures.png  alpha = buildings, walls, bridges, towers, tents...
&amp;lt;id&amp;gt;_raw.json        {&amp;quot;map&amp;quot;, &amp;quot;ppm&amp;quot;, &amp;quot;size&amp;quot;: [w, h] px, &amp;quot;rect&amp;quot;: [x, z, w, h] map-local meters}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This step needs a real window. Headless Godot doesn&#x27;t render (and doesn&#x27;t compile shaders), so the
baker opens one for each map, about 15 to 40 seconds each.&lt;/p&gt;
&lt;h2 id=&quot;step-2-python-makes-it-look-like-a-map&quot;&gt;Step 2: Python makes it look like a map &lt;a class=&quot;anchor&quot; href=&quot;#step-2-python-makes-it-look-like-a-map&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A raw top-down&lt;/span&gt; render is a photograph: busy, dark on the moody maps, and every tree a little cluster of
leaf cards. &lt;code&gt;tools/mapgen/bake_map_images.py&lt;/code&gt; (numpy, scipy, Pillow) turns it into something that reads at
the size of a minimap and sits in the game&#x27;s lacquer-and-gold UI:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Brighten dark maps.&lt;/strong&gt; Duskmoor and the three realms are dark by design. A gamma per map lifts the
land&#x27;s median brightness to a fixed grey, never darker than it was.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flatten.&lt;/strong&gt; A 3x3 median filter removes speckle, saturation drops a little, and the colours are
posterised to steps of 14 per channel. That gives the flat, few-colour look of the UI, and it keeps rare
hues like a lake&#x27;s blue that a palette reduction would lose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trees become blobs.&lt;/strong&gt; The tree mask is cleaned up (closed, then opened) and painted in a darker green,
lit from the top left with a bright rim, shaded on the bottom right, with a soft shadow. A forest reads as
a forest, and a clearing as a clearing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Roads are redrawn from the data,&lt;/strong&gt; on top of the trees: a road under a canopy disappears in the photo,
and roads are what you navigate by.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Buildings get an ink outline and a drop shadow,&lt;/strong&gt; and their rendered roofs are made a little brighter.
Bridges and piers count too, even over water.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The sea is flat,&lt;/strong&gt; lighter in the shallows, with a pale line along the coast.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A touch of the UI&#x27;s warmth&lt;/strong&gt; over everything, so the picture doesn&#x27;t look cold inside the gold frame.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/26-raw-vs-styled.jpg&quot; alt=&quot;The Vale&amp;#x27;s town square as rendered from above (left) and after the stylise step (right, 1 pixel per meter shown at 2x): flatter colours, roads redrawn from the map data, and every building outlined in ink with a drop shadow&quot; width=&quot;1600&quot; height=&quot;794&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Vale&amp;#x27;s town square as rendered from above (left) and after the stylise step (right, 1 pixel per meter shown at 2x): flatter colours, roads redrawn from the map data, and every building outlined in ink with a drop shadow&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The result is saved at &lt;strong&gt;1 pixel per meter&lt;/strong&gt;, at most 2048 pixels on a side. The Vale (1468 x 1856 m)
fits at full resolution; five larger maps are capped at 1.1 to 1.34 meters per pixel. Each picture is
imported VRAM-compressed with mipmaps, so it shrinks cleanly in the big map window. The script writes
&lt;code&gt;assets/maps/&amp;lt;id&amp;gt;_map.png&lt;/code&gt; and one index, &lt;code&gt;data/map_images.json&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;sanctuary_vale&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;image&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;res://assets/maps/sanctuary_vale_map.png&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;rect&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;-820.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;-928.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1468.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1856.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;size&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1468&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1856&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;source_md5&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;1be9034db171f8dce1161926b02781f4&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;rect&lt;/code&gt; is the part of the map, in map-local meters, that the picture covers. It is the land&#x27;s coastline
plus 40 m of sea, not the map&#x27;s declared size. That distinction found a bug in the old minimap: on some
maps the land sits off-centre within the declared &lt;code&gt;size&lt;/code&gt;. The Ember Wastes&#x27; coast runs from z -1347 to 370
while its size says plus or minus 900, so the old data drawing had been cutting part of the map off.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;source_md5&lt;/code&gt; is the hash of the map&#x27;s JSON when the picture was baked. That&#x27;s what &lt;code&gt;--stale&lt;/code&gt; compares:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/mapgen/bake_map_images.py&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# every world map (~4 min for all nine)&lt;/span&gt;
python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/mapgen/bake_map_images.py&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--stale&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# only maps whose JSON changed since their picture&lt;/span&gt;
python&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tools/mapgen/bake_map_images.py&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--restyle&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# reuse the renders, rerun only step 2 (tuning the look)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;--restyle&lt;/code&gt; made the look cheap to iterate: the expensive render runs once, and the stylise step takes
seconds. A render that fails&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; is retried three times, 20 seconds apart, before that map is skipped.&lt;/p&gt;
&lt;h2 id=&quot;step-3-the-game-draws-it&quot;&gt;Step 3: the game draws it &lt;a class=&quot;anchor&quot; href=&quot;#step-3-the-game-draws-it&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On the game&lt;/span&gt; side, a small static class reads the index:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class_name&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MapImage&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## Baked top-down pictures of the world maps [...]. Minimap and WorldMapWindow draw it under&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## their markers; a map without one (dungeons, a new map not baked yet) keeps Minimap.render&#x27;s data drawing.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It holds one texture at a time, the map you&#x27;re on, about 4 MB in video memory, and does the math between
map-local meters and the picture&#x27;s UV coordinates (&lt;code&gt;to_uv&lt;/code&gt;, &lt;code&gt;from_uv&lt;/code&gt;). The minimap&#x27;s &lt;code&gt;background()&lt;/code&gt; now
asks &lt;code&gt;MapImage&lt;/code&gt; first and only falls back to the old data drawing when there&#x27;s no picture. A new
&lt;code&gt;background_rect(map)&lt;/code&gt; tells the corner disc and the big map which part of the world the picture covers,
because it is no longer the map&#x27;s declared size.&lt;/p&gt;
&lt;p&gt;Everything drawn on top stayed as it was: the markers, the labels, the party dots, the arrow for you and
the world map&#x27;s fog of war over areas you haven&#x27;t explored. Only the background changed.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/26-worldmap-before-after.jpg&quot; alt=&quot;The world map (M) of Sanctuary Vale before and after: the same markers and labels over coloured zone discs, then over the baked picture with the forest, the walled town, the roads, the lakes and the mountain border&quot; width=&quot;1576&quot; height=&quot;837&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The world map (M) of Sanctuary Vale before and after: the same markers and labels over coloured zone discs, then over the baked picture with the forest, the walled town, the roads, the lakes and the mountain border&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;checking-that-it-lines-up&quot;&gt;Checking that it lines up &lt;a class=&quot;anchor&quot; href=&quot;#checking-that-it-lines-up&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A picture that&lt;/span&gt; is off by twenty meters is worse than no picture: you walk to the bridge on the map and
find water. So the check was about alignment, not looks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A test checks the world-to-picture math, that every picture covers its map&#x27;s coast at the right aspect
ratio, that dungeons, unknown maps and map instances (&lt;code&gt;&amp;lt;dungeon&amp;gt;@&amp;lt;n&amp;gt;&lt;/code&gt;) get no picture, and that the data
drawing still works as the fallback.&lt;/li&gt;
&lt;li&gt;In the screenshots, a fake party member placed on a portal landed on the torii at the end of its road,
our arrow sat on the town square, and the waystones, lakes and roads matched the old drawing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dungeons have no picture, on purpose. Down there the minimap shows &amp;quot;No map down here&amp;quot;, and the dungeon
generator&#x27;s own overview images&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; stay a development tool.&lt;/p&gt;
&lt;h2 id=&quot;what-it-costs&quot;&gt;What it costs &lt;a class=&quot;anchor&quot; href=&quot;#what-it-costs&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository:&lt;/strong&gt; the nine PNG sources are about 9 MB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exports:&lt;/strong&gt; each imported picture is 2.7 to 5.4 MB, about 33 MB per platform (desktop and mobile each get
their own compressed copy).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory:&lt;/strong&gt; one picture at a time, about 4 MB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upkeep:&lt;/strong&gt; a stale picture still lines up, because the rect doesn&#x27;t move. It just shows last week&#x27;s trees
and buildings. So the rule in the project&#x27;s notes is: after any map generator or dressing change, run
&lt;code&gt;--stale&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/26-all-maps.jpg&quot; alt=&quot;All nine world maps after baking, from the grey Duskmoor and the red Hellmouth Rift to the snow of Frostpeak and the green Vale&quot; width=&quot;1600&quot; height=&quot;832&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;All nine world maps after baking, from the grey Duskmoor and the red Hellmouth Rift to the snow of Frostpeak and the green Vale&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-isnt-perfect&quot;&gt;What isn&#x27;t perfect &lt;a class=&quot;anchor&quot; href=&quot;#what-isnt-perfect&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;dark realms&lt;/strong&gt; (Ember Wastes, Hellmouth Rift, Shadowlands) are readable but low in contrast: their
roads are faint, and the Hellmouth comes out strongly red after brightening.&lt;/li&gt;
&lt;li&gt;The minimap uses the project&#x27;s default nearest filtering. At 1 meter per pixel that looks fine standing
still; if the ground shimmers while walking, the minimap&#x27;s disc may need its own linear filter.&lt;/li&gt;
&lt;li&gt;The bake is a &lt;strong&gt;build step&lt;/strong&gt;, so it can be forgotten. The &lt;code&gt;source_md5&lt;/code&gt; makes forgetting visible, but
nothing fails when a picture is stale.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;If the game can render it, bake it.&lt;/strong&gt; A top-down photo of the real world beats any amount of drawing
code that guesses at it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stylise for the UI, not for realism.&lt;/strong&gt; Posterised colours, blob trees and outlined buildings read at
minimap size; a raw render doesn&#x27;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redraw what you navigate by from data.&lt;/strong&gt; Roads under trees vanish in a photo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store the covered rect with the picture,&lt;/strong&gt; and hash the source, so a stale picture stays aligned and
knows it&#x27;s stale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split slow and fast steps.&lt;/strong&gt; A &lt;code&gt;--restyle&lt;/code&gt; that skips the render makes tuning the look a matter of
seconds.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;One more tool&lt;/span&gt; that turns project data into something for people to look at: the website this blog lives
on, generated from the Markdown in the repository and styled with the game&#x27;s own UI textures.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Post 6: from an open world to torii portals, and nine maps with one town each. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Every world map has its generator in &lt;code&gt;tools/mapgen/maps/&amp;lt;id&amp;gt;.py&lt;/code&gt; plus a &lt;code&gt;_dressing.py&lt;/code&gt; for the
clutter, and the JSON it writes is never edited by hand. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;It happens: several agents work in the same repository at once (post 11), and the game can be
briefly uncompilable while one of them is mid-edit. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;Post 13: the dungeon generator writes an overview PNG next to every dungeon it builds. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Every Platform from One Windows PC</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/25-every-platform-from-one-pc.html"/>
<id>https://dekiro.d12r.io/25-every-platform-from-one-pc.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Windows, Linux, a universal macOS app, an Android APK, an iOS Xcode project and two dedicated servers, all exported from one Windows machine with Godot&#x27;s command line. What each one needed, the JSON files that silently didn&#x27;t ship, and how to test a build you can&#x27;t click through.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;How do I get an exported version of that game now?&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;seven-presets&quot;&gt;Seven presets &lt;a class=&quot;anchor&quot; href=&quot;#seven-presets&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Dekiro is developed&lt;/span&gt; on one Windows PC. The human doesn&#x27;t own a Mac, a Linux desktop or a test phone farm,
and the first build question, the one above, came on day two. Two weeks later the project has seven export presets in &lt;code&gt;export_presets.cfg&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Size (packed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Windows (Game)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dekiro.exe&lt;/code&gt; + &lt;code&gt;Dekiro.pck&lt;/code&gt; + Steam DLLs&lt;/td&gt;
&lt;td&gt;155 MB zip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux (Game)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dekiro.x86_64&lt;/code&gt; + &lt;code&gt;.pck&lt;/code&gt; + Steam &lt;code&gt;.so&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;146 MB tar.gz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS (Game)&lt;/td&gt;
&lt;td&gt;a universal &lt;code&gt;.app&lt;/code&gt; (Intel + Apple Silicon), ad-hoc signed&lt;/td&gt;
&lt;td&gt;178 MB zip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android (Game)&lt;/td&gt;
&lt;td&gt;a debug-signed arm64 APK&lt;/td&gt;
&lt;td&gt;147 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS (Game)&lt;/td&gt;
&lt;td&gt;an Xcode project&lt;/td&gt;
&lt;td&gt;486 MB folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows (Dedicated Server)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DekiroServer.exe&lt;/code&gt; + &lt;code&gt;.pck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux (Dedicated Server)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DekiroServer.x86_64&lt;/code&gt; + &lt;code&gt;.pck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Every one of them is a single command from a shell:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-release&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Windows (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game/Dekiro.exe
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-release&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Linux (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game-linux/Dekiro.x86_64
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-release&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;macOS (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game-mac/Dekiro.zip
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-debug&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Android (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game-android/Dekiro.apk
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-release&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;iOS (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game-ios/Dekiro.ipa&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# writes an Xcode project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The sizes tell their own story. The first Windows zip, on day two, was 38 MB. The sprite characters,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; 177 monster models, 20 dungeons and 30 music tracks brought it to 155 MB.&lt;/p&gt;
&lt;h2 id=&quot;export-templates-once&quot;&gt;Export templates, once &lt;a class=&quot;anchor&quot; href=&quot;#export-templates-once&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Godot exports by&lt;/span&gt; copying a prebuilt engine binary (an &lt;em&gt;export template&lt;/em&gt;) and appending the game&#x27;s data
pack. The templates weren&#x27;t installed when the first build was asked for. The AI downloaded the official
4.7.2 package (1.28 GB) from Godot&#x27;s GitHub releases, checked it against the published checksum, and
unpacked it to &lt;code&gt;%APPDATA%\Godot\export_templates\4.7.2.stable&lt;/code&gt;. That one package holds the templates for
Windows, Linux, macOS, Android, iOS and the web, so every later platform needed no further download from
Godot, only the platform&#x27;s own tools.&lt;/p&gt;
&lt;h2 id=&quot;the-json-that-didnt-ship&quot;&gt;The JSON that didn&#x27;t ship &lt;a class=&quot;anchor&quot; href=&quot;#the-json-that-didnt-ship&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Godot exports&lt;/span&gt; &lt;em&gt;resources&lt;/em&gt;: scenes, scripts, imported textures and sounds. A plain &lt;code&gt;.json&lt;/code&gt; file isn&#x27;t a
resource, so an export leaves it out unless the preset&#x27;s include filter names it. Dekiro is data-driven:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; skills, items, monsters, maps, quests and NPCs are all JSON. So the very first presets had:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;ini&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;include_filter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data/*.json,data/maps/*.json&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;exclude_filter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;tests/*,tools/*,docs/*&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The rule went into the project&#x27;s rulebook (&amp;quot;add &lt;code&gt;*.json&lt;/code&gt; to the export preset&#x27;s non-resource include
filter, or &lt;code&gt;data/*.json&lt;/code&gt; won&#x27;t ship&amp;quot;) and it worked for four days. Then the sprite characters arrived, and
each body&#x27;s sprites came with an &lt;code&gt;atlas.json&lt;/code&gt;: where every frame sits on the packed pages, where the feet
and hands are, which frame the blow lands on. It lived in &lt;code&gt;assets/sprites/&lt;/code&gt;, not &lt;code&gt;data/&lt;/code&gt;. Nothing failed:
no test ran an exported build, and the game code falls back to the old capsule when a sprite sheet can&#x27;t be
loaded, which is exactly what it should do headless. An exported build would have shown every player and
NPC as a capsule.&lt;/p&gt;
&lt;p&gt;It was caught before shipping, but only because a build request (&amp;quot;Get me new builds for Windows and
Linux&amp;quot;) was preceded by a check of the filters against what the game loads. The fix was one line in every
preset:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;ini&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;include_filter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data/*.json,data/maps/*.json,assets/sprites/*.json&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;and checking that the data files are inside the pack became part of every build since. The lesson is general. &lt;strong&gt;A fallback that keeps the game running also hides what&#x27;s missing&lt;/strong&gt;; for a
build, a missing data file should be a loud error, or at least something a check looks for.&lt;/p&gt;
&lt;h2 id=&quot;windows&quot;&gt;Windows &lt;a class=&quot;anchor&quot; href=&quot;#windows&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The simplest target&lt;/span&gt;, and the one everything else is measured against. The export folder holds the exe,
the &lt;code&gt;.pck&lt;/code&gt;, and GodotSteam&#x27;s two DLLs (the GDExtension and Valve&#x27;s &lt;code&gt;steam_api64.dll&lt;/code&gt;). Zip the folder,
send it. Two notes for friends that went into the instructions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The exe isn&#x27;t code-signed, so Windows SmartScreen says &amp;quot;Windows protected your PC&amp;quot;. &lt;em&gt;More info, Run
anyway&lt;/em&gt;. Signing comes with a real release.&lt;/li&gt;
&lt;li&gt;During development the game uses Steam&#x27;s test app 480,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; so Steam shows everyone as playing
&amp;quot;Spacewar&amp;quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One practical trap: the developer plays from the unpacked &lt;code&gt;build/share/Dekiro-Windows&lt;/code&gt; folder, and a
running game locks its exe and DLLs. Later builds zip straight from the export folder instead of copying
over the one in use.&lt;/p&gt;
&lt;h2 id=&quot;linux-checked-in-wsl&quot;&gt;Linux, checked in WSL &lt;a class=&quot;anchor&quot; href=&quot;#linux-checked-in-wsl&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A Linux build&lt;/span&gt; needs no Linux machine to export, but it does need one to run. WSL 2 with Ubuntu is the
Linux box here, with its own Godot 4.7.2 Linux binary. Two rules came out of using it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never point a Linux Godot at the Windows project folder.&lt;/strong&gt; It would rewrite &lt;code&gt;.godot/&lt;/code&gt;, the import
cache, with Linux paths. The project is copied with rsync (excluding &lt;code&gt;.godot&lt;/code&gt; and &lt;code&gt;.git&lt;/code&gt;) to &lt;code&gt;~/dekiro&lt;/code&gt;
and run there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrap every run in &lt;code&gt;timeout&lt;/code&gt;.&lt;/strong&gt; A headless server that doesn&#x27;t exit blocks the session; Ubuntu 26.04&#x27;s
&lt;code&gt;timeout&lt;/code&gt; needs &lt;code&gt;--&lt;/code&gt; before the command.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first Linux run found a real bug that Windows never showed. The exported Linux dedicated server logged
261 errors on shutdown, all &amp;quot;disconnect a nonexistent connection&amp;quot;, because the multiplayer spawner was
freed before the entities it had spawned. &lt;code&gt;World.shutdown&lt;/code&gt; now frees entities first, on every quit path.
The second found another: a blocked read on stdin deadlocks process exit on Linux, so the server console
got a &lt;code&gt;--no-console&lt;/code&gt; flag for services and a watchdog. The full suite, a passworded dedicated server,
Linux client bots joining it, the console commands and all three ways to stop the server were verified on
Ubuntu under WSL.&lt;/p&gt;
&lt;p&gt;The packaging detail that matters for Linux: a &lt;code&gt;.tar.gz&lt;/code&gt;, not a zip, because tar keeps the executable flag
and a friend can run &lt;code&gt;./Dekiro.x86_64&lt;/code&gt; straight away.&lt;/p&gt;
&lt;p&gt;The human&#x27;s next question was whether the Linux build runs on a Steam Machine. In principle yes: SteamOS
is Linux on x86-64 with an AMD GPU, and a non-Steam game can be added to the library from Desktop Mode. In
practice the game was keyboard and mouse only at the time, which made gamepad support the real follow-up.&lt;/p&gt;
&lt;h2 id=&quot;macos-universal-ad-hoc-signed-and-steam&quot;&gt;macOS: universal, ad-hoc signed, and Steam &lt;a class=&quot;anchor&quot; href=&quot;#macos-universal-ad-hoc-signed-and-steam&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;&amp;quot&lt;/span&gt;;Can I get a Mac OS build?&amp;quot; took a couple of minutes of work and one project setting. The macOS preset
exports a &lt;strong&gt;universal&lt;/strong&gt; app (one binary with both Intel x86_64 and Apple Silicon arm64 code). Apple
Silicon&#x27;s GPU reads ETC2/ASTC compressed textures, not the BC formats desktop PCs use, so the importer has
to write both:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;ini&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;[rendering]&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;textures/vram_compression/import_etc2_astc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That reimported every texture once and adds nothing to the Windows or Linux downloads, which only ship
their own format.&lt;/p&gt;
&lt;p&gt;Signing is the Mac&#x27;s real hurdle. A proper release needs an Apple Developer account for a signing
certificate and notarization. Without one, the preset uses &lt;strong&gt;ad-hoc signing&lt;/strong&gt; (&lt;code&gt;codesign/codesign=1&lt;/code&gt;,
notarization off): the app is signed, but by nobody Apple knows. macOS blocks it on first launch, and the
instructions for testers are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;right-click the app, &lt;strong&gt;Open&lt;/strong&gt;, then &lt;strong&gt;Open&lt;/strong&gt; again; after that it starts normally;&lt;/li&gt;
&lt;li&gt;if macOS says the app is &amp;quot;damaged&amp;quot;, run &lt;code&gt;xattr -cr&lt;/code&gt; on it in Terminal, which removes the quarantine flag
the download added.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first Mac build also logged one error at start: the GodotSteam plugin&#x27;s config pointed at
&lt;code&gt;addons/godotsteam/osx/&lt;/code&gt;, a folder that had never been added, because Steam was set up for Windows and
Linux only. The game runs fine without Steam (single player, hosting and joining by address), but Mac
players would have had no Steam lobbies, invites or friends list. The human asked &amp;quot;What&#x27;s the steam on Mac
thing?&amp;quot;, got the explanation, and answered &amp;quot;Yea. Fix it&amp;quot;. The fix was the matching GodotSteam 4.23.1
release&#x27;s two universal &lt;code&gt;.dylib&lt;/code&gt; files (the extension and Valve&#x27;s &lt;code&gt;libsteam_api.dylib&lt;/code&gt;), the same version
the other platforms use. The export then embedded them in the app bundle with no error.&lt;/p&gt;
&lt;p&gt;One thing nobody can check from Windows is whether it actually works on a Mac. As of this post, nobody
has reported opening it on one; the first Mac player is the test.&lt;/p&gt;
&lt;h2 id=&quot;android-and-ios&quot;&gt;Android and iOS &lt;a class=&quot;anchor&quot; href=&quot;#android-and-ios&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The phone builds&lt;/span&gt; have their own post (19), so briefly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Android&lt;/strong&gt; builds completely on Windows, given a JDK 17 (Godot 4.7 wants 17 even though newer ones
exist), the Android SDK&#x27;s command-line tools with platform-tools, build-tools 35 and android-35, and a
debug keystore. All three were installed into the user&#x27;s folder without admin rights. The same
ETC2/ASTC setting covers the textures. The result is a sideloadable APK.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS&lt;/strong&gt; exports an Xcode project from Windows, but building, signing and installing need Xcode on a Mac.
Godot won&#x27;t export without an App Store Team ID in the preset, and GodotSteam (which has no iOS
library) is excluded so the app doesn&#x27;t try to load it. Steam is also switched off in code on both phone
platforms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Phones exclude more than the desktop builds: &lt;code&gt;addons/godotsteam/*&lt;/code&gt; on both, and the iOS icon art on
Android. Everything else uses the same include filter, which is the point: one list of data files that
must ship, repeated in seven places, and checked.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/25-ios-art.png&quot; alt=&quot;The iOS icon and launch screen, drawn by a script from the game&amp;#x27;s UI art: a crimson seal with the kanji for &amp;quot;shadow&amp;quot; on the window lacquer, and the start menu&amp;#x27;s title row&quot; width=&quot;1600&quot; height=&quot;480&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The iOS icon and launch screen, drawn by a script from the game&amp;#x27;s UI art: a crimson seal with the kanji for &amp;quot;shadow&amp;quot; on the window lacquer, and the start menu&amp;#x27;s title row&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;dedicated-servers&quot;&gt;Dedicated servers &lt;a class=&quot;anchor&quot; href=&quot;#dedicated-servers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The dedicated server&lt;/span&gt; is the same project exported with Godot&#x27;s &lt;code&gt;dedicated_server&lt;/code&gt; feature tag, and the
game checks for it (or a &lt;code&gt;--server&lt;/code&gt; flag) to skip the menu, read &lt;code&gt;server.cfg&lt;/code&gt;, and run with no local
player. There are Windows and Linux presets. For Linux, &lt;code&gt;docs/HOSTING.md&lt;/code&gt; includes a systemd unit whose
stop command touches a &lt;code&gt;STOP&lt;/code&gt; file in the save folder: the server notices it within a second, saves the
world and exits, which is the clean way to stop a process that may have nothing attached to stdin.&lt;/p&gt;
&lt;p&gt;During development there&#x27;s a third way that needs no export at all: &lt;code&gt;tools/run_dedicated_server.bat&lt;/code&gt;
starts the server straight from the project folder with the Godot console binary.&lt;/p&gt;
&lt;h2 id=&quot;testing-a-build-you-cant-click-through&quot;&gt;Testing a build you can&#x27;t click through &lt;a class=&quot;anchor&quot; href=&quot;#testing-a-build-you-cant-click-through&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;An export that&lt;/span&gt; finishes without errors says little. The checks that run after one:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Is the data in the pack?&lt;/strong&gt; List the &lt;code&gt;.pck&lt;/code&gt; and look for &lt;code&gt;data/*.json&lt;/code&gt;, the maps and the sprite
atlases.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Does the exported pack run the game?&lt;/strong&gt; Release templates refuse a scene path on the command line
(you can&#x27;t tell &lt;code&gt;Dekiro.exe&lt;/code&gt; to open &lt;code&gt;main.tscn&lt;/code&gt; directly). The editor&#x27;s binary can, though, and it can
load any pack:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--main-pack&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game/Dekiro.pck&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;res://scenes/main.tscn&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--quit-after&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;m&quot;&gt;400&lt;/span&gt;
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--main-pack&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game/Dekiro.pck&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;res://scenes/main.tscn&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--join&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;127&lt;/span&gt;.0.0.1:24680
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That runs exactly the exported data with the same engine version, smoke-tests a session, and with
&lt;code&gt;--join&lt;/code&gt; makes a client of the exported pack join a server. The exported exe itself only writes a log
with &lt;code&gt;--log-file&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Linux in WSL&lt;/strong&gt;, headless: the real &lt;code&gt;Dekiro.x86_64&lt;/code&gt; starts, and the Linux pack joins a Linux server.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mac, Android and iOS&lt;/strong&gt;: export without errors, check the bundle&#x27;s contents, and say plainly that
nobody has run it yet.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A bug from the first real multiplayer session with a friend is a good reminder of where these checks
stop. Joined players and their dropped loot appeared sunk into the ground on hills. The exported pack
placed items correctly in single player, which pointed the search at the network code: the host stored a
joined player&#x27;s height as zero.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; No export check would have found it;
only two people on two PCs did.&lt;/p&gt;
&lt;h2 id=&quot;shipping-it&quot;&gt;Shipping it &lt;a class=&quot;anchor&quot; href=&quot;#shipping-it&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The builds go&lt;/span&gt; into &lt;code&gt;build/share/&lt;/code&gt; as one file per platform: &lt;code&gt;Dekiro-Windows.zip&lt;/code&gt;, &lt;code&gt;Dekiro-Linux.tar.gz&lt;/code&gt;
(executable flag intact), &lt;code&gt;Dekiro-macOS.zip&lt;/code&gt;, &lt;code&gt;Dekiro-Android.apk&lt;/code&gt;. When the human asked &amp;quot;Are the
builds now containing all changes?&amp;quot;, the answer came from facts, not memory: the working tree is clean,
no game file is newer than the exported packs, and the last commit before the export is named.&lt;/p&gt;
&lt;p&gt;Saves never live next to the game. Godot&#x27;s &lt;code&gt;user://&lt;/code&gt; maps to &lt;code&gt;%APPDATA%\Godot\app_userdata\Dekiro- Sanctuary of Shadows\&lt;/code&gt; on Windows and &lt;code&gt;~/.local/share/godot/app_userdata/...&lt;/code&gt; on Linux, so unpacking a new
build over an old one never touches a character. (The odd &amp;quot;Dekiro-&amp;quot; comes from the project name with the
colon removed, which no file system accepts.)&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One machine can export every platform;&lt;/strong&gt; running them is the hard part. Use what you have: WSL for
Linux, &lt;code&gt;--main-pack&lt;/code&gt; for the exported data, phone-shaped windows for the touch UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-resource files need include filters,&lt;/strong&gt; and a graceful fallback can hide a missing one. Check the
pack, not the export log.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ETC2/ASTC&lt;/strong&gt; is one setting that Apple Silicon Macs and phones both need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ad-hoc signing&lt;/strong&gt; gets a Mac build to testers without an Apple account; tell them about right-click Open
and &lt;code&gt;xattr -cr&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native plugins are per platform.&lt;/strong&gt; A GDExtension with a missing &lt;code&gt;.dylib&lt;/code&gt; or no iOS library has to be
added or excluded per preset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Answer &amp;quot;is it current?&amp;quot; from timestamps and git,&lt;/strong&gt; not from what you remember building.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The builds were&lt;/span&gt; heading to a friend&#x27;s PC and a first playtest. One of its notes was about the minimap:
it didn&#x27;t show what the world actually looked like. The next post is about baking it.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Post 12: every player and NPC is a stack of palette-indexed sprite sheets rendered from 3D
characters, eight directions per clip. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Post 2: skills, items and loot tables as data, with almost nothing hard-coded. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;Valve&#x27;s public test app id. A game uses it until it has its own Steam app id, which comes with a
store page. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;Post 20, on the multiplayer bugs only a friend can find, tells that story. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>A Tavern, Named Hens and Cats: The Small Stuff</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/24-a-tavern-named-hens-and-cats.html"/>
<id>https://dekiro.d12r.io/24-a-tavern-named-hens-and-cats.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>88 more townsfolk walk the roads out of every town, a tribe of brewers got a tavern, seven hens got names, and a route checker now makes sure nobody walks through a wall to get to work. None of it changes a single fight, and all of it came out of the first playtest.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;I would also like to name the chickens: Stefan, Gerrit, Mars, Daria, Gitti, Mike and Nici.&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;everything-is-happening-in-the-center&quot;&gt;&amp;quot;Everything is happening in the center&amp;quot; &lt;a class=&quot;anchor&quot; href=&quot;#everything-is-happening-in-the-center&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The living-world&lt;/span&gt; post&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; ended with 66 townsfolk on routines: Old Marta sweeping the square, two gossips at
the well, children chasing a dog. It worked, and the first playtest found its weak spot within minutes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Non-player-relevant NPCs should be scattered a bit out on the roads to the north, east, west and south.
Right now everything is happening in the center.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;True. Every town had a lively square and four empty streets leading out of it. You walked out through the
gate past nobody, which is the opposite of how a town works: the square is where people meet, but the roads
are where they &lt;em&gt;come from&lt;/em&gt;.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-gate-guards.jpg&quot; alt=&quot;The Vale&amp;#x27;s north gate: a guard pair now stands at every gate of every town&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Vale&amp;#x27;s north gate: a guard pair now stands at every gate of every town&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;out-onto-the-roads&quot;&gt;Out onto the roads &lt;a class=&quot;anchor&quot; href=&quot;#out-onto-the-roads&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;One agent took&lt;/span&gt; every town, the Vale first, then the other five, and lightly the three gate camps. Some
existing people moved out (Joss to the north gate, Brann to fish from the pond bridges, Xiao the courier to a
loop through all four gates), and &lt;strong&gt;88 new people&lt;/strong&gt; moved in:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Town&lt;/th&gt;
&lt;th&gt;Townsfolk before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sanctuary Vale&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marsh Camp&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ranger Outpost&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pilgrim&#x27;s Rest&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oasis Town&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pass Hut&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Camp, Warden&#x27;s Watch, Last Lantern&lt;/td&gt;
&lt;td&gt;3 each&lt;/td&gt;
&lt;td&gt;4 to 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;That&#x27;s 88 in the six towns, plus five at the camps: &lt;code&gt;data/townsfolk.json&lt;/code&gt; went from 66 people to 159. Every town now has a guard pair at each gate, and one pair
per town has something to say to each other. In Duskmoor:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;How many tonight?&amp;quot;
&amp;quot;Twelve strokes. Then one more.&amp;quot;
&amp;quot;There is no thirteenth hour.&amp;quot;
&amp;quot;Tell that to whoever&#x27;s ringing.&amp;quot;
&amp;quot;...Light the gate lantern. Both wicks.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In the desert, a guard has noticed that the big dune by the cairn was left of the cairn this morning and is
right of it now. Nobody goes north today.&lt;/p&gt;
&lt;p&gt;Between the gates there are carters with things to carry: Gerd pulls a cart through the Vale, Sun the
Porter pushes a wheelbarrow, Dirk hauls peat in the Marsh, Torvald drags a sled of firewood through
Frostpeak, and Grom brings ore to the forge camp (&amp;quot;Ore for Kael!&amp;quot;). Carts are a new routine option, a
clutter model the person pulls behind or pushes ahead. They were invisible at first: the person walked along
the street holding nothing, miming a cart, until a one-line fix.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; There are also sweepers,
travellers walking in and out, pilgrims and beggars by the gates, farmers working the field edge just outside
them, recruits jogging laps, an herbalist shouting the name of a plant (&amp;quot;Feverfew!&amp;quot;), and pairs of children on
the ring roads playing tag and freeze (&amp;quot;Freeze!&amp;quot; &amp;quot;Didn&#x27;t move!&amp;quot;).&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-carter-at-the-gate.jpg&quot; alt=&quot;A carter heading out of the Vale&amp;#x27;s north gate, past the guard pair&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A carter heading out of the Vale&amp;#x27;s north gate, past the guard pair&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Each one has a name in the game&#x27;s European and wuxia mix, a few lines when you talk to them, barks when you
pass, and a look in their region&#x27;s palette.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; Townsfolk animate only near the
camera, so 88 more of them cost very little.&lt;/p&gt;
&lt;h2 id=&quot;a-checker-that-walks-the-routes&quot;&gt;A checker that walks the routes &lt;a class=&quot;anchor&quot; href=&quot;#a-checker-that-walks-the-routes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A routine is&lt;/span&gt; a loop of stops, and a person walks in a straight line from one stop to the next. Townsfolk
have no collision: they&#x27;re presentation, and nothing may depend on where they stand. That makes them cheap,
and it means nothing in the game stops a baker from walking through the bakery&#x27;s back wall to reach the
square.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tools/mapgen/town_life.py&lt;/code&gt; already checked that every stop stood somewhere sensible: not inside a building,
a prop, clutter or water, and inside the town&#x27;s sanctuary. For the roads it got stricter. It now also walks
every leg of every routine in 0.75 m steps and complains if the line passes through a building, a wall,
clutter with a collider, or water off a bridge, and it checks that each stop lasts long enough to cover the
walk there at the person&#x27;s speed:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;  ! carter_gerd 3-&amp;gt;4 walks through house at (41.2, -18.5)
  ! sweeper_bao stop 2: 12 s is too short for the 19 s walk there
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Two typical complaints, made up for this post.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The first run of the stricter checker over the &lt;em&gt;existing&lt;/em&gt; routines, before any new person was added, found
about 120 problems. People had been walking through walls for over a week, and nobody noticed, because on screen
a person passing through a house corner for half a second looks like a person walking past it. All of them
are fixed now. The generators route walks around buildings themselves through extra &amp;quot;walk&amp;quot; stops, a stop can
say how long to &lt;em&gt;stay&lt;/em&gt; instead of how long it takes in total (the walk is worked out), and the dresser keeps
clutter off people&#x27;s walking lines.&lt;/p&gt;
&lt;p&gt;Two limits remain, and the agent wrote them down rather than working around them. People can&#x27;t stand
further out than the sanctuary&#x27;s edge, so &amp;quot;just outside the gate&amp;quot; means at most about 6 m outside. And
routines can&#x27;t depend on the time of day yet, which matters for exactly one person, introduced below.&lt;/p&gt;
&lt;h2 id=&quot;the-bierlinger-tribe&quot;&gt;The Bierlinger Tribe &lt;a class=&quot;anchor&quot; href=&quot;#the-bierlinger-tribe&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The next request&lt;/span&gt; came in the same playtest, between a bug report and the chickens:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I would also love to have a &amp;quot;bar&amp;quot; style of house that contains NPCs of the &amp;quot;Bierlinger Tribe&amp;quot;, who have the
names Stefan, Sebastian, Alexander, George, Bernhard and Dominik (plus a title that fits the theme).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Buildings in Dekiro have no interiors, so a bar has to be a place where the party happens outside. The
tavern is a new building type from the Blender pipeline: a two-storey izakaya with red lanterns along the
eaves, a long noren curtain, stacked straw-wrapped kegs, a cedar ball over the door, a balcony and a tall
sign. It replaced the first house down the Vale&#x27;s east street. The beer garden in front uses two new clutter
pieces, a long table with benches and a keg rack, plus banner poles and barrels. Like every building, it
comes in regional variants (desert, snow, swamp, ember), for whenever another town gets thirsty.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-tavern-day.jpg&quot; alt=&quot;The Bierlinger tavern on the Vale&amp;#x27;s east street, with the tribe in the beer garden&quot; width=&quot;1600&quot; height=&quot;542&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Bierlinger tavern on the Vale&amp;#x27;s east street, with the tribe in the beer garden&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The tribe got the titles, all six distinct and all short enough for a name tag:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stefan&lt;/td&gt;
&lt;td&gt;Bierlinger Brewmaster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sebastian&lt;/td&gt;
&lt;td&gt;Bierlinger Hop-Sage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alexander&lt;/td&gt;
&lt;td&gt;Bierlinger Toastmaster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;George&lt;/td&gt;
&lt;td&gt;Bierlinger Barrel-Keeper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bernhard&lt;/td&gt;
&lt;td&gt;Keeper of the Tap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dominik&lt;/td&gt;
&lt;td&gt;Last to Leave&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;They share an amber trim in their outfits, and the writing stays in the game&#x27;s voice: no real-world brands,
and the lore leaks in. Stefan claims the tribe brewed in the Vale &amp;quot;since before the walls. We were here
first. The walls came for the beer.&amp;quot; Sebastian grows his hops &amp;quot;on the north side of the wall, where the
shard&#x27;s song is faint. They come up bitter and proud.&amp;quot; George counts kegs: &amp;quot;Twelve. Should be thirteen.
Somebody&#x27;s been brave in the night.&amp;quot; They have two timed conversations. Stefan and Sebastian argue about
hops:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;It needs more hops, Stefan.&amp;quot;
&amp;quot;It needs fewer opinions.&amp;quot;
&amp;quot;A brew without bite is just wet bread.&amp;quot;
&amp;quot;And yours is a bite without bread. Drink.&amp;quot;
&amp;quot;...It is good, though.&amp;quot;
&amp;quot;Of course it is. I made it.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;and Alexander toasts, with Bernhard keeping count:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;To the Sanctuary Stone, that keeps the night outside!&amp;quot;
&amp;quot;That&#x27;s your third toast to the stone.&amp;quot;
&amp;quot;It&#x27;s a very good stone.&amp;quot;
&amp;quot;Then let it drink water. Here.&amp;quot;
&amp;quot;To water! ...No. To the tribe!&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At level 15 Stefan offers your character a cup &amp;quot;on the tribe, for the hero&amp;quot;. Nobody actually gives you a
drink. It&#x27;s a tavern without an inside.&lt;/p&gt;
&lt;p&gt;Dominik is the time-of-day problem. The brief said one of them should stumble home along a street at night.
Routines can&#x27;t depend on the clock&#x27;s hour yet, so Dominik walks home on a loop, at 0.8 times normal speed,
day and night, and comes back for one more. His lines cover it: &amp;quot;Home is up the street. The street is longer
at night. I&#x27;ve measured.&amp;quot;&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-tavern-night.jpg&quot; alt=&quot;The same tavern at night: lanterns lit, the tribe still at it&quot; width=&quot;1600&quot; height=&quot;583&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same tavern at night: lanterns lit, the tribe still at it&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;seven-hens-with-names&quot;&gt;Seven hens with names &lt;a class=&quot;anchor&quot; href=&quot;#seven-hens-with-names&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same message&lt;/span&gt; asked for the names at the top of this post: Stefan, Gerrit, Mars, Daria, Gitti, Mike and
Nici, for the chickens.&lt;/p&gt;
&lt;p&gt;Dekiro&#x27;s animals are presentation like townsfolk: built from map data on every machine, run from the wall
clock, no network traffic. Named animals get a name tag; the Vale already had Mochi the cat, Lady Plum,
Old Bao and Tofu. So this was a list in the Vale&#x27;s generator:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# The named hens (the user&#x27;s): two in the town&#x27;s yard behind the tea house, five at the farmstead nearest the town.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;HEN_NAMES&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Stefan&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Gerrit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Mars&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Daria&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Gitti&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Mike&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Nici&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Stefan and Gerrit live in a yard behind the tea house, among the market stalls. Mars, Daria, Gitti, Mike
and Nici live at the farmstead nearest the town, out in the Whispering Fields.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-hens-town.jpg&quot; alt=&quot;Gerrit and Stefan in the town yard&quot; width=&quot;1600&quot; height=&quot;711&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Gerrit and Stefan in the town yard&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Yes, there is now a Stefan the hen and a Stefan the Brewmaster. The name check that keeps people&#x27;s names
unique covers NPCs, townsfolk and companions, not animals, and the brief said the overlap was intended. The
brewer knows. One of his barks:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;Someone named a hen after me. I&#x27;m honoured. I think.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/24-hens-farm.jpg&quot; alt=&quot;Mars, Daria, Mike, Gitti and Nici at the farmstead&quot; width=&quot;1500&quot; height=&quot;650&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Mars, Daria, Mike, Gitti and Nici at the farmstead&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;petting-briefly&quot;&gt;Petting, briefly &lt;a class=&quot;anchor&quot; href=&quot;#petting-briefly&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The living-world post&lt;/span&gt; covered petting: press F next to a cat or a dog, your character kneels, a heart pops
up, and you get &lt;em&gt;Content&lt;/em&gt;, 2% movement speed for five minutes, once per animal per world day. It&#x27;s the one
piece of animal life the server checks, loosely, against where the animal&#x27;s schedule says it should be.&lt;/p&gt;
&lt;p&gt;Hens are not on that list, named or not. In &lt;code&gt;data/animals.json&lt;/code&gt;, cats and dogs belong to a pettable group
and hens have a &lt;code&gt;scatter&lt;/code&gt; rule instead: run through them and they bolt 2.4 m at 3.4 m/s for about a second,
with a sound, then go back to pecking. So you can visit Gitti, but you can&#x27;t pet her. That&#x27;s not an
oversight: it&#x27;s what hens do.&lt;/p&gt;
&lt;h2 id=&quot;still-no-network-traffic&quot;&gt;Still no network traffic &lt;a class=&quot;anchor&quot; href=&quot;#still-no-network-traffic&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;All of it&lt;/span&gt;, 88 people, carts, two conversations at the tavern, seven hens, is computed on every machine from
map data, the clock and a seeded hash. A friend joining your game sees Dominik at the same point of his walk
home and Gerrit by the same stall, without a packet. The one thing that had to be fixed for that:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; a friend&#x27;s PC clock that&#x27;s a few minutes off would put Dominik somewhere else, so
clients now take the server&#x27;s clock when they join.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Life belongs on the roads, not just the square.&lt;/strong&gt; People coming and going between the gates make a town
read as part of a world; a busy centre with empty streets reads as a stage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the walks, not just the stops.&lt;/strong&gt; The stop checker was clean; the walk checker found about 120
people passing through walls. Presentation without collision needs a tool that pretends it has some.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write the limits down.&lt;/strong&gt; &amp;quot;Can&#x27;t stand more than 6 m outside the gate&amp;quot; and &amp;quot;can&#x27;t depend on the hour&amp;quot; are
now known, and Dominik&#x27;s lines make the second one a joke instead of a bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cheapest features carry the most personality.&lt;/strong&gt; A list of seven names and one bark (&amp;quot;I&#x27;m honoured. I
think.&amp;quot;) cost minutes and give a town more character than a new building.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same playtest&lt;/span&gt; produced three dozen other notes, from potion numbers to a chat. The playtest post goes
through every one of them and what it became.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;&amp;quot;Making a Generated World Feel Lived In&amp;quot; (16): clutter by rules, houses, cats you can pet, and the first townsfolk. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;In &lt;code&gt;scripts/world/ambient_npc.gd&lt;/code&gt;. A townsperson&#x27;s &lt;code&gt;cart&lt;/code&gt; (data/townsfolk.json) is one of &lt;code&gt;cart&lt;/code&gt;, &lt;code&gt;wheelbarrow&lt;/code&gt; or &lt;code&gt;sled&lt;/code&gt;, a clutter model pulled behind or pushed ahead. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;From &lt;code&gt;data/npc_looks.json&lt;/code&gt;: a seeded archetype plus a regional palette, so the same person looks the same on every machine. The HD-2D post (12) has the sprite side. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;The real output, &lt;code&gt;python tools/mapgen/town_life.py&lt;/code&gt;, is one line per map, and now every one of them says &amp;quot;0 problems&amp;quot;. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;&amp;quot;Multiplayer Bugs Only a Friend Can Find&amp;quot; has the story: &lt;code&gt;WorldClock.server_skew&lt;/code&gt;, sent in the server&#x27;s welcome message. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Weather That Fights Back</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/23-weather-that-fights-back.html"/>
<id>https://dekiro.d12r.io/23-weather-that-fights-back.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Six maps, six mechanics: heat in the desert, cold on the mountain, cult rituals on the moor, lava that moves, drakes that dive, demon portals to close, and a shade that grows in you at the edge of the world. All of it runs on the server from one data file, and all of it had to learn that the ground isn&#x27;t flat.</summary>
<content type="html">&lt;h2 id=&quot;a-map-should-play-differently-not-just-look-different&quot;&gt;A map should play differently, not just look different &lt;a class=&quot;anchor&quot; href=&quot;#a-map-should-play-differently-not-just-look-different&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;By day six&lt;/span&gt; the game had nine world maps, each with its own monsters, music, sky and town. But a level 55
fight on Frostpeak played exactly like a level 35 fight on Duskmoor with different colours. The world plan
had always listed a mechanic per map, one line each:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Duskmoor: &amp;quot;&lt;strong&gt;rituals&lt;/strong&gt;: cult priests start rituals at circles on the moor; if not stopped in time...&amp;quot;&lt;/li&gt;
&lt;li&gt;Sunscar Desert: &amp;quot;&lt;strong&gt;heat&lt;/strong&gt; away from shade; oases reset it.&amp;quot;&lt;/li&gt;
&lt;li&gt;Frostpeak: &amp;quot;&lt;strong&gt;cold&lt;/strong&gt; (standing still in the open slows you), campfires; ice patches; avalanche lines.&amp;quot;&lt;/li&gt;
&lt;li&gt;Hellmouth Rift: &amp;quot;&lt;strong&gt;demon portals&lt;/strong&gt; open now and then and pour out waves until closed.&amp;quot;&lt;/li&gt;
&lt;li&gt;Shadowlands: &amp;quot;&lt;strong&gt;corruption&lt;/strong&gt; that builds away from Sanctuary Shards; destroying Shadows cleanses it.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When the AI listed what was still open, &amp;quot;map mechanics from the world plan&amp;quot; was item seven of nine. The
developer picked &amp;quot;1-7 and 9&amp;quot;, and on the morning of day six one agent built all six in a single run.&lt;/p&gt;
&lt;p&gt;The brief had a tone as well as a list, and it ended up written at the top of the data file where every
later change will see it:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;_doc&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;World-map mechanics (MapMechanics, scripts/world/mechanics/): the rules here, the placement in&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;each map&#x27;s \&quot;map_mechanics\&quot; block (written by the map generators&#x27; MECHANICS blocks). Everything runs on&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;the server; clients get the state (telegraphs, HUD) from MapMechanics. [...] Keep it modest: flavour and&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;reward, not frustration.&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A grind game is played for hours at a time. A mechanic that&#x27;s exciting the first time and a chore the
fiftieth is worse than none. So every one of these is either mild and constant, or rare and rewarding.&lt;/p&gt;
&lt;h2 id=&quot;one-node-two-halves&quot;&gt;One node, two halves &lt;a class=&quot;anchor&quot; href=&quot;#one-node-two-halves&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every map that&lt;/span&gt; has mechanics gets a child node, &lt;code&gt;MapMechanics&lt;/code&gt;, built on &lt;strong&gt;every machine&lt;/strong&gt; from the map&#x27;s
data. The split of work follows the project&#x27;s network rules:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The server runs everything&lt;/strong&gt;: it steps the meters, rolls the timers, deals the damage, spawns the
monsters and hands out the rewards.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clients only show it&lt;/strong&gt;: each part has a &lt;code&gt;state()&lt;/code&gt; the server sends to peers on that map when it
changes and every two seconds anyway, and one-off telegraphs (&amp;quot;a drake is about to land here&amp;quot;) go out
through &lt;code&gt;broadcast()&lt;/code&gt; to everyone on the map.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where things go is split the same way. The &lt;strong&gt;rules&lt;/strong&gt; live in &lt;code&gt;data/mechanics.json&lt;/code&gt;: rates, timers, damage
as shares of max HP, rewards. The &lt;strong&gt;placement&lt;/strong&gt; lives in each map&#x27;s &lt;code&gt;&amp;quot;map_mechanics&amp;quot;&lt;/code&gt; block: where the
shade is, where the avalanche chutes run, where the ritual circles stand. That block is written by the
map&#x27;s generator, in a marked &lt;code&gt;MECHANICS&lt;/code&gt; section, so regenerating a map keeps its mechanics in step with
its buildings and roads.&lt;/p&gt;
&lt;p&gt;Five of the mechanics are event-like and share a small base class, &lt;code&gt;MechanicPart&lt;/code&gt;: a server tick, a state
for clients, a way to show one-off events, lines for the quest tracker, and an optional bar on the HUD.
The meters are different enough to get their own code.&lt;/p&gt;
&lt;h2 id=&quot;heat-and-cold-one-meter-two-personalities&quot;&gt;Heat and Cold: one meter, two personalities &lt;a class=&quot;anchor&quot; href=&quot;#heat-and-cold-one-meter-two-personalities&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Heat&lt;/span&gt; (the Sunscar Desert) and Cold (Frostpeak) are the same thing: a number from 0 to 100 on the player,
&lt;code&gt;Actor.exposure&lt;/code&gt;, that builds out in the open and falls in shelter. The rules are pure functions in
&lt;code&gt;Exposure&lt;/code&gt;, unit tested, and the two maps differ only in their data:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Heat (Sunscar)&lt;/th&gt;
&lt;th&gt;Cold (Frostpeak)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Builds&lt;/td&gt;
&lt;td&gt;0.55 / s in the open, standing or moving&lt;/td&gt;
&lt;td&gt;2 / s standing still in the open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falls&lt;/td&gt;
&lt;td&gt;7 / s in shade, 25 / s in water&lt;/td&gt;
&lt;td&gt;4 / s while moving, 15 / s near warmth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shelter&lt;/td&gt;
&lt;td&gt;78 spots: tents, ruins, palms, oases; safe zones&lt;/td&gt;
&lt;td&gt;57 spots: 21 roadside campfires, buildings; safe zones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Penalty starts at&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At 100&lt;/td&gt;
&lt;td&gt;x0.88 move and attack speed, 1.2% max mana lost a second&lt;/td&gt;
&lt;td&gt;x0.8 move, x0.85 attack speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;Water Skin (120 gold): no heat for 3 minutes&lt;/td&gt;
&lt;td&gt;keep moving&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The interesting part is that they reward opposite behaviour. Heat is about &lt;strong&gt;where&lt;/strong&gt; you stand: it takes
about three minutes in the sun to fill, so a long fight in the open dunes slowly costs you, and a run to the
nearest palm or pool fixes it. Cold is about &lt;strong&gt;whether you move&lt;/strong&gt;: walking warms you, standing still in the
snow chills you in under a minute, so a fight on Frostpeak pushes you to stay mobile or to fight near a
campfire.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-heat.jpg&quot; alt=&quot;Heat in the Pale Sands: the bar under the health bar fills in the open (left) and falls in the shade of a tent at Palm Hollow, with the hint line &amp;quot;In the shade: cooling off&amp;quot; (right)&quot; width=&quot;1600&quot; height=&quot;425&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Heat in the Pale Sands: the bar under the health bar fills in the open (left) and falls in the shade of a tent at Palm Hollow, with the hint line &amp;quot;In the shade: cooling off&amp;quot; (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The server steps the meters once a second, and the value replicates with the player&#x27;s other synced state
(the network protocol version was bumped for it). The penalties are computed on every machine from the
replicated number, which matters for movement: your own client moves your character, so it needs to know
how slow you are without asking. Heat is reset when you change maps or die, and every safe zone counts as shelter, so no one ever
respawns into a full meter.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-cold-fire.jpg&quot; alt=&quot;Cold on Frostpeak: a roadside campfire warms you (&amp;quot;By the fire: warming up&amp;quot;), with an Elite snow wolf pack close by&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Cold on Frostpeak: a roadside campfire warms you (&amp;quot;By the fire: warming up&amp;quot;), with an Elite snow wolf pack close by&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The cold meter also turned out to be reusable. The Frost Monastery dungeon, built the same day by a
different agent, switched it on inside: every brazier is a warm spot, and the watchers&#x27; sight cones
make standing still a bad idea for two reasons at once.&lt;/p&gt;
&lt;h2 id=&quot;the-event-mechanics&quot;&gt;The event mechanics &lt;a class=&quot;anchor&quot; href=&quot;#the-event-mechanics&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;duskmoor-the-lightless-rite&quot;&gt;Duskmoor: the Lightless Rite &lt;a class=&quot;anchor&quot; href=&quot;#duskmoor-the-lightless-rite&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The moor&#x27;s mechanic is a world event with a map, the same kind as the desert&#x27;s sandstorm.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; Every
40 to 70 minutes the server may start &amp;quot;The Lightless Rite&amp;quot;: the moor&#x27;s sky turns purple for six minutes,
and one or two of its four ritual circles (the Ritual Circle, Nine Widows, Witch-light Hollow, the Hanging
Tree) light up, each with three priests and two guards.&lt;/p&gt;
&lt;p&gt;Kill a circle&#x27;s priests before the event ends and it breaks: everyone within 55 m gets 4% of their level
bar in XP, 700 gold and a roll on the Elite loot table, reserved for them. Leave it burning and a &lt;strong&gt;Ritual
Shadow&lt;/strong&gt; rises at the circle, and up to eight monsters within 45 m turn Elite until someone breaks it.
Breaking every circle ends the event early. It&#x27;s a choice the map offers rather than forces: race to the
circles for a reward, or ignore them and accept a tougher moor for a while.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-ritual.jpg&quot; alt=&quot;A circle the cult finished: the Ritual Shadow has risen in the purple light, and an acolyte nearby has turned Elite&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A circle the cult finished: the Ritual Shadow has risen in the purple light, and an acolyte nearby has turned Elite&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;frostpeak-avalanches&quot;&gt;Frostpeak: avalanches &lt;a class=&quot;anchor&quot; href=&quot;#frostpeak-avalanches&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Nine chutes run down Frostpeak&#x27;s crags, each marked by a pale strip of snow and red-tipped poles, so you
can learn where they are. Every 110 to 220 seconds, but only while a player is within 70 m, one of them
rumbles: a red warning strip lies along it for 3.5 seconds, then the snow comes down. Caught in it, you
take 14% of your max HP, get pushed downhill and gain 25 Cold.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-avalanche.jpg&quot; alt=&quot;An avalanche chute on Frostpeak: the warning strip lies on the slope for 3.5 seconds (left), then the snow slides down it (right)&quot; width=&quot;1600&quot; height=&quot;425&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;An avalanche chute on Frostpeak: the warning strip lies on the slope for 3.5 seconds (left), then the snow slides down it (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;ember-wastes-lava-that-moves-and-drakes-that-dive&quot;&gt;Ember Wastes: lava that moves, and drakes that dive &lt;a class=&quot;anchor&quot; href=&quot;#ember-wastes-lava-that-moves-and-drakes-that-dive&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Eight lava pools on the Ember Wastes have three shapes each and switch to the next one every 170 to 260
seconds. Twenty seconds before a shift, glowing cracks (a new shader) appear on the ground where the lava
is about to run, players within 70 m are told, and the quest tracker counts down. The map generator keeps
the ground flat and free of trees under every shape a pool can take, so the lava never flows through a
tree or up a hill.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-lava-shift.jpg&quot; alt=&quot;Lava shifting on the Ember Wastes: glowing cracks warn of the new flow 20 seconds ahead (left), then the lava has moved (right)&quot; width=&quot;1600&quot; height=&quot;425&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Lava shifting on the Ember Wastes: glowing cracks warn of the new flow 20 seconds ahead (left), then the lava has moved (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;In the Lava Fields and the Caldera, Fire Drakes also dive at players every 35 to 60 seconds: a 2.4 second
red circle with the drake&#x27;s shadow sweeping in, then a fiery impact for 10% of max HP and a knockback.
Nothing kills you on its own; it keeps you looking at the ground.&lt;/p&gt;
&lt;h3 id=&quot;hellmouth-rift-demon-portals&quot;&gt;Hellmouth Rift: demon portals &lt;a class=&quot;anchor&quot; href=&quot;#hellmouth-rift-demon-portals&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The rift&#x27;s mechanic is the one players act on most. Nine spots across its three hunting bands can open
a demon portal, at most two at a time: the first 40 to 90 seconds after players arrive, then every 150 to
300 seconds, announced on the map. While a player is within 90 m, an open portal sends out a wave of its
zone&#x27;s own monster packs every 22 seconds, with at most eight alive.&lt;/p&gt;
&lt;p&gt;You close it by standing in it for eight seconds without being hit. Any damage interrupts the channel,
so it&#x27;s a team play (someone holds the monsters off, someone channels) or a solo puzzle (clear the wave,
then channel in the gap). Everyone within 30 m shares the reward: 5% of the level bar, 1,500 gold, and an
Elite loot roll each. A portal nobody closes collapses after twelve minutes, with nothing.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-demon-portal.jpg&quot; alt=&quot;Closing a demon portal on the Hellmouth Rift: the channel bar (&amp;quot;Sealing the demon portal&amp;quot;) fills while you stand in it unhurt&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Closing a demon portal on the Hellmouth Rift: the channel bar (&amp;quot;Sealing the demon portal&amp;quot;) fills while you stand in it unhurt&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;shadowlands-the-shade&quot;&gt;Shadowlands: the shade &lt;a class=&quot;anchor&quot; href=&quot;#shadowlands-the-shade&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The last map is the prison of the First Shadow, and its mechanic is the one the final raid already had:
&lt;strong&gt;corruption&lt;/strong&gt;, the Shade bar. Out in the open it builds at 0.35 a second (about five minutes to full).
Near a Sanctuary Shard (18 new jade shards along the roads, plus the safe zones and the waystones) it falls
at 12 a second, and breaking a Shadow stone takes 45 off everyone within 35 m. A Fullmoon weapon in hand
halves the build-up, which makes the endgame weapon do something the stat sheet doesn&#x27;t show.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The damage is gentle on purpose: up to 1.2% of max HP a second while the bar is high, a 12% burst when it
fills, then it settles back to 65. Rather than copy the Citadel boss&#x27;s logic, the agent moved it into one
shared function in &lt;code&gt;Corruption&lt;/code&gt; and made the boss call it, so the map and the raid can&#x27;t drift apart.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/23-shade.jpg&quot; alt=&quot;The Shadowlands&amp;#x27; shade: the bar fills out in the open (&amp;quot;The shade grows: break Shadows or find a Sanctuary Shard&amp;quot;, left) and lifts in a shard&amp;#x27;s light (right)&quot; width=&quot;1600&quot; height=&quot;425&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Shadowlands&amp;#x27; shade: the bar fills out in the open (&amp;quot;The shade grows: break Shadows or find a Sanctuary Shard&amp;quot;, left) and lifts in a shard&amp;#x27;s light (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-ground-isnt-flat&quot;&gt;The ground isn&#x27;t flat &lt;a class=&quot;anchor&quot; href=&quot;#the-ground-isnt-flat&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The most useful&lt;/span&gt; finding of the run was a side note in the agent&#x27;s report:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Existing skill effects (SkillFx) are drawn at a fixed y=0.06, so on hilly maps the terrain hides them.
I added telegraphs that follow the ground. Boss warnings and skill effects on the overworld probably have
the same problem; that is outside this task.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The game&#x27;s combat is 2D: everything happens on the X/Z plane, and characters snap to the terrain&#x27;s height
after they move. Skill effects and boss warnings had been designed in flat dungeons and on the flat test
map, so they were flat quads at a fixed height just above zero. On a map with terrain, a 3.5 m avalanche
warning on a slope would be partly underground, and a drake&#x27;s circle on a hillside would show as a sliver.
For a telegraph that&#x27;s the worst possible bug: the warning exists, the player just can&#x27;t see it.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;MechanicPart&lt;/code&gt; got its own ground shapes. &lt;code&gt;ground_disc()&lt;/code&gt; builds a disc as rings of triangles (one ring
every 1.5 m, 32 segments), and &lt;code&gt;ground_strip()&lt;/code&gt; a strip as a grid of about 2 m cells. Every vertex samples
the map&#x27;s height and sits 14 cm above it:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;cos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;sin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height_at_local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lift&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The mesh drapes over the hill like a cloth. It costs a few hundred triangles per telegraph, built once
when the warning appears, which is nothing next to what&#x27;s on screen. Every map mechanic&#x27;s warning (the
avalanche strip, the drake circle, the ritual ring, the portal&#x27;s channel ring) uses these.&lt;/p&gt;
&lt;p&gt;The note about skill effects was correct, and it came back eight days later from the other direction. In
the first playtest&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; the developer reported:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Skills in the city seem to be &amp;quot;under&amp;quot; the texture of the street.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Town streets and paved squares lie slightly above the terrain, and the skill effects, still at their fixed
height, vanished underneath. The fix took the same idea further: ground effects now take their height
from the terrain under them and lean with the slope over their size (a big glyph tilts with
the hill instead of cutting into it), and enemy and boss warnings got the same treatment. The flaw had been
written down the first time it was seen; it only became urgent when a human tripped over it.&lt;/p&gt;
&lt;h2 id=&quot;testing-weather&quot;&gt;Testing weather &lt;a class=&quot;anchor&quot; href=&quot;#testing-weather&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Headless unit tests&lt;/span&gt; cover the rules: the exposure math, the corruption steps, ritual outcomes, portal
channelling, lava shape schedules, about 150 checks in ten tests. But &amp;quot;is the warning visible on the
slope?&amp;quot; is a question for a picture, so there&#x27;s a scenario, &lt;code&gt;tests/scenario_mechanics.gd&lt;/code&gt;, that visits
each mechanic on its real map, forces it to happen in front of the camera, and saves a screenshot: heat in
the sun and in the shade, cold in the open and at a fire, the avalanche warning and the slide, lava cracks
before and after a shift, a drake dive, a portal open and channelling, a ritual burning and risen, the
shade in the open and by a shard. Every image in this post comes from one run of it.&lt;/p&gt;
&lt;p&gt;What none of these tests can answer is whether the numbers are right. Is three minutes the right time for
the sun to get to you? Is eight seconds of channelling tense or tedious? Does a 14% avalanche every few
minutes add to a hunt or interrupt it? The agent&#x27;s report ended where all of these reports end: &amp;quot;All
rates, penalties and timings are first guesses and need a playtest.&amp;quot; They&#x27;re one data file, so changing
them is cheap; knowing what to change needs players.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Give each map a verb, not just a palette.&lt;/strong&gt; Seek shade, keep moving, break the circles, close the
portal, find the shards: each map asks something different of the same combat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mild and constant, or rare and rewarding.&lt;/strong&gt; A grind game is played for hours; nothing here kills you on
its own, and the risky ones pay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rules in one data file, placement in the map generator.&lt;/strong&gt; A map rebuild keeps its mechanics in the
right places, and a balance change never touches code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The server decides, clients draw.&lt;/strong&gt; One node built on every machine, state on change, one-off events
by broadcast, and penalties computed from replicated values where movement needs them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A telegraph you can&#x27;t see is worse than none.&lt;/strong&gt; Drape warnings over the terrain; and when an agent
writes &amp;quot;probably the same problem elsewhere&amp;quot; in a report, put it on the list.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The multiplayer post explains the server-authoritative model and the &amp;quot;single player is a local
server&amp;quot; rule this relies on. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;The desert post (21) explains how world events grew a &amp;quot;map&amp;quot;, a &amp;quot;look&amp;quot; and an &amp;quot;aftermath&amp;quot; for the
sandstorm. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;The Fullmoon weapons post (17) is about those weapons and their upgrade track. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;The first-playtest post (18) has a before-and-after picture of the street fix. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Bots That Play for Balance</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/22-bots-that-play-for-balance.html"/>
<id>https://dekiro.d12r.io/22-bots-that-play-for-balance.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>A thousand lines of GDScript that play the real game headless: bots with the gear, skills and attributes a player of their level would have, fighting every zone, Elite, Shadow and boss, and stepping out of every telegraph. What they found, what changed, and how a human&#x27;s first playtest moved the numbers again.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;Would you want to do a balancing exercise first before I playtest?&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;why-a-simulation-and-why-bots&quot;&gt;Why a simulation, and why bots &lt;a class=&quot;anchor&quot; href=&quot;#why-a-simulation-and-why-bots&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;By day six&lt;/span&gt; the game had 177 monster types, 20 dungeons with their bosses, four schools of six skills each,
and gear from level 1 to 90. Every number had been picked when its feature was built, by whoever built it.
Nobody had ever checked whether a level 44 Blade in level 44 gear kills a level 44 boss in one minute or
in one second.&lt;/p&gt;
&lt;p&gt;The balancing pass had two halves, run by two agents in parallel. One was a Python model of a whole
career: how many hours to level 90, where the gold goes, when gear arrives.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; This post is about the
other half, the one that answers a narrower question with much more fidelity: &lt;strong&gt;what actually happens in a
fight?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A spreadsheet can multiply attack by skill coefficients. It can&#x27;t tell you that a boss&#x27;s slam is
undodgeable when three of its adds are body-blocking you, that a mirror boss copying your skills one-shots
your whole party, or that a warded goblin king makes players hit an invulnerable target for four minutes.
Those come from the game&#x27;s own code: its AI, its telegraphs, its dungeon scripts. So the simulation runs
the game itself.&lt;/p&gt;
&lt;h2 id=&quot;the-rule-that-made-it-cheap&quot;&gt;The rule that made it cheap &lt;a class=&quot;anchor&quot; href=&quot;#the-rule-that-made-it-cheap&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tests/sim_combat.gd&lt;/code&gt; is a &lt;code&gt;SceneTree&lt;/code&gt; script of about 1,000 lines. It builds the real World, real Actors,
real dungeon instances with their beams and wards and phases, and runs them headless with
&lt;code&gt;--fixed-fps 30&lt;/code&gt;, as fast as the CPU allows: about 70 times real time.&lt;/p&gt;
&lt;p&gt;The bots needed almost no new game code because of an architecture rule from day one: &lt;strong&gt;controllers
drive actors only through &lt;code&gt;command_*&lt;/code&gt; methods&lt;/strong&gt;. The player&#x27;s controller, the monster AI, the companions&#x27;
AI and network input all go through the same small interface (&lt;code&gt;command_move&lt;/code&gt;, &lt;code&gt;command_attack&lt;/code&gt;,
&lt;code&gt;command_use_skill&lt;/code&gt;, &lt;code&gt;command_use_potion&lt;/code&gt;, &lt;code&gt;command_sprint&lt;/code&gt;). A bot is just one more controller. It can&#x27;t
cheat, because there&#x27;s nothing to cheat with: it moves at the speed the actor moves, swings at the speed
the combo allows, and casts only what&#x27;s off cooldown and affordable.&lt;/p&gt;
&lt;p&gt;The one change to the game was a hook. Telegraphs (the red circles and strips that warn of a boss&#x27;s slam
or an archer&#x27;s shot) are drawn by &lt;code&gt;SkillFx&lt;/code&gt;, which had no reason to tell anyone they exist. Now it has a
static Callable, &lt;code&gt;SkillFx.warning_watch&lt;/code&gt;, which is empty in the game and set by the sim. Every warning the
player could see, the bots see too: a circle or a line, where, and when it goes off.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/22-telegraph.jpg&quot; alt=&quot;What the bots read: the Sunken Pharaoh&amp;#x27;s sun lance (the red strip) about to fire along the floor, and a lit light beam across it. The bots get the same warning a player sees, and step out after 0.3 seconds&quot; width=&quot;1600&quot; height=&quot;960&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;What the bots read: the Sunken Pharaoh&amp;#x27;s sun lance (the red strip) about to fire along the floor, and a lit light beam across it. The bots get the same warning a player sees, and step out after 0.3 seconds&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;a-bot-that-plays-like-a-careful-player&quot;&gt;A bot that plays like a careful player &lt;a class=&quot;anchor&quot; href=&quot;#a-bot-that-plays-like-a-careful-player&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Each bot is&lt;/span&gt; built for one school and one level, the way a reasonable player would have it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gear&lt;/strong&gt;: for every slot, the best normal (not rare) item up to its level, Common rarity, upgraded +0 to
+6 by level (one step per ten levels). School weapon from level 5.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attributes&lt;/strong&gt;: all points spent by a school&#x27;s weights, e.g. Blade 50% STR, 30% VIT, 20% DEX; Sanctum
60% INT. Capped at 90 per attribute like the game.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skills&lt;/strong&gt;: one point in each skill, then the four damage skills in turn to Master; from level 47 one
more Mastery rank every seven levels, as if books dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Potions&lt;/strong&gt;: below 50% HP it drinks the potion tier the game drops at its level; mana below 30%.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In a fight, the bot thinks four times a second. It picks a target (a Shadow stone or a boss is its focus,
because area skills catch the adds around it anyway), casts the first skill that would hit (heals below
55% HP, buffs when a fight is close), and otherwise walks into range and swings its combo.&lt;/p&gt;
&lt;p&gt;And it dodges. Every physics frame it asks the &lt;code&gt;Danger&lt;/code&gt; tracker which warnings cover its position and go
off within 2.5 seconds, but only those it has seen for at least &lt;strong&gt;0.3 seconds&lt;/strong&gt;: a human&#x27;s reaction time.
If there&#x27;s one, it searches rings of 16 points at 1.5 to 13 m around itself for the closest spot that is
safe, on the floor, and not in a lit beam, and sprints there. What still hits it is, by construction, what
a careful player couldn&#x27;t avoid either.&lt;/p&gt;
&lt;p&gt;A few lines decide which target to hit, and one of them is an admission:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# a warded boss (Gorrak&#x27;s shamans: a big &quot;defense&quot; buff) can wait: its guards first, like a player would&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_valid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;focus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_hittable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;focus&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;focus&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buff_total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;defense&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;best&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The first runs had the bots beating on Gorrak the Goblin King for 150 to 230 seconds while his shamans
kept his ward up. Nothing was wrong with the data; the bots simply didn&#x27;t know to kill the shamans first.
They had to be told, which raised the real question for the playtest: will players see it? He does shout
&amp;quot;Shamans! Shield your king!&amp;quot; when the ward goes up; whether that&#x27;s enough is on the playtest list.&lt;/p&gt;
&lt;h2 id=&quot;three-configurations-four-suites&quot;&gt;Three configurations, four suites &lt;a class=&quot;anchor&quot; href=&quot;#three-configurations-four-suites&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The sim runs&lt;/span&gt; any of four suites, each over any subset of schools and configurations:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;What fights&lt;/th&gt;
&lt;th&gt;Configurations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every hunting zone at its middle level: each pack group, one Elite of each monster type, the zone&#x27;s Shadow&lt;/td&gt;
&lt;td&gt;solo, per school&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bosses&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every boss, mini-boss and rare boss at its level, in its own room of a fresh dungeon instance&lt;/td&gt;
&lt;td&gt;solo per school, Blade + 2 companions, a party of the four schools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rooms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every dungeon&#x27;s trash rooms&lt;/td&gt;
&lt;td&gt;solo Blade, party of four&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curve&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a reference swing at every level&lt;/td&gt;
&lt;td&gt;(the yardstick for the HP targets below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The zone suite takes about a minute, the boss suite about twelve. A dead bot comes back after 15 seconds
(respawn plus the walk back), and the death is counted.&lt;/p&gt;
&lt;p&gt;The brief asked for &amp;quot;solo with three companions&amp;quot;; the game caps a party at two, so the sim used two and
said so. That kind of small, explicit correction turned out to be a pattern in this pass.&lt;/p&gt;
&lt;p&gt;Every fight produces a row: time to kill, damage taken as a share of max HP, potions, deaths, the biggest
single hit, and &lt;strong&gt;what dealt the damage&lt;/strong&gt;. Damage is matched to the telegraph that went off (&amp;quot;crypt_lord:
slam&amp;quot;), or labelled melee or &amp;quot;environment&amp;quot; (beams, traps, lava). &lt;code&gt;tools/balance/sim_report.py&lt;/code&gt; turns the
JSON into the markdown tables of &lt;code&gt;docs/BALANCE_COMBAT.md&lt;/code&gt;: per band, per school, per zone, per boss, and
the top damage sources for every boss.&lt;/p&gt;
&lt;h2 id=&quot;what-it-found&quot;&gt;What it found &lt;a class=&quot;anchor&quot; href=&quot;#what-it-found&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The headline was&lt;/span&gt; that the game was too easy in a way no single fight shows. Player power grows about 45
times from level 1 to 90 (attack, weapon times upgrade, STR, skill ranks times Mastery), monster HP only
about 20 times, and the last map&#x27;s monsters still had early-game stat lines. Packs died to the opening
skill burst in about a second. Article 15 has that story; here&#x27;s the shape of it in the sim&#x27;s numbers.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/22-zone-bands.png&quot; alt=&quot;Medians over the four schools by level band, before (orange) and after (blue) the combat pass: seconds to kill a pack from aggro, seconds to break a Shadow stone solo, and basic hits per monster. The green bands are the brief&amp;#x27;s targets&quot; width=&quot;1560&quot; height=&quot;552&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Medians over the four schools by level band, before (orange) and after (blue) the combat pass: seconds to kill a pack from aggro, seconds to break a Shadow stone solo, and basic hits per monster. The green bands are the brief&amp;#x27;s targets&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Some findings only a fight could produce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Your Shadow&lt;/strong&gt; (a level 85 boss that copies your skills and swing speed) had a damage multiplier of 0.6
of yours. Against a party of four that meant four copies of each school&#x27;s burst at once: it one-shot the
party, and the party killed it in 0.4 seconds. It now has 600 times your HP and 0.07 of your damage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ranged telegraphs too short to dodge in a crowd&lt;/strong&gt;: some shots warned for 0.5 seconds, some bursts for
1.0 with a 3 m radius, and with wave monsters body-blocking, about half the bots&#x27; dodges failed. Shots now
warn at least 0.65 s, bursts at least 1.1 s with a radius of at most 2.6 m, and a test
(&lt;code&gt;test_ranged_telegraphs_can_be_dodged&lt;/code&gt;) checks every ranged monster&#x27;s data against those floors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Iron Fist was 1.5 times slower&lt;/strong&gt; than the other schools: weaker fist weapons, two of six skills
defensive, no damage buff. Four skill multipliers more than doubled and Iron Skin gained a damage bonus.
Packs are now 0.92 to 1.15 times the mean for every school up to level 60.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Companions had no gear.&lt;/strong&gt; In boss fights they died every 20 to 60 seconds and got back up eight seconds
later. A follow-up gave them their level&#x27;s armor; companion deaths per boss dropped to 0 to 2, and in the
final raid from 70 to 3.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flat potions&lt;/strong&gt;: with one 250 HP potion for everything past level 15, every Shadow from level 50 on killed
a careful bot about once. That one went to the economy agent, who made four potion tiers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The boss numbers were the most dramatic. Bosses were sized with a target in mind (about a minute for a
party of four, about two minutes solo with two companions) and the sim measured the fit:&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/22-boss-ttk.png&quot; alt=&quot;Party-of-four kill times for every dungeon boss, before and after, on a log scale. The final raid&amp;#x27;s target was four to six minutes. The Crypt Lord&amp;#x27;s &amp;quot;before&amp;quot; had no party run&quot; width=&quot;1500&quot; height=&quot;1075&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Party-of-four kill times for every dungeon boss, before and after, on a log scale. The final raid&amp;#x27;s target was four to six minutes. The Crypt Lord&amp;#x27;s &amp;quot;before&amp;quot; had no party run&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The &amp;quot;what hurt&amp;quot; table was the other surprise. For almost every boss, 60 to 97% of the damage a solo bot
took came from plain melee, and the telegraphed slams were 2 to 13%. That&#x27;s what dodging bots should
produce: the big hits are avoidable, the steady ones aren&#x27;t. The biggest single hit on any bot after the
pass was 5 to 15% of max HP (before: up to 82%, on unarmored companions). No unavoidable one-shots.&lt;/p&gt;
&lt;p&gt;It also confirmed a design choice from the desert&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;: on the empty dunes, an Elite King Scorpion
takes a level 42 Blade 53 basic hits, against 14 for the same monster on the islands.&lt;/p&gt;
&lt;h2 id=&quot;the-targets-that-had-to-move&quot;&gt;The targets that had to move &lt;a class=&quot;anchor&quot; href=&quot;#the-targets-that-had-to-move&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The brief had&lt;/span&gt; numbers: a normal monster dies in 2 to 4 hits, a pack in 8 to 15 seconds, Shadows in 30 to
60 seconds solo. The sim showed that the first two can&#x27;t both hold once skills scale. At level 40, a
Blade&#x27;s first second (Cleave, Whirlwind, Earthsplitter, Blade Storm) is worth about 14 basic hits on
everything in range. If a monster dies in four basic hits, the pack is gone before the second skill
lands.&lt;/p&gt;
&lt;p&gt;So the agent kept the pack time and the time to kill a lone monster (about 2 to 3 seconds), and let basic
hits per monster grow with level: 4 at the start, then 7, 9, 11. Monster HP was rebuilt from that rule,
using the &lt;code&gt;curve&lt;/code&gt; suite as the yardstick: HP equals the target number of hits times the average combo hit
of the physical schools at that level. Damage became &amp;quot;5% of the player&#x27;s max HP after armor per hit for
the zone&#x27;s typical monster&amp;quot;, easing to 4% at level 90.&lt;/p&gt;
&lt;p&gt;Two other targets turned out not to be measurable: &amp;quot;a dungeon run of 15 to 30 minutes&amp;quot; is mostly puzzles
and walking, which bots can&#x27;t do (they walk in straight lines, so rooms behind a maze, a rail line or deep
water end &amp;quot;stuck&amp;quot;). The sim reports fighting time only (2 to 5 minutes with companions) and says the rest
needs a stopwatch.&lt;/p&gt;
&lt;h2 id=&quot;the-sim-as-a-measuring-stick&quot;&gt;The sim as a measuring stick &lt;a class=&quot;anchor&quot; href=&quot;#the-sim-as-a-measuring-stick&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Once the combat&lt;/span&gt; numbers settled, the sim became the reference for the career model. The model had been
estimating solo boss fights at 12 to 40 minutes; the sim said 3 to 4. A calibration factor (solo Blade kills
take about 0.87 times the model&#x27;s HP-over-DPS estimate) replaced the model&#x27;s guess, dungeons dropped from
8.3 to 5.9 of the 40 hours, and the XP data was refit around the result. That&#x27;s the order that worked:
the fight first, measured; the career built on top.&lt;/p&gt;
&lt;h2 id=&quot;playtest-1-half-the-hours-and-feeling-powerful&quot;&gt;Playtest 1: half the hours, and feeling powerful &lt;a class=&quot;anchor&quot; href=&quot;#playtest-1-half-the-hours-and-feeling-powerful&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;About a week&lt;/span&gt; later the developer played the game for the first time, at length, and came back with a long list.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;
Two items rewrote the balance:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;EXP progression can feel a bit faster. Let&#x27;s scale to 20 hours.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;I don&#x27;t feel so powerful, especially on the lower levels. Can you adjust the health slightly?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The first went to the career model: its fit mode searched for an XP curve exponent and quest XP that hit
a target of half the hours at every level. The XP bar dropped from &lt;code&gt;80 x level^2.58&lt;/code&gt; to &lt;code&gt;80 x level^2.37&lt;/code&gt;
(about 250,000 XP at level 30 instead of 520,000), quest XP followed the bar, and drop rates for the rare
things (Fullmoon weapons, Sunstone, key fragments) doubled so that the shorter career still finds them.
Out-of-combat regeneration (back to full in about 25 seconds) and stronger potions came from the same
list.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/22-hours-20h.png&quot; alt=&quot;Hours to reach each level after playtest 1: the median of 60 simulated characters (blue, with the spread) against the 20-hour target points (green). It lands at 20.7 hours&quot; width=&quot;1170&quot; height=&quot;676&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Hours to reach each level after playtest 1: the median of 60 simulated characters (blue, with the spread) against the 20-hour target points (green). It lands at 20.7 hours&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The second item was ambiguous, and the AI said so. &amp;quot;Health&amp;quot; could mean the player&#x27;s or the monsters&#x27;. It
took it to mean the monsters&#x27; (the combat pass had raised low-level monster HP 2.7 times, so you needed
noticeably more hits than in the version the developer remembered), acted on that, and added: &amp;quot;If you
meant your own character&#x27;s health, tell me and I&#x27;ll raise that too.&amp;quot;&lt;/p&gt;
&lt;p&gt;The change is one new key in &lt;code&gt;monsters.json&lt;/code&gt;: a monster HP factor by level, applied on top of the normal
scaling. Points &lt;code&gt;[15, 0.65], [30, 0.85], [40, 1.0]&lt;/code&gt;: 35% less HP up to level 15, tapering to none from 40.
It applies to packs, Elites and Shadows. The sim then reran the zones, before and after:&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/22-low-level-hp.png&quot; alt=&quot;The new HP factor by level (left), and what the sim measured: basic hits for a Blade to kill a typical pack monster, before and after (right)&quot; width=&quot;1560&quot; height=&quot;552&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The new HP factor by level (left), and what the sim measured: basic hits for a Blade to kill a typical pack monster, before and after (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th class=&quot;numeric&quot;&gt;Level&lt;/th&gt;
&lt;th&gt;Pack kill time&lt;/th&gt;
&lt;th&gt;Shadow, solo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;3&lt;/td&gt;
&lt;td&gt;3.0 s -&amp;gt; 2.1 s&lt;/td&gt;
&lt;td&gt;47 s -&amp;gt; 31 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;12&lt;/td&gt;
&lt;td&gt;2.6 s -&amp;gt; 1.7 s&lt;/td&gt;
&lt;td&gt;30 s -&amp;gt; 19 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;20&lt;/td&gt;
&lt;td&gt;2.7 s -&amp;gt; 2.0 s&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;24&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;34 s -&amp;gt; 26 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;28&lt;/td&gt;
&lt;td&gt;4.7 s -&amp;gt; 3.1 s&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two details show why the sim matters even for a one-line change:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bosses are exempt&lt;/strong&gt; (&lt;code&gt;&amp;quot;hp_by_level_bosses&amp;quot;: false&lt;/code&gt;). With the factor on bosses too, the sim had the
Crypt Lord, the Leech Matriarch and Old Mother Gloam, the first three dungeon bosses, falling in 38 to 43
seconds with two companions. They&#x27;d have melted. Without it they stay around a minute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XP doesn&#x27;t move.&lt;/strong&gt; Monster XP is tied to base HP (&amp;quot;XP follows HP&amp;quot; was a rule of the first pass), and
the factor sits on top of it, so lighter monsters die faster but pay the same. That&#x27;s exactly the
&amp;quot;powerful&amp;quot; feeling: more kills per minute at the same reward, and the 20-hour timeline unchanged.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-bots-cant-tell-you&quot;&gt;What bots can&#x27;t tell you &lt;a class=&quot;anchor&quot; href=&quot;#what-bots-cant-tell-you&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The sim&#x27;s report&lt;/span&gt; ends with a list of things it can&#x27;t know, and they&#x27;re worth repeating:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It&#x27;s a careful player.&lt;/strong&gt; Perfect potion timing, 0.3 second reactions, no greed. Real players take more
from telegraphs and less from standing in crowds. Nobody simulated kiting or pulling three packs at once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It walks in straight lines.&lt;/strong&gt; Mazes, minecart rails and deep water stop it; dungeon run times are
fighting only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It has no opinion.&lt;/strong&gt; Whether 9 to 11 basic hits on a monster feels spongy while your skills are on
cooldown, whether a two-minute boss with companions is fun, whether a 45-second Shadow is 45 seconds well
spent: those went on a &amp;quot;needs a human&amp;quot; list, and the first item to come back from the human was exactly
that kind of thing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Run the real code.&lt;/strong&gt; A bot driving the same &lt;code&gt;command_*&lt;/code&gt; interface as the player found the one-shots,
the undodgeable shots and the warded king that no spreadsheet would.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the bots human limits.&lt;/strong&gt; A reaction time and a &amp;quot;seen the warning&amp;quot; rule turn &amp;quot;damage taken&amp;quot; into
&amp;quot;damage a careful player can&#x27;t avoid&amp;quot;, which is the number you actually want.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attribute every point of damage.&lt;/strong&gt; &amp;quot;60 to 97% plain melee&amp;quot; tells you more about a boss than its total.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let targets fail in the open.&lt;/strong&gt; &amp;quot;2 to 4 hits&amp;quot; and &amp;quot;8 to 15 s a pack&amp;quot; couldn&#x27;t both hold; the sim said so
and the brief changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure the fight, then model the career.&lt;/strong&gt; The sim calibrated the career model, not the other way
round.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A playtest beats both.&lt;/strong&gt; One sentence (&amp;quot;I don&#x27;t feel so powerful&amp;quot;) changed the low levels more than a
day of simulation, and the sim&#x27;s job became checking that the fix didn&#x27;t break the bosses.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The bots dodge&lt;/span&gt; telegraphs on flat dungeon floors. Out in the world, some telegraphs belong to the weather
itself, and the ground isn&#x27;t flat. That&#x27;s the next post.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The balancing post (15) covers that model, the 40-hour target and the economy fixes in depth.
This post stays with the combat side. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;The desert post (21) explains the &amp;quot;less but stronger&amp;quot; wanderers. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;The first-playtest post (18) covers the rest of that list. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Making an Empty Desert Worth Crossing</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/21-making-an-empty-desert-worth-crossing.html"/>
<id>https://dekiro.d12r.io/21-making-an-empty-desert-worth-crossing.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>The developer wanted the desert bigger and emptier, and still worth the trip. What came out of one afternoon: a sandstorm that leaves treasure behind, a material you only find here, two roaming bosses, a caravan that keeps a timetable without sending a single packet, lore tablets, and a tomb you need a key for.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;The desert I feel like should be larger and feel somewhat empty. What are rewards we can have there to
still make it valuable for players to go there? Even though it should feel somewhat empty?&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-problem-with-empty&quot;&gt;The problem with empty &lt;a class=&quot;anchor&quot; href=&quot;#the-problem-with-empty&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Sunscar Desert is&lt;/span&gt; the level 40 to 50 map, the fifth of nine. On day five it was about 1.3 by 1.2 km of
walkable sand with roughly 1,100 monster packs spread evenly, about 35 m apart, the same density as every
other map. It played like a hunting ground that happened to be yellow.&lt;/p&gt;
&lt;p&gt;A desert should feel like a desert: wide, quiet, a little hostile. But in a grind game, empty space is
dead time. Every minute spent walking across dunes is a minute not spent killing things, and players
notice. So the question had two halves: make it emptier, and give people a reason to cross the emptiness
anyway.&lt;/p&gt;
&lt;h2 id=&quot;seven-ideas-six-kept&quot;&gt;Seven ideas, six kept &lt;a class=&quot;anchor&quot; href=&quot;#seven-ideas-six-kept&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The AI&#x27;s answer&lt;/span&gt; started with the layout: grow the map to about 2 by 2 km, keep roughly the same number of
monsters, and gather them into &amp;quot;islands of life&amp;quot; (oases, canyons, ruins, a raider camp) with nearly empty
dunes in between. Then it listed seven rewards that fit an empty map:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Buried treasure&lt;/strong&gt;: glinting sand mounds you dig up with a Shovel, plus Treasure Maps dropped by
raiders that mark one dig spot on your world map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A Sandstorm world event&lt;/strong&gt;: low visibility, storm elementals, and half-buried chests uncovered when it
passes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A desert-only material&lt;/strong&gt; needed for the top upgrade steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A wandering caravan merchant&lt;/strong&gt; on a schedule, selling things nobody else sells.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rare desert bosses&lt;/strong&gt; with long respawns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key fragments for the Sun Tomb&lt;/strong&gt;, a dungeon already in the world plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lore tablets&lt;/strong&gt; at hidden landmarks, with a small permanent bonus for reading all of them, in the
spirit of the biologist quests in a popular Korean MMO.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The AI recommended building the shovel first, because it &amp;quot;makes the empty sand the reward itself&amp;quot;. The
developer disagreed:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I do like 2-7. Not so much of a fan of the shovel thing, to be honest. Let&#x27;s go.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That was the whole design meeting. The shovel was dropped entirely (no item, no dig spots, no treasure
maps), and four agents started on the other six in parallel: one for the map, one for the storm, Sunstone
and bosses, one for the caravan and tablets, one for the Sun Tomb.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;A minute later, a follow-up on monster count:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I guess a few less would also make sense. Maybe less but stronger? Not sure. What do you think?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The AI&#x27;s answer was a split rather than a yes. In a grind game, levels 40 to 50 run on kills per hour; if
every monster gets tougher, the whole map is just slower. So the islands keep the normal density and
strength (they&#x27;re the grinding spots), and the dunes get only a few wanderers that are much stronger and
pay much better. About 30% fewer packs overall, nearly all of the cut taken out of the empty sand.&lt;/p&gt;
&lt;h2 id=&quot;the-map-islands-and-dunes&quot;&gt;The map: islands and dunes &lt;a class=&quot;anchor&quot; href=&quot;#the-map-islands-and-dunes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The map&lt;/span&gt; generator&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; lays the desert out inside a ring of sandstone mesas. Oasis Town sits in the south;
levels rise to the north. The numbers on the current map:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Packs&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Shadows&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Spacing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9 islands (oases, ruins, canyons, camp, glass dunes, temple, crater)&lt;/td&gt;
&lt;td&gt;0.93 km²&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;758&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;66&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;~35 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 dune bands (Pale Sands, Red Dunes, Deep Dunes)&lt;/td&gt;
&lt;td&gt;2.76 km²&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;25&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;~330 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three quarters of the land holds 3% of the packs. That&#x27;s the emptiness, by construction.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-desert-before-after.jpg&quot; alt=&quot;The Sunscar Desert&amp;#x27;s top-down overview before (left, about 1.3 x 1.2 km) and after (right, about 2 x 2 km). Each picture is fitted to its frame, so the new map is drawn at a smaller scale: the town in the south is the same size in both&quot; width=&quot;1600&quot; height=&quot;949&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Sunscar Desert&amp;#x27;s top-down overview before (left, about 1.3 x 1.2 km) and after (right, about 2 x 2 km). Each picture is fitted to its frame, so the new map is drawn at a smaller scale: the town in the south is the same size in both&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The dune packs use three keys the storm agent added to the pack data for exactly this: &lt;code&gt;&amp;quot;elite&amp;quot;: true&lt;/code&gt;
makes a group spawn as Elites, &lt;code&gt;&amp;quot;level_bonus&amp;quot;&lt;/code&gt; lifts it a few levels above the zone, and &lt;code&gt;&amp;quot;loot&amp;quot;&lt;/code&gt; swaps in
a different drop table. A dune wanderer is either a lone Elite (weight 3) or a pair three levels above the
zone (weight 2), both on the &lt;code&gt;desert_wanderer&lt;/code&gt; table.&lt;/p&gt;
&lt;p&gt;One more knob turned out to matter. The game spawns roaming packs near players every 15 to 25 seconds,
up to three around each of them, so that a cleared area doesn&#x27;t stay dead. On the dunes that would have
quietly refilled the emptiness. The map agent added a per-zone &lt;code&gt;&amp;quot;roam_chance&amp;quot;&lt;/code&gt;, and the dunes use 0.12:
about one roaming pack for every eight elsewhere.&lt;/p&gt;
&lt;p&gt;How much stronger are the wanderers? The combat simulation (the next post) measured it: a Blade at level
42 needs about 14 basic hits for a King Scorpion on the islands, and 53 for the Elite version out on the
Pale Sands. That&#x27;s &amp;quot;less but stronger&amp;quot;, in numbers.&lt;/p&gt;
&lt;h2 id=&quot;sunstone-the-reason-to-come-back&quot;&gt;Sunstone: the reason to come back &lt;a class=&quot;anchor&quot; href=&quot;#sunstone-the-reason-to-come-back&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Rewards for a&lt;/span&gt; visit are nice; a reason to come back is better. Upgrades already had a currency (Shadow
Shards), and the top three blacksmith steps are where a game in the style of that MMO gets its long tail. So the
desert got its own material, &lt;strong&gt;Sunstone&lt;/strong&gt; (&amp;quot;glass the Sun King&#x27;s mirror-light fused out of the desert&amp;quot;),
and the steps +6 to +7, +7 to +8 and +8 to +9 need 1, 2 and 3 of it. The Fullmoon weapons&#x27; own track needs
more.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Where it comes from, today:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Sunstone&lt;/th&gt;
&lt;th&gt;Sun Tomb Key Fragment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Island monsters&lt;/td&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dune wanderers&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desert Elites&lt;/td&gt;
&lt;td&gt;60%, 1-2&lt;/td&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storm Cache&lt;/td&gt;
&lt;td&gt;60%, 1-2&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rare bosses&lt;/td&gt;
&lt;td&gt;always, 3-6&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The caravan&lt;/td&gt;
&lt;td&gt;6,000 gold, 2 per stop&lt;/td&gt;
&lt;td&gt;30,000 gold, 1 per stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Level 55+ Shadows anywhere&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The last row came later. The balance model noticed that the level-gap rule (monsters seven or more levels
below you stop dropping things) cut characters past level 57 off from the desert&#x27;s Sunstone, and weapons
stalled at +6 or +7. The developer&#x27;s call was that it &amp;quot;should also drop from Shadows after 55+&amp;quot;. Those rates
were doubled again when the game was retuned from 40 to 20 hours, so the desert band still yields about
ten Sunstone an hour.&lt;/p&gt;
&lt;h2 id=&quot;the-sandstorm&quot;&gt;The Sandstorm &lt;a class=&quot;anchor&quot; href=&quot;#the-sandstorm&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;World events already&lt;/span&gt; existed: a server picks one every 40 to 70 minutes (Shadow Surge, Harvest Moon,
Gilded Night). The Sandstorm is the first one tied to a single map, and the event system grew a few keys
for it, all data in &lt;code&gt;events.json&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;quot;map&amp;quot;&lt;/code&gt;&lt;/strong&gt;: it only happens on the Sunscar Desert, but the start is announced to everyone, so players
on other maps can decide to travel there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;quot;look&amp;quot;&lt;/code&gt;&lt;/strong&gt;: for its 12 minutes, the map&#x27;s sky, fog and weather are overlaid with ochre fog, wind-blown
grit at eye height and big soft dust veils. The overlay fades in over five seconds, and the veils fade
out near the camera so your own character stays readable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;quot;roamers&amp;quot;&lt;/code&gt;&lt;/strong&gt;: Storm Elementals (a sandy figure on a spinning funnel of sand bands) rise out of the
ground 12 to 18 m from a player every 12 to 22 seconds, one to three at a time, at most six around each
player. They vanish when the storm ends.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;quot;aftermath&amp;quot;&lt;/code&gt;&lt;/strong&gt;: when it passes, 6 to 10 &lt;strong&gt;Storm Caches&lt;/strong&gt; appear at random spots from a list of 34
candidates spread over the map, mostly out in the dunes, for 10 minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-sandstorm.jpg&quot; alt=&quot;A sandstorm in the Red Dunes: the ochre overlay, grit in the air, a Storm Elemental and scorpions. The quest tracker counts the storm down&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A sandstorm in the Red Dunes: the ochre overlay, grit in the air, a Storm Elemental and scorpions. The quest tracker counts the storm down&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The caches are the payoff, and one choice there is worth explaining. The storm agent made each cache
&lt;strong&gt;openable once per player&lt;/strong&gt;, not first-come: &amp;quot;Friends who ride out the storm together each get their own
share instead of racing each other to the chest. The 10-minute clock and the spread-out spots are still
the challenge.&amp;quot; The server checks that you&#x27;re standing at it and rolls the &lt;code&gt;storm_cache&lt;/code&gt; table for you
alone: gold (a base of 900, times 0.8 to 1.6), gear at a 60% chance with much better rarity odds than
monsters, and the materials in the table above.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-storm-cache.jpg&quot; alt=&quot;A Storm Cache after the storm: a half-buried chest with a light beam (left) and opened (right). Each player opens it once&quot; width=&quot;1600&quot; height=&quot;420&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A Storm Cache after the storm: a half-buried chest with a light beam (left) and opened (right). Each player opens it once&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The storm also changes how the empty dunes feel. For 12 minutes they&#x27;re the most dangerous place on the
map, and then for 10 minutes they&#x27;re the most valuable.&lt;/p&gt;
&lt;h2 id=&quot;two-rare-bosses&quot;&gt;Two rare bosses &lt;a class=&quot;anchor&quot; href=&quot;#two-rare-bosses&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;RareSpawns&lt;/code&gt; is a small server-only node: for each entry in a map&#x27;s &lt;code&gt;&amp;quot;rare_spawns&amp;quot;&lt;/code&gt;, at most one boss is
alive, roaming its area instead of pacing a lair, and the next one comes &lt;code&gt;respawn_minutes&lt;/code&gt; after it dies.
Everyone on the map hears when one appears. The desert has two:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Sand Wyrm&lt;/strong&gt; (level 50) roams 190 m around the Bones of the Sand Wyrm and respawns after 45
minutes. It burrows: it goes under the sand where nothing can hit it, chases its target as a moving mound
at 1.7 times its speed, and comes up under it after a 1.3 second telegraph.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Raider Warlord&lt;/strong&gt; (level 48) holds the raider camp, respawns after 30 minutes, and calls three
raiders at 95%, 60% and 30% of his health.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-sand-wyrm.jpg&quot; alt=&quot;The Sand Wyrm under the sand, a mound in the ground (left), and surfaced (right)&quot; width=&quot;1600&quot; height=&quot;420&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Sand Wyrm under the sand, a mound in the ground (left), and surfaced (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;They&#x27;re tuned like mini-bosses (about a minute and a half solo, under 25 seconds for a party of four), and
their table is the best outside a dungeon: three gear rolls at Rare or better, 3 to 6 Sunstone, Shadow
Shards and Cores, a 40% key fragment, and a 3% chance at each of the four Fullmoon weapons. Timers live
only for the server session, so a restart spawns both fresh.&lt;/p&gt;
&lt;h2 id=&quot;a-caravan-that-needs-no-network&quot;&gt;A caravan that needs no network &lt;a class=&quot;anchor&quot; href=&quot;#a-caravan-that-needs-no-network&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The caravan was&lt;/span&gt; the most interesting one to build, because of a rule the project already had: townsfolk
are presentation, built on every machine from map data and moved by the wall clock, with no spawn and no
sync.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; A merchant who walks a 2.9 km loop with two camels and two guards could work the same way.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CaravanRoute&lt;/code&gt; is a pure timetable. The map gives it a looped road (22 points) and which points are stops
(five: Oasis Town, Palm Hollow, Mirage Springs, The Last Well, The Oasis Ruins). It walks at 2.2 m/s and
halts 240 seconds at each stop. From those numbers it computes, for any clock time, where the caravan is
and what it&#x27;s doing:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## A wandering caravan&#x27;s timetable (map data &quot;caravan&quot;: {route: [[x, z], ...], stops: [route indices],&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## speed?, halt?, offset?, npc?}): a looped road it walks at `speed`, halting `halt` seconds at each&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## stop. Pure: where it is follows from a clock time alone (AmbientNpc.clock(): the wall clock), so&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## every machine (server, clients, the world map) agrees without any network traffic.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;On the desert that makes a 42-minute loop: 22 minutes of walking, 20 of trading. Every client computes the
same position from the same clock, the server&#x27;s copy stands exactly where the clients see it, and the
world map can draw the caravan&#x27;s live position with no extra message.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-caravan.jpg&quot; alt=&quot;Zarah, the Thousand-Li Merchant, leaving Oasis Town with her camels and guards. The banner appears when the caravan comes within 60 m&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Zarah, the Thousand-Li Merchant, leaving Oasis Town with her camels and guards. The banner appears when the caravan comes within 60 m&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The caravan agent made one design call of its own: &lt;strong&gt;it trades only while halted&lt;/strong&gt;. &amp;quot;This makes the stops
places worth knowing, and a moving merchant would otherwise walk out of range and close its own shop
window.&amp;quot; Talk to Zarah on the road and she tells you where she camps next and in how many minutes. A
banner says &amp;quot;A caravan is near&amp;quot; within 60 m, the world map always shows the road as a dotted line, and it
shows her live position if you&#x27;ve seen her in the last 15 minutes.&lt;/p&gt;
&lt;p&gt;Her shop sells things no town does: the rare Green and Purple potions, the Scroll of Renewal, two pieces of
desert gear (Sandstriders, shoes with 7% move speed; the Oasis Charm), and the two items that make the trip
worth timing: Sunstone and a Sun Tomb Key Fragment. Those carry a &lt;code&gt;&amp;quot;limit&amp;quot;&lt;/code&gt;: two Sunstone and one fragment
per character &lt;strong&gt;per halt&lt;/strong&gt;, counted on the server. Five stops per loop means the caravan is a steady but
slow source, never a replacement for hunting.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-caravan-shop.jpg&quot; alt=&quot;The caravan&amp;#x27;s shop window, with the per-stop limits on Sunstone and the key fragment&quot; width=&quot;900&quot; height=&quot;1001&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The caravan&amp;#x27;s shop window, with the per-stop limits on Sunstone and the key fragment&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;lore-tablets&quot;&gt;Lore tablets &lt;a class=&quot;anchor&quot; href=&quot;#lore-tablets&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Eight tablets stand&lt;/span&gt; at the desert&#x27;s remote landmarks: the Sand Ship, the Sunken Colossus, the Buried Gate,
the Lone Obelisk and the rest. Each tells part of the Sun King&#x27;s story, and they&#x27;re numbered I to VIII,
but placed so that reading them in order means crossing the whole map. Press F at one and its text opens
with &amp;quot;Lore 3 / 8&amp;quot;. The server records the read only if you&#x27;re actually standing at that tablet.&lt;/p&gt;
&lt;p&gt;Reading all eight gives a permanent bonus that counts like gear you can&#x27;t take off: 3% move speed and 150
max HP for the desert. Small on purpose, like the classic&#x27;s biologist rewards. The system was built
for the desert and has since spread to every map, 58 tablets in all, each map telling one chapter.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-sun-tomb&quot;&gt;The Sun Tomb &lt;a class=&quot;anchor&quot; href=&quot;#the-sun-tomb&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The last piece&lt;/span&gt; turns the desert&#x27;s drops into a destination. Three &lt;strong&gt;Sun Tomb Key Fragments&lt;/strong&gt; combine
(right-click in the bag) into a Sun Tomb Key, and the key opens a portal at the Temple of the Sun, in the
level 46 to 50 north. Entering uses one key; your party follows you in for free.&lt;/p&gt;
&lt;p&gt;Inside is an instanced dungeon for levels 46 to 52 built around the Sun King&#x27;s mirrors: &lt;strong&gt;light beams&lt;/strong&gt;.
The developer&#x27;s first question after seeing the screenshots was &amp;quot;What are the light beams?&amp;quot;, and the
answer is the dungeon in one sentence. Fixed, sweeping, rotating and pulsing bands of light cross the
halls, each with a red-orange warning strip before it lights. A lit beam burns players for a share of
their max HP, then leaves them alone for 0.8 seconds so it never wipes anyone in one frame.&lt;/p&gt;
&lt;p&gt;The beams also solve the tomb&#x27;s monsters. Mummies are &amp;quot;undying&amp;quot;: killing one only knocks it down, and it
stands up seven seconds later at 60% HP, unless a beam touches it, or it dies within four seconds of being
touched. So you fight next to the light and use the hazard against them. Pressure plates hold doors open
while someone stands on them and for a few seconds after (six in one room, nine in another): a party just
stands on both, a solo player runs between them. At the end, the Sunken Pharaoh lights extra beams and
raises mummies at 70% and 40% of his health.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/21-sun-tomb.jpg&quot; alt=&quot;The Hall of Rays on day five: a sweeping beam crosses the hall, with a scarab and mummies. The walls were 3.2 m then; they are 6 m now, with a cutaway near the camera&quot; width=&quot;1600&quot; height=&quot;855&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Hall of Rays on day five: a sweeping beam crosses the hall, with a scarab and mummies. The walls were 3.2 m then; they are 6 m now, with a cutaway near the camera&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The developer&#x27;s other reaction to these screenshots was about the walls: &amp;quot;The walls of the dungeons need to
be higher. Otherwise people will just look over.&amp;quot; They had been kept at 3.2 m so the camera could see over
them. The fix was taller walls (6 m) that get out of the camera&#x27;s way instead: wall pieces between the
camera and your character dissolve, Diablo-style. That went into the dungeon generator, which was being
built the same afternoon, so every later dungeon has it too.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;heat-a-day-later&quot;&gt;Heat, a day later &lt;a class=&quot;anchor&quot; href=&quot;#heat-a-day-later&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The next day&lt;/span&gt; the desert got one more layer: &lt;strong&gt;Heat&lt;/strong&gt;, a meter that builds while you&#x27;re out in the open and
falls in shade, in water, and in safe zones. The generator wrote 78 shade spots (tents, ruins, palms,
oases) into the map. It builds slowly (about three minutes to full), and above 60% it starts to cost a
little move and attack speed and some mana. A Water Skin from the oasis supplier (120 gold) stops it for
three minutes. It&#x27;s deliberately mild: the point is to make the empty dunes feel hot and to make an oasis
feel like relief, not to punish the crossing. It shares its machinery with the other maps&#x27; weather, which
is the subject of a later post.&lt;/p&gt;
&lt;h2 id=&quot;what-it-adds-up-to&quot;&gt;What it adds up to &lt;a class=&quot;anchor&quot; href=&quot;#what-it-adds-up-to&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;None of the&lt;/span&gt; six features is big on its own. Together they give the empty part of the map a rhythm:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Routine&lt;/strong&gt;: grind the islands for XP and a trickle of Sunstone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detours&lt;/strong&gt;: a dune wanderer on the way pays several times what an island pack does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Appointments&lt;/strong&gt;: the caravan&#x27;s stops, the rare bosses&#x27; respawn timers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Events&lt;/strong&gt;: a sandstorm announced on every map pulls people into the dunes, and its caches keep them
there for ten more minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A goal&lt;/strong&gt;: three fragments, from any of those sources, open the Sun Tomb.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The balance model put a number on the last one: at the current rates, a key takes about two and a half
hours of desert hunting. All of these rates, timers and the heat itself are still first guesses; the
first playtest so far changed drop rates (doubled, along with the shorter game) but not the structure.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Emptiness is a design choice you build, not one you leave.&lt;/strong&gt; Three quarters of the land with 3% of the
monsters, plus a switch that stops roaming packs from filling it back in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the empty space pay, briefly.&lt;/strong&gt; Strong wanderers and timed caches make the dunes worth crossing
without turning them into another grinding spot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A unique material is the long tail.&lt;/strong&gt; Sunstone gave level 40 to 50 a reason to come back, and then the
model showed it also needed a late source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A timetable is cheaper than a network message.&lt;/strong&gt; If everyone shares a clock, a pure function of time is
a replicated entity for free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A one-line &amp;quot;no&amp;quot; saves a feature&#x27;s worth of work.&lt;/strong&gt; The shovel was the AI&#x27;s top recommendation; it never
got built, and nothing is missing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The sim mentioned&lt;/span&gt; above (bots that fight every zone, every Elite and every boss) is the next post: how it
works, what it found in this desert and elsewhere, and how the first playtest changed it again.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The coordinator-and-subagents post has the details of how parallel agents share one repository,
and what went wrong when two of them ran the network test on the same port at the same time (it happened
here too). &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;code&gt;tools/mapgen/maps/sunscar_desert.py&lt;/code&gt;, a rerunnable Python script like every map generator. The
dunes are listed after the islands because the first zone that contains a point wins. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;The Fullmoon weapons post covers that track, its Moon Seals and the 1000-fragment forge. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;The &amp;quot;lived-in world&amp;quot; post explains the townsfolk routines this borrows from. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;The story post covers what the tablets say and how the main quest line uses them, without
spoilers. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;The dungeon generator post tells that story, including how the beams became a reusable device
for other dungeons. &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Multiplayer Bugs Only a Friend Can Find</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/20-multiplayer-bugs-only-a-friend-can-find.html"/>
<id>https://dekiro.d12r.io/20-multiplayer-bugs-only-a-friend-can-find.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:21:58+02:00</updated>
<summary>A server-authoritative game, a bot that plays it, thousands of tests, and still: a friend joins and sinks into the hills, can&#x27;t pick anything up, and everyone freezes when someone takes a portal. Six bugs that needed a second person, or at least a second point of view, and what each one taught.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;On the Windows build it seems that items as well as other players are all below the actual surface, so on
the Y axis 0. What could that be?&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;why-the-bots-didnt-find-them&quot;&gt;Why the bots didn&#x27;t find them &lt;a class=&quot;anchor&quot; href=&quot;#why-the-bots-didnt-find-them&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The multiplayer post&lt;/span&gt; described the setup: single player is a local server, every actor has a network
role, and &lt;code&gt;tests/net_test.sh&lt;/code&gt; starts a host, a client and a dedicated server with bots that walk, fight,
trade, die and come back. It runs after every network change and reports engine errors. It&#x27;s good at what
it was built for: race conditions, missing spawns, RPCs to peers that can&#x27;t see an entity yet.&lt;/p&gt;
&lt;p&gt;What it can&#x27;t do is notice that something &lt;em&gt;looks&lt;/em&gt; wrong, and it only goes where it&#x27;s told. Most of the bugs
below lived in exactly those two gaps. Three came from the developer&#x27;s evening with a friend on the Windows
build, one from playing alone, and two were caught by thinking about what a friend&#x27;s machine would do
differently. This
post follows on from the multiplayer post,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and the order is roughly from &amp;quot;embarrassing&amp;quot; to &amp;quot;subtle&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;a-friend-at-y-0&quot;&gt;A friend at y = 0 &lt;a class=&quot;anchor&quot; href=&quot;#a-friend-at-y-0&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The report came&lt;/span&gt; in one sentence (the epigraph above), then a second:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can&#x27;t seem to pick up any items and so on.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The first suspicion was the items. A dropped item takes its height from the ground like everything else, so
the first checks measured that: in the editor, on a hill, items landed exactly on the ground. In the
exported game, in single player, too. So it wasn&#x27;t export-specific and it wasn&#x27;t items. It was something
only a &lt;em&gt;joining&lt;/em&gt; player sees, and reproducing it with two game instances was slow, so the investigation went
to the code that receives a client&#x27;s position instead. On the host:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;net_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;When a client sends its position, the host keeps x and z and sets the height to zero. That line was written
on day two, in the commit that added multiplayer, when every map was flat and zero &lt;em&gt;was&lt;/em&gt; the ground. Rolling
hills arrived the next day. Characters don&#x27;t collide with the terrain in this game: an actor snaps its y to
&lt;code&gt;map.height_at()&lt;/code&gt; after moving. But a joined player&#x27;s copy on the host (a PROXY, in the post&#x27;s terms)
doesn&#x27;t move itself; it takes the client&#x27;s samples. So for eleven days, every joined player stood at height
zero on the host, which is inside any hill.&lt;/p&gt;
&lt;p&gt;That one line caused all three symptoms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Players sank into the ground.&lt;/strong&gt; The host sends its copy&#x27;s position to everyone else, so every other
player saw the friend at height zero, which on a hill means buried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No pickups.&lt;/strong&gt; Loot is collected on the server when your character&#x27;s body overlaps the pickup&#x27;s area. The
host had the friend&#x27;s body under the hill, so on any slope it never touched anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;Items under the surface&amp;quot;&lt;/strong&gt; was the same effect seen from the other side: the friend stood at the right
height on their own screen, the items were where they should be, and the host disagreed.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/20-height-bug.png&quot; alt=&quot;On the host, a joined player&amp;#x27;s copy stood at y = 0, inside the hill, under the loot&amp;#x27;s pickup area. The fix: the host puts them on its own ground&quot; width=&quot;1540&quot; height=&quot;506&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;On the host, a joined player&amp;#x27;s copy stood at y = 0, inside the hill, under the loot&amp;#x27;s pickup area. The fix: the host puts them on its own ground&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Why didn&#x27;t the bots find it? Because towns are flat. The terrain generator flattens the ground at safe
zones, coasts, portals and buildings, and the network test&#x27;s bots spend their time in town. On flat ground
zero is correct, so everything worked. The bug needed someone to walk out into the hills and try to pick
something up.&lt;/p&gt;
&lt;p&gt;The fix&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; takes the height from the host&#x27;s own ground, not from the client:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;apply_owner_state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sent_msec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_OWNER_STEP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# ground distance: hills don&#x27;t count&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_rpc_teleport&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rpc_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;owner_peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# too far: put them back&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_instance_valid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Trusting the client&#x27;s height would have been the quick fix, and the wrong one in a server-authoritative
game: a client could then float. The second change fixes a related bug: the &amp;quot;you moved too far, back you
go&amp;quot; check measured in 3D, so on a steep hill the difference in height alone could look like a cheat. It now
measures distance along the ground only. The receiving code moved out of the RPC into its own function so a
test can feed it.&lt;/p&gt;
&lt;p&gt;The test, &lt;code&gt;test_joined_players_stand_on_the_ground_on_the_server&lt;/code&gt;, finds a hill on the test map,
sends a sample with height zero, then one with a fake height 30 m up, and checks the player stands on the
ground both times.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/20-party-on-a-slope.jpg&quot; alt=&quot;Four characters on a forest slope (a staged shot from the Steam screenshot set): the kind of ground where joined players used to disappear&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Four characters on a forest slope (a staged shot from the Steam screenshot set): the kind of ground where joined players used to disappear&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;connecttoglobaluser-failed&quot;&gt;&amp;quot;ConnectToGlobalUser failed&amp;quot; &lt;a class=&quot;anchor&quot; href=&quot;#connecttoglobaluser-failed&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same evening&lt;/span&gt; started with a different report: &amp;quot;It seems there&#x27;s a Steam bug.&amp;quot; Then the error text:
&amp;quot;ConnectToGlobalUser failed.&amp;quot; Steam&#x27;s own message for &amp;quot;this program can&#x27;t talk to your Steam account&amp;quot; is not
self-explanatory, and the logs on the machine showed nothing. The coordinator guessed Family View, Steam&#x27;s
PIN-locked parental mode, which blocks exactly this connection. A few minutes later the developer confirmed
it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Ah, never mind, this is me: the family guard was active in Steam.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Not a game bug, then. But other players will run into the same wall, and they won&#x27;t have an AI to guess
for them. So the start menu now translates the error.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; For
ConnectToGlobalUser it says: make sure Steam is running and signed in, not in offline mode, with Family
View unlocked; that the game and Steam run as the same user, neither &amp;quot;as administrator&amp;quot;; and on Linux, that
a Flatpak or Snap Steam can&#x27;t see games started outside it. It ends with &amp;quot;. Playing without Steam (join by
address still works)&amp;quot;, and keeps Steam&#x27;s own text in brackets for anyone searching for it.&lt;/p&gt;
&lt;p&gt;The second half of the fix matters more: the start menu now tries to connect to Steam again every 5
seconds. Before, Steam was initialized once at launch, so unlocking Family View or starting Steam after the
game meant restarting the game. Now the Steam row just lights up. The test is small:
&lt;code&gt;test_steam_init_errors_are_explained&lt;/code&gt; checks the text names what to check and keeps Steam&#x27;s message.&lt;/p&gt;
&lt;h2 id=&quot;the-host-that-froze&quot;&gt;The host that froze &lt;a class=&quot;anchor&quot; href=&quot;#the-host-that-froze&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;&amp;quot&lt;/span&gt;;It lags a bit for other players if someone uses a portal.&amp;quot; This is the playtest note that sounds smallest
and was the most work.&lt;/p&gt;
&lt;p&gt;The network agent measured before touching anything. A new test, &lt;code&gt;tests/net_portal_test.sh&lt;/code&gt;, starts a host
and two clients: a &amp;quot;mover&amp;quot; who travels through five maps nobody is on yet and two dungeon instances, and a
&amp;quot;watcher&amp;quot; who stands in the Vale. The host logs its worst frame around each trip; the watcher logs the
biggest gap between updates of another player. The first run on the development PC:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trip&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Host&#x27;s worst frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shadow Marsh (first load)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;6,260 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frostpeak&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1,102 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sunscar Desert&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;557 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun Tomb (dungeon)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;79 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crypt of Whispers (dungeon)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;77 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;back to the Vale (already loaded)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;13 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Six seconds. &amp;quot;Lags a bit&amp;quot; was generous. The cause was the one everybody suspected: when someone travels to a
map that isn&#x27;t loaded, the host builds the whole map, terrain, tens of thousands of plants, clutter batches,
buildings, NPCs and monsters, inside a single frame. In single player that&#x27;s a loading screen and nobody
cares. On a host, everyone else&#x27;s game stops with it: their characters freeze, their monsters freeze, and
then the world catches up in a jump.&lt;/p&gt;
&lt;p&gt;The fix is to build maps in steps. &lt;code&gt;GameMap&lt;/code&gt; now queues its build as a list of resumable steps, and the
&lt;code&gt;World&lt;/code&gt; drives them a slice per frame: 6 ms on a host with a screen, 12 ms on a headless server. Loops that
place plants or clutter remember where they stopped and continue next frame; the terrain bake and the ground
mesh run on a worker thread. The travelling player sees a short &amp;quot;Travelling...&amp;quot; screen and stays where they
are until the map is ready, then arrives. Maps that are already loaded are instant. Single player still
builds at once, because there&#x27;s nobody else to stall.&lt;/p&gt;
&lt;p&gt;Two tests guard it: &lt;code&gt;test_map_built_in_steps_matches_built_at_once&lt;/code&gt; builds the test map and a dungeon both
ways and compares a signature of every placed thing, line by line, because a step that resumes in the wrong
place would quietly shift every plant after it; and &lt;code&gt;test_travel_waits_for_a_map_built_in_steps&lt;/code&gt; checks the
traveller waits and then arrives.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/20-portal-frames.png&quot; alt=&quot;The host&amp;#x27;s worst frame when one player first travels to each place, before and after building maps in slices (headless host, an intermediate run while the work was going on)&quot; width=&quot;1430&quot; height=&quot;572&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The host&amp;#x27;s worst frame when one player first travels to each place, before and after building maps in slices (headless host, an intermediate run while the work was going on)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The last stragglers were found the same way. Frostpeak kept one 550 ms frame until the scatter step learned
to look up marsh pools and lakes in a grid instead of testing every plant against every pool, and dungeons
cost 6 to 10 seconds on a host with a screen because merging the kit pieces read each tile&#x27;s mesh back from
the GPU; caching the pieces as plain arrays took the Crypt from 9.6 s to 0.3 s. Monster packs near the
arriving player now appear one per slice, nearest first, instead of all in one frame.&lt;/p&gt;
&lt;p&gt;The final run, same trips:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Headless host, worst frame&lt;/td&gt;
&lt;td&gt;5.8 s (Marsh; 22 s when its terrain cache was missing), desert 7.5 s, Frostpeak 6.2 s&lt;/td&gt;
&lt;td&gt;30 ms; nothing over 33 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host with a screen, worst frame&lt;/td&gt;
&lt;td&gt;up to 7.2 s&lt;/td&gt;
&lt;td&gt;72 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The watcher&#x27;s longest gap between updates&lt;/td&gt;
&lt;td&gt;28 s headless, 10.9 s with a screen&lt;/td&gt;
&lt;td&gt;55 ms / 90 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The traveller&#x27;s wait&lt;/td&gt;
&lt;td&gt;none (everyone else waited instead)&lt;/td&gt;
&lt;td&gt;0.1-0.9 s for a dungeon, 2.4-6.4 s for a big map&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The traveller now waits a few seconds on a &amp;quot;Travelling...&amp;quot; screen for a fresh map, which is the price of nobody
else noticing.&lt;/p&gt;
&lt;h2 id=&quot;a-clock-that-disagreed&quot;&gt;A clock that disagreed &lt;a class=&quot;anchor&quot; href=&quot;#a-clock-that-disagreed&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;This one was&lt;/span&gt; found without a friend, by imagining one. Townsfolk, animals, the desert caravan and the time
of day don&#x27;t go over the network. Every machine computes them from the wall clock: a 36-minute day, a
routine at a time of the clock, a caravan stop at a time of the clock. So every player sees the caravan at
the same well without a packet.&lt;/p&gt;
&lt;p&gt;That works if every PC agrees on the time. The caravan agent wrote that its merchant trades &amp;quot;only while
halted (plus 5 s slack for machines whose clocks differ a little)&amp;quot;. A little is the normal case. But a
friend&#x27;s PC with a clock five minutes off would see the caravan at a different stop than the host, walk up
to it, and be refused by the server, which thinks the caravan left long ago. The same friend would see a
different time of day, and townsfolk in different places.&lt;/p&gt;
&lt;p&gt;The fix is small. On join, the server&#x27;s welcome message carries its wall clock, and the client keeps the
difference:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;WorldClock&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_skew&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;clock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_unix_time_from_system&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;WorldClock.now()&lt;/code&gt; is the local time plus that skew, and everything timed by the wall clock reads it. The
server resets its own skew to zero, so a host that was a client earlier in the session doesn&#x27;t inherit
someone else&#x27;s offset. &lt;code&gt;test_clients_take_the_wall_clock_from_the_server&lt;/code&gt; feeds a welcome two minutes ahead
and checks that the clock, the townsfolk and the caravan follow.&lt;/p&gt;
&lt;h2 id=&quot;dashing-out-of-the-world&quot;&gt;Dashing out of the world &lt;a class=&quot;anchor&quot; href=&quot;#dashing-out-of-the-world&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The developer found&lt;/span&gt; this one alone, but it&#x27;s a multiplayer bug in spirit:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;With some of the skills like dash, one can move over boundaries such as the ocean&#x27;s invisible wall or into
mountains. Any way to fix this? I guess this would also work and break dungeons.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Dash and Charge simply teleported the character 6 to 8 m to the end point. Nothing in between was checked:
not the coast&#x27;s wall, not a mountain, not a dungeon wall or a locked door. In a single-player game that&#x27;s an
exploit. In a server-authoritative game it&#x27;s worse, because the server itself was doing the teleport, so no
amount of client checking would stop it, and a party member could dash past a puzzle door everyone else had
to solve.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Actor.dash_end&lt;/code&gt; now walks the path in 0.4 m steps with a 0.35 m sphere and stops just before the first
static body, the edge of the map&#x27;s land, or the start of a mountain. Monsters don&#x27;t block it, so Charge still
goes through the enemies it hits. Knockback, pulls and the attack lunge already moved through normal physics
and needed nothing. &lt;code&gt;test_dash_stops_at_walls_and_the_edge_of_the_map&lt;/code&gt; covers four cases: an open dash goes
the full distance, a monster in the way doesn&#x27;t stop it, a wall stops it just in front, and a dash across the
edge ends on land. The test itself had a bug of its own.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-build-that-would-have-shown-capsules&quot;&gt;The build that would have shown capsules &lt;a class=&quot;anchor&quot; href=&quot;#the-build-that-would-have-shown-capsules&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The last one&lt;/span&gt; never reached a friend, which is the point. Asked for new Windows and Linux builds, the
coordinator checked the export settings first and found the sprite atlases missing. Godot exports resources
it knows about; plain &lt;code&gt;.json&lt;/code&gt; files have to be listed in an export preset&#x27;s include filter, and the filter
had &lt;code&gt;data/*.json&lt;/code&gt; and &lt;code&gt;data/maps/*.json&lt;/code&gt; but not &lt;code&gt;assets/sprites/*.json&lt;/code&gt;. Every sprite sheet&#x27;s atlas, the
file that says where each frame of each direction lives, would have been left out, and the game falls back to
the capsule when a sheet has no atlas. The exported game would have run without a single error and shown
every player and NPC as a coloured capsule.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-capsule-vs-sprite.jpg&quot; alt=&quot;The town square with capsules and with the first sprite characters&quot; width=&quot;1600&quot; height=&quot;530&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;What an export without the atlases would have looked like: the capsule fallback next to the sprite (from the HD-2D post)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Commit &lt;code&gt;21dd86c&lt;/code&gt; adds the path to every preset. It&#x27;s also why the project&#x27;s rulebook has a line about it:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt; &amp;quot;add &lt;code&gt;*.json&lt;/code&gt;
to the export preset&#x27;s non-resource include filter, or &lt;code&gt;data/*.json&lt;/code&gt; won&#x27;t ship.&amp;quot; Headless tests run from
the project folder, where every file exists, so no test could see this. The check now is to look inside the
built package before handing it over.&lt;/p&gt;
&lt;h2 id=&quot;what-they-have-in-common&quot;&gt;What they have in common &lt;a class=&quot;anchor&quot; href=&quot;#what-they-have-in-common&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Six bugs&lt;/span&gt;, and none of them was a race condition or a desync, the things network code is usually afraid of.
They were all &lt;strong&gt;assumptions that held on the developer&#x27;s machine&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The ground is at zero (true on day two, and in every town).&lt;/li&gt;
&lt;li&gt;The person reading the error knows Steam&#x27;s internals.&lt;/li&gt;
&lt;li&gt;Building a map takes a moment (true when only you are waiting).&lt;/li&gt;
&lt;li&gt;Every PC&#x27;s clock agrees.&lt;/li&gt;
&lt;li&gt;A short teleport can&#x27;t leave the map.&lt;/li&gt;
&lt;li&gt;The files in the project folder are the files in the build.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bots test what you point them at.&lt;/strong&gt; The network bots stayed in flat towns, so the y = 0 bug was
invisible to them for eleven days. Send at least one bot somewhere inconvenient.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never take the client&#x27;s height, and don&#x27;t fix a bug by trusting the client.&lt;/strong&gt; The server owns the
ground; the fix put the player on it rather than believing them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure the lag before fixing it.&lt;/strong&gt; &amp;quot;Lags a bit&amp;quot; was a six-second freeze, and the numbers per map showed
which maps mattered.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Presentation from a clock needs one agreed clock.&lt;/strong&gt; One number sent on join keeps every machine&#x27;s
townsfolk, sun and caravan together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Translate the platform&#x27;s errors.&lt;/strong&gt; &amp;quot;ConnectToGlobalUser failed&amp;quot; means nothing to a player; a list of
four things to check does. And retry, so fixing the cause doesn&#x27;t need a restart.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Look inside the package.&lt;/strong&gt; A build that runs without errors can still be missing half its look.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The friend&#x27;s evening&lt;/span&gt; was also the first real playtest, and most of its notes weren&#x27;t bugs but wishes. The
playtest post goes through all of them, from potion numbers to a chat that was once &amp;quot;not needed&amp;quot;.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;&amp;quot;Server-Authoritative Multiplayer in Godot, Single Player Included&amp;quot; (03) has the roles, the spawn function and the network bot. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Line 1909 of &lt;code&gt;scripts/actors/actor.gd&lt;/code&gt;, in &lt;code&gt;_rpc_owner_state&lt;/code&gt;, unchanged since the multiplayer commit &lt;code&gt;be14886&lt;/code&gt; of day two. Hills came with &lt;code&gt;21a6b5b&lt;/code&gt; the next afternoon. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;Commit &lt;code&gt;4feeb21&lt;/code&gt;: 11 lines in &lt;code&gt;actor.gd&lt;/code&gt;, 24 in the tests. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;&lt;code&gt;SteamService.explain()&lt;/code&gt; in &lt;code&gt;scripts/autoload/steam_service.gd&lt;/code&gt;, commit &lt;code&gt;a2db5ce&lt;/code&gt;; the start menu&#x27;s Steam row calls &lt;code&gt;SteamService.start()&lt;/code&gt; again every 5 seconds until it works. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;It passed alone and failed after other tests, because maps that earlier tests left behind sat at the same spot. It now runs 41 km away from everything (maps sit 5 km apart). &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;CLAUDE.md, the file every Claude session reads first; the first post explains it. &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Taking a PC ARPG to iPhone and Android</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/19-taking-a-pc-arpg-to-phones.html"/>
<id>https://dekiro.d12r.io/19-taking-a-pc-arpg-to-phones.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>A game built for keyboard, mouse and a 1080p monitor, moved onto a 65 mm tall screen in one afternoon and one evening: millimetres instead of pixels, a notch the HUD has to dodge, a second finger Godot doesn&#x27;t see, and an iPhone build made on Windows that still needs a Mac.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;Mobile controls would be nice as we want to play it on iPhone and Android. Not sure how the server stuff
will work though.&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;one-sentence-two-agents&quot;&gt;One sentence, two agents &lt;a class=&quot;anchor&quot; href=&quot;#one-sentence-two-agents&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Dekiro was a&lt;/span&gt; PC game for six days. Its windows were drawn for 960 x 540 UI pixels, every tooltip opened on
mouse hover, splitting a stack of potions was Shift + drag, and the hotbar answered to the number keys. Then
the human asked for phones, with the worry quoted above.&lt;/p&gt;
&lt;p&gt;The server worry turned out to be the small part. The game is server-authoritative even in single player:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; a phone runs its own local server exactly as a PC does, and a phone joining a friend is just
another ENet client. Two things don&#x27;t carry over. Steam doesn&#x27;t exist on phones, so phones join by address,
and hosting on a phone (battery, mobile networks, carrier NAT) is labelled &amp;quot;experimental&amp;quot; in the menu with a
tooltip that recommends a PC host or a dedicated server. Characters stay on the device; one character
across PC and phone would need a cloud save, which is a later step.&lt;/p&gt;
&lt;p&gt;The real work was the interface. After a short plan, the human&#x27;s reply was one line: &amp;quot;Spawn two agents.
Android and iPhone.&amp;quot; The split that made them able to work in parallel&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; was by layer, not by
platform:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Android agent&lt;/strong&gt; built what both platforms share in the &lt;em&gt;world&lt;/em&gt;: the on-screen controls, a mobile
renderer and quality profile, and the Android export.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;iPhone agent&lt;/strong&gt; built what both share in the &lt;em&gt;windows&lt;/em&gt;: screen density, the notch, touch-friendly
windows, and the iOS export.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They met in one small static class, &lt;code&gt;Mobile&lt;/code&gt;, that the iPhone agent published as an API on its first pass
so the other one could size its buttons with it.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-touch-fight.jpg&quot; alt=&quot;The touch HUD on a simulated iPhone 15 Pro: a floating stick on the left, the attack button with six skills on two rings and two potions on the right, the menu row as icon tiles left of the minimap, and the notch and home bar drawn over the screenshot&quot; width=&quot;1600&quot; height=&quot;738&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The touch HUD on a simulated iPhone 15 Pro: a floating stick on the left, the attack button with six skills on two rings and two potions on the right, the menu row as icon tiles left of the minimap, and the notch and home bar drawn over the screenshot&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;sizes-in-millimetres&quot;&gt;Sizes in millimetres &lt;a class=&quot;anchor&quot; href=&quot;#sizes-in-millimetres&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The PC UI&lt;/span&gt; scales by whole numbers: 960 x 540 UI pixels times 2 on a 1080p monitor, times 4 on 4K. That rule
means nothing on a phone. An iPhone 15 Pro in landscape is 2556 x 1179 pixels but only about 65 mm tall,
and what matters is how big a button is under a thumb, not how many pixels it has.&lt;/p&gt;
&lt;p&gt;So on touch screens the scale comes from the screen&#x27;s density:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## The UI scale on touch screens: one UI pixel is about UI_PX_MM millimetres (a 10 px body font is then&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## ~1.7 mm, iOS&#x27;s smallest comfortable text), never fewer than TOUCH_MIN_HEIGHT UI pixels high (the&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## windows were drawn for 540) and snapped to quarter steps.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UI_PX_MM&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.17&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TOUCH_MIN_HEIGHT&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;360.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An iPhone 15 Pro (460 dpi) gets 3x, which makes its screen 852 x 393 UI pixels. A 2400 x 1080 Android
phone gets 2.5x, an iPad 1.75x. The player&#x27;s &amp;quot;Interface size&amp;quot; option still multiplies the result.&lt;/p&gt;
&lt;p&gt;Everything that has to be touched is specified in millimetres too, through &lt;code&gt;Mobile.target_px(viewport, mm)&lt;/code&gt;. The brief asked for buttons of about 10 mm, which is what platform guidelines recommend. The agent
pushed back with arithmetic: a landscape phone is only about 65 mm tall, so 10 mm buttons (or even
Apple&#x27;s 7 mm) would leave room for about five rows, and a 9-wide bag would no longer fit. Its rule, from the
code:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TARGET_MM&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;6.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SLOT_MM&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;5.5&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SNAP_MM&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;3.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That is the &lt;strong&gt;fat-finger snap&lt;/strong&gt;: buttons &lt;em&gt;look&lt;/em&gt; 6 mm, but a tap that hits nothing pressable goes to the
nearest button within 3 mm. Visually the windows keep their density; functionally each button is as wide
as a fingertip wherever it has no close neighbour. Item slots are 5.5 mm so the bag stays 9 wide. The close X keeps its drawn size and gets a 6 mm hit
area.&lt;/p&gt;
&lt;h2 id=&quot;the-notch&quot;&gt;The notch &lt;a class=&quot;anchor&quot; href=&quot;#the-notch&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SafeArea&lt;/code&gt; is the second piece. Phones have a notch or Dynamic Island, a camera hole, rounded corners and a
home indicator, and none of the HUD may sit under them. &lt;code&gt;SafeArea&lt;/code&gt; attaches to a CanvasLayer and moves
each child&#x27;s offsets in by the safe-area insets, keeping its anchors. The full-screen HUD layers (player
frame, hotbar, quest box, banners) shrink to the safe rectangle; anchored windows stay inside it; dragged
windows are clamped to it by the window dragger. Things drawn over world positions (health plates, name
tags, the fishing bobber&#x27;s &amp;quot;!&amp;quot;) opt out, because they belong to the 3D world, not the screen.&lt;/p&gt;
&lt;p&gt;On a PC, without a phone in reach, the same code runs against a simulated device:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--touch&lt;span class=&quot;w&quot;&gt;                  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# touch mode + a simulated iPhone 15 Pro notch and home bar&lt;/span&gt;
godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--touch&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--notch&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;android&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--dpi&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;420&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# a punch-hole Android phone at its density&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;--touch&lt;/code&gt; turns on every touch rule (the mouse becomes one finger), &lt;code&gt;--notch&lt;/code&gt; picks the outline, &lt;code&gt;--dpi&lt;/code&gt;
the density. The notch, rounded corners and home bar are drawn &lt;em&gt;over&lt;/em&gt; the screenshot, so every image in
this post shows exactly what would be hidden on the real device. Without the flags a PC gets the desktop UI
unchanged; one agent checked that with a pixel diff of the inventory window before and after its changes.&lt;/p&gt;
&lt;h2 id=&quot;controls-a-stick-a-big-button-and-things-that-appear&quot;&gt;Controls: a stick, a big button, and things that appear &lt;a class=&quot;anchor&quot; href=&quot;#controls-a-stick-a-big-button-and-things-that-appear&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The on-screen controls&lt;/span&gt; follow the shape most mobile ARPGs (that popular Korean MMO&#x27;s own mobile version included) settled on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Left:&lt;/strong&gt; a floating stick that appears where the thumb lands. Pushing it past 1.3 times its rim sprints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right:&lt;/strong&gt; a 15 mm attack button (hold it and the combo keeps going), the six skill slots on two rings
around it with cooldown sweeps, seconds left, a mana tint and their rank, and the two potion slots beside
them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context buttons&lt;/strong&gt; appear only when there is something to do, and name it: Talk, Pet, Open, Read,
Travel, or the dungeon devices&#x27; verbs (Pull a lever, Light a candle, Turn a valve). A Take button appears
when loot is in reach.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Camera:&lt;/strong&gt; one finger on empty screen rotates, two fingers pinch to zoom.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Menu row:&lt;/strong&gt; Bag, Skills, Hero, Map, Party, Menu, top right.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every finger belongs to whatever it first touched, so the stick, the attack button and the camera work at
the same time. The project&#x27;s first architecture rule paid off here: controllers drive actors only through
&lt;code&gt;command_*&lt;/code&gt; methods.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; Touch input is a new source in &lt;code&gt;PlayerController&lt;/code&gt;, next to the keyboard and
the gamepad, and nothing on the actor side changed. Touch skills aim like the gamepad&#x27;s (in the direction
you face), and a touch attack while standing still turns toward the nearest enemy within 4.5 m, because
there is no mouse cursor to aim with.&lt;/p&gt;
&lt;p&gt;A test feeds the layout four screen shapes (an iPhone, an Android phone with a camera hole, a small phone
and an iPad) and checks that no two controls overlap and none is smaller than its minimum size. It caught
one real problem: the top button of the outer skill ring sat on the minimap&#x27;s clock. The ring now leans
left.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-school-banner.jpg&quot; alt=&quot;School selection on an Android-shaped screen: before, the &amp;quot;School of Blade&amp;quot; popup floats over the character; after, it is a banner under the menu row. The green Talk button only appears next to an NPC&quot; width=&quot;1600&quot; height=&quot;357&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;School selection on an Android-shaped screen: before, the &amp;quot;School of Blade&amp;quot; popup floats over the character; after, it is a banner under the menu row. The green Talk button only appears next to an NPC&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;a-crowded-top-edge&quot;&gt;A crowded top edge &lt;a class=&quot;anchor&quot; href=&quot;#a-crowded-top-edge&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first screenshot&lt;/span&gt; of the touch HUD worked and looked busy. The menu was six big round buttons that
covered the zone banner and touched the minimap, and the NPC name tags of the town piled up behind them.
A cleanup pass the same evening changed four things, all only on touch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The menu row&lt;/strong&gt; became a tight row of 7 mm square tiles with pixel icons, left of the minimap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A banner band&lt;/strong&gt; hangs under the row. The zone name shows there, and so do achievements, &amp;quot;New quest&amp;quot;
and &amp;quot;Quest complete&amp;quot;, queued behind each other, instead of floating over the fight. A later pass sent the
other big popups over your own character there too: &amp;quot;LEVEL N!&amp;quot; (several level-ups in the queue merge into
one banner), &amp;quot;School of X&amp;quot;, and any popup at least 1.4 times the normal size that isn&#x27;t a number. Damage
numbers (crits are 1.35) and small messages stay in the world.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Name tags&lt;/strong&gt; dodge the HUD. Every HUD part reports its rectangles through a &lt;code&gt;hud_rects()&lt;/code&gt; method; a tag
that collides with another tag or a HUD part drops the NPC&#x27;s title first, and if it is still under the
HUD it dims to 22 percent. The placement became a pure function, so it has its own test.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The hotbar&lt;/strong&gt; moves beside an open window, whole, or hides when there is no room. On touch it is only
shown while a window is open, as the drop target for skills and potions dragged out of it.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-hud-before-after.jpg&quot; alt=&quot;The same fight before and after the cleanup: the round menu buttons and the zone banner competing for the top edge, then a row of icon tiles with the banner band under it, here showing an achievement&quot; width=&quot;1600&quot; height=&quot;1452&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same fight before and after the cleanup: the round menu buttons and the zone banner competing for the top edge, then a row of icon tiles with the banner band under it, here showing an achievement&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;windows-taller-than-the-screen&quot;&gt;Windows taller than the screen &lt;a class=&quot;anchor&quot; href=&quot;#windows-taller-than-the-screen&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every window was&lt;/span&gt; designed for 540 UI pixels of height. An iPhone&#x27;s safe area at 3x is 372. A new &lt;code&gt;fit&lt;/code&gt; shot
in the mobile scenario opens every window one by one and prints its rectangle against the safe area,
&amp;quot;OK&amp;quot; or &amp;quot;CUT&amp;quot;. Three windows were cut: the inventory, the skill window and the shop.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FitScroll&lt;/code&gt; is the general tool: a container that grows up to a height and then scrolls. Wrapping a whole
window in it works, but it scrolls the buttons away too, and a shop whose Sell button is off screen is
broken. So windows can now mark their middle as the &lt;em&gt;body&lt;/em&gt; (&lt;code&gt;TouchUi.mark_body&lt;/code&gt;), and on touch only that
part scrolls: the skill list, the NPC window&#x27;s quests and goods, the storage pages. The title, the gold
line, Sell and Goodbye stay where they are. Windows that mark nothing still scroll as a whole.&lt;/p&gt;
&lt;p&gt;The inventory got its own compact layout instead: the equipment moves into two columns beside the Bag,
Skillbooks and Runes pages. That makes it 332 UI pixels tall, and it fits without scrolling, with every
slot still 5.5 mm.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-inventory-before-after.jpg&quot; alt=&quot;The inventory on an iPhone, before and after: equipment above the bag with the gold row cut off by the home bar, then equipment in two columns beside it, with gold, Split and the bin in view&quot; width=&quot;1278&quot; height=&quot;1192&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The inventory on an iPhone, before and after: equipment above the bag with the gold row cut off by the home bar, then equipment in two columns beside it, with gold, Split and the bin in view&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;touch-has-no-hover-and-no-right-button&quot;&gt;Touch has no hover and no right button &lt;a class=&quot;anchor&quot; href=&quot;#touch-has-no-hover-and-no-right-button&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;TouchUi&lt;/code&gt; is the layer that makes Godot&#x27;s ordinary Controls behave on a touch screen without rewriting each
window. It walks the UI as windows open and applies a set of rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Long-press tooltips.&lt;/strong&gt; Hover doesn&#x27;t exist, so a finger held still for 0.45 s shows the tooltip,
placed above, below or beside the finger so the finger doesn&#x27;t cover it. It stays until the next tap,
and the release that ended the long press doesn&#x27;t count as a click.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Double-tap is right-click.&lt;/strong&gt; Equip, use and read are right-click on a PC. Every hint and tooltip text
that mentions right-click says &amp;quot;double-tap&amp;quot; on touch (&lt;code&gt;Mobile.touch_text&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drag to scroll,&lt;/strong&gt; with a fling, on every ScrollContainer, list and rich text. A press that moves more
than 2.5 mm becomes a scroll and cancels the button under it, except on drag sources: item slots and
skills keep drag and drop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Splitting a stack&lt;/strong&gt; was Shift + drag. There is no Shift on a phone, so the inventory has a Split toggle
next to the bin. Turn it on, drag a stack onto an empty square, and the amount comes from a slider
instead of a number field, so no keyboard opens. The toggle turns itself off after one use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text fields&lt;/strong&gt; open the right on-screen keyboard (a number pad for the port, a URL keyboard for the join
address, a password keyboard for passwords). The screen slides up so the focused field stays above the
keyboard, and a tap elsewhere puts the keyboard away.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-long-press.jpg&quot; alt=&quot;A long press on a sword in the bag: the tooltip with the comparison to the equipped sword opens beside the finger and ends with &amp;quot;Double-tap to equip&amp;quot;&quot; width=&quot;1278&quot; height=&quot;590&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A long press on a sword in the bag: the tooltip with the comparison to the equipped sword opens beside the finger and ends with &amp;quot;Double-tap to equip&amp;quot;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-second-finger&quot;&gt;The second finger &lt;a class=&quot;anchor&quot; href=&quot;#the-second-finger&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;One problem needed&lt;/span&gt; a piece of engine knowledge. Godot turns touches into mouse events so that every
Control works on a touch screen, but only for &lt;strong&gt;one&lt;/strong&gt; finger: the first one down, until it lifts. With a
thumb on the stick, a tap of another finger on a window&#x27;s button reached nothing, because buttons only
listen to the mouse.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;TouchRouter&lt;/code&gt; fills that gap. TouchUi feeds it every touch. A finger that isn&#x27;t the mouse finger and lands
on a window or on anything pressable becomes a tap: when it lifts, still on the same control, the router
pushes a left press and release at that point into the viewport. It stays deliberately small. Drag and
drop, long-press tooltips and scrolling remain with the first finger, and nothing is routed while the first
finger is itself on the UI, because it holds the interface&#x27;s mouse focus and a second press would steal
it mid-drag.&lt;/p&gt;
&lt;p&gt;This is the one feature here that is covered by a unit test only. The PC simulation has one mouse, so one
finger. It is on the list for a real phone.&lt;/p&gt;
&lt;h2 id=&quot;the-mobile-renderer&quot;&gt;The mobile renderer &lt;a class=&quot;anchor&quot; href=&quot;#the-mobile-renderer&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The desktop build&lt;/span&gt; uses Godot&#x27;s Forward+ renderer with volumetric fog, SSAO, glow and dense grass. Phones
get the Mobile renderer (set per platform in &lt;code&gt;project.godot&lt;/code&gt;, so the PC keeps Forward+) and a &amp;quot;Mobile&amp;quot;
quality profile that phones start on:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# phones: plants / clutter drawn ~60% as far (the fog hides the edge), half the weather, short 2-split shadows&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;&quot;mobile&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;view&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;particles&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.45&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_distance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;45.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_splits&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;camera_far&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;190.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;On top of that: grass at a quarter of its density, fading out at 26 to 38 m; a 2048 shadow map with hard
filtering; no SSAO or volumetric fog (the Mobile renderer has neither); 75 percent render resolution and 30
fps by default. A new Upscaling option offers FSR, but Godot 4.7 only has FSR in Forward+, so on the Mobile
renderer it falls back to bilinear by itself.&lt;/p&gt;
&lt;p&gt;Shaders were the risk. Every custom shader in the game (the sprite characters, the skill effects, the
foliage, the dungeon cutaway) was written against Forward+, and headless tests never compile shaders.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; The agent took windowed screenshots with &lt;code&gt;--rendering-method mobile&lt;/code&gt; of the Vale, every room of a
dungeon, every Blade skill and the sprites, and compared them with Forward+. No shader needed a change.
The visible difference is the missing ambient occlusion. One limit to watch on a device: the Mobile renderer
draws at most 8 point and 8 spot lights per object, and the towns have many flickering lanterns.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/19-renderers.jpg&quot; alt=&quot;The Vale&amp;#x27;s town square at night with the desktop renderer and with the Mobile renderer: the same sprites, shaders and lights, minus ambient occlusion&quot; width=&quot;1600&quot; height=&quot;425&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Vale&amp;#x27;s town square at night with the desktop renderer and with the Mobile renderer: the same sprites, shaders and lights, minus ambient occlusion&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;android-an-apk-from-a-windows-pc&quot;&gt;Android: an APK from a Windows PC &lt;a class=&quot;anchor&quot; href=&quot;#android-an-apk-from-a-windows-pc&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The Android preset&lt;/span&gt; is plain: arm64 only, package &lt;code&gt;io.dekiro.game&lt;/code&gt;, landscape following the phone&#x27;s
rotation, immersive mode, internet and Wi-Fi state permissions, the Steam addon excluded. The same include
filters as every other preset.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The first export stopped at missing tools. Building an APK from Godot needs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A Java JDK 17.&lt;/strong&gt; Not the newest JDK: Godot 4.7&#x27;s Android export wants 17, so that&#x27;s what went in,
as a portable zip in the user&#x27;s folder, since no installer for it runs without admin rights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Android SDK&lt;/strong&gt; command-line tools with platform-tools, build-tools 35 and the android-35 platform,
licences accepted. The AI asked the developer before accepting those on their behalf.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A debug keystore&lt;/strong&gt; for signing, one &lt;code&gt;keytool&lt;/code&gt; command.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ETC2/ASTC textures.&lt;/strong&gt; Desktop GPUs read BC-compressed textures; phones (and Apple Silicon Macs) need
ETC2 or ASTC. One project setting, &lt;code&gt;import_etc2_astc=true&lt;/code&gt;, makes the importer write both. It had
already been switched on a day earlier for the macOS build.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both tool paths go into Godot&#x27;s editor settings, and from then on an APK is one command:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;godot&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--export-debug&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Android (Game)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;build/game-android/Dekiro.apk
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The result is a 147 MB debug-signed APK that installs from a file, after allowing &amp;quot;install unknown apps&amp;quot;
for whatever app opened it.&lt;/p&gt;
&lt;h2 id=&quot;ios-an-xcode-project-not-an-app&quot;&gt;iOS: an Xcode project, not an app &lt;a class=&quot;anchor&quot; href=&quot;#ios-an-xcode-project-not-an-app&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;An iPhone build&lt;/span&gt; can&#x27;t be finished on Windows, and the iPhone agent didn&#x27;t pretend otherwise. Godot can
export an iOS &lt;strong&gt;Xcode project&lt;/strong&gt; from any platform: &lt;code&gt;Dekiro.xcodeproj&lt;/code&gt;, an Info.plist with every icon size,
the launch storyboard, the 120 MB game pack, the Godot engine as an xcframework and MoltenVK (Vulkan on
Metal). Building, signing and installing it needs Xcode, which only runs on macOS. Godot says as much:
&amp;quot;.ipa can only be built on macOS&amp;quot;.&lt;/p&gt;
&lt;p&gt;Two smaller lessons from the export:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Godot refuses to export at all with an empty App Store Team ID&lt;/strong&gt;, even for a project-only export. The
agent tested with a dummy ID and set it back to empty; the real one goes in on the Mac.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GodotSteam has no iOS library.&lt;/strong&gt; The preset excludes &lt;code&gt;addons/godotsteam/*&lt;/code&gt;, and the export then leaves
out the extension list as well, so nothing tries to load a library that isn&#x27;t there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The app icon and the launch screen are drawn by a script from the game&#x27;s own UI art: a crimson seal with
影 (&amp;quot;shadow&amp;quot;) on the window lacquer, and the start menu&#x27;s title row. &lt;code&gt;docs/MOBILE.md&lt;/code&gt; has the Mac steps:
Xcode 16 or newer, an Apple ID (a free one runs on your own phone by cable for 7 days; TestFlight needs the
paid program), automatic signing, Developer Mode on the iPhone, and a local network permission text for
joining friends on the same Wi-Fi.&lt;/p&gt;
&lt;h2 id=&quot;testing-on-a-pc&quot;&gt;Testing on a PC &lt;a class=&quot;anchor&quot; href=&quot;#testing-on-a-pc&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Everything in this&lt;/span&gt; post was built and checked without a phone in the room. Unit tests cover the pure
parts: the size math, the safe-area insets, long-press timing, the stick&#x27;s dead zone, the HUD layout on four
screen shapes, name tags against the HUD, the second-finger router and the iOS preset&#x27;s filters.
&lt;code&gt;tests/scenario_mobile_ui.gd&lt;/code&gt; opens a phone-shaped window with &lt;code&gt;--touch&lt;/code&gt;, takes shots of every window, and
&lt;em&gt;checks&lt;/em&gt; behaviour on the way: a drag scrolls a list, a tap beside a button snaps to it, an item dragged by
&amp;quot;finger&amp;quot; lands in its slot. &lt;code&gt;tests/scenario_touch.gd&lt;/code&gt; sends real touch events through the engine in town
and in a fight. The monitor can&#x27;t show a 2556 x 1179 window at full size, so many shots ran at half size
(&lt;code&gt;--size=1278x590 --dpi=230&lt;/code&gt;), which gives the same layout.&lt;/p&gt;
&lt;h2 id=&quot;what-a-pc-cant-tell-you&quot;&gt;What a PC can&#x27;t tell you &lt;a class=&quot;anchor&quot; href=&quot;#what-a-pc-cant-tell-you&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;As of this&lt;/span&gt; post, no phone has run the game while anyone watched. The open questions are the honest ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Frame rate.&lt;/strong&gt; A desktop GPU says nothing about a phone&#x27;s. The mobile profile is an educated guess.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feel.&lt;/strong&gt; Stick radius, camera drag speed (1.6 times the mouse&#x27;s), and how much the 4.5 m auto-aim helps
or annoys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The second finger&lt;/strong&gt; on real hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lights&lt;/strong&gt; in lantern-heavy towns under the Mobile renderer&#x27;s per-object limit.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size touch UI in millimetres,&lt;/strong&gt; from the screen&#x27;s density, not in pixels.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small visible targets plus a snap radius&lt;/strong&gt; fit more on a 65 mm screen than big buttons, and still answer
to a fingertip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin the header and footer, scroll the middle.&lt;/strong&gt; A window whose action buttons scroll away is broken on a
phone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Know your engine&#x27;s touch emulation.&lt;/strong&gt; Godot&#x27;s mouse comes from one finger; anything a second finger
should do needs its own path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simulate the device, and draw its notch over your screenshots,&lt;/strong&gt; so a PC can catch most layout bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep input behind one interface.&lt;/strong&gt; Touch was a new input source for &lt;code&gt;PlayerController&lt;/code&gt;, not a change to
the game.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Phones and PCs&lt;/span&gt; can now play in the same world. The next post is about the bugs that only showed up once a
friend actually joined one.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Post 3 explains the four network roles and why single player is a local server. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Post 11 has the rules for running agents side by side in one repository, most of them learned from
file collisions. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;From the project&#x27;s rulebook (post 1): PlayerController, the AI and network input must be
interchangeable, so none of them may touch an actor&#x27;s velocity or combat internals. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;Post 4 tells the story of a uniform name that turned every sprite into a white wall, which no
headless test could see. Since then every shader change gets a windowed screenshot. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;Post 25 tells how the sprite atlases once went missing from every export because a JSON file
isn&#x27;t a resource. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>The First Playtest: Forty Notes and What Each One Became</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/18-the-first-playtest.html"/>
<id>https://dekiro.d12r.io/18-the-first-playtest.html</id>
<published>2026-10-19T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>The developer played the game properly for the first time and wrote down everything that bugged them. How one long message turned into ten parallel agents in an evening, which notes were a single number in a JSON file and which were real work, and what the AI had to ask back.</summary>
<content type="html">&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;I don&#x27;t feel so powerful, especially on the lower levels. Can you adjust the health slightly?&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-list&quot;&gt;The list &lt;a class=&quot;anchor&quot; href=&quot;#the-list&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Until this week&lt;/span&gt;, almost every number in Dekiro came from a model. The balance post describes a Monte Carlo
career and a combat simulation with bots, and it says plainly that none of it had met a human. Then the
human sat down with the Windows build, played for an evening, part of it with a friend,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and sent one
long message. It starts like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Items in shops don&#x27;t show stats, so there is no way of knowing if they are better or not. HP regeneration
out of combat is way too low, let&#x27;s adjust that to be higher. The potions also heal too little. EXP
progression can feel a bit faster, let&#x27;s scale to 20 hours. I want to have an option to &amp;quot;restart here&amp;quot;
or &amp;quot;restart in town&amp;quot; instead of restarting in the city.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It goes on for nineteen points: buff skills that run out too fast, dropped items that vanish, lag when
someone uses a portal, switching schools, upgrade materials, a white outline for loot, a more readable
font, skills that stay behind when you move, a classic item from a popular Korean MMO, which the developer knew by its German name
(&lt;em&gt;Tapferkeitsumhang&lt;/em&gt;), Shadow minions, an XP potion, mouse capture, the camera, and trading skillbooks.&lt;/p&gt;
&lt;p&gt;Over the next ninety minutes more notes followed, one or two lines each: a ten-slot hotbar, party members
on the big map, tooltips, skills hidden under the street, grandmasters staring at their own buildings, a
sprint toggle, Game Master tools, townsfolk out on the roads, a minimap without trees, an easter egg, wrong
quest markers, named chickens, a tavern, a chat, a skill rank above Grandmaster, and one question: &amp;quot;Do
bosses have HP recovery?&amp;quot;&lt;/p&gt;
&lt;p&gt;About forty notes in all. This post goes through what each one became.&lt;/p&gt;
&lt;h2 id=&quot;from-a-list-to-ten-agents&quot;&gt;From a list to ten agents &lt;a class=&quot;anchor&quot; href=&quot;#from-a-list-to-ten-agents&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The coordinator workflow&lt;/span&gt; from the subagents post was built for exactly this. Within two minutes of the
first message, four agents were running, each with its own slice of the list and its own files:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Took&lt;/th&gt;
&lt;th&gt;Owned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;shop tooltips, respawn choice, mouse capture, camera drag, loot outline, font comparison&lt;/td&gt;
&lt;td&gt;the windows, the HUD, the camera rig&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gameplay and balance&lt;/td&gt;
&lt;td&gt;regen, potions, the 20-hour pace, drop lifetime, upgrade materials, Cape of Valour, XP potion, Shadow rules, school switching, book exchange&lt;/td&gt;
&lt;td&gt;data tables, the balance model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;4-minute buffs, buff particles, skills that follow the caster&lt;/td&gt;
&lt;td&gt;skills.json, the skill effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;portal lag&lt;/td&gt;
&lt;td&gt;map loading&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Every later note went either to the agent already working in that area (the UI agent ended up with eight
extra tasks, from the hotbar to the XP tooltip) or to a new agent when it was its own thing: GM tools,
townsfolk on the roads, baked map pictures, the easter egg, the chat, and the Grandmaster ranks. Ten
agents in total. The coordinator did two small ones itself between reports: the grandmasters&#x27; facing and
the boss recovery.&lt;/p&gt;
&lt;p&gt;The brief they all shared was one file,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; with the rules from the subagents post: own your files, edit shared ones only with exact-match edits and re-read first, don&#x27;t fix
other people&#x27;s half-finished work, run the full tests, look at your screenshots, don&#x27;t commit. Those rules
were tested hard this time. The agents&#x27; reports are full of lines like &amp;quot;the compile broke twice because of
that same unfinished skills work&amp;quot; and &amp;quot;the earlier potion-heal failure no longer shows&amp;quot;. The test suite
went from 15,085 checks before the list to 17,695 at the end of the evening, and at most moments some of
them were failing because somebody else was mid-edit.&lt;/p&gt;
&lt;h2 id=&quot;the-one-line-changes&quot;&gt;The one-line changes &lt;a class=&quot;anchor&quot; href=&quot;#the-one-line-changes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A surprising share&lt;/span&gt; of the list was numbers. Because content lives in data, these were edits to a JSON file
or a constant, plus a test and a rerun of the balance model to see what else moved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Regeneration.&lt;/strong&gt; Out of combat (no damage dealt or taken for 5 seconds) players and companions now
regenerate 4% of their HP and mana per second, so an empty bar is full again in about 25 seconds. Three
constants in &lt;code&gt;attributes.gd&lt;/code&gt;. In a fight nothing changed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Potions.&lt;/strong&gt; Each tier heals about 1.6 to 1.9 times as much, and costs about a quarter more:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Potion&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Heal before&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Heal after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Red Potion (S)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;80&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Red Potion (L)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;250&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greater Red Potion&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;700&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Superior Red Potion&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;1,300&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;One small code change came with it: the potion key used to drink the weakest potion in the bag. It now
drinks the smallest one that fills what&#x27;s missing. With the new regen on top, the model&#x27;s character drinks
30 to 190 potions an hour instead of 175 to 430.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Twenty hours to level 90.&lt;/strong&gt; This one was a number too, but it needed the model. The XP curve&#x27;s exponent
went from 2.58 to 2.37, which makes the bar at level 90 about 3.4 million XP instead of 8.7 million, and
every quest&#x27;s XP followed the bar. The model now reaches level 90 in 20.7 hours (it was 38.6). Everything
that was paced against the old 40 hours had to move with it: monster gold up a third, Fullmoon weapons
twice as likely from Shadows and bosses, Sunstone more common in the desert, Sun Tomb key fragments
doubled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lighter low levels.&lt;/strong&gt; &amp;quot;I don&#x27;t feel so powerful&amp;quot; was the vaguest note on the list. The coordinator read it
as monster health (the combat pass had raised low-level monster HP about 2.7 times) and asked back in case
it meant the player&#x27;s own health. The fix is a level factor in &lt;code&gt;monsters.json&lt;/code&gt;: x0.65 up to level 15,
tapering to x0.85 at 30 and none from 40. A level 3 monster now takes 2.1 hits instead of 3.2. Bosses were
first included and then exempted, because the Crypt Lord fell in 40 seconds with two companions, which is
not a boss fight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Upgrade materials.&lt;/strong&gt; &amp;quot;Shadow Shards should only be required from +5 and then go up as they do right now
from +1.&amp;quot; Taken literally: +1 to +4 now need no shards at all, the attempt to +5 needs what +1 needed (one
shard), +6 what +2 needed, and so on. &amp;quot;Shadow Cores only with items on level 30+&amp;quot; became a new key on a
material, &lt;code&gt;&amp;quot;min_item_level&amp;quot;: 30&lt;/code&gt;, which the upgrade window and the server both read. That&#x27;s the whole
change: nine lines of &lt;code&gt;upgrades.json&lt;/code&gt; and one check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shadows.&lt;/strong&gt; Three notes landed in one small block of data:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;shadow_waves&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;engaged_for&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;9.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;calm_after&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;60.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&quot;shadow_break&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;heal_share&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.25&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;heal_radius&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;25.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;minions_die&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A Shadow now spawns nothing until someone hits it, keeps spawning while it was hit in the last 9 seconds,
and goes back to sleep after 60 seconds alone. When it breaks, its monsters die with it (you still get their
XP, but not their loot: otherwise piling them up and breaking the stone would be free loot), and everyone
within 25 m heals 25% of their max HP. The coordinator decided companions get the heal too, and said so.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two new items.&lt;/strong&gt; The &lt;em&gt;Cape of Valour&lt;/em&gt; (that MMO&#x27;s Tapferkeitsumhang) drops from monsters level 10 and up at
1% (3% from Elites). Swing it and every monster on the map comes running at you for 40 seconds; bosses and
Shadows ignore it, and it doesn&#x27;t work in safe zones. The &lt;em&gt;Potion of Insight&lt;/em&gt; doubles XP for 15 minutes at
the requested 0.1% drop chance; another one adds 15 minutes, up to an hour, and death doesn&#x27;t wash it out.
Both are a few lines in &lt;code&gt;items.json&lt;/code&gt; plus an icon and a model from the Blender pipeline. The cape needed one
new effect, a map-wide taunt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schools and books.&lt;/strong&gt; Switching schools is allowed while no skill has reached Master and the character is
below level 30, where the school&#x27;s own questline starts. Skill points come back one to one. Trading a
skillbook of another school for a random book of your own works at your own grandmaster, then waits an hour
of real time. Both rules are two numbers in a new &lt;code&gt;data/schools.json&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Buffs.&lt;/strong&gt; War Cry, Iron Skin, Haste and Blessing now last 4 minutes with a 60-second cooldown, so you can
keep them up the way the classic does. Two values went &lt;em&gt;down&lt;/em&gt;: held up permanently, the old numbers would have given a
Grandmaster 86% damage reduction from Iron Skin, or double running speed from Haste.&lt;/p&gt;
&lt;h2 id=&quot;the-ones-that-were-real-work&quot;&gt;The ones that were real work &lt;a class=&quot;anchor&quot; href=&quot;#the-ones-that-were-real-work&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Other notes sounded&lt;/span&gt; just as small and weren&#x27;t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Restart here&amp;quot; or &amp;quot;restart in town&amp;quot;.&lt;/strong&gt; The game had one respawn: back to the map&#x27;s spawn. The new death
window offers both. &lt;em&gt;In town&lt;/em&gt; is available after 3 seconds, at full health, in the town or gate camp the
character last stood in, which the character now remembers and saves.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; &lt;em&gt;Here&lt;/em&gt; waits 10 seconds and gives
50% health and 5 seconds of protection that your own first hit ends, so it can&#x27;t be used to hit a boss for
free. In a dungeon, &amp;quot;town&amp;quot; means the entrance, so the party&#x27;s run isn&#x27;t thrown away.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-death-window.jpg&quot; alt=&quot;The new death window: restart in town after 3 s, or here after 10 s with half health&quot; width=&quot;1600&quot; height=&quot;835&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The new death window: restart in town after 3 s, or here after 10 s with half health&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Shop tooltips.&lt;/strong&gt; The shop rows simply had no tooltip. They now show the bag&#x27;s tooltip, compared with what
you wear.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-shop-tooltip.jpg&quot; alt=&quot;A shop tooltip comparing an Iron Sword with the equipped one, and a Sell box tooltip on the left&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A shop tooltip comparing an Iron Sword with the equipped one, and a Sell box tooltip on the left&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Buff effects.&lt;/strong&gt; &amp;quot;For all the passive skills I would like to have a particle effect on the character or
weapon.&amp;quot; Every active buff now has its own small emitter: embers from the weapon hand for War Cry, steel
flakes round the waist for Iron Skin, wisps at the feet for Haste, motes from a halo for Blessing. Other
players see them, which meant a new synced value on the player (&lt;code&gt;buff_look&lt;/code&gt;, for example
&lt;code&gt;&amp;quot;war_cry:2,iron_skin:1&amp;quot;&lt;/code&gt;). The coordinator&#x27;s own verdict on the screenshots was that they read as &amp;quot;sparse
specks&amp;quot;, not yet as an aura, and offered a denser pass.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-buff-auras.jpg&quot; alt=&quot;Each buff&amp;#x27;s particles, standing and running&quot; width=&quot;1600&quot; height=&quot;570&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Each buff&amp;#x27;s particles, standing and running&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Skills that follow you.&lt;/strong&gt; Spins, Blade Storm, Consecration and the self buffs now move with the caster,
both their effects and their damage area, on the server. Aimed and thrown skills (Charge, Light Lance, Night
Blades) stay where they land. The rule is in data: circle, arc and self skills follow unless a skill&#x27;s
&lt;code&gt;&amp;quot;fx&amp;quot;&lt;/code&gt; says otherwise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skills under the street.&lt;/strong&gt; The developer&#x27;s first guess was a masking problem: &amp;quot;It seems a masking issue
of the skill, tower buildings and NPCs somehow?&amp;quot; The sprite outline does use a stencil mask, so it was a
good guess, and the skills agent checked it first. The real cause was draw order. Roads and paved squares
have soft edges, so they are transparent materials, and transparent things are drawn in priority order.
Floors were at priority 0, skill effects at -2, so the floor was drawn &lt;em&gt;after&lt;/em&gt; the effects and covered them.
Consecration was completely invisible on the Vale&#x27;s square. The fix is one constant, &lt;code&gt;FLOOR_PRIORITY = -20&lt;/code&gt;,
plus ground effects that now follow the terrain&#x27;s height and tilt with slopes instead of sitting at a fixed
6 cm.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-ground-fx-before-after.jpg&quot; alt=&quot;The Vale square before (top: only the boss telegraph shows) and after (the skill glyphs draw over the paving)&quot; width=&quot;1600&quot; height=&quot;1800&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Vale square before (top: only the boss telegraph shows) and after (the skill glyphs draw over the paving)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Quest markers.&lt;/strong&gt; &amp;quot;Some NPCs have ! on top even when there is no quest action possible; some NPCs that have
quests don&#x27;t.&amp;quot; The audit found the &amp;quot;!&amp;quot; wasn&#x27;t connected to quests at all. It was a fixed label for NPCs whose
role was &amp;quot;quest&amp;quot;, and only Elder Mira had that role. So Mira always showed it and every other quest giver
never did. Markers are now computed from the character&#x27;s own quest state with the same conditions the NPC
window uses: a yellow &amp;quot;!&amp;quot; for a quest you can take, a light blue &amp;quot;?&amp;quot; for something to hand in. A test checks
every NPC on every map for two characters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Portal lag.&lt;/strong&gt; &amp;quot;It lags a bit for other players if someone uses a portal.&amp;quot; The host was building the whole
target map in a single frame, which on the Shadow Marsh took over six seconds, frozen for everyone. Maps now
build in slices of a few milliseconds per frame.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Game Master tools.&lt;/strong&gt; The developer asked for GM controls on their own Steam account: give a Fullmoon
sword, add gold, change levels and points. The panel was the easy part. The hard part is who counts as a GM.
The answer is that only the server decides, from identities it can verify: its own Steam login, or Steam&#x27;s
authenticated mapping from a lobby peer to an account. A client that joins by IP address is never a GM, and
an ID sent in a message is ignored.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; Every GM action
goes through the normal character systems, so a reload&#x27;s sanitizing keeps it, and there is no &amp;quot;gm&amp;quot; flag in
the save, because a client could write one into its own file.&lt;/p&gt;
&lt;h2 id=&quot;the-chat-that-wasnt-needed&quot;&gt;The chat that wasn&#x27;t needed &lt;a class=&quot;anchor&quot; href=&quot;#the-chat-that-wasnt-needed&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On day two&lt;/span&gt; the developer had ruled chat out: &amp;quot;Chat is not needed. Most will be in Discord anyway.&amp;quot; It sat in
the backlog as &amp;quot;not planned&amp;quot;. The playtest changed that in one sentence:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;There is no way for me to understand what my quest rewards were that I have just received. It might be
time to add a chat, so we can have system messages where these rewards can be dropped into, and players
can also write things in.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;and a follow-up four minutes later: &amp;quot;every popup that appears on screen should then be shown in that chat
to the one player that triggered that message, so a user can always scroll back if the message disappeared
too fast.&amp;quot;&lt;/p&gt;
&lt;p&gt;The second sentence decided the design. Instead of adding chat lines in forty places, the chat agent hooked
the one function every popup over your own character goes through (&lt;code&gt;DamageNumber.spawn_text&lt;/code&gt;), so popups
added later land in the chat automatically. Damage numbers and other players&#x27; popups are skipped, repeats
merge into &amp;quot;(x2)&amp;quot;, pickups merge into one line, kill XP is summed every 4 seconds. Player chat is
server-authoritative like everything else: the server stamps the sender&#x27;s name from its own copy of the
player, strips markup so nobody can fake a system line, caps messages at 200 characters and 5 per 5
seconds, and routes say, party, whisper and shout only to the right peers.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-chat.jpg&quot; alt=&quot;The chat: a quest&amp;#x27;s rewards listed in full, merged pickups, summed XP, a refusal popup (x2), and player lines. &amp;quot;[b]not bold[/b]&amp;quot; stays plain text&quot; width=&quot;1600&quot; height=&quot;640&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The chat: a quest&amp;#x27;s rewards listed in full, merged pickups, summed XP, a refusal popup (x2), and player lines. &amp;quot;[b]not bold[/b]&amp;quot; stays plain text&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-got-asked-back&quot;&gt;What got asked back &lt;a class=&quot;anchor&quot; href=&quot;#what-got-asked-back&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;An agent working&lt;/span&gt; from a one-line note has to fill in details. The rule from the subagents post is to decide
sensibly and say so, and to ask where it&#x27;s a matter of taste. After this round the open questions were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Respawn values.&lt;/strong&gt; 10 seconds and 50% health for &amp;quot;here&amp;quot;, 3 seconds for town, 5 seconds of protection,
town after 3 minutes without a choice. All guesses; the developer will feel them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The font.&lt;/strong&gt; The developer asked for &amp;quot;a bit more readable&amp;quot; and &amp;quot;any suggestions?&amp;quot;. The answer was a
comparison sheet rather than a switch: the same HUD, tooltip, shop and quest tracker in four candidates.
Jersey 10 (current) crowds its letters; Fusion Pixel 12 stays pixel art, is clearly spaced and also covers
Chinese and Japanese, at about 20% more width; m6x11plus is very readable, but its licence couldn&#x27;t be
confirmed; Noto Sans is the most readable and breaks the pixel look. The recommendation is Fusion Pixel 12.
Swapping is one path in &lt;code&gt;UiTheme&lt;/code&gt;. The developer hasn&#x27;t picked yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;2 minutes&amp;quot;.&lt;/strong&gt; &amp;quot;Dropped items can stay a bit longer, let&#x27;s say 2 minutes.&amp;quot; It turned out that items
monsters drop never disappeared at all. Only items you drop from your own bag expired, after 20 seconds.
That&#x27;s now 120 seconds, and the question went back: did you mean something else?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Cape&#x27;s reach&lt;/strong&gt;: the whole map, as asked, or a radius like in the classic? It&#x27;s one number.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;!&amp;quot; and &amp;quot;?&amp;quot;&lt;/strong&gt;: two symbols, WoW-style, or a single &amp;quot;!&amp;quot; as before?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Buff strength and cooldown&lt;/strong&gt;: should Iron Skin and Haste be higher after all, and is 60 seconds right?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The loot outline&lt;/strong&gt;: 2 pixels at 1080p, white, unshaded so it reads the same at night. Enough?&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/18-fonts.jpg&quot; alt=&quot;The font comparison sheet, first two of four: Jersey 10 (current) and Fusion Pixel 12&quot; width=&quot;1240&quot; height=&quot;2120&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The font comparison sheet, first two of four: Jersey 10 (current) and Fusion Pixel 12&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;One misunderstanding went the other way. &amp;quot;Skills should also be accessible with the shortcut V,&amp;quot; then a
minute later &amp;quot;ah sorry, F not V&amp;quot;, then, asked whether F should open skills instead of talking to NPCs, &amp;quot;don&#x27;t
adopt this&amp;quot;, and finally &amp;quot;V as an additional shortcut (next to K) for skills would be great.&amp;quot; The result: K
and V both open the skill window, F stays the interact key, and players who saved key settings before still
get V, unless they&#x27;ve bound it to something else.&lt;/p&gt;
&lt;h2 id=&quot;the-small-ones-and-one-question&quot;&gt;The small ones, and one question &lt;a class=&quot;anchor&quot; href=&quot;#the-small-ones-and-one-question&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Some notes took&lt;/span&gt; a few minutes. The four grandmasters stood facing their school halls; the map generator now
turns them to the square (one &lt;code&gt;atan2&lt;/code&gt; each), and every NPC turns toward whoever talks to them. The oversized
colour picker was Godot&#x27;s full editor dialog at two to three times UI scale; it&#x27;s now a square, a hue strip
and a hex field. Alt names gold and items on the ground. The hotbar has ten slots (touch keeps eight, because
ten wouldn&#x27;t fit at finger size).&lt;/p&gt;
&lt;p&gt;A few notes were bigger than they sounded and have their own posts: townsfolk spread out onto the roads, a
tavern for the &amp;quot;Bierlinger Tribe&amp;quot; and seven named hens;&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt; minimap pictures with trees and buildings,
baked from top-down renders;&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-7&quot; id=&quot;fnref-7&quot;&gt;7&lt;/a&gt;&lt;/sup&gt; and Grandmaster ranks G1 to G10 with a hidden rank above them and a
study item that drops from bosses by chance, which gets its own post once it&#x27;s finished. There is also an easter egg for a friend, which stays a secret.&lt;/p&gt;
&lt;p&gt;And the question. &amp;quot;Do bosses have HP recovery?&amp;quot; They didn&#x27;t: you could wear a boss down, die, run back
from the entrance and continue where you left off. The coordinator suggested the classic MMO&#x27;s rule, and the
developer set the numbers:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The suggestion makes sense: if no player hits for 20 seconds, 2% per second is OK.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That&#x27;s &lt;code&gt;BossAI.recover()&lt;/code&gt;: a boss nobody has hurt for 20 seconds heals 2% of its max HP a second, so it&#x27;s
full again within a minute. Phase triggers (summons at 66% and 33%, the enrage) still fire only once per
fight, so healing back over a threshold doesn&#x27;t replay them.&lt;/p&gt;
&lt;h2 id=&quot;by-the-numbers&quot;&gt;By the numbers &lt;a class=&quot;anchor&quot; href=&quot;#by-the-numbers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;About &lt;strong&gt;40 notes&lt;/strong&gt;, from 19 in the first message to a question at the end.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;10 agents&lt;/strong&gt; plus the coordinator, the first four briefed within two minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;15,085 → 17,695&lt;/strong&gt; test checks over the evening.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;38.6 → 20.7&lt;/strong&gt; hours to level 90 in the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;6,260 → 18 ms&lt;/strong&gt;: the host&#x27;s worst frame when someone first travelled to the Shadow Marsh.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2 of the notes&lt;/strong&gt; turned out to be misunderstandings worth asking about; &lt;strong&gt;0&lt;/strong&gt; were declined.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of it has been playtested a second time yet. At the time of writing it&#x27;s in the working tree, waiting
for the last agents and the full check before the commit.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Playtest notes are mostly data, if your content is.&lt;/strong&gt; Potions, regen, XP pace, upgrade materials and
Shadow rules were numbers. The balance model is what made changing them safe in minutes rather than days.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The vague notes need a reading and a question.&lt;/strong&gt; &amp;quot;I don&#x27;t feel powerful&amp;quot; could mean two things; the agent
picked one, said which, and offered the other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask about taste, decide about mechanics, and say which is which.&lt;/strong&gt; The font, respawn timings and marker
symbols went back to the developer; loot-free Shadow minions and boss phases that don&#x27;t replay were
decided and explained.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hook the funnel, not the callers.&lt;/strong&gt; One hook in the popup function put every message into the chat,
including the ones nobody has written yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A good guess still needs checking.&lt;/strong&gt; &amp;quot;A masking issue&amp;quot; was a sensible hypothesis and wrong; the cause was
one priority number.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Several of these&lt;/span&gt; notes came from playing with a friend, and some of the bugs behind them can only be found
that way: players sunk into hills, a clock that disagreed, and a host that froze when someone took a portal.
That&#x27;s the next multiplayer post.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The bugs that only showed up with a second player are in the post &amp;quot;Multiplayer Bugs Only a Friend Can Find&amp;quot;. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;code&gt;playtest_brief.md&lt;/code&gt; in the coordinator&#x27;s scratchpad; each agent&#x27;s own prompt only added its tasks, in the developer&#x27;s words. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;On load the remembered town is kept only if it&#x27;s a real, non-dungeon map whose safe zones that character has visited: a server can&#x27;t trust a client&#x27;s save file. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;The measurements and the stepped map build are in &amp;quot;Multiplayer Bugs Only a Friend Can Find&amp;quot;. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;&lt;code&gt;tests/net_bot.gd&lt;/code&gt; sends exactly that: a client joined by IP claiming the developer&#x27;s account. The server logs the refusal. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;&amp;quot;A Tavern, Named Hens and Cats: The Small Stuff&amp;quot;. &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-7&quot;&gt;The minimap post (26) covers the baker: a top-down render of each map in Godot, then a stylising step in Python. &lt;a href=&quot;#fnref-7&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>The Fullmoon Weapons: an Old-School +9 Gamble, and Three Steps That Never Fail</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/17-fullmoon-weapons-upgrades-and-awakening.html"/>
<id>https://dekiro.d12r.io/17-fullmoon-weapons-upgrades-and-awakening.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>How a level 30 weapon stays relevant to level 90: a cruel upgrade track with its own currency, a slow and certain alternative, and an awakening that is deliberately not a gamble.</summary>
<content type="html">&lt;h2 id=&quot;the-weapon-everyone-remembers&quot;&gt;The weapon everyone remembers &lt;a class=&quot;anchor&quot; href=&quot;#the-weapon-everyone-remembers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Anyone who played&lt;/span&gt; a certain popular Korean MMO remembers the Full Moon Sword: a mid-level weapon that players kept upgrading long
after they could have replaced it. Dekiro&#x27;s version is the &lt;strong&gt;Fullmoon&lt;/strong&gt; set, one sacred weapon per school: the
Fullmoon Sword (Blade), Fists (Iron Fist), Daggers (Shadowstep) and Staff (Sanctum). The lore makes them the
heirs of the four weapons the founding disciples forged under a full moon to seal the First Shadow, and
the game treats them accordingly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They&#x27;re the only items of &lt;strong&gt;Unique&lt;/strong&gt; rarity, with 5 to 6 random bonuses, and never come from normal
rolls.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;
&lt;li&gt;They don&#x27;t drop like normal gear. A level 28+ Shadow has a 0.3% chance per weapon; a boss 2%. When one
drops, the game announces a &amp;quot;Special weapon&amp;quot; and it stands in a beam of light.&lt;/li&gt;
&lt;li&gt;All four drop for everyone, and only their school can equip them. The rest can be traded or sold, so
luck for one player can be a gift for a friend.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In the balance model, 98% of characters find their own school&#x27;s Fullmoon weapon by level 90, at a median
of about 13 hours of play (around level 45). That&#x27;s late enough to feel like an event and early enough to
carry you through half the game.&lt;/p&gt;
&lt;h2 id=&quot;problem-a-level-30-weapon-at-level-90&quot;&gt;Problem: a level 30 weapon at level 90 &lt;a class=&quot;anchor&quot; href=&quot;#problem-a-level-30-weapon-at-level-90&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A level 30&lt;/span&gt; item that outclasses later drops has an obvious problem: either it stops mattering at 50, or
it&#x27;s so strong that nothing else matters. The design answer is to make the Fullmoon weapon a &lt;strong&gt;project&lt;/strong&gt;
rather than an item. Its power comes from upgrading it, on its own track, with its own currency.&lt;/p&gt;
&lt;h3 id=&quot;its-own-upgrade-track&quot;&gt;Its own upgrade track &lt;a class=&quot;anchor&quot; href=&quot;#its-own-upgrade-track&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Normal gear gets +12% base stats per upgrade level, with chances from 100% down to 20% and downgrades on
failure from +5. The Fullmoon track is steeper on both sides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;+36% attack, armor, HP and mana per level&lt;/strong&gt; (percentage stats +6%), so a +9 Fullmoon weapon is about a
+9 level 60 weapon. That&#x27;s the calibration target written into the data.&lt;/li&gt;
&lt;li&gt;Chances fall from 95% to &lt;strong&gt;15%&lt;/strong&gt; at the last step, and failures from +5 drop a level.&lt;/li&gt;
&lt;li&gt;Every step costs Shadow Shards and Cores, and the last three add &lt;strong&gt;Sunstone&lt;/strong&gt; (2, 3, then 5), the desert
glass that the top of every upgrade track needs.&lt;/li&gt;
&lt;li&gt;Every step also needs the school&#x27;s &lt;strong&gt;Moon Seal&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The seals are the interesting part. They come from a questline: finding your own school&#x27;s Fullmoon weapon
starts &amp;quot;The Sacred Weapon&amp;quot; (show it to your grandmaster), then nine seal quests unlock at levels 30, 30,
30, 35, 40, 45, 50, 55 and 60, one seal each. A seal is used up on success. On a failure, an early version
destroyed it; a later design change made it kinder: &amp;quot;a failed Fullmoon upgrade keeps the Moon Seal but
drains it.&amp;quot; To recharge it you destroy Shadows, 5 for the first
step up to 25 for the last:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;recharge_shadows&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;18&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;22&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;25&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;So a failure costs materials and gold and sends you back out to hunt, but never your progress through the
questline. The model estimates what a full +9 takes on this track: about 270 attempts, 1,100 Shadow Cores,
230 Sunstone and all nine seals. That&#x27;s over 100 hours of play, well past the 40-hour story. &lt;strong&gt;+9
Fullmoon is endgame by design.&lt;/strong&gt; In a first playthrough the median character ends at about +6 or +7.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-upgrade-sparks.jpg&quot; alt=&quot;+7, +8 and +9 katanas before and after the change: a full glow became a few pixel sparks&quot; width=&quot;1600&quot; height=&quot;670&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;+7, +8 and +9: from a full glow to a few pixel sparks, after the human pointed out that in that MMO &amp;quot;you can see the sparkling&amp;quot;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;sparks-not-glow&quot;&gt;Sparks, not glow &lt;a class=&quot;anchor&quot; href=&quot;#sparks-not-glow&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;In the old MMO a +7, +8 or +9 weapon is visible from across the map. Ours first glowed red, green and gold; the
human corrected it: &amp;quot;shouldn&#x27;t glow but have particular sparks around the weapon, not too many.&amp;quot; Now a few
pixel sparkles run along the blade in the step&#x27;s colour, and at +9 a shine travels up the edge. Because
weapons in hand are live 3D models on the sprites&#x27; hand anchors,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; the
sparkles are real and every player sees them.&lt;/p&gt;
&lt;h2 id=&quot;the-slow-certain-road&quot;&gt;The slow, certain road &lt;a class=&quot;anchor&quot; href=&quot;#the-slow-certain-road&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A 0&lt;/span&gt;.3% drop is a lottery, and some players never win lotteries. The human asked how the weapons can be
found and with what chance, and decided on a second road with no luck in it, but no shortcut either:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We do the moonsteel fragments only. But it would be 1000 for sure. Shadows also don&#x27;t drop these by
default, also random.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Moonsteel Fragments&lt;/strong&gt; drop from level 28+ Shadows (30% of the time, 1 to 3). With 1,000 of them and
300,000 gold, Forgemaster Kael at the forge camp in the Ember Wastes forges your own school&#x27;s Fullmoon
weapon. There is no bad-luck protection on the drop itself; the fragments &lt;em&gt;are&lt;/em&gt; the protection. The
balance pass sized the drop rate for the real speed of Shadow fights (one to three minutes each, after the
combat retune), which comes to about 1,700 Shadows: &lt;strong&gt;55 to 60 hours&lt;/strong&gt; of Shadow hunting. The design doc
calls it &amp;quot;the slow, certain road next to the 0.3% drop&amp;quot;, and that&#x27;s exactly its job: a guarantee for the
unlucky that no one takes by choice.&lt;/p&gt;
&lt;h2 id=&quot;awakening-past-9-and-why-it-never-fails&quot;&gt;Awakening: past +9, and why it never fails &lt;a class=&quot;anchor&quot; href=&quot;#awakening-past-9-and-why-it-never-fails&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The human wanted&lt;/span&gt; more: &amp;quot;I guess I want them to scale up even further to end game.&amp;quot; A +9 Fullmoon weapon is
about a +9 level 60 weapon. By level 90 it would be outclassed. So after +9, the weapon can &lt;strong&gt;wake&lt;/strong&gt; three
times at a blacksmith:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Level&lt;/th&gt;
&lt;th&gt;Needs&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Base stats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Moon Awakening I&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;60&lt;/td&gt;
&lt;td&gt;a quest at your grandmaster, 60 Moonsteel Ore, 10 Shadow Cores, 250,000 gold&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;x1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moon Awakening II&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;75&lt;/td&gt;
&lt;td&gt;the next quest, 50 Demon-Forged Essence, 30 ore, 15 cores, 600,000 gold&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;x1.45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;True Fullmoon&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;90&lt;/td&gt;
&lt;td&gt;the last quest, a &lt;strong&gt;Shard of the Sanctuary Stone&lt;/strong&gt;, 30 essence, 20 cores, 1,500,000 gold&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;x1.72&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;figure class=&quot;fig margin&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/17-awaken-window.jpg&quot; alt=&quot;A blacksmith&amp;#x27;s window for Moon Awakening II: the stats before and after, the quest, gold and materials (screenshot from before the balance pass raised the gold cost to 600,000)&quot; width=&quot;1075&quot; height=&quot;1294&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A blacksmith&amp;#x27;s window for Moon Awakening II: the stats before and after, the quest, gold and materials (screenshot from before the balance pass raised the gold cost to 600,000)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Each stage keeps the weapon at +9 and multiplies its base stats on top of the upgrade scaling. The
multipliers are calibrated against the normal weapons: stage I is about 14% over a +9 level 60 weapon, II
about 13% over a +9 level 75 one, and the True Fullmoon about 7 to 15% over a +9 level 90 weapon and about
20% over the +9 level 86 raid weapons. That margin is the &amp;quot;Fullmoon premium&amp;quot;: the reason to keep it.&lt;/p&gt;
&lt;p&gt;And here&#x27;s the design decision this post is named for: &lt;strong&gt;awakening always succeeds.&lt;/strong&gt; The data&#x27;s own
comment says why: &amp;quot;the quest and the materials are the gate, a raid-only shard is never gambled.&amp;quot; The
Shard of the Sanctuary Stone drops only from the First Shadow at the end of the final raid. Putting that
on a 15% roll would turn the best moment of the game into the worst one. The upgrade track is where the
gambling lives; the awakening is where effort is rewarded with certainty. Two different feelings, on
purpose, on the same weapon.&lt;/p&gt;
&lt;p&gt;The awakening stages are school-bound (only the weapon&#x27;s school can do the quests), and each one sends you
somewhere: the first to Forgemaster Kael and the Cinder Shadows of the Ember Wastes, the second to the
Hellmouth Rift, the third to the Shadowlands. The costs are the game&#x27;s biggest gold sinks; the balance
model&#x27;s median character reaches level 90 with about enough gold for the first two.&lt;/p&gt;
&lt;h3 id=&quot;how-it-looks&quot;&gt;How it looks &lt;a class=&quot;anchor&quot; href=&quot;#how-it-looks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Each stage shows: at I the moonsteel shines, at II it glows with a breathing light and moonlight motes, at
III a full-moon halo stands behind it. Icons get a brighter tint, a glow or a moon ring, and the numeral.
The name becomes &amp;quot;True Fullmoon Sword&amp;quot;. It&#x27;s the one place where the game breaks its &amp;quot;sparks, not glow&amp;quot;
rule, because by then you&#x27;ve earned it.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/17-stages-night.jpg&quot; alt=&quot;The Fullmoon Sword at +9 asleep, then at Awakening I, II and III, at night&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Fullmoon Sword at +9 asleep, then at Awakening I, II and III, at night&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/17-schools-true.jpg&quot; alt=&quot;All four True Fullmoon weapons, one per school&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;All four True Fullmoon weapons, one per school&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;and-the-raid-needs-one&quot;&gt;And the raid needs one &lt;a class=&quot;anchor&quot; href=&quot;#and-the-raid-needs-one&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The Fullmoon weapons&lt;/span&gt; also tie into the story&#x27;s end. The Shadow Citadel, the final raid, only lets you in
with a Fullmoon weapon in your bag or in hand, any school&#x27;s (companions follow without one). It&#x27;s not used
up. The questline calls this step the Moonsteel Gate. In play it means everyone at the final boss has held
one of the four weapons at least once, even if it came through a trade. The raid&#x27;s boss drops the Shard
for the True Fullmoon (always), and his own Sovereign weapons at 6% each.&lt;/p&gt;
&lt;h2 id=&quot;what-were-not-sure-about&quot;&gt;What we&#x27;re not sure about &lt;a class=&quot;anchor&quot; href=&quot;#what-were-not-sure-about&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The drop chance.&lt;/strong&gt; 0.3% per weapon per Shadow, with 15 to 30 Shadows an hour in the model, gives a
median of 13 hours. Players will feel the variance more than the median. The fragments are the answer,
but 55 hours is a long answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The seal drain.&lt;/strong&gt; Draining instead of destroying is gentler, but 25 Shadows to recharge the last seal
after a 15% roll may still feel bad in practice. It&#x27;s a data change if it does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gold.&lt;/strong&gt; The awakening costs were raised during the balance pass to soak up late gold. If the economy
moves after the playtest, these move with it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of these are numbers in &lt;code&gt;data/upgrades.json&lt;/code&gt; and &lt;code&gt;data/items.json&lt;/code&gt;, which is the point of keeping them
there.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Make a signature item a project.&lt;/strong&gt; A weapon that grows with you for 60 levels is worth more than ten
that replace each other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gamble on progress, never on rare rewards.&lt;/strong&gt; Upgrade rolls can fail; a raid-only material must not be
rolled away.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the unlucky a certain road.&lt;/strong&gt; A long fixed path next to a lottery keeps the lottery exciting and
fair.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Calibrate against the alternatives.&lt;/strong&gt; &amp;quot;About a +9 level 60 weapon&amp;quot; and &amp;quot;20% over the raid weapon&amp;quot; are
written into the data, so the premium survives the next balance pass.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;That&#x27;s the end&lt;/span&gt; of the series so far. What&#x27;s next for the game is the part no agent can do: the human&#x27;s
playtest, and the notes on how all of it actually feels.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Or from an edited save file: loading clamps them. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html&quot;&gt;The HD-2D post&lt;/a&gt; explains how. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Making a Generated World Feel Lived In: Clutter, Houses, Cats and Townsfolk</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/16-making-the-world-feel-alive.html"/>
<id>https://dekiro.d12r.io/16-making-the-world-feel-alive.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Generated maps are tidy, and tidy looks empty. How two thousand crates were placed by rules, houses got roofs worth looking at, cats got names, and 66 townsfolk got daily routines, all without sending a single packet.</summary>
<content type="html">&lt;h2 id=&quot;mostly-low-poly-flat-boxes&quot;&gt;&amp;quot;Mostly low poly flat boxes&amp;quot; &lt;a class=&quot;anchor&quot; href=&quot;#mostly-low-poly-flat-boxes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;By day five&lt;/span&gt; every map had a town, roads, landmarks and monsters. Walking through them, the human put the
problem plainly:&lt;/p&gt;
&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;Can we add some more stuff, clutter like boxes and so on, to make the world more populated and lively.
Right now buildings are mostly low poly flat boxes. No character.&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;p&gt;Generators are good at placing the &lt;em&gt;important&lt;/em&gt; things: roads, houses, NPCs, spawns. They&#x27;re bad at the
unimportant things that make a place look used: the crate by the back door, the cart nobody unloaded, the
laundry line. Those are exactly the things a level designer adds by hand in the last weeks. We had a
generator and a day. The answer came in four parts, each its own agent: a building detail pass, a clutter
kit with automatic dressing, ambient animals, and later more and better townsfolk, drawn as sprites.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/16-street-before-after.jpg&quot; alt=&quot;A Vale street before and after the dressing pass&quot; width=&quot;1600&quot; height=&quot;426&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A Vale street before and after the dressing pass&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;houses-with-character&quot;&gt;Houses with character &lt;a class=&quot;anchor&quot; href=&quot;#houses-with-character&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The houses came&lt;/span&gt; from the Blender pipeline as clean low-poly shapes. The detail pass rebuilt them with what
the eye looks for in East Asian architecture: tiled roofs with visible courses, timber frames on the
walls, eaves, porches, window lattices, and trade signs on shops. Weathering (darker roof edges, grime
at the foot of walls) is painted into &lt;strong&gt;vertex colours&lt;/strong&gt; rather than textures, so every building in the
game still shares &lt;strong&gt;one material&lt;/strong&gt;. That matters for the GPU budget,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and it means a new house style costs nothing at runtime.&lt;/p&gt;
&lt;p&gt;The kit now has five house styles plus a tall house, a teahouse, a shop, a storehouse, a granary, a
farmhouse and a guard tower, and each comes in regional variants: desert, snow, swamp and ember. The map
data still just says &amp;quot;a house, 7 x 6 m, here&amp;quot;, and the game scales the model to the footprint.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/16-buildings.jpg&quot; alt=&quot;Part of the building kit after the detail pass: tiled roofs, timber frames, porches, signs&quot; width=&quot;1440&quot; height=&quot;1560&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Part of the building kit after the detail pass: tiled roofs, timber frames, porches, signs&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;clutter-by-rules-not-by-hand&quot;&gt;Clutter by rules, not by hand &lt;a class=&quot;anchor&quot; href=&quot;#clutter-by-rules-not-by-hand&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The clutter kit&lt;/span&gt; is 53 models (crates, barrels, sacks, carts, market stalls, racks, nets, laundry, benches,
planters, a noodle stand) from the same kind of Blender script. Placing them is the interesting part.
&lt;code&gt;tools/mapgen/dressing.py&lt;/code&gt; is called by every map generator, and its central idea is in its docstring:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;How it reads as lived in, not scattered: every placement is a VIGNETTE (a small hand-made group: a crate
stack with a sack against it, two barrels and a bucket, a woodpile, a bench with a planter...) set against
a wall or a road edge, turned to it, with a little jitter. Each building ROLE has its own vignettes per
wall: a smithy gets coal, racks and firewood; a farm hay, a cart, a trough and a fenced plot with a
scarecrow; a dock fish racks and nets.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Random scatter looks random. Small authored groups, placed by rules, look like someone put them there. The
rules are checked for every item as an oriented box: never on a road&#x27;s walkable width, in water, inside a
building or on a mountain, never in front of a door or in a gatehouse passage, never near a portal. If the
first item of a group breaks a rule, the whole group is dropped, so you never get half a woodpile.&lt;/p&gt;
&lt;p&gt;The dresser has a small vocabulary: dress a building by its role, a street edge, a market lane (two rows of
stalls on an arc round a square), a food court (a noodle stand, tea tables under parasols), a camp (cook
fire, bedrolls, crates, a banner), and the space behind an NPC (a stall behind a merchant, keeping their
talk spot clear). The result is 2,129 placements across the maps, 918 of them in the Vale. They&#x27;re drawn
in batches per model and 48 m chunk with one shared material, so a dressed town costs about as much as an
empty one.&lt;/p&gt;
&lt;h2 id=&quot;animals-and-petting-them&quot;&gt;Animals, and petting them &lt;a class=&quot;anchor&quot; href=&quot;#animals-and-petting-them&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Animals came next&lt;/span&gt;: cats, dogs (huskies in Frostpeak), chickens, goats, oxen, donkeys, a camel in the
desert, frogs and herons by the water, and flocks of birds, gulls, crows, butterflies and fish. The Vale
alone has 115 animal and flock entries. They&#x27;re driven by small schedules with personality in the data: cats sit, groom and nap
in sun spots and on doorsteps; dogs sniff about, lie by doors and trot after a nearby player for a few
steps; chickens peck and scatter when you run through them; birds take off when you get close and land on
roofs and fences.&lt;/p&gt;
&lt;p&gt;Cats and dogs can be &lt;strong&gt;petted&lt;/strong&gt; (press F). Your character kneels (a pet clip was rendered for the sprites),
the animal reacts, a heart pops up, and you get a small buff called &lt;em&gt;Content&lt;/em&gt;: 2% movement speed for five
minutes. Two achievements count it, &amp;quot;Friend of Cats&amp;quot; and &amp;quot;Good Dog!&amp;quot;. Every town has named pets: Mochi,
Lady Plum, Old Bao and Tofu in the Vale, Kuma and Snowball the huskies in Frostpeak, Cinder and Bellows at
the forge camp, Wick, alone in the Shadowlands. It&#x27;s the cheapest feature in the game and possibly the
most liked.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/16-pet.jpg&quot; alt=&quot;Petting a cat in the Sanctuary&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Petting a cat in the Sanctuary&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;townsfolk-with-routines&quot;&gt;Townsfolk with routines &lt;a class=&quot;anchor&quot; href=&quot;#townsfolk-with-routines&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The Vale started&lt;/span&gt; with a few static NPCs and some townsfolk. A towns pass and a later NPC pass brought the
count to 43 NPCs (merchants, the blacksmith, the four grandmasters, quest givers, Gate Keepers) and &lt;strong&gt;66
townsfolk&lt;/strong&gt; across the maps. Townsfolk aren&#x27;t quest machines; they&#x27;re there to make a town feel inhabited.
Each one has a &lt;strong&gt;routine&lt;/strong&gt;, a loop of stops with an activity and a duration: idle, work, sweep, farm, sit,
chat, play, music, look. Old Marta sweeps the square. Two people meet by the well and have a
&lt;strong&gt;conversation&lt;/strong&gt;: lines alternate every 4.5 seconds once both have arrived.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;Did you hear? Old Marta saw a Shadow by the well last night.&amp;quot;
&amp;quot;Marta sees Shadows in her porridge.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;They also have lines when you talk to them and &lt;strong&gt;barks&lt;/strong&gt; when you pass, which can depend on your level,
your school, or how far you are in the main story. Old Marta, before the finale: &amp;quot;Mud. Always mud.&amp;quot; After
it: &amp;quot;The shard sang all night. My knees stopped aching. Don&#x27;t tell anyone.&amp;quot;&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Three maps were deliberately left without a town. The Ember Wastes, the Hellmouth Rift and the
Shadowlands keep only a gate camp with a few people, a fire and a dog or a cat. A lived-in world also
needs places that aren&#x27;t.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/16-crowd.jpg&quot; alt=&quot;The busy-square benchmark: the Vale&amp;#x27;s NPCs and townsfolk plus 60 extra passers-by with seeded looks&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The busy-square benchmark: the Vale&amp;#x27;s NPCs and townsfolk plus 60 extra passers-by with seeded looks&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;from-capsules-to-people&quot;&gt;From capsules to people &lt;a class=&quot;anchor&quot; href=&quot;#from-capsules-to-people&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;For five days&lt;/span&gt; the NPCs were coloured capsules. The human had grown fond of them (&amp;quot;Not sure why I like the
capsules so much. Somehow they grew on me. But I guess we have to replace them right?&amp;quot;) and asked for
&amp;quot;NPCs with different sizes, clothing and colour&amp;quot;. They became sprites, rendered from the same character
sheets as the player with seven NPC outfits and five hats. Named NPCs have fixed looks; townsfolk pick
theirs from a regional palette with a random seeded by their id, so the same person always looks the same
on every machine, and the desert&#x27;s people dress differently from the Marsh&#x27;s. Sizes and builds vary: a
stocky or slender stretch of the card, and heights from 0.86 (an apprentice) to 1.14 (the Iron Fist grandmaster). The HD-2D post has the pipeline; a video memory test keeps a
busy square (the Vale&#x27;s 33 NPCs and townsfolk plus 30 passers-by) at 92.7 MB of sheets, inside a 128 MB
budget.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/16-square-before-after.jpg&quot; alt=&quot;The Vale square with capsule NPCs, and with the same people as sprites&quot; width=&quot;1600&quot; height=&quot;1720&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Vale square with capsule NPCs, and with the same people as sprites&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;none-of-it-goes-over-the-network&quot;&gt;None of it goes over the network &lt;a class=&quot;anchor&quot; href=&quot;#none-of-it-goes-over-the-network&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;All of this&lt;/span&gt; is &lt;strong&gt;presentation&lt;/strong&gt;. Townsfolk, animals and flocks are built on every machine from the map data
and run from the wall clock (the same 36-minute day as the sky) and a seeded hash, so every player sees the
cat on about the same doorstep without any synchronization. There&#x27;s no spawn, no sync and no collision. The
architecture rule from CLAUDE.md is strict about it: &amp;quot;nothing may depend on where they stand.&amp;quot;&lt;/p&gt;
&lt;p&gt;There is one exception, and it&#x27;s small: petting is a real action, so the server checks it, loosely, against
where the animal&#x27;s schedule says it should be (an 18 m &amp;quot;leash&amp;quot;), and relays the reaction to other players.
Reactions that only concern you (a dog following you, chickens scattering) happen on your machine only, and
then the animal walks back to where its schedule says.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Place vignettes, not items.&lt;/strong&gt; Small authored groups placed by rules read as lived in; scattered single
props read as noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make rules that drop whole groups.&lt;/strong&gt; Half a woodpile is worse than none.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detail in vertex colours, one material.&lt;/strong&gt; Character without a draw-call or descriptor cost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Presentation can be deterministic.&lt;/strong&gt; A clock and a seed let every machine agree on where the cat is
without a packet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the cheap features names.&lt;/strong&gt; A named cat you can pet costs almost nothing and makes a town somebody&#x27;s
home.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The last post&lt;/span&gt; is about the weapons at the end of the road: the Fullmoon weapons, the +9 upgrade gamble, and
why their final steps never fail.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/07-performance-on-a-3km-map.html&quot;&gt;The performance post&lt;/a&gt; has the story of why. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/14-lore-and-a-story-that-unfolds-with-your-level.html&quot;&gt;The story post&lt;/a&gt; covers progress-gated dialogue. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Balancing a 40-Hour Grind Before Anyone Plays It</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/15-balancing-a-40-hour-grind.html"/>
<id>https://dekiro.d12r.io/15-balancing-a-40-hour-grind.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>A Monte Carlo model of a character&#x27;s whole career, a headless combat simulation with bots that dodge, and what they found: the game was 15 hours long and nothing in it could hurt you.</summary>
<content type="html">&lt;h2 id=&quot;the-target&quot;&gt;The target &lt;a class=&quot;anchor&quot; href=&quot;#the-target&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;By day six&lt;/span&gt; the game had nine maps, 20 dungeons, 177 monster types and a story from level 1 to 88. It had
never been balanced as a whole. Every system had sensible numbers on its own, picked when it was built.
Before playtesting, the AI asked whether to do a balancing pass first, and the human answered with a
target:&lt;/p&gt;
&lt;blockquote class=&quot;epigraph&quot;&gt;
&lt;p&gt;I would like to have about 40 hours of actual gameplay minimum potential. You don&#x27;t need to test for
that amount of time but estimate if level, gear and so on progression fits that timeline.&lt;/p&gt;
&lt;footer&gt;— the developer&lt;/footer&gt;
&lt;/blockquote&gt;
&lt;p&gt;Forty hours to level 90 and the end of the main story, with level, gear and upgrades moving along the
whole way. The endgame (the Fullmoon weapons&#x27; awakening, a 1000-fragment forge, the weekly Demon Tower)
may run longer.&lt;/p&gt;
&lt;p&gt;Nobody can playtest 40 hours in an afternoon, so two agents worked in parallel on two halves of the
question, and a third did the follow-ups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The progression model&lt;/strong&gt;: how long does a level take, where do the hours go, and does gear keep up?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The combat simulation&lt;/strong&gt;: how long does a fight take, and how much does it hurt?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The two depend on each other (fight times feed the model), which is why the numbers below moved more than
once.&lt;/p&gt;
&lt;h2 id=&quot;half-one-a-career-in-five-minute-steps&quot;&gt;Half one: a career in five-minute steps &lt;a class=&quot;anchor&quot; href=&quot;#half-one-a-career-in-five-minute-steps&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tools/balance/progression.py&lt;/code&gt; is plain Python. It reads the game&#x27;s own data files and the constants of the
GDScript rules (damage formulas, the combo chain, weapon types, skill multipliers and cooldowns, upgrade
scaling, drop tables) and plays a character from level 1 to 90 in five-minute steps, sixty times over
with different random rolls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A hunting loop per zone&lt;/strong&gt;: walk to the next Shadow (spacing from the zone&#x27;s real area and Shadow
count), kill the packs met on the way, break the Shadow while its waves come, move on. Mana limits skill
use; incoming damage turns into potions and, if a fight outlasts what HP and potions hold, deaths.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zone choice&lt;/strong&gt;: the zone with the most XP per hour net of deaths, at most two levels below its
monsters, with a small bonus for the zone the story points you to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dungeons&lt;/strong&gt; once each at their entry level, &lt;strong&gt;quests&lt;/strong&gt; with realistic overhead (&amp;quot;kill quests cost 35% of
their grind time on top, talk quests 4-6 minutes&amp;quot;), daily bounties, world events.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loot and upgrades&lt;/strong&gt;: every drop rolled, gear worn if better, the rest sold; every blacksmith attempt
rolled, failures and downgrades included, while gold and materials last.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The behaviour assumptions sit in one block at the top, each documented: efficiency 78% (time actually
fighting), 4 seconds to engage a pack, 45% of enemy hits landing. They&#x27;re the knobs a playtest should
calibrate, so the model also prints how sensitive the result is to them:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Hours to 30&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;to 60&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;to 90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;as tuned&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;7.4&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;22.0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;39.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;efficiency 65% (more downtime)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;8.0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;24.4&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;44.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;efficiency 90% (a focused grinder)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;7.0&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;20.6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;36.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30% of hits land (better dodging)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;7.4&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;21.8&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;39.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 s per pack to engage&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;7.6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;22.6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;41.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dungeons slower (150 s rooms, boss 3x)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;8.6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;24.8&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;43.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;So &amp;quot;40 hours&amp;quot; really means &amp;quot;about 37 to 45 hours for reasonable play styles&amp;quot;. That&#x27;s the honest version of
the number.&lt;/p&gt;
&lt;h2 id=&quot;half-two-bots-that-fight&quot;&gt;Half two: bots that fight &lt;a class=&quot;anchor&quot; href=&quot;#half-two-bots-that-fight&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tests/sim_combat.gd&lt;/code&gt; runs the real game code headless at about 70 times real time.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; Bot players of a given level and school, wearing the gear a player of that level would have,
fight every hunting zone, every boss and mini-boss in its own dungeon room, and every dungeon&#x27;s trash
rooms: solo per school, with two AI companions, and as a party of four. They step out of every telegraph
after a 0.3 second reaction time, so what still hits them is what a careful player can&#x27;t avoid.&lt;/p&gt;
&lt;p&gt;The first run was sobering. From the report:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nothing could hurt you, and nothing lived.&lt;/strong&gt; Packs died in 0.5-1.5 s (the opening skill burst), normal
monsters in 0.8-2 basic hits, Elites as fast as packs, a pack cost 0-5% HP. Player power grows ~x45 from
level 1 to 90 [...], monster HP only x20, and the level 76-90 monsters were early-game stat lines.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Bosses died in 1 to 15 seconds solo. A party killed &amp;quot;Your Shadow&amp;quot; in 0.4 seconds and the First Shadow,
the final raid boss, in 3.4. Iron Fist was about 1.5 times slower than the other schools. Some ranged
attacks gave half a second of warning, which in a crowd meant about half the dodges failed. And every
Shadow from level 50 on killed a careful solo bot about once, because the only healing potion past level
15 healed a flat 250 HP against health bars of 2,000 to 4,000.&lt;/p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What changed &lt;a class=&quot;anchor&quot; href=&quot;#what-changed&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Monsters&lt;/strong&gt; got their HP and damage fitted per level and band, boss HP sized for a fight of about one
minute in a party and one to two minutes with companions, ranged telegraphs got minimum warning times,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; and
Iron Fist and Sanctum got skill buffs. After:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A pack, from aggro&lt;/td&gt;
&lt;td&gt;0.5-1.5 s&lt;/td&gt;
&lt;td&gt;4-7 s (plus walking and looting: 8-15 s a pack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An Elite&lt;/td&gt;
&lt;td&gt;as fast as a pack&lt;/td&gt;
&lt;td&gt;6-8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Shadow, solo&lt;/td&gt;
&lt;td&gt;9-22 s&lt;/td&gt;
&lt;td&gt;29-45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A boss, solo + 2 companions&lt;/td&gt;
&lt;td&gt;1-23 s&lt;/td&gt;
&lt;td&gt;78-136 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A boss, party of four&lt;/td&gt;
&lt;td&gt;under 1-33 s&lt;/td&gt;
&lt;td&gt;44-61 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The biggest single hit&lt;/td&gt;
&lt;td&gt;up to 82% of max HP (on companions)&lt;/td&gt;
&lt;td&gt;5-15% of max HP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schools, pack kill time&lt;/td&gt;
&lt;td&gt;up to 1.5x apart&lt;/td&gt;
&lt;td&gt;0.9-1.15x of the mean up to level 60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;One target from the brief had to give. &amp;quot;A normal monster dies in 2-4 hits&amp;quot; and &amp;quot;a pack dies in 8-15
seconds&amp;quot; can&#x27;t both hold once skills scale: at level 40 a Blade&#x27;s first second of skills is worth about 14
basic hits on everything in it. The sim kept the pack time and the time to kill a lone monster, so basic
hits per monster now grow with level (4, then 7, 9, 11).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;XP&lt;/strong&gt; was the other half. With tougher monsters the old curve gave 25 hours to 90, not 40. The model&#x27;s
&lt;code&gt;--fit&lt;/code&gt; mode searches for the data that hits a target curve, and the result was simpler than expected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The XP curve became one power law, &lt;code&gt;80 x level^2.58&lt;/code&gt; (from &lt;code&gt;80 x level^1.6 x (1 + 0.05 (level - 10))&lt;/code&gt;).
The first level still costs 80, so the opening stays quick.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monster XP follows monster HP&lt;/strong&gt;: &lt;code&gt;0.1 x base HP&lt;/code&gt; for pack monsters, &lt;code&gt;0.11 x&lt;/code&gt; for Shadows. The combat
pass had raised HP between 2x (early beasts) and 15x (the last map&#x27;s shades) while XP stayed, so a Shade
paid 12 XP for 831 HP. Tying XP to HP means XP per hour follows your damage everywhere, and no zone is
a trap or a jackpot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quest XP is a share of the level bar&lt;/strong&gt; at the quest&#x27;s level: task quests 70%, travel and talk quests
25%, the final boss 150%.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/15-hours-per-level.png&quot; alt=&quot;Hours to reach each level: before (dashed), after the combat retune only (dotted), and after (solid, with the spread of 60 runs), against the target points&quot; width=&quot;1170&quot; height=&quot;676&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Hours to reach each level: before (dashed), after the combat retune only (dotted), and after (solid, with the spread of 60 runs), against the target points&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-economy-which-the-model-caught&quot;&gt;The economy, which the model caught &lt;a class=&quot;anchor&quot; href=&quot;#the-economy-which-the-model-caught&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Once the hours&lt;/span&gt; were right, the model&#x27;s gold and gear charts showed problems no single fight would.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gold piled up.&lt;/strong&gt; Gold was &amp;quot;the monster&#x27;s XP reward times a table factor&amp;quot;, and XP now grew much faster with
level than prices did. The median character ended the run with about 16 million unspent gold out of 18.5
million earned. The fix moved gold onto the monster&#x27;s &lt;strong&gt;level&lt;/strong&gt; (&lt;code&gt;1.2 x (level + 2)&lt;/code&gt; times the table&#x27;s
factor), the same shape as item prices. Now the median character earns about 4.9 million, spends about 3.5
million (potions 2.25M, upgrades 1.1M), and reaches 90 with about 1.3 million: roughly the price of the
first two Fullmoon awakenings, which became the late game&#x27;s gold sinks. Income stays ahead of spending in
every band; the tightest is the desert at 40-50, where Sunstone upgrades and stronger potions cost the most.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/15-gold-over-time.png&quot; alt=&quot;Gold over time for the median character: earned, spent and held&quot; width=&quot;1170&quot; height=&quot;624&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Gold over time for the median character: earned, spent and held&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;A 56-level armor gap.&lt;/strong&gt; Body armor stopped at level 30, helmets at 26, shields at 20, until the level 86
raid set. The model&#x27;s deaths clustered in the 66-85 zones, where under-armoured characters met the new
monster damage. 41 new pieces in six sets (with models and icons from the Blender pipeline) now give every
slot a tier at least every ten levels, and a test checks that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Too many potions.&lt;/strong&gt; With flat potions, the model drank 1,000 to 1,300 an hour from level 15 on, one every
three seconds. Affordable, but &amp;quot;a lot of clicking&amp;quot;. Red potions now come in four tiers that track max HP
(80, 250, 700, 1,300 HP), monsters drop the tier of your level, and the model drinks 175 to 430 an hour:
about one per pack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sunstone dried up.&lt;/strong&gt; The top three upgrade steps need Sunstone from the desert, and the level-gap rule
stops drops from monsters seven levels below you, so from level 57 the caravan was the only source and
weapons stalled at +6/+7. The human&#x27;s call: Sunstone &amp;quot;should also drop from Shadows after 55+&amp;quot;. At 18% per
Shadow, characters who stay on a normal weapon reach +9 at a median of level 69.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/15-gear-over-time.png&quot; alt=&quot;Gear over time for one median character: the weapon jumps to the Fullmoon weapon (a level 30 item that outclasses later drops) and back to new tiers, armor follows the level&quot; width=&quot;1170&quot; height=&quot;832&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Gear over time for one median character: the weapon jumps to the Fullmoon weapon (a level 30 item that outclasses later drops) and back to new tiers, armor follows the level&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-result&quot;&gt;The result &lt;a class=&quot;anchor&quot; href=&quot;#the-result&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Sixty simulated Blade&lt;/span&gt; characters reach level 90 in a median of &lt;strong&gt;39.5 hours&lt;/strong&gt; (the middle 80% between
37.9 and 40.9). The other schools land at 38.7 (Iron Fist), 40.6 (Shadowstep) and 39.0 (Sanctum). The
shape is the one the human wanted from a popular Korean MMO: the first ten levels in about an hour and a half (quests carry
the first five), then 15 to 30 minutes a level through the 20s, 25 to 35 minutes through the middle, and
35 to 55 minutes from 60 to 90. Every level from 1 to 90 has a hunting zone in its band, and each map is
used for the levels it was built for.&lt;/p&gt;
&lt;p&gt;Where the hours go, for the median character: about 26 hours of grinding, about 6 hours in dungeons (one clear each), about 5.5 hours of quests and 1.5 hours of daily
bounties.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/15-zone-per-level.png&quot; alt=&quot;Where each level is spent: map by map, with dungeon runs in orange&quot; width=&quot;1170&quot; height=&quot;728&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Where each level is spent: map by map, with dungeon runs in orange&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Some numbers from the rare-item table, because a grind game is also a waiting game:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Time to get (median)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your own school&#x27;s Fullmoon weapon (0.3% per Shadow, 2% per boss)&lt;/td&gt;
&lt;td&gt;about 13 hours of play; 98% of characters by 90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 Moonsteel Fragments for the forge&lt;/td&gt;
&lt;td&gt;about 57 hours of Shadow hunting: the slow, certain road&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A +9 Fullmoon weapon on its own track&lt;/td&gt;
&lt;td&gt;100+ hours: endgame&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;what-a-model-cant-tell-you&quot;&gt;What a model can&#x27;t tell you &lt;a class=&quot;anchor&quot; href=&quot;#what-a-model-cant-tell-you&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The report ends&lt;/span&gt; with its own list of risks, and the last one is the most important: &amp;quot;The model is a
model.&amp;quot; Efficiency, engage time, hit rate and potion use are guesses until a human plays. Party play,
trading and other schools&#x27; Fullmoon weapons aren&#x27;t modelled at all. Dungeon times are fighting time only,
because the bots can&#x27;t solve puzzles or walk mazes. And no simulation can say whether a 45-second Shadow
&lt;em&gt;feels&lt;/em&gt; like 45 seconds well spent.&lt;/p&gt;
&lt;p&gt;What the model and the sim do give is a calibrated starting point. After the human&#x27;s first playtest, the
PLAY block gets their real numbers (how much of their time they actually fight, how many hits they take),
the model reruns in a minute, and the data moves with it.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Simulate the whole career, not a fight.&lt;/strong&gt; The worst problems (16 million spare gold, a 56-level armor
gap, Sunstone running out) only show up over 90 levels.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tie rewards to the thing that costs time.&lt;/strong&gt; XP from monster HP and gold from monster level keep zones
honest when the combat numbers change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use the real code for combat.&lt;/strong&gt; Bots that drive the same commands as players find the one-shots and the
invincible phases that a spreadsheet can&#x27;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish your sensitivity, not just your number.&lt;/strong&gt; &amp;quot;40 hours&amp;quot; is really &amp;quot;37 to 45, depending on how you
play&amp;quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The model is a model.&lt;/strong&gt; Plan the playtest that calibrates it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Forty hours is&lt;/span&gt; a long time to spend in a world, so it should feel alive. Next: clutter, buildings,
animals you can pet, and townsfolk who keep their own schedules.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/04-testing-a-game-with-13800-checks.html&quot;&gt;The testing post&lt;/a&gt; has the setup. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;A shot&#x27;s line at least 0.65 s, a burst&#x27;s circle at least 1.1 s; a test now checks every ranged monster. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>A Story That Unfolds with Your Level: Murim Lore for a Grind Game</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/14-lore-and-a-story-that-unfolds-with-your-level.html"/>
<id>https://dekiro.d12r.io/14-lore-and-a-story-that-unfolds-with-your-level.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>How a grinder in the style of a popular Korean MMO got a story without cutscenes: a lore that explains the game&#x27;s own systems, a questline across nine maps, NPCs who change their minds as you progress, and stone tablets that tell the parts nobody in town will.</summary>
<content type="html">&lt;h2 id=&quot;the-brief&quot;&gt;The brief &lt;a class=&quot;anchor&quot; href=&quot;#the-brief&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Grind games are&lt;/span&gt; not known for their stories, and players of the old MMO skip quest text faster than anyone. On
day five the human asked for a lore anyway, with two conditions: &amp;quot;Come up with a lore that works with
Chinese martial arts murim style and shadows. Make room for potentially adding some form of &#x27;solo
leveling&#x27; type of portals/rifts that we might want to build in the future.&amp;quot; Five hours later came the
question that turned the lore into a design: &amp;quot;Is the lore already built into quests? So that as we get
closer to the level cap we discover more and more?&amp;quot;&lt;/p&gt;
&lt;p&gt;That second question is the whole idea of this post. In a game where the main activity is killing the
same kind of monster for 40 hours, the story can&#x27;t compete with the grind. It can &lt;em&gt;ride&lt;/em&gt; it: every
ten levels you reach a new map, and every new map should know a little more than the last.&lt;/p&gt;
&lt;h2 id=&quot;a-lore-that-explains-the-game&quot;&gt;A lore that explains the game &lt;a class=&quot;anchor&quot; href=&quot;#a-lore-that-explains-the-game&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first rule&lt;/span&gt; was that the lore must explain what the game already does, rather than add things the
game would then need. The result, in short:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Three hundred years ago the greatest martial artist who ever lived, &lt;strong&gt;Yan Wuying&lt;/strong&gt;, called the
Shadowless, tried to become immortal by eating his own shadow. It worked, almost. He became the &lt;strong&gt;First
Shadow&lt;/strong&gt;. Four of his own disciples turned on him; they couldn&#x27;t kill him, so they sealed him away behind
a great jade stone that sang with their four arts. The seal is breaking.&lt;/p&gt;
&lt;p&gt;Everything the player does maps onto that:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the game&lt;/th&gt;
&lt;th&gt;In the lore&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shadows&lt;/strong&gt;, the stones you break&lt;/td&gt;
&lt;td&gt;splinters of the First Shadow that fall through the cracks of the seal and corrupt what&#x27;s around them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Four schools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the four disciples&#x27; arts: Blade, Iron Fist, Shadowstep, Sanctum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safe zones&lt;/strong&gt;, one town per map&lt;/td&gt;
&lt;td&gt;every town is built around a shard of the broken Sanctuary Stone; shade can&#x27;t cross its song&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portal shrines and waystones&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gates and steles cut from the Stone, holding a sliver of shard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Levelling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;breaking a Shadow releases shade; a disciple marked by the Stone refines it into strength&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Skillbooks dropping from Shadows&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;techniques of masters who fell in the war, swallowed with their shadows, released when a splinter breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+7 to +9 sparks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;moonsteel waking up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The player&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a Wanderer who woke beside a broken Shadow with a silver crescent on their palm: a &lt;strong&gt;Moon Mark&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The &amp;quot;one town per map&amp;quot; rule from the map rework suddenly had a reason, and so did the maps without a town:
in the Ember Wastes, the Hellmouth Rift and the Shadowlands no shard was big enough, and the last people
there camp inside a portal shrine. The Fullmoon weapons, the game&#x27;s sacred late-game weapons, became the
heirs of the four weapons the disciples forged under a full moon. The world is the Jianghu, the murim&#x27;s
&amp;quot;rivers and lakes&amp;quot;, or rather one corner of it at the edge of the western kingdoms. That edge is also why
the names are a mix: about half the people carry European names (Mira, Varek, Haro, Maud, Nyra) and half
wuxia-style ones (Fang, Shen, Bai Yun, Ming), which the human asked for: &amp;quot;A fair mix of European names and
maybe titles in Wuxia style&amp;quot;. People of note carry an epithet under their name: Varek the Unbroken Edge,
Haro the Hundred-Fold Hammer, Kael, Tender of the Undying Fire. A test checks that every name of every
NPC, townsperson and companion is used only once.&lt;/p&gt;
&lt;h2 id=&quot;the-cracked-seal-one-questline-nine-provinces&quot;&gt;The Cracked Seal: one questline, nine provinces &lt;a class=&quot;anchor&quot; href=&quot;#the-cracked-seal-one-questline-nine-provinces&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The main questline&lt;/span&gt;, &lt;strong&gt;The Cracked Seal&lt;/strong&gt;, has 25 quests from level 1 to 88, out of 87 quests in the game.
It starts the moment you load in:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You woke in the Whispering Fields beside a broken black stone, with no memory of the night. A silver
crescent burns on your palm and will not wash off. Show it to Elder Mira in the Sanctuary.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Each province adds one piece. The Marsh is a battlefield of the old war, drowned by a broken dam. The orc
clans of Orc Valley were mountain tribes until a splinter in their sacred totem made them warlike, and
their warlord thinks the shade is a god&#x27;s gift. Duskmoor holds what&#x27;s left of the cult that served the
First Shadow. The Sunscar Desert was green until a king tried to burn every shadow away with a temple of
mirrors and burned the land instead. Frostpeak&#x27;s monks froze their own masters to keep a Shadow from
waking in them. The quest text stays short and in-world, and it moves you on:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The Sun King failed because one light alone can&#x27;t hold the dark; the seal was made from four arts. Iron
Abbot Bao&#x27;s order kept its last monastery on Frostpeak, and it stopped answering a year ago. Brother
Ming waits at the Pass Hut.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Dungeons carry the local story. The Bog Witch&#x27;s Hollow isn&#x27;t &amp;quot;the swamp dungeon&amp;quot;; it&#x27;s where the fen&#x27;s
old hedge-witch found a splinter first and put it in her pot, and has been brewing herself younger on its
shade ever since. That&#x27;s why the dungeon&#x27;s puzzle is brewing her potion.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/14-tablet.jpg&quot; alt=&quot;A lore tablet in the Shadow Woods: one chapter of the Vale&amp;#x27;s set, with its permanent bonus&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A lore tablet in the Shadow Woods: one chapter of the Vale&amp;#x27;s set, with its permanent bonus&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;people-who-remember-what-you-did&quot;&gt;People who remember what you did &lt;a class=&quot;anchor&quot; href=&quot;#people-who-remember-what-you-did&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The cheapest way&lt;/span&gt; to make a world feel like it notices you is to let people change what they say. NPC data
has a &lt;code&gt;lines_after&lt;/code&gt; block: lines keyed by a story quest, used once you&#x27;ve completed it, the last one
winning. Elder Mira has eleven of them. Right after you show her your palm:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A Moon Mark. I&#x27;ve only ever read about them. The old books get quiet about what happens to the Marked.
I never noticed that before.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After you&#x27;ve learned what the Lightless Sect prays for:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The Sect prays for his forgiveness? I suppose everyone wants the same thing in the end. Someone to say
it wasn&#x27;t their fault.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The four grandmasters, the outpost leaders and others have their own. Townsfolk work the same way, one
level down. They have &lt;strong&gt;barks&lt;/strong&gt;, short lines they say as you pass, and a bark can need a quest done (or
not done), a level range, or a school. After the finale, a town guard asks: &amp;quot;They say you&#x27;ve been to the
Citadel, {name}. Is it true he had four faces?&amp;quot; A bard in the square sings
&amp;quot;...and the one with the Mark went in where the four could not...&amp;quot;&lt;/p&gt;
&lt;p&gt;The writing rule for all of it, from the NPC data&#x27;s own documentation: people &amp;quot;only know what they&#x27;d
know: hints and rumours, not lectures&amp;quot;. Townsfolk &amp;quot;gossip and half-know things, they don&#x27;t explain.&amp;quot; Two
of them gossip by the well in the Vale:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;She says it couldn&#x27;t cross the square. Bounced off, like a moth on a lantern.&amp;quot;
&amp;quot;That&#x27;s the shard. My gran said it sings the dark away.&amp;quot;
&amp;quot;And the new ones? With the moon on their hands?&amp;quot;
&amp;quot;Heroes. They track mud everywhere.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/14-grandmaster.jpg&quot; alt=&quot;A grandmaster&amp;#x27;s school window: the founder&amp;#x27;s story in two sentences, then the skills&quot; width=&quot;1600&quot; height=&quot;770&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A grandmaster&amp;#x27;s school window: the founder&amp;#x27;s story in two sentences, then the skills&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;tablets-the-parts-nobody-tells-you&quot;&gt;Tablets: the parts nobody tells you &lt;a class=&quot;anchor&quot; href=&quot;#tablets-the-parts-nobody-tells-you&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every world map&lt;/span&gt; has six to eight &lt;strong&gt;lore tablets&lt;/strong&gt;, 58 in all, standing at landmarks and in the wilds.
Each map&#x27;s set tells one chapter of the old story, and the further from the start the map is, the deeper
the secret: the Vale&#x27;s set is &amp;quot;The Sealing&amp;quot;, the Marsh&#x27;s &amp;quot;The Eclipse War&amp;quot;, the Shadowlands&#x27; &amp;quot;The Prison
of the First Shadow&amp;quot;. The Vale&#x27;s first tablet:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I. Four Who Stood.&lt;/strong&gt; Four disciples stood against the master who taught them: Ling Feng the Blade, Bao
the Iron Fist, the Night Heron, Lady Yue of the Ninth Moon. They could not kill him. So they shut the
door behind him.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Reading a whole set gives a small permanent bonus,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; the way the classic&#x27;s biologist quests reward collecting. It&#x27;s modest on
purpose: enough to make finding them worth a detour, not enough to make it mandatory. A tablet can also be
a quest objective, or start a &lt;strong&gt;hidden chain&lt;/strong&gt;. One of them does. It casts no shadow, and it&#x27;s signed with
a bird&#x27;s track. What it starts is for players to find.&lt;/p&gt;
&lt;h2 id=&quot;the-finale-without-spoilers&quot;&gt;The finale, without spoilers &lt;a class=&quot;anchor&quot; href=&quot;#the-finale-without-spoilers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;At level 86&lt;/span&gt; the questline reaches the &lt;strong&gt;Shadow Citadel&lt;/strong&gt;, the First Shadow&#x27;s prison and now his palace. It&#x27;s
the game&#x27;s only raid: you need a Fullmoon weapon to enter, the fight splits the boss in a way that makes
each school matter, and a &amp;quot;shade&amp;quot; meter climbs the longer you stand near him unless you break the splinters
that feed it. The combat simulation says a party of four kills him in about four and a half minutes, and a
solo player with two companions in about five and a half. Nobody has done it with real hands yet.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/14-citadel-approach.jpg&quot; alt=&quot;The avenue to the Shadow Citadel in the Shadowlands&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The avenue to the Shadow Citadel in the Shadowlands&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/14-throne.jpg&quot; alt=&quot;The Throne of the First Shadow, before the fight&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Throne of the First Shadow, before the fight&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Killing him is not the end of the story. The last three quests are conversations, not fights, and they
answer the question Mira asked at level 1: why the old books get quiet about what happens to the Marked.
The answer reframes the whole game, from the first Shadow you broke to the reason you level so fast. We&#x27;ll
leave it there.&lt;/p&gt;
&lt;h2 id=&quot;the-door-left-open&quot;&gt;The door left open &lt;a class=&quot;anchor&quot; href=&quot;#the-door-left-open&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The human&#x27;s second&lt;/span&gt; condition was room for &amp;quot;Solo Leveling&amp;quot;-style rifts later. The lore has that door built
in: as the seal weakens, the Veil tears in places where no Shadow fell, opening pockets shaped by what
they swallowed. Rift grades read from the colour of the light at their edge, rifts that aren&#x27;t closed in
time spill their monsters into the hunting grounds, and one school, it turns out, learned more of the
First Shadow&#x27;s art than it admitted. None of it is built. The last quest of The Cracked Seal ends by
pointing at it, and the lore already plans every rift as a new seed of the dungeon generator.&lt;/p&gt;
&lt;h2 id=&quot;how-it-was-written&quot;&gt;How it was written &lt;a class=&quot;anchor&quot; href=&quot;#how-it-was-written&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The lore was&lt;/span&gt; drafted first as a document (&amp;quot;for the user&#x27;s review&amp;quot;, says its header) and then woven
into the game by an agent: NPC lines, townsfolk barks and conversations, quest texts, item and skillbook
flavour lines, achievement names, world event banners. Later agents extended it as they built: every
dungeon batch added its paragraphs to LORE.md and its boss&#x27;s backstory to its spec. The music fits the
same arc: the Shadowlands and Citadel tracks quote the Vale&#x27;s opening motif, slowed down and bent out of
key.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The biggest risk with an AI writing lore at speed is contradiction, so the rules are mechanical where they
can be: one LORE.md that everything cites, names unique by test, and NPCs who are only allowed to know
what they&#x27;d know.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Make the lore explain your systems.&lt;/strong&gt; If your monsters spawn from stones and your towns are safe, say
why. Players will believe a world whose rules have reasons.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gate dialogue on progress, not just quests.&lt;/strong&gt; A few keyed lines per NPC make the world notice you, for
very little text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let ordinary people half-know things.&lt;/strong&gt; Gossip carries more lore than lectures and is more fun to read.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put the deep story where the curious look.&lt;/strong&gt; Tablets, a hidden chain and a twist at the end reward the
players who read, and cost nothing to the ones who don&#x27;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leave a door open, in the lore first.&lt;/strong&gt; The rifts aren&#x27;t built, but the world already knows they&#x27;re
coming.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A story that&lt;/span&gt; unfolds with your level only works if levelling takes the right amount of time. Next: how a
progression model and a combat simulation aimed the game at 40 hours.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;&lt;code&gt;docs/LORE.md&lt;/code&gt; has the long version. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;40 max HP for the Vale, up to 250 HP and 4% damage against Shadows for the Shadowlands. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/09-synthesized-music-in-python.html&quot;&gt;The music post&lt;/a&gt; has that story. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Twenty Dungeons from One Generator: Sequence Graphs, ASCII Rooms and Solvers That Say No</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/13-a-dungeon-generator-with-solvers.html"/>
<id>https://dekiro.d12r.io/13-a-dungeon-generator-with-solvers.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>A dungeon is written as a graph of rooms and locks, laid out on a grid, stitched from ASCII templates, and checked by a solver before it&#x27;s allowed to exist. The mechanics are data blocks the game runs.</summary>
<content type="html">&lt;h2 id=&quot;the-question&quot;&gt;The question &lt;a class=&quot;anchor&quot; href=&quot;#the-question&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On day five&lt;/span&gt;, with one hand-built dungeon (the Crypt) and a second one in progress (the Sun Tomb), the
human asked: &amp;quot;Should we build a procedural dungeon generator? [...] I guess it&#x27;s ok to build such a tool
once and not at runtime.&amp;quot; They linked two open-source generators as inspiration and then, a few minutes
later: &amp;quot;I would not use the tools I linked, we should build our own. Come up with 20 dungeons [...] Happy
to have a [popular Korean MMO] inspiration there including some puzzles. I would also like to have some form of
labyrinth as a puzzle for some dungeons.&amp;quot;&lt;/p&gt;
&lt;p&gt;Two decisions in that request shaped everything. &lt;strong&gt;Offline&lt;/strong&gt;, not at runtime: the generator produces a
map file that a human reviews and the game loads like any other map, so a bad seed never reaches a
player. And &lt;strong&gt;puzzles&lt;/strong&gt;: a generator that only places rooms and corridors would be easy; one that places a
lever behind a door that the lever opens would be a bug factory. So the generator had to &lt;em&gt;prove&lt;/em&gt; every
dungeon could be finished.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The pipeline &lt;a class=&quot;anchor&quot; href=&quot;#the-pipeline&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tools/dungeongen/&lt;/code&gt; is plain Python (3.13 and Pillow, about 3,600 lines). A &lt;strong&gt;spec&lt;/strong&gt; per dungeon becomes
the map JSON the game reads, plus a top-down overview PNG to review:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;python tools/dungeongen/dungeongen.py crypt_of_whispers             # spec seed -&amp;gt; map + overview
python tools/dungeongen/dungeongen.py crypt_of_whispers --search 12 # overviews of 12 seeds, writes no map
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A seed &lt;em&gt;is&lt;/em&gt; a layout: the same spec and seed always give the same dungeon. The workflow is to generate a
sheet of seeds, pick one by eye, and write it into the spec.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-seeds.jpg&quot; alt=&quot;Four seeds of the same spec (the Red Hand Mines): same rooms, locks and maze rules, different layouts&quot; width=&quot;1600&quot; height=&quot;1064&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Four seeds of the same spec (the Red Hand Mines): same rooms, locks and maze rules, different layouts&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;1-the-spec-is-a-sequence-graph&quot;&gt;1. The spec is a sequence graph &lt;a class=&quot;anchor&quot; href=&quot;#1-the-spec-is-a-sequence-graph&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;A dungeon starts as rooms with &lt;strong&gt;roles&lt;/strong&gt; (entrance, fight, key, puzzle, miniboss, treasure, maze, boss)
and edges between them, optionally with a &lt;strong&gt;lock&lt;/strong&gt;. Here are the Crypt of Whispers&#x27; edges:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;entrance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ossuary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;flow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;E&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ossuary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;charnel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gap&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ossuary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whispers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;flow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;E&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;lock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;lever&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;winch&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gate_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;portcullis&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;label&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Chained Portcullis&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;hint&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Chained shut. A winch somewhere must hold the chain.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whispers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;warden&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;lock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;puzzle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whisper&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gate_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;crypt_door&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;look&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;door&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;label&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Sealed Crypt Door&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;hint&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;The door will not move while their shadows are bound. Listen to the whispers.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whispers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;reliquary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;lock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;any&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;flag&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whisper.flawless&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;boss&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}]},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;warden&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;lords&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;lock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;cleared&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;warden&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gate_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;bone_gate&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;label&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bone Gate&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Locks are a small condition language: a lever, a solved puzzle, a cleared room, a key, a flag, the boss
dead, and &lt;code&gt;any&lt;/code&gt; / &lt;code&gt;all&lt;/code&gt; / &lt;code&gt;not&lt;/code&gt; to combine them. The reliquary above opens if you lit the candles without
a mistake &lt;em&gt;or&lt;/em&gt; if you killed the boss: a reward for doing the puzzle well, without locking out a party
that didn&#x27;t. The game evaluates the &lt;strong&gt;same language&lt;/strong&gt; at runtime, so what the generator proved is what the
game checks.&lt;/p&gt;
&lt;h3 id=&quot;2-rooms-templates-or-shapes&quot;&gt;2. Rooms: templates or shapes &lt;a class=&quot;anchor&quot; href=&quot;#2-rooms-templates-or-shapes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Set-piece rooms are drawn as &lt;strong&gt;ASCII templates&lt;/strong&gt;, one character per 2 m cell, so a room can be sketched in
a text editor. This is the Hall of Whispers, the Crypt&#x27;s puzzle room:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;.......DD.......
.O............O.
................
....C......C....
................
................
D......W.......D
D..............D
................
....C......C....
................
.O..b......b..O.
................
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;D&lt;/code&gt; marks door sockets, &lt;code&gt;C&lt;/code&gt; the candles of the sequence puzzle (numbered in reading order), &lt;code&gt;W&lt;/code&gt; the
whispering ghost who gives the hint, &lt;code&gt;O&lt;/code&gt; pillars, &lt;code&gt;b&lt;/code&gt; bone piles. The legend has 30-odd marks by now:
levers, chests, pressure plates, light-beam emitters, fox statues, valves, spike traps, dart slits, war
gongs, a cauldron and its ingredient rack, watcher monks, anvils, sluices. Ordinary rooms are generated
from a shape instead (rectangle, L, round, octagon, pillared hall, maze) with decor, monsters and devices
placed by rules: &amp;quot;a lever, far from the doors&amp;quot;.&lt;/p&gt;
&lt;h3 id=&quot;3-layout-and-corridors&quot;&gt;3. Layout and corridors &lt;a class=&quot;anchor&quot; href=&quot;#3-layout-and-corridors&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Rooms go on a grid of 2 m cells, grown from the entrance in tree order: each child on one side of its
parent with a corridor gap and a sideways jog. If a child fits nowhere, the search backs up and moves
earlier rooms. Corridors are routed with A* over 2x2-cell windows, so they&#x27;re exactly 4 m wide, and turns
cost extra. One rule matters more than it looks: &lt;strong&gt;a corridor never touches another room or corridor
except at its two doorways&lt;/strong&gt;, so a locked door can&#x27;t be walked around. Extra &lt;code&gt;loop&lt;/code&gt; edges are routed
afterwards for shortcuts.&lt;/p&gt;
&lt;h3 id=&quot;4-validation-or-the-solver-says-no&quot;&gt;4. Validation, or: the solver says no &lt;a class=&quot;anchor&quot; href=&quot;#4-validation-or-the-solver-says-no&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;This is the step that makes puzzles safe. With every gate shut, the floor must split into exactly the
groups of rooms that the open edges join (no leaks through a wall). Every room is one piece, every spawn
stands on floor, there&#x27;s a player spawn and a boss. Then the &lt;strong&gt;lock solver&lt;/strong&gt; runs: a breadth-first search
over states of (room, flags). Events inside rooms set flags: clearing a room&#x27;s monsters, pulling a lever,
solving a puzzle, standing on a pressure plate (which come back up, so those flags clear again), killing
the boss. Gates latch open once their condition holds.&lt;/p&gt;
&lt;p&gt;The solver must reach the boss, visit every room, and find &lt;strong&gt;no soft-lock state&lt;/strong&gt;: no reachable state from
which the boss can no longer be reached. With plates, valves and toggles that&#x27;s a real risk, not a
formality. If any check fails, the generator prints the problems, draws them on the overview, and
&lt;strong&gt;refuses to write the map&lt;/strong&gt;. The biggest search so far is the Hall of Mirrors: about 47,000 states, no
soft lock.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-ossuary-overview.png&quot; alt=&quot;The Ossuary of Bells as the generator draws it: rooms by role, locks in red, the shifting maze in the middle&quot; width=&quot;1205&quot; height=&quot;420&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Ossuary of Bells as the generator draws it: rooms by role, locks in red, the shifting maze in the middle&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;six-kinds-of-labyrinth&quot;&gt;Six kinds of labyrinth &lt;a class=&quot;anchor&quot; href=&quot;#six-kinds-of-labyrinth&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The human asked&lt;/span&gt; for labyrinths. A plain maze is a chore, so each labyrinth dungeon got a rule that changes
how you read it, and each rule got its own solver:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dungeon&lt;/th&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;What the generator proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foxfire Maze&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;guide&lt;/strong&gt;: a white fox runs the true way; false foxes lead into dead ends&lt;/td&gt;
&lt;td&gt;the fox&#x27;s path is a real walk through the braided maze, and every false fox starts on a fork of the true path and ends in a dead end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Red Hand Mines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;dark&lt;/strong&gt;: you see a few metres; kindled totems clear the dark; minecart shortcuts&lt;/td&gt;
&lt;td&gt;the solver walks rail lines both ways while their condition holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ossuary of Bells&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;shifting&lt;/strong&gt;: a bell tolls 1 to 3 times, and every bone wall marked with that many skulls turns&lt;/td&gt;
&lt;td&gt;from every reachable (cell, wall state), the exit can still be reached whatever the bell tolls; every passage opens at some point; the way through needs 2 to 5 knells&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mirage Palace&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;illusion&lt;/strong&gt;: fake walls and fake openings; a carried Sunstone lantern shows the truth&lt;/td&gt;
&lt;td&gt;trusting your eyes alone never gets you through, and the false maze shows a way that isn&#x27;t there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glacier Halls&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ice slide&lt;/strong&gt;: on Mirror Ice you slide until something stops you; boulders can be pushed&lt;/td&gt;
&lt;td&gt;every door reaches every other, the main crossing takes a set range of slides, and no state is stuck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hall of Mirrors&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;split&lt;/strong&gt;: twin mazes; a lever in one opens bars in the other; mirrors between them&lt;/td&gt;
&lt;td&gt;the 47,000-state search above, with a party or solo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The shifting maze is a good example of how much the solver carries. Walls sit on pivots; each blocks one
of two passages next to a post. A knell of &lt;em&gt;k&lt;/em&gt; tolls turns every wall of section &lt;em&gt;k&lt;/em&gt;. The planner places
the walls on a stamped maze where both passages are floor, then a 0-1 breadth-first search over cell x
section bits checks the whole space. The game then plays the same rules the solver checked; for the ice,
the commit message calls them &amp;quot;shared rules with the generator&#x27;s solver&amp;quot;.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-knell.jpg&quot; alt=&quot;The Ossuary of Bells in play: the knell has tolled, and the walls marked with that many skulls are turning&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Ossuary of Bells in play: the knell has tolled, and the walls marked with that many skulls are turning&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-mirage-unlit-lit.jpg&quot; alt=&quot;The Mirage Palace without the lantern, and in its light: false walls turn into lines of light, hidden ones show as gold glass&quot; width=&quot;1600&quot; height=&quot;1720&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Mirage Palace without the lantern, and in its light: false walls turn into lines of light, hidden ones show as gold glass&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-ice-slide.jpg&quot; alt=&quot;Glacier Halls: on Mirror Ice you slide on the grid until a wall, a rock or a boulder stops you&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Glacier Halls: on Mirror Ice you slide on the grid until a wall, a rock or a boulder stops you&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-foxfire.jpg&quot; alt=&quot;The Foxfire Maze: the white fox casts a shadow; the false ones don&amp;#x27;t&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Foxfire Maze: the white fox casts a shadow; the false ones don&amp;#x27;t&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;mechanics-as-data-blocks&quot;&gt;Mechanics as data blocks &lt;a class=&quot;anchor&quot; href=&quot;#mechanics-as-data-blocks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The game side&lt;/span&gt; follows the same rule as skills: &lt;strong&gt;a new mechanic is a new building block plus a test,
never a script for one dungeon.&lt;/strong&gt; The generator writes a &lt;code&gt;mechanics&lt;/code&gt; block into the map (devices,
puzzles, rules, objectives), and a server-side &lt;code&gt;DungeonScript&lt;/code&gt; runs it. By the 20th dungeon there were 31
device kinds: gates, levers, plates, light beams, valves and water, spike traps, gongs, totems, mine carts,
a cauldron and ingredients, shifting walls on a bell, illusion walls and a lantern, ice and boulders,
watcher monks with sight cones, sluices and anvils for lava, rising lava, warps between floors, key
carriers, seals, waves to survive, chests. Rules are &amp;quot;when this condition holds, do these actions&amp;quot;, and
actions are things like spawn, say, open, set a flag, toll the bell or curse everyone nearby.&lt;/p&gt;
&lt;p&gt;The blocks compose. The Bog Witch&#x27;s Hollow asks you to brew her potion: three mini-bosses each drop an
ingredient, her recipe book reads out an order rolled per instance, and a wrong ingredient boils over
into a curse and three hexed toads. That&#x27;s an &lt;code&gt;ingredient&lt;/code&gt; device, a &lt;code&gt;cauldron&lt;/code&gt; device, a &lt;code&gt;brew&lt;/code&gt; puzzle and
a &lt;code&gt;curse&lt;/code&gt; action, none of which knows about witches.&lt;/p&gt;
&lt;p&gt;A few blocks that came out of specific requests:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Walls 6 m high.&lt;/strong&gt; The first remake had low walls, and the human pointed out the obvious: &amp;quot;The walls of
the dungeons need to be higher. Otherwise people will just look over.&amp;quot; With tall walls, the camera side
is cut away near your character so you can see in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weekly chests.&lt;/strong&gt; Each Demon Tower floor has a coffer that opens once per character per week.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; An echo of that MMO.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A boss copied from you.&lt;/strong&gt; At the end of the Hall of Mirrors, &amp;quot;Your Shadow&amp;quot; steps out of the great
mirror with the look, school, skill ranks and stats of the strongest player in the room, darkened.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-cutaway.jpg&quot; alt=&quot;Wall cutaway on the camera side: before and after the cleanup&quot; width=&quot;1600&quot; height=&quot;428&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Wall cutaway on the camera side: before and after the cleanup&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-your-shadow.jpg&quot; alt=&quot;Your Shadow: a boss built at runtime from the strongest player present&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Your Shadow: a boss built at runtime from the strongest player present&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-twenty&quot;&gt;The twenty &lt;a class=&quot;anchor&quot; href=&quot;#the-twenty&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th class=&quot;numeric&quot;&gt;#&lt;/th&gt;
&lt;th&gt;Dungeon&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Levels&lt;/th&gt;
&lt;th&gt;Map&lt;/th&gt;
&lt;th&gt;Idea&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;1&lt;/td&gt;
&lt;td&gt;Crypt of Whispers&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;8-12&lt;/td&gt;
&lt;td&gt;Sanctuary Vale&lt;/td&gt;
&lt;td&gt;light the candles in the order the ghosts whisper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;2&lt;/td&gt;
&lt;td&gt;Foxfire Maze&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;12-16&lt;/td&gt;
&lt;td&gt;Sanctuary Vale&lt;/td&gt;
&lt;td&gt;follow the fox with a shadow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;3&lt;/td&gt;
&lt;td&gt;Drowned Temple&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;16-20&lt;/td&gt;
&lt;td&gt;Shadow Marsh&lt;/td&gt;
&lt;td&gt;three valves raise and lower the water&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;4&lt;/td&gt;
&lt;td&gt;Bog Witch&#x27;s Hollow&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;20-24&lt;/td&gt;
&lt;td&gt;Shadow Marsh&lt;/td&gt;
&lt;td&gt;brew the witch&#x27;s potion in the right order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;5&lt;/td&gt;
&lt;td&gt;Goblin Warren&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;22-28&lt;/td&gt;
&lt;td&gt;Shadow Marsh&lt;/td&gt;
&lt;td&gt;spike traps, alarm gongs, a king warded by shamans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;6&lt;/td&gt;
&lt;td&gt;Red Hand Mines&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;28-32&lt;/td&gt;
&lt;td&gt;Orc Valley&lt;/td&gt;
&lt;td&gt;dark maze, light totems, mine carts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;7&lt;/td&gt;
&lt;td&gt;Orc Stronghold&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;32-38&lt;/td&gt;
&lt;td&gt;Orc Valley&lt;/td&gt;
&lt;td&gt;break the gate under waves; generals left alive buff the warlord&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;8&lt;/td&gt;
&lt;td&gt;The Black Abbey&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;38-44&lt;/td&gt;
&lt;td&gt;Duskmoor&lt;/td&gt;
&lt;td&gt;unseen haunts until a candle finds them; break the ritual&#x27;s anchors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;9&lt;/td&gt;
&lt;td&gt;Ossuary of Bells&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;40-44&lt;/td&gt;
&lt;td&gt;Duskmoor&lt;/td&gt;
&lt;td&gt;count the tolls; the walls turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;10&lt;/td&gt;
&lt;td&gt;Sun Tomb&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;46-52&lt;/td&gt;
&lt;td&gt;Sunscar Desert&lt;/td&gt;
&lt;td&gt;light beams and pressure plates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;11&lt;/td&gt;
&lt;td&gt;Spider Hollows&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;44-50&lt;/td&gt;
&lt;td&gt;Sunscar Desert&lt;/td&gt;
&lt;td&gt;webs, egg sacs, a boss that drops from the ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;12&lt;/td&gt;
&lt;td&gt;Mirage Palace&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;46-50&lt;/td&gt;
&lt;td&gt;Sunscar Desert&lt;/td&gt;
&lt;td&gt;carry the lantern; trust nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;13&lt;/td&gt;
&lt;td&gt;Glacier Halls&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;52-56&lt;/td&gt;
&lt;td&gt;Frostpeak&lt;/td&gt;
&lt;td&gt;slide on the ice, push the boulders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;14&lt;/td&gt;
&lt;td&gt;Frost Monastery&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;54-60&lt;/td&gt;
&lt;td&gt;Frostpeak&lt;/td&gt;
&lt;td&gt;stay out of the monks&#x27; sight; braziers against the cold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;15&lt;/td&gt;
&lt;td&gt;Old King&#x27;s Forge&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;58-62&lt;/td&gt;
&lt;td&gt;Ember Wastes&lt;/td&gt;
&lt;td&gt;route the lava to heat the anvils without cutting off your path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;16&lt;/td&gt;
&lt;td&gt;Caldera Depths&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;62-65&lt;/td&gt;
&lt;td&gt;Ember Wastes&lt;/td&gt;
&lt;td&gt;climb before the lava does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;17&lt;/td&gt;
&lt;td&gt;Demon Tower&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;66-76&lt;/td&gt;
&lt;td&gt;Hellmouth Rift&lt;/td&gt;
&lt;td&gt;nine floors, a task each, weekly coffers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;18&lt;/td&gt;
&lt;td&gt;Chamber of Seals&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;70-75&lt;/td&gt;
&lt;td&gt;Hellmouth Rift&lt;/td&gt;
&lt;td&gt;break seven seals in the order the litany gives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;19&lt;/td&gt;
&lt;td&gt;Hall of Mirrors&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;80-85&lt;/td&gt;
&lt;td&gt;Shadowlands&lt;/td&gt;
&lt;td&gt;twin mazes; your own shadow at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&quot;numeric&quot;&gt;20&lt;/td&gt;
&lt;td&gt;Shadow Citadel&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;86-90&lt;/td&gt;
&lt;td&gt;Shadowlands&lt;/td&gt;
&lt;td&gt;the final raid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Nineteen come from specs. The Sun Tomb was hand-built before the generator existed; its walls were later
regenerated from the same kit, and its plates, beams and doors were merged into the same mechanic blocks
so there&#x27;s one system, not two.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/13-hall-of-mirrors-overview.png&quot; alt=&quot;The Hall of Mirrors&amp;#x27; twin mazes, connected by mirrors and levers that open each other&amp;#x27;s bars&quot; width=&quot;953&quot; height=&quot;666&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Hall of Mirrors&amp;#x27; twin mazes, connected by mirrors and levers that open each other&amp;#x27;s bars&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;how-they-were-built&quot;&gt;How they were built &lt;a class=&quot;anchor&quot; href=&quot;#how-they-were-built&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;After the generator&lt;/span&gt; core and the Crypt remake as a pilot, the dungeons were built in batches of three or
four per agent, at most about four agents at once. Each dungeon came with a spec and a seed picked from a
search sheet, its own kit theme (20 themes, 458 pieces from a parameterised Blender script, merged per
chunk at load), monsters reusing existing bodies recoloured with an accessory, a boss with its own
silhouette, its own music track, a portal by a landmark of its map, tests, and a folder of screenshots:
the gate, every room, each mechanic in action, the boss and the overview.&lt;/p&gt;
&lt;h2 id=&quot;what-we-dont-know-yet&quot;&gt;What we don&#x27;t know yet &lt;a class=&quot;anchor&quot; href=&quot;#what-we-dont-know-yet&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The generator proves&lt;/span&gt; a dungeon &lt;em&gt;can&lt;/em&gt; be finished. It doesn&#x27;t prove it&#x27;s fun, and nobody has played most of
them yet. The combat simulation from the testing post can fight every boss and every room, but its bots
walk in straight lines, so anything behind a maze, a rail line or deep water ends &amp;quot;stuck&amp;quot;, and dungeon run
times need a human with a stopwatch. Every batch report ends the same way: timings, damage and puzzle
difficulty are &amp;quot;a first guess, not playtested&amp;quot;. That&#x27;s honest, and it&#x27;s the next step.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Generate offline, review by eye, load like any map.&lt;/strong&gt; A seed you picked is a layout you saw.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write dungeons as graphs with locks&lt;/strong&gt;, and use the same condition language in the generator and the
game.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the generator refuse.&lt;/strong&gt; A solver that rejects soft-locks is cheaper than a bug report from a
party stuck behind a door.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give every labyrinth a rule, and every rule a solver.&lt;/strong&gt; Six kinds of maze, six proofs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mechanics are blocks.&lt;/strong&gt; A witch&#x27;s brew is four generic pieces with a story on top.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Twenty dungeons need&lt;/span&gt; a reason to go into them. Next: the lore, and a story that unfolds as you level.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Monday 00:00 UTC on the server&#x27;s clock, saved with the world. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>HD-2D on a Budget: Rendering 3D Characters into 8-Direction Palette Sprites</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html"/>
<id>https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html</id>
<published>2026-10-11T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Six 3D character drafts were rejected. The seventh idea kept the 3D models and turned them into pixel art: a Blender render pass, a Python post step that thinks in palettes, and a shader that dresses the result at runtime.</summary>
<content type="html">&lt;h2 id=&quot;why-sprites-at-all&quot;&gt;Why sprites at all &lt;a class=&quot;anchor&quot; href=&quot;#why-sprites-at-all&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The world of&lt;/span&gt; Dekiro is 3D: terrain, buildings, trees, an orbit camera. For four days the player was a
coloured capsule, and a parallel line of work tried to replace it with a real character built by a
Blender script. Six drafts came and went within a single day: flat primitives, a bulky
&amp;quot;Valheim hulk&amp;quot;, proportions modelled on a popular Korean MMO, then anime-style characters with haori jackets, masks and
glowing eyes. Each got closer and none got there. The human&#x27;s notes on one draft: &amp;quot;the faces are bad, like
really bad&amp;quot;, &amp;quot;the hair looks also like rubber&amp;quot;. Procedural faces at that size don&#x27;t forgive anything.&lt;/p&gt;
&lt;p&gt;Then the human sent a pixel-art sprite as a reference: &amp;quot;This is the style of pixel art sprite I&#x27;m looking
for for the character. Proportions especially are better there, same for shadings.&amp;quot; That suggested the
look players know as &lt;strong&gt;HD-2D&lt;/strong&gt;,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; a 3D world with 2D characters standing in it.
Two approaches ran in parallel: drawing the sprites directly as 2D art, and &lt;em&gt;rendering&lt;/em&gt; the existing 3D
characters into sprites, the way games like Dead Cells turned 3D animation into pixel art. The verdict on
the drawn ones was not printable. On the renders: &amp;quot;the rendered 3d to 2d one look way better.&amp;quot;&lt;/p&gt;
&lt;p&gt;That choice has a big practical advantage for a team without an artist. The 3D characters already had a
rig, clips and a whole armor progression. Rendering them means every animation, every armor tier and every
hairstyle comes from data, and adding a clip is a re-render, not a week of hand-drawn frames.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-3d-drafts.jpg&quot; alt=&quot;One of the 3D character drafts in the game, with toon shading and outlines&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;One of the 3D character drafts in the game, with toon shading and outlines&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-capsule-vs-sprite.jpg&quot; alt=&quot;The town square with capsules and with the first sprite characters&quot; width=&quot;1600&quot; height=&quot;530&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The town square with capsules and with the first sprite characters&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;a-style-target-before-a-pipeline&quot;&gt;A style target before a pipeline &lt;a class=&quot;anchor&quot; href=&quot;#a-style-target-before-a-pipeline&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Before rendering anything&lt;/span&gt;, the AI wrote a small pixel-art &amp;quot;style target&amp;quot; generator in plain Python: hand
placed pixels for a character in five armor tiers, plus the &lt;strong&gt;colour rule&lt;/strong&gt; every later step follows.
The rule is a hue-shifted ramp in the OKLCH colour space. A material&#x27;s colour becomes five steps:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;step:      0 deep   1 shadow  2 base  3 light  4 highlight
dL:       -0.25    -0.125     0      +0.095   +0.175     (OKLab lightness)
chroma x:  0.80     0.95      1.0     0.92     0.72
hue shift: 22 deg   11 deg    0       8 deg    15 deg
           toward COOL (h=265, blue) for 0/1, toward WARM (h=75, gold) for 3/4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Shadows don&#x27;t just get darker; they lean blue. Highlights lean gold. Near-grey colours get a little chroma
so their shadows still lean cool, dark colours are lifted so dark cloth keeps five distinct steps, and the
outline is a dark version of the colour, never black. The same rule exists three times,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; and they&#x27;re kept in step on purpose: a red roof and a red armor plate land on the same five
colours.&lt;/p&gt;
&lt;figure class=&quot;fig full pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-style-target.png&quot; alt=&quot;The pixel style target: the Blade school&amp;#x27;s five armor tiers, drawn by code to pin down proportions and the colour rule&quot; width=&quot;1568&quot; height=&quot;472&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The pixel style target: the Blade school&amp;#x27;s five armor tiers, drawn by code to pin down proportions and the colour rule&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;step-1-blender-renders-data-not-pictures&quot;&gt;Step 1: Blender renders data, not pictures &lt;a class=&quot;anchor&quot; href=&quot;#step-1-blender-renders-data-not-pictures&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tools/blender/render_sprites.py&lt;/code&gt; drives Blender headless. The camera is orthographic at the game camera&#x27;s
pitch (28 degrees down), the character is turned to &lt;strong&gt;8 directions&lt;/strong&gt;, and every second frame of a 24 fps
clip is rendered (12 fps, the classic pixel-art rate; idles at 6 fps). Rendering uses EEVEE with one
sample and no anti-aliasing, at &lt;strong&gt;4x the final size&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Two tricks happen here. First, a &lt;strong&gt;sprite-only proportion&lt;/strong&gt; pass scales pose bones in the render scene
only: head 1.35x, bigger hands and boots, heavier shoulders, shorter legs, about five heads tall. The rig,
its bone names and the clips stay untouched. Second, the renders aren&#x27;t pictures. Each layer gets two
passes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;albedo&lt;/strong&gt;: every material as flat emission of its base colour;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data&lt;/strong&gt;: red = the light (a white surface lit by one sun fixed to the camera&#x27;s upper left, so every
direction is lit the same, as drawn sprites are), green = a &lt;strong&gt;material id&lt;/strong&gt;, blue = how much a pixel
belongs to a hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The post step needs to know &lt;em&gt;what&lt;/em&gt; each pixel is, not just its colour.&lt;/p&gt;
&lt;h2 id=&quot;step-2-the-paper-doll&quot;&gt;Step 2: the paper doll &lt;a class=&quot;anchor&quot; href=&quot;#step-2-the-paper-doll&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A character isn&#x27;t&lt;/span&gt; one sprite but a stack of &lt;strong&gt;layers&lt;/strong&gt; that share one skeleton and one set of clips:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;body&lt;/code&gt;: skin, the painted face, bare hands and feet;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hair_&amp;lt;style&amp;gt;&lt;/code&gt;: each of the 9 hairstyles is its own layer, rendered white so the game can tint it;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eyes&lt;/code&gt;: two glowing pixels;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;armor_default&lt;/code&gt; (the outfit worn with no armor) and five armor tiers;&lt;/li&gt;
&lt;li&gt;NPC outfits and hats (more below), and a pre-rendered weapon for far-away characters.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While one layer renders, the others act as &lt;strong&gt;holdouts&lt;/strong&gt;: they cut the layer where they&#x27;d be in front. One
more flag solves a classic paper-doll problem. Hair that sits more than 3 cm behind the head, as the
camera sees it, is marked as &lt;em&gt;back hair&lt;/em&gt;. The game draws back hair under the armor and the rest over it,
so every hairstyle works with every armor without rendering combinations.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-layers.jpg&quot; alt=&quot;Paper-doll layers in the prototype: body, body + armor, body + armor + weapon&quot; width=&quot;1500&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Paper-doll layers in the prototype: body, body + armor, body + armor + weapon&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;step-3-post-processing-in-palettes&quot;&gt;Step 3: post-processing in palettes &lt;a class=&quot;anchor&quot; href=&quot;#step-3-post-processing-in-palettes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tools/blender/sprite_post.py&lt;/code&gt; (numpy, scipy, PIL) turns 4x raw frames into pixel art:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Each final pixel is a 4x4 block of raw pixels. The &lt;strong&gt;dominant material&lt;/strong&gt; wins the block. Glow seams on
the armor are thinner than a pixel, so they win a block from just 2 of its 16 samples and survive as
1 px lines.&lt;/li&gt;
&lt;li&gt;Per layer and material, a &lt;strong&gt;k-means&lt;/strong&gt; in OKLab finds up to three base colours (an indigo haori and its
wave pattern, say), and each base colour gets a ramp by the rule above.&lt;/li&gt;
&lt;li&gt;The light value picks the ramp step. Tiny specks are merged, eyes become 2 px glowing dots, hands
become clean &amp;quot;mittens&amp;quot; with one outline, and a &lt;strong&gt;selective 1 px outline&lt;/strong&gt; is drawn round the
silhouette.&lt;/li&gt;
&lt;li&gt;The result is not RGB but &lt;strong&gt;8-bit palette indices&lt;/strong&gt;. Index 0 is transparent; ramp &lt;em&gt;r&lt;/em&gt; owns indices
1+6r to 6+6r: outline, deep, shadow, base, light, highlight. A palette image per layer holds the
colours.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class=&quot;fig margin pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-eight-directions.png&quot; alt=&quot;The walk cycle in 8 directions (rows) after the post step&quot; width=&quot;1152&quot; height=&quot;1280&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The walk cycle in 8 directions (rows) after the post step&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Then every frame is trimmed to its opaque box, duplicates are dropped,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; and frames are shelf-packed into 2048 px pages. An &lt;code&gt;atlas.json&lt;/code&gt; records, per clip,
direction and frame, where the pixels are, where the feet are, where each hand is (with depth and roll),
and which frame the blow lands on.&lt;/p&gt;
&lt;h2 id=&quot;why-palettes-pay-off-recolouring-at-runtime&quot;&gt;Why palettes pay off: recolouring at runtime &lt;a class=&quot;anchor&quot; href=&quot;#why-palettes-pay-off-recolouring-at-runtime&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Because every pixel&lt;/span&gt; is &amp;quot;ramp 3, step 4&amp;quot; rather than a colour, recolouring is rewriting six palette entries.
That turned three expensive features into cheap ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hair and eye colours&lt;/strong&gt; are picked at character creation (12 hair and 10 eye presets, or free colours)
and changed later at Mirei the Stylist. The game builds a ramp from the chosen colour with the same rule.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rarity trims.&lt;/strong&gt; An armor&#x27;s trim ramp is rewritten with the item&#x27;s rarity colour: plain for Common, blue
for Magic, yellow for Rare, orange-gold for Legendary, crimson for Unique.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;School dyes.&lt;/strong&gt; The plan was a different armor cut per school: samurai, monk, ninja, priest. That&#x27;s four
times the rendering. The human cut it to one line: &amp;quot;Just take the same armor but add other colors from the
school.&amp;quot; Every school now wears the Blade cut, and its &lt;em&gt;dye&lt;/em&gt; ramps are rebuilt per school at runtime. Each
dye ramp stores its offset from the rendered dye as ratios of lightness and chroma plus a hue shift, so a
dark trim stays dark relative to a light dye in every school. The tier 5 glow seams are an emissive ramp
in the school colour.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-school-dyes.jpg&quot; alt=&quot;One armor cut, four schools: tiers 1 to 5 for Blade, Iron Fist, Shadowstep and Sanctum, recoloured at runtime&quot; width=&quot;1600&quot; height=&quot;1590&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;One armor cut, four schools: tiers 1 to 5 for Blade, Iron Fist, Shadowstep and Sanctum, recoloured at runtime&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-hairstyles.png&quot; alt=&quot;Every hairstyle over every armor: back hair under the armor, front hair over it&quot; width=&quot;1510&quot; height=&quot;1060&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Every hairstyle over every armor: back hair under the armor, front hair over it&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;step-4-in-the-game&quot;&gt;Step 4: in the game &lt;a class=&quot;anchor&quot; href=&quot;#step-4-in-the-game&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SpriteVisual&lt;/code&gt; replaces the capsule node (logic and visuals were separate from day one, so nothing else
changed). It is one upright card turned toward the camera. The row is picked from the angle between the
actor&#x27;s facing and the camera, snapped to 45 degrees with a little hysteresis so it doesn&#x27;t flicker at the
boundary. The card is lit by the scene&#x27;s sun and moon through a fixed normal, so sprites get darker at
night and dim under cloud shadows like everything else. All layers composite in one shader. A flat
silhouette of the sprite is projected on the ground as its shadow.&lt;/p&gt;
&lt;p&gt;Weapons are the one part that stays 3D. Near the camera (within 25 m) the real weapon model is drawn into
a small viewport with the same orthographic camera as the render, on the sprite&#x27;s own pixel grid, and
placed on the hand anchor of each frame, in front of or behind the body depending on that frame&#x27;s hand
depth. That&#x27;s what lets a +9 katana sparkle and every weapon model appear in hand without rendering
weapons into the sheets. Far away, a pre-rendered weapon layer takes over.&lt;/p&gt;
&lt;p&gt;Animations come from the clip list: idles, walk and run by speed, a three-hit combo per weapon type
(sword, twin daggers, a two-handed staff, fists), 13 shared skill motions such as spin, dash and slam,
hit, death, and a kneeling &amp;quot;pet&amp;quot; emote. Every attack strikes on frame 4, the same convention the monster
models use, so the damage number appears as the blow lands. In total each body has 51 clips. One small
change came from playing it: &amp;quot;I feel like the player model can be a bit bigger?&amp;quot; Sprites are now drawn
1.25 times their rendered size. Purely visual: hitboxes didn&#x27;t change.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-t5-night.jpg&quot; alt=&quot;Two tier-5 armors at night: the glow seams in the school colour are an emissive palette ramp&quot; width=&quot;1600&quot; height=&quot;998&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Two tier-5 armors at night: the glow seams in the school colour are an emissive palette ramp&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;npcs-the-same-sheets-different-clothes&quot;&gt;NPCs: the same sheets, different clothes &lt;a class=&quot;anchor&quot; href=&quot;#npcs-the-same-sheets-different-clothes&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On day six&lt;/span&gt; the townsfolk and NPCs, still capsules, got the same treatment. The human liked the capsules
(&amp;quot;Somehow they grew on me&amp;quot;) but wanted &amp;quot;NPCs with different sizes, clothing and colours&amp;quot;. The answer was
more layers, not more characters: seven &lt;strong&gt;NPC outfits&lt;/strong&gt; (farmer, merchant, apron, monk, guard, traveller,
noble) and five &lt;strong&gt;hats&lt;/strong&gt;, rendered for the NPC clips only, plus NPC-only clips for talking, sitting and
working.&lt;/p&gt;
&lt;p&gt;A look is pure data: body, outfit, hat, hairstyle, hair, skin and eye colour, cloth and accent colour, a
size and a build (stocky or slender, a stretch of the card). Named NPCs have fixed looks. Townsfolk give an
archetype, and the rest is picked by a random number seeded with the person&#x27;s id from their region&#x27;s
palette, so a person looks the same every time and on every machine, and the desert&#x27;s people don&#x27;t look
like the Vale&#x27;s. Grandmasters wear tier 5 armor in their school&#x27;s dye with glowing seams.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/12-npc-grandmaster.jpg&quot; alt=&quot;A grandmaster in the Sanctuary: tier 5 armor in the Blade school&amp;#x27;s dye, rendered from the same sheets as the player&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A grandmaster in the Sanctuary: tier 5 armor in the Blade school&amp;#x27;s dye, rendered from the same sheets as the player&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-budget&quot;&gt;The budget &lt;a class=&quot;anchor&quot; href=&quot;#the-budget&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Sprites move the&lt;/span&gt; cost from geometry to texture memory, so the budget lives in tests:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per character&lt;/strong&gt;: a dressed character (body, one hairstyle, the heaviest armor, eyes) loading every
clip one weapon type uses must stay under 19 MB. It&#x27;s 18.3 MB for the male body and 16.4 MB for the
female. Before the sheets were packed in &lt;strong&gt;banks&lt;/strong&gt; (the shared clips, then each weapon set&#x27;s own, so a
character only loads pages for clips it can play), it was about 22 MB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per town&lt;/strong&gt;: every NPC and townsperson of the Vale plus 30 passers-by, 63 people, against 128 MB:
they need 92.7 MB. Looks share pages, and the palettes per look are cached.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On disk&lt;/strong&gt;: about 20 MB of PNG per body.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rendering is the slow part: the full pipeline for both bodies takes about 50 minutes with 14 Blender jobs
in parallel. Re-rendering only the armor layers, or only the NPC layers, reuses the other raw frames.&lt;/p&gt;
&lt;p&gt;Headless machines (dedicated servers and tests) never load any of it; they keep the capsule.&lt;/p&gt;
&lt;h2 id=&quot;what-didnt-work-at-first&quot;&gt;What didn&#x27;t work at first &lt;a class=&quot;anchor&quot; href=&quot;#what-didnt-work-at-first&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Faces.&lt;/strong&gt; At about 100 px tall, a face is a few pixels. The final characters wear a mask or scarf, and
the eyes are two glowing pixels in the chosen eye colour. It reads better than any modelled face did.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hands.&lt;/strong&gt; Rendered hands at that size became noise, so the post step redraws them as mittens with one
outline (gloves included).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dyes too close to the trim.&lt;/strong&gt; On some schools the rarity trim almost vanished into the dye; the trim
is now lifted when it gets too close.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Builds without the atlases.&lt;/strong&gt; The first exported build drew capsules: the atlas &lt;code&gt;.json&lt;/code&gt; files weren&#x27;t
in the export filter. CLAUDE.md already warned about &lt;code&gt;data/*.json&lt;/code&gt;; the sprites&#x27; JSON lived elsewhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Render 3D into 2D&lt;/strong&gt; if you have rigs and no artist. Every clip, armor tier and hairstyle stays data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Render data passes, not pictures.&lt;/strong&gt; Material ids, light and masks let the post step make decisions an
image can&#x27;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Think in palettes.&lt;/strong&gt; Indexed pixels turn hair colours, rarity trims and school dyes into rewriting six
entries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One colour rule, implemented everywhere it&#x27;s needed,&lt;/strong&gt; keeps sprites and 3D models in the same world.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put the memory budget in a test.&lt;/strong&gt; It&#x27;s the number that grows quietly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Characters needed somewhere&lt;/span&gt; to fight. Next: a dungeon generator that writes specs as sequence graphs,
stitches ASCII rooms into layouts, and refuses to save any dungeon its solver can&#x27;t finish.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Or older: Ragnarok Online. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;In the Python post step, in GDScript for runtime recolouring, and as a shader include for the world&#x27;s sprite-style
shading. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;2,836 unique of 3,528 body frames for the male body. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>From One AI Session to a Team: Coordinator and Subagents</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/11-coordinator-and-subagents.html"/>
<id>https://dekiro.d12r.io/11-coordinator-and-subagents.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>How the work moved from one Claude Code session doing everything to a coordinator that briefs parallel subagents, what broke when four of them shared one repository, and the rules that came out of it.</summary>
<content type="html">&lt;h2 id=&quot;the-single-session-era&quot;&gt;The single-session era &lt;a class=&quot;anchor&quot; href=&quot;#the-single-session-era&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;For most of&lt;/span&gt; its first three and a half days, Dekiro was built the way the earlier posts describe: one
Claude Code session at a time, working through tasks in order. The human set direction and playtested,
and the AI implemented, tested, screenshotted and committed. It worked well: well over a hundred commits,
from an arc attack against training dummies to a 2.6 x 3 km map with music, multiplayer and fishing.&lt;/p&gt;
&lt;p&gt;It had one obvious limit: everything was serial. While the session was composing music, it wasn&#x27;t fixing
the grass. While it was building item icons, the map waited. Some tasks, like rebuilding every monster
model or designing a full UI theme, are long and mostly independent of everything else. Doing them one
after another meant long stretches where the human had nothing new to look at.&lt;/p&gt;
&lt;h2 id=&quot;the-new-setup&quot;&gt;The new setup &lt;a class=&quot;anchor&quot; href=&quot;#the-new-setup&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On day four&lt;/span&gt; the human asked for a different way of working: the main session should &lt;strong&gt;spawn subagents
freely&lt;/strong&gt; and act as &lt;strong&gt;coordinator and quality control&lt;/strong&gt;. The working notes from that day put it this way:
review, test, screenshot and fix before work reaches the human, give agents self-contained briefs with
file boundaries, and commit only after checking.&lt;/p&gt;
&lt;p&gt;In practice there are two kinds of Claude sessions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The coordinator&lt;/strong&gt; holds the conversation with the human and the overall picture. It writes briefs,
launches subagents, reviews what comes back, runs the full test suite, looks at the screenshots, fixes or
sends back what isn&#x27;t right, and makes the commits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subagents&lt;/strong&gt; each get one job with clear boundaries: which files they may touch, which they must not,
what &amp;quot;done&amp;quot; looks like, and how to verify it. They work in parallel and report back. They don&#x27;t commit.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The human talks to one session, as before, and sees more finished work per hour.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/11-workflow.png&quot; alt=&quot;The coordinator workflow: briefs out, reports back, gates before a commit&quot; width=&quot;1322&quot; height=&quot;603&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The coordinator workflow: briefs out, reports back, gates before a commit&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;by-the-numbers&quot;&gt;By the numbers &lt;a class=&quot;anchor&quot; href=&quot;#by-the-numbers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The coordinator&#x27;s session&lt;/span&gt; log records every subagent it launched: &lt;strong&gt;70 in three days&lt;/strong&gt;.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; Their one-line descriptions read like the
changelog of the second half of this series: &amp;quot;Compose original game music&amp;quot;, &amp;quot;Roll out organic
quadrupeds&amp;quot;, &amp;quot;Move tests to the test map&amp;quot;, &amp;quot;Prototype 2D sprites in 3D world&amp;quot;, &amp;quot;Fix D3D12 descriptor heap
exhaustion&amp;quot;, &amp;quot;Dungeon generator core + Crypt remake&amp;quot;, &amp;quot;Story wave: First Shadow raid + finale&amp;quot;, &amp;quot;Balance:
combat simulation + tuning&amp;quot;. The update of this very blog is number 70.&lt;/p&gt;
&lt;p&gt;The commit history changed shape with it. Day four had 76 commits. Days five and six had 11 between
them, each a reviewed batch of several agents&#x27; work; the largest touches 941 files.&lt;/p&gt;
&lt;h2 id=&quot;the-first-job-music&quot;&gt;The first job: music &lt;a class=&quot;anchor&quot; href=&quot;#the-first-job-music&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first subagent&lt;/span&gt; job was the music. It suited delegation well:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Self-contained&lt;/strong&gt;: the composing tools live in &lt;code&gt;tools/audio/&lt;/code&gt; and write files to &lt;code&gt;audio/music/&lt;/code&gt;. Nothing
else in the game had to change except a &lt;code&gt;&amp;quot;music&amp;quot;&lt;/code&gt; key per map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verifiable without the game&lt;/strong&gt;: each render prints its length, loudness and true peak, so the
coordinator can check the numbers before anyone listens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clear taste gate&lt;/strong&gt;: whether it sounds right is the human&#x27;s call. The Vale theme was the reference.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result was &amp;quot;Music for every map&amp;quot;: nine new tracks, a shared synth library and one data module per map,
in one commit. Since then every dungeon got a track the same way, 30 in total.&lt;/p&gt;
&lt;h2 id=&quot;briefs-are-the-new-claudemd&quot;&gt;Briefs are the new CLAUDE.md &lt;a class=&quot;anchor&quot; href=&quot;#briefs-are-the-new-claudemd&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A subagent starts&lt;/span&gt; cold, just like every session did before. It doesn&#x27;t know the conversation, the
decisions of the last hour, or why the open world was switched off. CLAUDE.md gives it the project&#x27;s
rules, and the brief gives it the task. A good brief turned out to need:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The goal in one paragraph&lt;/strong&gt;, including what the human has already decided (&amp;quot;NO shovel/digging: the
user disliked it&amp;quot;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File boundaries&lt;/strong&gt;: &amp;quot;you own these paths; read anything; don&#x27;t touch these.&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Facts it can&#x27;t discover&lt;/strong&gt;: the human&#x27;s preferences, the reference to match, numbers agreed in
conversation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How to verify&lt;/strong&gt;: which tests to run, which screenshots to take, and what to report.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What not to do&lt;/strong&gt;: don&#x27;t commit, don&#x27;t kill Godot processes you didn&#x27;t start (the human&#x27;s editor often
runs alongside), wrap test runs in &lt;code&gt;timeout&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;when-four-agents-share-one-repository&quot;&gt;When four agents share one repository &lt;a class=&quot;anchor&quot; href=&quot;#when-four-agents-share-one-repository&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The plan said&lt;/span&gt; file boundaries would keep parallel agents apart. They mostly did. The interesting failures
were all at the edges, in the files every feature has to touch: the one big test file, shared shader
includes, the boss AI, the map generators. A selection from the agents&#x27; own reports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The test file.&lt;/strong&gt; Every feature adds tests to &lt;code&gt;tests/run_tests.gd&lt;/code&gt;, so every agent edits it. Two
dungeon agents each added a helper function named &lt;code&gt;_room_mid&lt;/code&gt;. GDScript doesn&#x27;t allow two functions with
the same name, so the whole test file stopped parsing for every agent until one renamed theirs
(&lt;code&gt;_caldera_mid&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared shader includes.&lt;/strong&gt; An agent named a new uniform in the sprite shader &lt;code&gt;shade&lt;/code&gt;. A shared cloud
shadow include already used &lt;code&gt;shade&lt;/code&gt; as a local variable. The shader stopped compiling, and the player and
every NPC turned into a white rectangle in the human&#x27;s running game. Headless tests don&#x27;t compile
shaders, so every suite still passed. Another agent&#x27;s first screenshots of its ice dungeon caught it;
the human caught it first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Other people&#x27;s half-finished work.&lt;/strong&gt; Agents regularly saw failures that weren&#x27;t theirs: a parse error
in &lt;code&gt;main.gd&lt;/code&gt; from a change in progress, a test failing in another agent&#x27;s area, a physics warning from a
map being regenerated. Without a rule, an eager agent &amp;quot;fixes&amp;quot; the other agent&#x27;s file and both lose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The class cache.&lt;/strong&gt; When one agent adds a new &lt;code&gt;class_name&lt;/code&gt; script, the other agents&#x27; test runs fail to
compile until Godot&#x27;s class cache is refreshed. &amp;quot;Can&#x27;t find class Waystone&amp;quot; is not a bug to fix; it&#x27;s an
&lt;code&gt;--import&lt;/code&gt; to run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rule slips.&lt;/strong&gt; Several agents edited shared files with short Python rewrite scripts instead of the
exact-match Edit tool, which the briefs asked for. Their own reports flagged it. Nothing was lost,
because the scripts refused to write when the target text had changed, but it&#x27;s exactly how a silent
overwrite would happen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The machine.&lt;/strong&gt; Godot test runs and Blender builds are heavy, and they all run on the human&#x27;s PC. One
map agent wrote in its report that it couldn&#x27;t measure the frame rate cleanly &amp;quot;with several agents
running&amp;quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-rules-that-came-out-of-it&quot;&gt;The rules that came out of it &lt;a class=&quot;anchor&quot; href=&quot;#the-rules-that-came-out-of-it&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Each of those&lt;/span&gt; turned into a line in the briefs (and some into CLAUDE.md). The current shared brief for a
batch of parallel agents includes, in substance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Who owns what.&lt;/strong&gt; Each agent owns its files outright. Shared files (data tables like &lt;code&gt;items.json&lt;/code&gt;, the
test file, the boss AI) are &amp;quot;edit carefully with Edit (never rewrite whole files)&amp;quot;, re-reading first if
the file changed. In the test file you ADD your own &lt;code&gt;test_*&lt;/code&gt; methods and touch nobody else&#x27;s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unique names for anything you add to a shared space.&lt;/strong&gt; Temporary screenshot scripts get names like
&lt;code&gt;tests/_tmp_&amp;lt;job&amp;gt;_*.gd&lt;/code&gt; and are deleted afterwards. The &lt;code&gt;_room_mid&lt;/code&gt; collision showed the same goes for
helper functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#x27;t fix what isn&#x27;t yours.&lt;/strong&gt; &amp;quot;If something fails in files you didn&#x27;t touch, it may be another agent&#x27;s
work in progress: say so, don&#x27;t fix it.&amp;quot; The coordinator sorts it out after merging.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refresh the class cache&lt;/strong&gt; (&lt;code&gt;--import&lt;/code&gt;) before believing a &amp;quot;missing class&amp;quot; error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shader edits need a windowed check.&lt;/strong&gt; Headless runs never compile shaders; after any shader change,
take a windowed screenshot and grep its log for errors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At most about four agents at once&lt;/strong&gt; on the human&#x27;s PC, and anything that measures performance runs
when the others are done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Report what you touched outside your lane,&lt;/strong&gt; including slips. Agent reports end with a list of the
shared files they edited, and several of the most useful lines in them were confessions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nobody but the coordinator commits.&lt;/strong&gt; After merging a batch, the coordinator runs the full suite, the
smoke runs and the network bots, and looks at the screenshots, before one commit with a message that
lists everything in it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Work also got split along the lines that collide least. Maps went out two per agent, each writing its own
generator file, with portal links fixed afterwards by one script. Dungeons went out three or four per
agent, each with its own spec, kit theme, monsters and music, and shared building blocks added with tests.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/11-eight-maps.jpg&quot; alt=&quot;Eight maps rebuilt by four agents in parallel, two maps each: one town, roads, landmarks, mountain borders&quot; width=&quot;1600&quot; height=&quot;1502&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Eight maps rebuilt by four agents in parallel, two maps each: one town, roads, landmarks, mountain borders&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;approval-gates&quot;&gt;Approval gates &lt;a class=&quot;anchor&quot; href=&quot;#approval-gates&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The second rule&lt;/span&gt; of the setup: &lt;strong&gt;the human approves designs before builds.&lt;/strong&gt; A subagent can produce
something in an hour that would take a day to undo. So anything with taste in it goes through a design
step first. Three gates from day four, and how they ended:&lt;/p&gt;
&lt;figure class=&quot;fig margin&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/11-characters.jpg&quot; alt=&quot;One of the 3D character drafts next to the old capsule. None of them shipped as 3D models&quot; width=&quot;1100&quot; height=&quot;1520&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;One of the 3D character drafts next to the old capsule. None of them shipped as 3D models&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;UI&lt;/strong&gt;: the HUD and windows were drafted as a design in Claude Design and shown to the human; the
approved lacquer-and-gold pixel style was then built into the game&#x27;s UI code and textures. It landed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monsters&lt;/strong&gt;: the organic rebuild that replaces the &amp;quot;Pinocchio doll&amp;quot; primitives&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;
went to the human as a single wolf first, then rolled out to every four-legged monster and critter. It
landed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Player characters&lt;/strong&gt;: real 3D player models to replace the capsule. This gate did its job by saying no
six times. The 3D drafts never shipped; the human chose to render them into pixel sprites instead, which
became the HD-2D post.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/11-ui-before-after.jpg&quot; alt=&quot;The HUD before and after the UI rebuild&quot; width=&quot;1600&quot; height=&quot;426&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The HUD before and after the UI rebuild&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/11-monster-pilot.jpg&quot; alt=&quot;The monster pilot: a wolf before and after the organic rebuild&quot; width=&quot;1600&quot; height=&quot;518&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The monster pilot: a wolf before and after the organic rebuild&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;quality-control-before-and-after&quot;&gt;Quality control: before and after &lt;a class=&quot;anchor&quot; href=&quot;#quality-control-before-and-after&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The coordinator&#x27;s main&lt;/span&gt; job is to catch problems before the human sees them. The checks are the same ones
a single session used, applied to someone else&#x27;s work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The full test suite&lt;/strong&gt; (now about 13,800 checks) plus the grep for engine errors, after merging each
agent&#x27;s work. A subagent may have run only the tests near its own files.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smoke runs&lt;/strong&gt; of the menu and the game, and the network bots when anything networked changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before and after screenshots&lt;/strong&gt; for every visual change, looked at side by side. A shader change that
&amp;quot;should only affect monsters&amp;quot; deserves a shot of a building too, and after the white-wall incident, a
windowed run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A read of the diff&lt;/strong&gt;: scope (did it stay inside its files?), the CLAUDE.md conventions (static typing,
no &lt;code&gt;:=&lt;/code&gt; on Variant expressions, &lt;code&gt;command_*&lt;/code&gt; methods only, content in data), and new tests for new
behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It doesn&#x27;t catch everything. The white wall reached the human&#x27;s screen before the coordinator saw it.
Balance numbers from the agents are &amp;quot;a first guess, not playtested&amp;quot;, and every dungeon report says so.
What the setup guarantees is that problems are found by one session looking at everything, not by the
human finding them one by one.&lt;/p&gt;
&lt;h2 id=&quot;what-changes-for-the-human&quot;&gt;What changes for the human &lt;a class=&quot;anchor&quot; href=&quot;#what-changes-for-the-human&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Less waiting&lt;/span&gt;, mostly. Instead of one stream of commits, there are several lines of work moving at once,
and the human&#x27;s time goes where it matters most: approving designs, judging feel, playtesting. They
don&#x27;t supervise each agent. The coordinator is the single point of contact and the single gate to the
repository.&lt;/p&gt;
&lt;p&gt;It also changes how decisions are kept. Since a brief has to be self-contained, a preference the human
states in conversation gets written down sooner and more precisely: first in a brief, then in a design,
then in an approval, instead of living only in a chat.&lt;/p&gt;
&lt;h2 id=&quot;a-meta-note&quot;&gt;A meta note &lt;a class=&quot;anchor&quot; href=&quot;#a-meta-note&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;This blog series&lt;/span&gt; is itself a subagent job, twice. A subagent wrote the first drafts from the git history;
another (number 70) updated them three days later, counted everything again in the repository rather
than trusting the old numbers, and wrote the newer posts. The coordinator reviews them, and the human
decides what gets published. Most of what you&#x27;ve read comes from commit messages and agent reports, which
is a good argument for writing both well.&lt;/p&gt;
&lt;h2 id=&quot;what-wed-tell-another-team&quot;&gt;What we&#x27;d tell another team &lt;a class=&quot;anchor&quot; href=&quot;#what-wed-tell-another-team&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parallelize the long, separable jobs.&lt;/strong&gt; Music, a UI theme, a model rebuild, two maps, three dungeons:
each has its own files and its own way to verify. Keep the tightly coupled work in one place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expect collisions at the shared files, not the owned ones.&lt;/strong&gt; The test file, shared shader includes and
central AI code are where parallel agents meet. Give those files explicit edit rules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write briefs like contracts.&lt;/strong&gt; Goal, boundaries, facts, verification, don&#x27;ts. A cold-start agent only
knows what you give it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask agents to report their slips.&lt;/strong&gt; An honest &amp;quot;I edited a shared file the wrong way&amp;quot; is cheaper than
finding it in the history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gate on taste, not on code.&lt;/strong&gt; Let the human approve designs; let the tests approve code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One committer.&lt;/strong&gt; A single reviewer between the agents and the repository keeps the history clean and
the rules enforced.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The posts after&lt;/span&gt; this one cover what the parallel phase built: the HD-2D sprite pipeline, the dungeon
generator, the story, the 40-hour balance model, the living towns and the Fullmoon weapons. What&#x27;s left
after that is the part no agent can do: the human&#x27;s playtest, and the notes on how all of it feels.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;35 on day four, 24 on day five, 11 on day six, the later ones much bigger. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;See &lt;a href=&quot;https://dekiro.d12r.io/05-everything-from-code-headless-blender.html&quot;&gt;the Blender post&lt;/a&gt;. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>A Stardew-Style Fishing Minigame in a Server-Authoritative ARPG</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/10-fishing-in-a-server-authoritative-arpg.html"/>
<id>https://dekiro.d12r.io/10-fishing-in-a-server-authoritative-arpg.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>How to add a twitchy, client-side minigame to a game where the server decides everything, and test it without a human.</summary>
<content type="html">&lt;h2 id=&quot;why-fishing&quot;&gt;Why fishing? &lt;a class=&quot;anchor&quot; href=&quot;#why-fishing&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Grind games need&lt;/span&gt; a breather. Between Shadow runs it&#x27;s nice to stand at a lake at night and try for the
Golden Carp. Fishing sat on the backlog early on, and lakes were placed with it in mind.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; The model was Stardew Valley&#x27;s reeling minigame:
a vertical meter, a fish that darts up and down, and a bar you lift by holding a button. Keep the fish
inside the bar until the catch meter fills.&lt;/p&gt;
&lt;p&gt;The interesting part is not the minigame itself. It&#x27;s how a reflex game fits into an architecture where
the server decides everything, and where a client should never be able to say &amp;quot;I caught a Golden Carp&amp;quot;
and be believed.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-loop&quot;&gt;The loop &lt;a class=&quot;anchor&quot; href=&quot;#the-loop&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Equip a Fishing&lt;/span&gt; Rod on the hotbar (or right-click it in the bag) and use it near water:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cast&lt;/strong&gt;: the bobber lands on the nearest fishable water within 9 m of where you aim.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait&lt;/strong&gt;: after 2.5 to 7 seconds, a fish bites. A &amp;quot;!&amp;quot; appears.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hook&lt;/strong&gt;: press attack within 1.1 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reel&lt;/strong&gt;: win the minigame, and the fish goes into your bag.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Moving more than a metre, getting hit or dying reels the line in.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/10-cast.jpg&quot; alt=&quot;Casting at Mirror Lake: rod, line and bobber&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Casting at Mirror Lake: rod, line and bobber&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/10-bite.jpg&quot; alt=&quot;A bite: press attack within the hook window&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A bite: press attack within the hook window&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;fish-are-data&quot;&gt;Fish are data &lt;a class=&quot;anchor&quot; href=&quot;#fish-are-data&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Which fish bites&lt;/span&gt; depends on the liquid, the map and the time of day, all in &lt;code&gt;data/fish.json&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;silver_eel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;liquids&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;water&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;swamp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;time&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;night&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;weight&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;14&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;difficulty&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;55&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;behavior&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;dart&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;golden_carp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;liquids&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;water&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;time&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;night&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;weight&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;difficulty&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;behavior&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;floater&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ghostfin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;liquids&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;swamp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;time&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;night&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;weight&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;difficulty&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;85&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;behavior&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;dart&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;oasis_koi&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;liquids&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;water&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;maps&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;sunscar_desert&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;time&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;any&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;weight&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;difficulty&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;65&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;behavior&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;floater&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There are 14 fish, from the Old Boot (difficulty 5, it just sinks) to the Ghostfin that only bites in the
Marsh at night. Time of day comes from the shared &lt;code&gt;WorldClock&lt;/code&gt;, a 36-minute day read from the wall clock,
so everyone on a server agrees whether it&#x27;s night without any network traffic. Fish are ordinary items
and can be sold.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/10-night-lake.jpg&quot; alt=&quot;Mirror Lake at night, when the Golden Carp and the Silver Eel bite&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Mirror Lake at night, when the Golden Carp and the Silver Eel bite&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-minigame-is-pure-logic&quot;&gt;The minigame is pure logic &lt;a class=&quot;anchor&quot; href=&quot;#the-minigame-is-pure-logic&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;FishingMinigame&lt;/code&gt; is a &lt;code&gt;RefCounted&lt;/code&gt; with no nodes, no input and no drawing. One method advances it:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## One step; holding = the reel key is down. Returns 1 when caught, -1 when it got away, else 0.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;step&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;holding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;elapsed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clampf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LIFT&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;holding&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GRAVITY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_BAR_SPEED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_BAR_SPEED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;top&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_size&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BOUNCE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;elif&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;top&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;top&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_move_fish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fish_inside&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;progress&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FILL&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;progress&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DRAIN&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;difficulty&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DRAIN_PER_DIFFICULTY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Holding the key accelerates the bar up; letting go lets gravity pull it down, and it bounces a little off
the bottom. The fish picks a new target every so often and swims toward it. &lt;strong&gt;Difficulty&lt;/strong&gt; (0-100) sets
how far and how fast it moves, and &lt;strong&gt;behaviour&lt;/strong&gt; shapes the moves: &lt;code&gt;smooth&lt;/code&gt;, &lt;code&gt;dart&lt;/code&gt; (bigger, quicker
jumps), &lt;code&gt;sinker&lt;/code&gt; (drifts down), &lt;code&gt;floater&lt;/code&gt; (drifts up) or &lt;code&gt;mixed&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Keeping it pure pays off twice. The UI (&lt;code&gt;FishingWindow&lt;/code&gt;) just draws its state each frame. And it can be
tested in a tight loop with no scene at all, which matters below.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/10-minigame.jpg&quot; alt=&quot;The reeling meter: keep the fish inside the green bar until the catch meter fills&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The reeling meter: keep the fish inside the green bar until the catch meter fills&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;who-decides-what&quot;&gt;Who decides what &lt;a class=&quot;anchor&quot; href=&quot;#who-decides-what&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Here is how&lt;/span&gt; the work splits between server and client:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The server&lt;/strong&gt; runs the state machine in the &lt;code&gt;Fishing&lt;/code&gt; node: cast, bite timer, hook window, and the
result. It picks the fish (with its item, difficulty, behaviour and a &lt;strong&gt;seed&lt;/strong&gt;), and it never tells the
client which item it is until the catch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The client&lt;/strong&gt; plays the minigame locally with that difficulty, behaviour and seed. Reeling is a reflex
game, and a round trip per frame would make it unplayable online.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The server checks the result for plausibility.&lt;/strong&gt; The client reports a win or a loss. The server knows
when reeling started, and a win that came too fast is rejected:&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Reeling can&#x27;t be won faster than this (the minigame needs ~3.2 s at best).&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MIN_REEL&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;won&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_since&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MIN_REEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;escaped&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;It got away...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;item&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemDb&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bite&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;item&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inventory&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inventory&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;add_item&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;item&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;escaped&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Your bag is full: you let it go&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Is this cheat-proof? No. A modified client could always &amp;quot;win&amp;quot; after two seconds. But it can&#x27;t choose the
fish, can&#x27;t catch faster than the rules allow, can&#x27;t fish without a rod or away from water, and can&#x27;t skip
the bite timer. For fishing in a co-op game, that&#x27;s the right amount of server authority. The hook window
gets a 0.4 s grace for network delay, so playing online isn&#x27;t harsher than playing alone.&lt;/p&gt;
&lt;p&gt;Every step (cast, bite, reel, caught, escaped, cancel) goes out as an event to the peers who can see the
player, so other players see your rod, the line and the bobber dipping. &lt;code&gt;FishingFx&lt;/code&gt; draws all that: the
rod in hand, a line, a bobber that bobs, dips hard on a bite and shakes while reeling, and splashes. It&#x27;s
purely cosmetic, like every visual node in the game.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/10-fish-in-bag.jpg&quot; alt=&quot;The catch in the bag, with a Golden Carp tooltip&quot; width=&quot;1600&quot; height=&quot;903&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The catch in the bag, with a Golden Carp tooltip&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;testing-a-reflex-game-without-a-human&quot;&gt;Testing a reflex game without a human &lt;a class=&quot;anchor&quot; href=&quot;#testing-a-reflex-game-without-a-human&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Because the minigame&lt;/span&gt; is pure logic, a test can play it. &lt;code&gt;FishingMinigame&lt;/code&gt; even ships an &lt;code&gt;autopilot()&lt;/code&gt;, &amp;quot;a
decent player&amp;quot; that holds while the fish is above the bar&#x27;s middle and looks a little ahead:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;autopilot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fish_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_pos&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_size&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bar_speed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The test then checks the properties that matter:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FishingMinigame&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;12.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;smooth&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;while&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;elapsed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;60.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# keep the bar&#x27;s middle on the fish&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;step&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;60.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;autopilot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;following the fish catches it&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;elapsed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Fishing&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MIN_REEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;no catch faster than the server allows (&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%.2f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; s)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;easy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;elapsed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The second check is important. It proves that an honest, &lt;em&gt;good&lt;/em&gt; player can never be rejected by the
server&#x27;s plausibility rule. If someone later tunes &lt;code&gt;FILL&lt;/code&gt; up and makes catches faster than &lt;code&gt;MIN_REEL&lt;/code&gt;, this
test fails. Other checks in the same test: doing nothing against a hard darting fish loses it within 10
seconds, every fish in the data is a real item, the Ghostfin bites only at night, regional fish stay in
their region, and there&#x27;s no fishing in lava.&lt;/p&gt;
&lt;p&gt;A second test runs the full flow on the Proving Grounds test map: no rod, no fishing; a cast onto the
pond; the bite; the hook; the catch arriving in the bag.&lt;/p&gt;
&lt;h2 id=&quot;gamepad&quot;&gt;Gamepad &lt;a class=&quot;anchor&quot; href=&quot;#gamepad&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Fishing arrived on&lt;/span&gt; the evening of day three. Later that night the game got gamepad support, which
touched fishing too: on a pad, the reel is the attack button like everything else. The &lt;code&gt;GamePad&lt;/code&gt; node
remembers whether the last input came from a pad, and hints then name pad buttons (A/B or Cross/Circle
depending on the controller). While a window is open, the pad drives its buttons: the d-pad moves focus,
A presses, LB/RB switch tabs, and B closes it. Bindings live in a rebindable pad column in the Controls
settings.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Split reflex from authority.&lt;/strong&gt; Let the client run the twitchy part with server-chosen parameters and
seed; let the server own the outcome and check it for plausibility.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep minigames pure.&lt;/strong&gt; A &lt;code&gt;step(delta, input)&lt;/code&gt; function is trivial to draw, network and test.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give tests an autopilot.&lt;/strong&gt; A bot that plays &amp;quot;decently&amp;quot; lets you prove that honest players never trip
your anti-cheat rules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time of day from the wall clock&lt;/strong&gt; gives every player the same world without any network traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The next post&lt;/span&gt; is about how the work itself is organized now: a coordinator session with parallel
subagents, approval gates, before/after reviews, and what happens when four agents edit one repository.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The backlog names Mirror Pond, Fisher&#x27;s Pool and the Moonlake. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;The screenshots in this post are from the day fishing landed, when the player was still a placeholder
capsule. The character is a pixel sprite now; the fishing code hasn&#x27;t needed to change, because the rod,
line and bobber are their own visual node. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Composing Original Music in Python: Thirty Tracks, One Motif</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/09-synthesized-music-in-python.html"/>
<id>https://dekiro.d12r.io/09-synthesized-music-in-python.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>No samples and no DAW: plucked strings from Karplus-Strong, a reverb made of noise, loudness measured the broadcast way (ITU-R BS.1770), and seamless loops.</summary>
<content type="html">&lt;h2 id=&quot;why-synthesize&quot;&gt;Why synthesize? &lt;a class=&quot;anchor&quot; href=&quot;#why-synthesize&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Music is the&lt;/span&gt; asset a small team most often licenses. We didn&#x27;t, for the same reasons as everything else
in this series: the game&#x27;s content is built from code, so it stays original, consistent and changeable.
Every track in Dekiro is composed as data and rendered by a Python script with numpy and scipy into a
looping Ogg file. The first set had eleven: one per map, a calmer variation for the Vale&#x27;s town, and one
for the Crypt dungeon.&lt;/p&gt;
&lt;p&gt;Every one of the 20 dungeons has since got its own track too, so the game now ships &lt;strong&gt;30&lt;/strong&gt;, all
made the same way.&lt;/p&gt;
&lt;p&gt;The music was also the &lt;strong&gt;first job given to a parallel subagent&lt;/strong&gt;, a separate Claude session working in
its own lane while the main session coordinated.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;a-song-is-data&quot;&gt;A song is data &lt;a class=&quot;anchor&quot; href=&quot;#a-song-is-data&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A track is&lt;/span&gt; a Python dictionary: tempo, reverb length, a seed, chord voicings and a list of sections. Each
section is a chord progression (one chord per bar) plus the parts that play over it. Here is the opening
of the Sanctuary Vale theme:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;s2&quot;&gt;&quot;sanctuary_vale&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
		&lt;span class=&quot;s2&quot;&gt;&quot;bpm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;76&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;reverb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;2.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;seed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;11&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;chords&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CHORDS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
		&lt;span class=&quot;s2&quot;&gt;&quot;sections&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
			&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Dm Bb F C&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;pad&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;drone&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;arp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;eighths&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;bass&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;whole&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.45&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;koto&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTRO_HINT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
			&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PROG_A&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;pad&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;arp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;eighths&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;bass&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;roots&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;koto&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MOTIF_A&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
			&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PROG_A&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;pad&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;arp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;eighths&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;bass&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;roots&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;koto&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MOTIF_A&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
					&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;flute&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ANSWER_A&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;drums&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;full&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;.8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Melodies are written as text, &lt;code&gt;NOTE:beats[:velocity]&lt;/code&gt;, with &lt;code&gt;r&lt;/code&gt; for rests and &lt;code&gt;|&lt;/code&gt; for bar lines:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;MOTIF_A&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;r:1 A4:.5 C5:.5 D5:1.5 F5:.5 | D5:3 r:.5 C5:.5 | A4:1 C5:.5 A4:.5 G4:1 F4:1 | G4:3 r:1 |&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;r:1 A4:.5 C5:.5 D5:1.5 G5:.5 | F5:2 D5:1 F5:.5 G5:.5 | A5:1.5 G5:.5 F5:1 D5:1 | D5:1 A4:3&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;&quot;&quot;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The parser checks that every bar adds up to four beats and fails with the offending bar if it doesn&#x27;t. It
sounds pedantic, but it&#x27;s the musical version of a type check, and it catches the most common mistake when
writing melodies as text.&lt;/p&gt;
&lt;p&gt;The engine (&lt;code&gt;music_lib.py&lt;/code&gt;) and the songs are separate. The library holds the instruments, the
arrangement engine and the mastering. Each map&#x27;s track lives in its own module in
&lt;code&gt;tools/audio/music_tracks/&lt;/code&gt;. It didn&#x27;t start that way. The Vale theme came first as a single script, and
when nine more tracks were needed, it was split into a shared library and one data module per map. After
that, a new track means new data for the same engine.&lt;/p&gt;
&lt;h2 id=&quot;the-instruments&quot;&gt;The instruments &lt;a class=&quot;anchor&quot; href=&quot;#the-instruments&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every instrument is&lt;/span&gt; a function that returns a numpy array. A few highlights:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Koto&lt;/strong&gt; (and the oud in the desert) is a &lt;strong&gt;Karplus-Strong&lt;/strong&gt; plucked string: a burst of noise fed back
through a delay line one period long, with a filter that keeps the tuning exact. The docstring:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;pluck&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ring&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bright&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vibrato&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pos_frac&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.13&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;sd&quot;&gt;&quot;&quot;&quot;Karplus-Strong string: block-wise feedback (blocks of one period) so numpy does the work.&lt;/span&gt;
&lt;span class=&quot;sd&quot;&gt;	The loop filter is the classic two-point average convolved with a linear fractional delay,&lt;/span&gt;
&lt;span class=&quot;sd&quot;&gt;	which keeps the tuning exact. vibrato &amp;gt; 0 adds a guzheng-like left-hand wobble after the attack.&quot;&quot;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The trick in &amp;quot;block-wise&amp;quot; is that the feedback loop runs one &lt;em&gt;period&lt;/em&gt; at a time instead of one sample at
a time, so numpy can vectorize it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bells and glass&lt;/strong&gt; are FM synthesis: a carrier with a modulator at a non-integer ratio, which gives the
inharmonic shimmer of metal.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Flute, shakuhachi and ney&lt;/strong&gt; are additive tones with a scooped onset, delayed vibrato and band-passed breath noise.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Choir&lt;/strong&gt; is detuned saw waves with vibrato, sent through vowel formant filters.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Percussion&lt;/strong&gt; (taiko, frame drum, rim, finger cymbals, anvil, heartbeat) is shaped noise and pitched
sweeps.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notes are slightly humanized in timing and velocity, and the seed makes every render repeatable, so a
rerun gives the same music unless the data changes.&lt;/p&gt;
&lt;h2 id=&quot;one-motif-eleven-moods&quot;&gt;One motif, eleven moods &lt;a class=&quot;anchor&quot; href=&quot;#one-motif-eleven-moods&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;To tie the&lt;/span&gt; maps together, every track quotes the Vale&#x27;s opening pickup, the three
notes A C D.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; Each map transposes and bends it into its own
scale, as the track headers record:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Map&lt;/th&gt;
&lt;th&gt;Mode, tempo&lt;/th&gt;
&lt;th&gt;How it quotes the motif&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sanctuary Vale&lt;/td&gt;
&lt;td&gt;D minor pentatonic, 76 BPM&lt;/td&gt;
&lt;td&gt;The source: koto, then bamboo flute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadow Marsh&lt;/td&gt;
&lt;td&gt;E &amp;quot;in&amp;quot; scale, 58 BPM&lt;/td&gt;
&lt;td&gt;B D E opens the koto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crypt of Whispers&lt;/td&gt;
&lt;td&gt;A minor + phrygian Bb, 72 BPM&lt;/td&gt;
&lt;td&gt;Lone koto plinks &amp;quot;half-remember&amp;quot; E G A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orc Valley&lt;/td&gt;
&lt;td&gt;A minor pentatonic, 92 BPM&lt;/td&gt;
&lt;td&gt;Every horn call opens with E G A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duskmoor&lt;/td&gt;
&lt;td&gt;C minor + phrygian Db, 66 BPM&lt;/td&gt;
&lt;td&gt;The koto opens with G Bb C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sunscar Desert&lt;/td&gt;
&lt;td&gt;D hijaz, 84 BPM&lt;/td&gt;
&lt;td&gt;The ney&#x27;s opening line borrows A C D&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frostpeak&lt;/td&gt;
&lt;td&gt;B minor pentatonic, 70 BPM&lt;/td&gt;
&lt;td&gt;The flute opens with F# A B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ember Wastes&lt;/td&gt;
&lt;td&gt;D phrygian, 80 BPM&lt;/td&gt;
&lt;td&gt;A C D on horn, &amp;quot;dragged down a step into Eb&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hellmouth Rift&lt;/td&gt;
&lt;td&gt;C phrygian dominant, 96 BPM&lt;/td&gt;
&lt;td&gt;G Bb C in the choir, &amp;quot;bent into Db&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadowlands&lt;/td&gt;
&lt;td&gt;sliding away from D minor, 54 BPM&lt;/td&gt;
&lt;td&gt;The Vale theme itself on a detuned, sagging piano&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The Shadowlands track is the payoff. It&#x27;s the place the Shadows come from, and its music is the Vale&#x27;s own
theme slowed down and &amp;quot;heard through the Shadows&amp;quot;, over chords that slide away from D minor. Players who
have heard the Vale theme for 80 levels should recognize it, and feel that something is wrong with it.&lt;/p&gt;
&lt;h3 id=&quot;twenty-more-one-per-dungeon&quot;&gt;Twenty more, one per dungeon &lt;a class=&quot;anchor&quot; href=&quot;#twenty-more-one-per-dungeon&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When the dungeons arrived, each one got a track written for its mechanic, by the same engine and the
same text notation. A few examples, from the track headers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bog Witch&#x27;s Hollow&lt;/strong&gt;: &amp;quot;a crooked nursery tune gone off in the damp&amp;quot;, a detuned music-box piano in D
phrygian with a raised third now and then (&amp;quot;the witch&#x27;s wrong note&amp;quot;), drips, glass plinks and a
heartbeat drum stirring the pot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Demon Tower&lt;/strong&gt;: a stair motif in the low horns &amp;quot;that climbs a step every time it comes back&amp;quot;,
because the dungeon is nine floors that each get worse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Hall of Mirrors&lt;/strong&gt;: glass phrases answered by their own retrograde, the music of a dungeon made of
two mirrored mazes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Shadow Citadel&lt;/strong&gt;, the final raid: &amp;quot;the Shadowlands&#x27; twisted Vale motif grown into a march&amp;quot;, with
temple bells tolling four times, one per school. A detuned piano states the motif bent out of the
scale; then a horn answers with it straight, &amp;quot;the four disciples&#x27; version&amp;quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the motif from the first village still closes the story, more than eighty levels after you first
heard it in its home key.&lt;/p&gt;
&lt;h2 id=&quot;mixing-reverb-from-noise-loops-without-seams&quot;&gt;Mixing: reverb from noise, loops without seams &lt;a class=&quot;anchor&quot; href=&quot;#mixing-reverb-from-noise-loops-without-seams&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;There is no&lt;/span&gt; plugin chain. The reverb is a &lt;strong&gt;synthetic stereo impulse response&lt;/strong&gt;: decorrelated noise for
each channel, with the highs decaying faster than the lows, and a 22 ms pre-delay. The whole wet signal is
convolved with it in one &lt;code&gt;oaconvolve&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;Game music loops, and a loop that clicks or drops its reverb tail at the seam is worse than no loop. The
trick is in how the render ends. Everything is rendered past the loop end (16 seconds of notes and reverb
tail) and then &lt;strong&gt;wrapped back onto the start&lt;/strong&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;n&quot;&gt;n_loop&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;round&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;length&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RATE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;np&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zeros&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n_loop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;np&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arange&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PRE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n_loop&lt;/span&gt;
	&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;np&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bincount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;weights&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;minlength&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n_loop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The tail of the last chord rings into the first bar, just as it would if the piece had really been played
twice. Every later processing step is &lt;strong&gt;circular&lt;/strong&gt; for the same reason: the high-pass filter, the section
loudness rider, and the limiter&#x27;s gain smoothing all use &lt;code&gt;mode=&amp;quot;wrap&amp;quot;&lt;/code&gt;, so nothing happens at the seam that
doesn&#x27;t happen everywhere else.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/09-waveform-loop.png&quot; alt=&quot;The end of the Vale theme played into its start: no click at the seam&quot; width=&quot;1560&quot; height=&quot;728&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The end of the Vale theme played into its start: no click at the seam&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;mastering-to-a-standard&quot;&gt;Mastering to a standard &lt;a class=&quot;anchor&quot; href=&quot;#mastering-to-a-standard&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Each track is&lt;/span&gt; mastered to &lt;strong&gt;-15 LUFS&lt;/strong&gt; integrated loudness, measured by a small implementation of
ITU-R BS.1770 (K-weighting, 400 ms blocks, gating) right in the library. A limiter holds sample peaks
at -1.5 dBFS. Then comes a step many people skip. Vorbis encoding can overshoot on sharp plucks, so the
script decodes the Ogg it just wrote, measures the &lt;strong&gt;true peak&lt;/strong&gt; with 4x oversampling, and trims and
re-encodes until it&#x27;s at or below -1 dBFS:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;c1&quot;&gt;# Vorbis overshoots on sharp plucks: decode, measure the true peak, trim and re-encode until &amp;lt;= -1 dBFS&lt;/span&gt;
	&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;write_ogg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;over&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;true_peak_db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sf&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TRUE_PEAK_DB&lt;/span&gt;
		&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;over&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
			&lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;audio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;over&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each render prints a report line with length, loudness, true peak and file size, so a check is one glance.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/09-render-report.png&quot; alt=&quot;Render reports: length, loudness, true peak and file size per track&quot; width=&quot;956&quot; height=&quot;329&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Render reports: length, loudness, true peak and file size per track&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;in-the-game&quot;&gt;In the game &lt;a class=&quot;anchor&quot; href=&quot;#in-the-game&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;MusicPlayer&lt;/code&gt; plays the track of the zone you stand in, or else the map&#x27;s, with a 1.5 s crossfade between
two players. The Vale&#x27;s town has its own calmer variation (62 BPM, temple bells, quarter-note arps). Walking
out of the gate crossfades into the field theme. A map can name its music before the file exists; the
player stays silent until it&#x27;s there.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/09-vale-gate.jpg&quot; alt=&quot;Just outside the north gate, where the town music hands over to the field theme&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Just outside the north gate, where the town music hands over to the field theme&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;sound-effects-from-placeholder-tones-to-a-synthesizer&quot;&gt;Sound effects: from placeholder tones to a synthesizer &lt;a class=&quot;anchor&quot; href=&quot;#sound-effects-from-placeholder-tones-to-a-synthesizer&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;When this series&lt;/span&gt; was first drafted, sound effects were far behind the music. &lt;code&gt;data/sounds.json&lt;/code&gt; mapped
every event (swing, hit, skill by tier, rare drop, level up, UI click) to real files when they existed,
and to a &lt;strong&gt;placeholder tone&lt;/strong&gt; generated in code when they didn&#x27;t:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;rare_drop&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;files&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;bus&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;SFX&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;pitch_jitter&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;synth&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;wave&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;sine&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;from&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;880&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;to&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1760&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;length&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.45&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;volume&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.4&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Only the Shadow stone breaking had a designed sound (&amp;quot;a sharp crack, a heavy low thud, then crumbling
debris that settles, with a little room echo&amp;quot;). The placeholder system meant the game had sound
everywhere from day one, and real sounds could slot in by adding a file path.&lt;/p&gt;
&lt;p&gt;They did, a few hours later, from the same approach as the music. &lt;code&gt;tools/audio/sfx.py&lt;/code&gt; synthesizes every
effect from noise, oscillators and filters, with &amp;quot;an East Asian touch (wooden clacks for the interface,
gongs and temple bells for milestones, plucked silk strings)&amp;quot;. The plucks reuse the music library&#x27;s
Karplus-Strong string. Frequent sounds get several variations, which the game picks at random (never the
same one twice in a row) and jitters in pitch. Loudness is set per category from the loudest 100 ms of
each sound, and peaks are limited to -1 dBFS. Footsteps change with the ground, fists sound different
from swords. At the time of writing, 62 of the 63 sound events have synthesized files (126 WAV files in
all); the one still on a placeholder tone is waystone travel.&lt;/p&gt;
&lt;p&gt;Like the music, the script can render a &amp;quot;listening sheet&amp;quot;: a waveform and spectrogram per category plus a
montage WAV, so the effects can be reviewed at a glance before anyone listens to them in the game. The
human listens and decides; the numbers just make sure nothing clips or drowns out the rest.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Treat songs as data.&lt;/strong&gt; A small arrangement engine plus text melodies lets you write, review and diff
music like code. Bar-length checks catch typos.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make loops circular end to end.&lt;/strong&gt; Render past the end, wrap the tail onto the start, and keep every
later filter circular.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure loudness and true peak after encoding.&lt;/strong&gt; The encoder can undo your limiter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A shared motif ties a world together&lt;/strong&gt; for almost no cost, and it lets you tell a story with the
last map&#x27;s music.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The next post&lt;/span&gt; is about a calmer side of the game: fishing, a Stardew-style minigame inside a
server-authoritative ARPG.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;See &lt;a href=&quot;https://dekiro.d12r.io/11-coordinator-and-subagents.html&quot;&gt;the coordinator post&lt;/a&gt;, number 11. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Scale degrees 5, flat 7 and 1 in D minor. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;One honest note: libsndfile&#x27;s Vorbis encoder crashed on one huge write, so &lt;code&gt;write_ogg&lt;/code&gt; feeds it a second at
a time. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Chasing the Valheim Look, and Leaving It for HD-2D</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/08-chasing-the-valheim-look.html"/>
<id>https://dekiro.d12r.io/08-chasing-the-valheim-look.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Pixel texels on every surface, leaf cards, grass that parts and leaves trails, shadows from clouds that don&#x27;t exist, and why a day later every surface except the grass was redrawn in the style of a pixel sprite.</summary>
<content type="html">&lt;h2 id=&quot;the-target&quot;&gt;The target &lt;a class=&quot;anchor&quot; href=&quot;#the-target&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Valheim is a&lt;/span&gt; good reference for a small team. Its models are simple, but the world looks rich, because
of a handful of tricks applied everywhere: low-resolution textures with hard pixels, dense foliage made
of textured cards, grass you walk through, strong light and atmosphere, and a slightly soft lens. None of
it needs detailed models. That made it a perfect target for a game whose every model comes from a Python
script.&lt;/p&gt;
&lt;p&gt;The human set the direction and judged every step. The AI implemented each piece as a shader or a small
script, took screenshots, and iterated. Almost all of it landed in one night, in about twenty commits.&lt;/p&gt;
&lt;h2 id=&quot;1-pixel-texels-on-everything&quot;&gt;1. Pixel texels on everything &lt;a class=&quot;anchor&quot; href=&quot;#1-pixel-texels-on-everything&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Our models have&lt;/span&gt; no textures: each material is a flat colour from the Blender builders. To get Valheim&#x27;s
pixel texture feel without painting textures, we generate the texels in the shader. &lt;code&gt;pixel_noise.gdshaderinc&lt;/code&gt;
picks the plane a surface faces, cuts it into a grid of texels, and tints each texel a little lighter or
darker, warmer or cooler, in two sizes:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;glsl&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pixel_tint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;col&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;density&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strength&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;abs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uv&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xz&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zy&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pos&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uv&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;density&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;floor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fine&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pn_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;coarse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pn_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;floor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;4.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;57.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;v&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fine&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.65&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;coarse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.35&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;texel_px&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fwidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fwidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// texels per screen pixel&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strength&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smoothstep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;texel_px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Two details make it work. The texels are computed in the model&#x27;s &lt;strong&gt;own space&lt;/strong&gt;, scaled to metres, so they
stick to a monster as it walks instead of swimming across it. And the effect &lt;strong&gt;fades out&lt;/strong&gt; where a texel
gets smaller than a screen pixel (&lt;code&gt;fwidth&lt;/code&gt;), so distant surfaces don&#x27;t shimmer.&lt;/p&gt;
&lt;p&gt;A small script, &lt;code&gt;PixelLook&lt;/code&gt;, applies it after a model loads. It swaps every plain material for the pixel
shader with the same colour, roughness and glow. Materials with transparency stay untouched, and so,
after the latest commit, do painted textures: they keep their pixels and get the rest of the shader&#x27;s
features (rim light, fade-in, cloud shadows).&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-before-after-pixel.jpg&quot; alt=&quot;Town houses with PixelLook off and on&quot; width=&quot;1600&quot; height=&quot;530&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Town houses with PixelLook off and on&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;2-the-ground-and-a-precision-bug&quot;&gt;2. The ground, and a precision bug &lt;a class=&quot;anchor&quot; href=&quot;#2-the-ground-and-a-precision-bug&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The ground shader&lt;/span&gt; mixes several noise scales into patches, dry and damp areas, grain and tufts, and paves
safe zones with flagstones. For a while it had a bug that made the ground look flat, with almost none of
the intended variation. The fix touched two things, how the shader got the world position and the hash
function, and the comment on the hash explains the main cause:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;glsl&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Arithmetic hashes on the lattice cell wrapped into a small range (no sin(): the joined world&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// sits ~10 km from the origin, where sin() of the huge arguments loses all precision).&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mod&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;289.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fract&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xyx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.1031&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;yzx&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;33.33&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fract&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The classic &lt;code&gt;fract(sin(dot(p, ...)) * 43758.5453)&lt;/code&gt; hash is fine near the origin. Our maps sit thousands of
metres from it.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; At
those coordinates the GPU&#x27;s 32-bit &lt;code&gt;sin()&lt;/code&gt; of a huge argument returns garbage that barely changes between
neighbouring cells, so the noise flattens out. Wrapping the cell into a small range first and using an
arithmetic hash fixed it. Every hash on world coordinates in our shaders now works this way.&lt;/p&gt;
&lt;h2 id=&quot;3-leaf-cards&quot;&gt;3. Leaf cards &lt;a class=&quot;anchor&quot; href=&quot;#3-leaf-cards&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first trees&lt;/span&gt; had crowns made of solid low-poly blobs. Valheim-style crowns are made of many small
quads, each carrying a cut-out texture of a leaf cluster. The Blender builder now scatters those cards
through each crown, and &lt;code&gt;tools/flora_textures.py&lt;/code&gt; draws the leaf textures as pixel art with Pillow:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Light, nearly neutral colors: the game multiplies them by each plant&#x27;s tint (map color + jitter).
Transparent between the leaves; the cluster thins out toward the edge so a card&#x27;s square never shows.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Cards have a known weakness: their flat normals make crowns look like a pile of paper. The fix is to point
each card&#x27;s normal &lt;strong&gt;out of the crown&#x27;s centre&lt;/strong&gt;, so the crown lights like one soft volume (the commit
calls them &amp;quot;cloud-like normals&amp;quot;). The follow-up commit was called &amp;quot;Crowns all leaves (no solid blobs),
wider and fuller&amp;quot;. Flowers, reeds, ferns and dead-tree twigs became cards too.&lt;/p&gt;
&lt;p&gt;Foliage between the camera and your character dissolves in a dither pattern, using a global shader
uniform, &lt;code&gt;focus_position&lt;/code&gt;, that holds where the character stands. The human asked for &amp;quot;solid trees, but
see in deep woods&amp;quot;, so only leaves dissolve and trunks stay solid.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-leaf-cards.jpg&quot; alt=&quot;Broadleaf crowns built from leaf cards&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Broadleaf crowns built from leaf cards&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;4-grass&quot;&gt;4. Grass &lt;a class=&quot;anchor&quot; href=&quot;#4-grass&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Grass took the&lt;/span&gt; most iterations: dense curved blades, then pixel texels on the blades, then textured cards
with a pixel blade texture, then tall and short patches, thin spots, thinning toward roads and leaning
cards. The current shader has a few ideas worth stealing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stepped gradient&lt;/strong&gt;: the root-to-tip colour moves in steps that match the texture&#x27;s pixel rows, so it
reads as pixel art instead of a smooth fade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backlight&lt;/strong&gt;: &lt;code&gt;BACKLIGHT = col * 0.6 * height_k;&lt;/code&gt; lets the sun shine through the tips.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distance thinning with a dither&lt;/strong&gt;, skipped in the shadow pass (&lt;code&gt;PROJECTION_MATRIX[3][3] &amp;lt; 0.5&lt;/code&gt; is true
only for the perspective camera), so grass shadows don&#x27;t vanish with distance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The part players notice most is that &lt;strong&gt;grass parts around characters&lt;/strong&gt;. The shader gets a list of
&amp;quot;pushers&amp;quot; (up to 64, each a position and a radius) and bends blades away from them and down:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;glsl&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mo&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pusher_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;away&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VERTEX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xz&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pushers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xz&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;away&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smoothstep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pushers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.35&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pushers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;abs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VERTEX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pushers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;2.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;away&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.05&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;press&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;press&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Trails&lt;/strong&gt; came almost for free. The script drops a trampled spot every 1.2 m behind each character and
passes those spots to the shader as extra pushers whose radius shrinks over eight seconds. The grass stands
back up, slowly at first.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-grass-trail.jpg&quot; alt=&quot;Walking through the tall grass of the Whispering Fields&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Walking through the tall grass of the Whispering Fields&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;5-cloud-shadows-without-clouds&quot;&gt;5. Cloud shadows without clouds &lt;a class=&quot;anchor&quot; href=&quot;#5-cloud-shadows-without-clouds&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Seen from a&lt;/span&gt; third-person camera you rarely look at the sky, but you always see the ground. Big soft
shadows drifting over the land say &amp;quot;clouds&amp;quot; more than any skybox. So there are no clouds, only their
shadows: a shared shader include that every ground, grass, plant and model shader calls.&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;glsl&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cloud_light&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xz&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cloud_shade&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;vec2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xz&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cloud_shade&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;xy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;110.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// clouds some 30-80 m across&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cl_noise&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.6&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cl_noise&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;2.3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;5.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.28&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cl_noise&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;5.1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cut&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cloud_shade&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shade&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smoothstep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cut&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.06&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cut&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.06&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// soft edges&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shade&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cloud_shade&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;MapLook&lt;/code&gt; moves the pattern every frame through one global uniform. About a third of the ground is in
shade at any time, 22% darker. It&#x27;s off at night and in dungeons. Because it&#x27;s the same function
everywhere, a shadow passes over the grass, the path and a monster at the same moment.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-cloud-shadows.jpg&quot; alt=&quot;The fields at noon, with cloud shadows on the ground&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The fields at noon, with cloud shadows on the ground&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;6-the-lens-depth-of-field-and-light&quot;&gt;6. The lens, depth of field, and light &lt;a class=&quot;anchor&quot; href=&quot;#6-the-lens-depth-of-field-and-light&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The last layer&lt;/span&gt; is the camera:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;lens shader&lt;/strong&gt; draws over the 3D view: the picture blurs slightly toward the corners (sampling a
higher mip level), darkens a little there and splits its colours at the edges. The field of view widened
to 60°. Tuning took three quick follow-up commits: &amp;quot;only at the outer corners&amp;quot;, &amp;quot;half the corner blur&amp;quot;,
&amp;quot;a bit more color fringing&amp;quot;. It&#x27;s a Video setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Depth of field&lt;/strong&gt; focused on the character softens the far distance. Also a toggle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lighting&lt;/strong&gt;: a 36-minute day from the wall clock, a moving sun with golden mornings and evenings, a
bright silver moon with its own shadows, AgX tonemapping, bloom, SSAO and volumetric fog by quality
setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rim light on monsters&lt;/strong&gt;: a soft blue-white Fresnel edge (gold for Elites) so they read in tall grass
and at night. It&#x27;s an instance uniform on the shared model shader, so monsters don&#x27;t need materials of their own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scale&lt;/strong&gt;: Map 1&#x27;s beasts are drawn 1.8 times larger (the giant rat 2.6 times), and small monsters at
least 1 m tall.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-lens.jpg&quot; alt=&quot;A full frame with the lens on: soft corners and a slight colour fringe&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A full frame with the lens on: soft corners and a slight colour fringe&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-night.jpg&quot; alt=&quot;The same fields at night: moonlight on two wolves and a boar&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same fields at night: moonlight on two wolves and a boar&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-it-cost&quot;&gt;What it cost &lt;a class=&quot;anchor&quot; href=&quot;#what-it-cost&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;All of this&lt;/span&gt; went on top of the performance budget from the previous post. Most of it is cheap by design:
the pixel texels and cloud shadows are a few hash lookups per pixel; grass and leaves are cut-out cards
with no extra geometry; rim light and the fade-in are instance uniforms. The expensive parts (grass
shadows, volumetric fog, SSAO) are tied to the Graphics quality setting.&lt;/p&gt;
&lt;h2 id=&quot;then-the-characters-changed-everything&quot;&gt;Then the characters changed everything &lt;a class=&quot;anchor&quot; href=&quot;#then-the-characters-changed-everything&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Everything above was&lt;/span&gt; in place by the morning of day four, and the world looked good. The characters
didn&#x27;t. The player was still a capsule, and a separate line of work was trying to replace it with a 3D
character from a Blender script. It went through six drafts: flat primitives, a &amp;quot;Valheim hulk&amp;quot;,
proportions borrowed from a popular Korean MMO, then anime-style characters with outfits, masks and glowing eyes. The human&#x27;s
feedback on one of them is a fair summary of the problem: &amp;quot;the faces are bad, like really bad&amp;quot;, &amp;quot;the hair
looks also like rubber&amp;quot;. A procedurally modelled face at that size, in a 3D world, never looked right.&lt;/p&gt;
&lt;p&gt;The human then shared a pixel-art sprite as a reference: &amp;quot;This is the style of pixel art sprite I&#x27;m looking
for for the character. Proportions especially are better there, same for shadings.&amp;quot; Two approaches were
tried in parallel. One drew the sprites directly as 2D pixel art, in a design tool and from code. The
other kept the 3D characters and &lt;em&gt;rendered&lt;/em&gt; them into 8-direction pixel sprites. The design-tool drawings
were rejected in no uncertain terms.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;
The renders won: &amp;quot;the rendered 3d to 2d one look way better.&amp;quot;&lt;/p&gt;
&lt;p&gt;That decision is the subject of its own post. What matters here is what it did to the world: a lit,
outlined pixel sprite standing next to monsters and houses with soft Valheim-style shading looked like
two games glued together. The human&#x27;s next instruction came shortly after the sprites went in:
&amp;quot;now with the new 2d shader on the character I would like to have all the monsters have the same shader,
also all the buildings.&amp;quot;&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-sprite-look-pair.jpg&quot; alt=&quot;Before and after the sprite look on monsters: the same wolf pack with pixel-texel shading (left) and sprite-style shading (right)&quot; width=&quot;1600&quot; height=&quot;427&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Before and after the sprite look on monsters: the same wolf pack with pixel-texel shading (left) and sprite-style shading (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;7-the-sprite-look-for-everything-else&quot;&gt;7. The sprite look for everything else &lt;a class=&quot;anchor&quot; href=&quot;#7-the-sprite-look-for-everything-else&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The sprites follow&lt;/span&gt; a strict colour rule:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; every material is a &lt;em&gt;ramp&lt;/em&gt; of a
few colours computed in the OKLCH colour space, where shadows don&#x27;t just get darker but shift in hue, and
the light is quantized into hard steps. To make 3D models sit next to them, &lt;code&gt;PixelLook&lt;/code&gt; gained a second
style, &lt;code&gt;SPRITE&lt;/code&gt;, for monsters, Shadow stones, buildings, torii, waystones and props:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;the sun in four hard bands, each a hue-shifted step of the surface&#x27;s colour ramp (the sprites&#x27; rule,
sprite_ramp.gdshaderinc), plus a 1-sprite-pixel outline in a darker violet-leaning shade drawn in screen
space.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The shader include that builds the ramp is the same one the sprite pipeline&#x27;s Python mirrors, so a red
roof and a red armor plate land on the same five colours. The outline is drawn in screen space with a
stencil, one &lt;em&gt;sprite&lt;/em&gt; pixel wide rather than one screen pixel, so at 4K it matches the thickness of the
outline baked into the character sheets.&lt;/p&gt;
&lt;p&gt;A day later the same treatment reached the flora, rocks, mountains and the ground. One thing was kept on
purpose: &lt;strong&gt;the grass&lt;/strong&gt;. &amp;quot;I guess not the grass. I like the grass.&amp;quot; The grass cards, their trails and their
stepped gradient from section 4 are unchanged. Cloud shadows, the lens, depth of field and the lighting
also stayed: they&#x27;re camera and atmosphere, and they work for sprites as well as for models.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-flora-before-after.jpg&quot; alt=&quot;Frostpeak&amp;#x27;s pines with the pixel-texel look and with the sprite look&quot; width=&quot;1600&quot; height=&quot;528&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Frostpeak&amp;#x27;s pines with the pixel-texel look and with the sprite look&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;how-the-switch-was-made-safe&quot;&gt;How the switch was made safe &lt;a class=&quot;anchor&quot; href=&quot;#how-the-switch-was-made-safe&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Changing the look&lt;/span&gt; of every surface in a game on short notice is risky, so it was built as a switch rather
than a replacement. Every mesh drawn in the sprite style keeps a &lt;em&gt;plain twin&lt;/em&gt; material, and a Video
setting (&amp;quot;Sprite-style shading&amp;quot;) swaps every one of them at runtime. Flora, the ground and the mountains
keep both materials and swap an override. That made before/after screenshots trivial (flip the setting,
take the same shot) and leaves an escape hatch if a player prefers the softer look or a machine struggles.&lt;/p&gt;
&lt;p&gt;Two smaller fixes followed from the change. World name tags above NPCs, portals and loot used to be 3D
labels, which the depth of field and the lens blurred. They&#x27;re now drawn as a crisp 2D overlay with pixel
spacing and overlap nudging. And a shader bug from this period became a project rule: a uniform named
like a local variable in a shared include broke the sprite shader, and every character rendered as a white
rectangle.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/08-now.jpg&quot; alt=&quot;The current look: sprite-shaded houses and lanterns, the unchanged grass, cloud light and a sprite character at the harbor at sunset&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The current look: sprite-shaded houses and lanterns, the unchanged grass, cloud light and a sprite character at the harbor at sunset&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;was-the-valheim-chapter-wasted&quot;&gt;Was the Valheim chapter wasted? &lt;a class=&quot;anchor&quot; href=&quot;#was-the-valheim-chapter-wasted&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Less than it&lt;/span&gt; looks. The texel shader still draws everything when the sprite setting is off. The grass,
cloud shadows, lens, lighting, depth of field, the foliage dissolve and the far-origin hash fix are all in
the current game. What changed is the &lt;em&gt;surface&lt;/em&gt; rule: from &amp;quot;invent texture in the shader&amp;quot; to &amp;quot;quantize
light into a hue-shifted ramp and outline it&amp;quot;. Both are cheap, and both were built so the next change of
mind costs a setting and a shader, not a rebuild.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pick a reference and steal its principles, not its assets.&lt;/strong&gt; Pixel texels, cards and atmosphere
transfer to any style of model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generate texture in the shader&lt;/strong&gt; when you have no textures. Object-space texels that fade below
pixel size look like a texture without being one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never hash with &lt;code&gt;sin()&lt;/code&gt; far from the origin.&lt;/strong&gt; Wrap and use an arithmetic hash.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Share includes across shaders.&lt;/strong&gt; Cloud shadows, pixel noise and the colour ramp live in one file each
and are used everywhere, so every surface agrees. (Name their variables carefully.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One look for the whole frame.&lt;/strong&gt; The best character art fails next to a world drawn by a different rule.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ship big visual changes as a switch.&lt;/strong&gt; A runtime toggle gives you before/after shots for free and a way
back.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A world needs&lt;/span&gt; sound. The next post is about the music: one original track per map, composed and
synthesized in Python, all quoting the same motif.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;This is where the transparent-roof bug from &lt;a href=&quot;https://dekiro.d12r.io/05-everything-from-code-headless-blender.html&quot;&gt;the Blender post&lt;/a&gt;
came from: the swap originally ignored double-sided materials. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Each map has its own slot, 5000 m apart, and the joined world sat about 10 km out. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;The code-drawn ones survived as a style target for the colour rules. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html&quot;&gt;The HD-2D post&lt;/a&gt; has the details. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/04-testing-a-game-with-13800-checks.html&quot;&gt;The testing post&lt;/a&gt; has the story. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>From 20 fps to 144, and a GPU Heap That Ran Out: Performance on Big Maps</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/07-performance-on-a-3km-map.html"/>
<id>https://dekiro.d12r.io/07-performance-on-a-3km-map.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Nine hundred animated monsters, tens of thousands of plants, two thousand crates, and the one rule that saved us twice: measure before you optimize.</summary>
<content type="html">&lt;h2 id=&quot;the-problem&quot;&gt;The problem &lt;a class=&quot;anchor&quot; href=&quot;#the-problem&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The new Sanctuary&lt;/span&gt; Vale (previous post) is 2.6 x 3 km. In its first version the generator placed 270
monster packs and 54 Shadows across three hunting grounds, plus a walled town, farmsteads and over 20,000
trees, plants and rocks. The commit that built the map already batched plants in chunks and split the
tree colliders (more on both below). Even so, with everything in place the game dropped to about
&lt;strong&gt;20 fps&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The tempting move is to guess: &amp;quot;it&#x27;s the grass&amp;quot;, &amp;quot;it&#x27;s the shadows&amp;quot;, &amp;quot;it&#x27;s the trees&amp;quot;. Those guesses are
expensive because they&#x27;re often wrong, and a wrong optimization leaves complexity behind. So the first
step was to measure where the frame time actually goes.&lt;/p&gt;
&lt;h2 id=&quot;measure-first-the-cpu-not-the-gpu&quot;&gt;Measure first: the CPU, not the GPU &lt;a class=&quot;anchor&quot; href=&quot;#measure-first-the-cpu-not-the-gpu&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The measurement pointed&lt;/span&gt; away from rendering. The frame was &lt;strong&gt;CPU-bound&lt;/strong&gt;, and the biggest item was
&lt;strong&gt;941 animated monsters&lt;/strong&gt;. Every one of them had a live &lt;code&gt;AnimationPlayer&lt;/code&gt; updating a skeleton every frame,
including monsters a kilometre away that nobody could see. Their AI was already asleep at that distance
(see &amp;quot;dozing&amp;quot; below); their skeletons were not.&lt;/p&gt;
&lt;p&gt;The comment that went into the code says it in one line:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Distance level of detail (the big cost on large maps was hundreds of far-away models animating):&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## animate within ANIM_RANGE of the camera, hold the pose up to HIDE_RANGE, beyond that not drawn.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ANIM_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;70.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HIDE_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;170.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LOD_CHECK&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h2 id=&quot;step-1-animation-lod&quot;&gt;Step 1: animation LOD &lt;a class=&quot;anchor&quot; href=&quot;#step-1-animation-lod&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first fix&lt;/span&gt; was cheap and local to &lt;code&gt;ModelVisual&lt;/code&gt;, the node that shows a monster&#x27;s model. Twice a second
each monster checks its distance to the camera. Within 70 m it animates. Between 70 and 170 m it holds its
pose: the &lt;code&gt;AnimationPlayer&lt;/code&gt; is switched off, so no skeleton updates happen. Beyond 170 m it isn&#x27;t drawn at
all.&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_update_lod&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cam&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_viewport&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_camera_3d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_inside_tree&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cam&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayServer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;headless&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cam&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;global_position&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;distance_to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;global_position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;show&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HIDE_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_dead&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;visible&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;visible&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;show&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;show&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ANIM_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_dead&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_animating&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_animating&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;animate&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_anim&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_anim&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;active&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# a far model holds its pose: no skeleton updates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;One detail matters more than it looks: the check timers are &lt;strong&gt;staggered&lt;/strong&gt;
(&lt;code&gt;LOD_CHECK * randf_range(0.8, 1.2)&lt;/code&gt;). Otherwise 941 monsters would all check on the same frame and cause
a hitch twice a second.&lt;/p&gt;
&lt;h2 id=&quot;step-2-dont-have-941-monsters&quot;&gt;Step 2: don&#x27;t have 941 monsters &lt;a class=&quot;anchor&quot; href=&quot;#step-2-dont-have-941-monsters&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;LOD made far&lt;/span&gt; monsters cheap. The better question was why they existed at all. A player can see perhaps
150 m. Monsters 2 km away are only a cost: memory, AI ticks, network state.&lt;/p&gt;
&lt;p&gt;So we changed how a map is populated. Every Shadow and every pack now has a &lt;strong&gt;slot&lt;/strong&gt;: a place in its zone
and a list of who stands there (types, levels, which ones are Elites). The monsters only exist while a
player is near:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SPAWN_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;250.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DESPAWN_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;320.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DESPAWN_AFTER&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;15.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SLOT_TICK&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;figure class=&quot;fig margin pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/07-spawn-radius.png&quot; alt=&quot;Monster slots around one player, dumped from a running game: only the slots inside 250 m are spawned&quot; width=&quot;1500&quot; height=&quot;1500&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Monster slots around one player, dumped from a running game: only the slots inside 250 m are spawned&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Once a second the server checks each slot. An empty slot within 250 m of a player spawns. A filled slot
whose monsters are all idle at home, with every player beyond 320 m for 15 seconds, goes away again, and
the slot remembers exactly what stood there. The gap between 250 and 320 m, plus the 15 seconds, is
&lt;strong&gt;hysteresis&lt;/strong&gt;: walking back and forth along the edge doesn&#x27;t spawn and despawn the same pack over and
over. And since clients only see 160 m (the network interest range), all of this happens out of sight.&lt;/p&gt;
&lt;p&gt;Two conditions keep it honest. A slot only despawns if its monsters are idle: dozing at home, unhurt, and
for a Shadow, untouched with no waves out. Nobody can escape a fight by running away and coming back to a
fresh, full-health monster. And a killed slot refills only after its normal respawn time.&lt;/p&gt;
&lt;p&gt;A test walks a player around the Vale and checks both directions: everything spawned is within range,
and the far fields go away when the player goes back to town.&lt;/p&gt;
&lt;h2 id=&quot;dozing-monsters&quot;&gt;Dozing monsters &lt;a class=&quot;anchor&quot; href=&quot;#dozing-monsters&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;An older optimization&lt;/span&gt; from the open-world days still does a lot of work: monsters with no player within
70 m &lt;strong&gt;doze&lt;/strong&gt; (&lt;code&gt;Actor.dormant&lt;/code&gt; skips their per-frame work until a player comes near or something hits
them), and maps with no players at all sleep completely.&lt;/p&gt;
&lt;h2 id=&quot;plants-in-chunks&quot;&gt;Plants in chunks &lt;a class=&quot;anchor&quot; href=&quot;#plants-in-chunks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The rendering side&lt;/span&gt; needed the same treatment, and got it with the map itself. Today the Vale&#x27;s scatter
lists 15,000 pines and 50,000 ferns in the Shadow Woods alone. Plants were always drawn as &lt;code&gt;MultiMesh&lt;/code&gt;
batches, but one batch per kind per map would mean the GPU draws (and shadows) every tree on the map
every frame. So they&#x27;re batched per 96 m chunk with a draw distance:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Plants are batched per CHUNK square and drawn up to DRAW_RANGE m away (small ones half that):&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## a big map would otherwise draw (and shadow) every tree it has, every frame.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CHUNK&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;96.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Trees are drawn to 200 m and small plants to half that. Grass works differently: it only exists within
150 m of the player, and its chunks are built a few per frame as you walk and freed behind you. In the
code&#x27;s words: &amp;quot;a huge map costs no more than a small one.&amp;quot;&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/07-plant-chunks.jpg&quot; alt=&quot;The Whispering Fields from 110 m up (fog off): trees everywhere, grass and small plants only near the camera&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Whispering Fields from 110 m up (fog off): trees everywhere, grass and small plants only near the camera&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;load-time-one-collider-body-was-too-many&quot;&gt;Load time: one collider body was too many &lt;a class=&quot;anchor&quot; href=&quot;#load-time-one-collider-body-was-too-many&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Frame rate wasn&#x27;t&lt;/span&gt; the only problem. Loading the big map was slow too, and the cause was surprising. Every tree
trunk has a collider. All of them were shapes on &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;StaticBody3D&lt;/code&gt;, and each shape added to a body
with thousands of shapes made the next one slower. The fix is again a grid:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Trunk colliders go into one body per COLLIDER_CELL square: thousands of shapes on a single body&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## made each new one slower (a big map took 25 s to plant).&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;COLLIDER_CELL&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;64.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;With one body per 64 m cell, no body grows large enough for that slowdown to matter.&lt;/p&gt;
&lt;h2 id=&quot;the-terrain-cache-that-never-hit&quot;&gt;The terrain cache that never hit &lt;a class=&quot;anchor&quot; href=&quot;#the-terrain-cache-that-never-hit&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The terrain is&lt;/span&gt; a height grid baked from noise and then flattened around towns, water, roads and portals.
On a 2.6 x 3 km map that bake is expensive, and it gives the same result every time, so it should happen
once. We added a disk cache in &lt;code&gt;user://terrain_cache&lt;/code&gt;, keyed by a hash of the map&#x27;s data.&lt;/p&gt;
&lt;p&gt;The first version never hit the cache. The map&#x27;s data dictionary is &lt;strong&gt;mutated at runtime&lt;/strong&gt;: code adds
parsed helper values to it, such as a polygon&#x27;s &lt;code&gt;_points&lt;/code&gt;. So by the time the hash was taken, the
&amp;quot;same&amp;quot; map data hashed differently on every run. The fix hashes only the data from the file and skips
the keys that code adds (which by convention start with &lt;code&gt;_&lt;/code&gt;):&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Hash of map data, without the caches code adds to it (&quot;_&quot; keys, e.g. a polygon&#x27;s parsed &quot;_points&quot;).&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;static&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;v&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Variant&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;v&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Dictionary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Variant&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;v&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;begins_with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;_&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;				&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;v&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The key also includes a &lt;code&gt;BAKE_VERSION&lt;/code&gt;, so changing the bake code invalidates old files. A test checks
that a second load comes from disk in under a second and gives the same heights.&lt;/p&gt;
&lt;p&gt;The lesson is general: &lt;strong&gt;a cache keyed on mutable state is a cache that silently doesn&#x27;t work.&lt;/strong&gt; It
didn&#x27;t crash or give wrong results. It was just slow, which is the hardest kind of bug to notice.&lt;/p&gt;
&lt;h2 id=&quot;the-result-round-one&quot;&gt;The result, round one &lt;a class=&quot;anchor&quot; href=&quot;#the-result-round-one&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;With animation LOD&lt;/span&gt;, proximity spawning, dozing, chunked plants and the cache, the Vale ran at &lt;strong&gt;144 fps
at 4K&lt;/strong&gt;, the refresh-rate cap of the test machine. That left room to spend: shortly after, a commit made
the woods denser, drew trees out to 200 m, and put &lt;strong&gt;three times as many packs and Shadows&lt;/strong&gt; on the map.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Then the art pass (next post) added pixel-texture shaders, leaf cards, denser grass, cloud shadows and a
lens effect, all on top of this budget.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/07-woods-4k.jpg&quot; alt=&quot;The Shadow Woods at 3840x2054 with a measured fps overlay (capped by the monitor&amp;#x27;s 144 Hz)&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Shadow Woods at 3840x2054 with a measured fps overlay (capped by the monitor&amp;#x27;s 144 Hz)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;round-two-the-gpu-heap-that-ran-out&quot;&gt;Round two: the GPU heap that ran out &lt;a class=&quot;anchor&quot; href=&quot;#round-two-the-gpu-heap-that-ran-out&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The frame rate&lt;/span&gt; was fine. Then, on day five, an agent&#x27;s screenshot run on the reworked Hellmouth Rift
produced 7,518 error lines, almost all of them the same:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;text&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;Cannot create uniform set ... not enough room in the RESOURCES descriptor heap
(please increase rendering/rendering_device/d3d12/max_resource_descriptors)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;On Direct3D 12, every live material, skeleton and skinned mesh holds GPU descriptors, and the heap
has a fixed size. The error message suggests the obvious fix: make the heap bigger. The brief for the
agent that investigated said no: raising the limit was &amp;quot;acceptable only as an extra safety margin with a
stated reason, not as the fix.&amp;quot; Find out what is holding descriptors, and why the number keeps growing.&lt;/p&gt;
&lt;p&gt;The diagnostic scenario it wrote (&lt;code&gt;scenario_render_heap.gd&lt;/code&gt;) teleports the player across a map, hop
after hop, and prints the live skeletons, the distinct materials drawn and the video memory at each
stop. Two things were growing without limit:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Monster models that were never freed.&lt;/strong&gt; The big maps keep a few hundred monsters around a player,
and every one of them built its skinned model when it spawned, even out of sight. Animation LOD
stopped them &lt;em&gt;animating&lt;/em&gt;, but each model still held its skeleton and buffers. After a few teleports
or waystone hops, the Hellmouth Rift had 2,419 live skeletons and climbing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Materials made per object.&lt;/strong&gt; Each monster had its own hit-flash material, each capsule its own
material, and props asked a helper for a material by colour and then modified the one they got,
which forced a new one each time. The Rift drew 3,308 distinct materials.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The fixes are small:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## ... beyond RELEASE_RANGE the model is freed (built again on the way back in). A model holds GPU&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## descriptors for its skeleton and every skinned surface (D3D12: rendering_device/d3d12/max_resource_descriptors,&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## ~30 per monster): the big maps keep ~400 monsters around a player [...], so after a few teleports or&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## waystone hops the heap ran out. Now only the monsters near the camera hold a model.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ANIM_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;70.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HIDE_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;170.0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RELEASE_RANGE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;200.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The hit-flash material became one static material shared by every monster. Capsule materials and the
swing effect are only created when something actually needs them. The map&#x27;s material helper caches by
look (colour, glow, roughness), and the five places that used to modify the returned material now pass
those values in instead. The heap limit stayed at its default.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hellmouth Rift, same hops&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Live skeletons&lt;/td&gt;
&lt;td&gt;269, rising to 2,419&lt;/td&gt;
&lt;td&gt;105 to 402, bounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Descriptor heap errors&lt;/td&gt;
&lt;td&gt;3,826&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct materials drawn&lt;/td&gt;
&lt;td&gt;1,100 at start, rising to 3,308&lt;/td&gt;
&lt;td&gt;159 at start, about 300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video memory&lt;/td&gt;
&lt;td&gt;up to 2.69 GB&lt;/td&gt;
&lt;td&gt;about 1.2 to 1.4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The Vale went from up to 1,781 skeletons to 126-293. Prop materials alone dropped from 731 to 67 on the
Rift. A regression test checks that twelve props share at most four materials, that the material helper
returns the same material for the same look, and that a far monster gives its model back.&lt;/p&gt;
&lt;p&gt;The lesson is the same one as in round one, one level lower: &lt;strong&gt;level of detail has to reach memory, not
just work.&lt;/strong&gt; A monster that doesn&#x27;t animate still costs whatever it holds.&lt;/p&gt;
&lt;h2 id=&quot;clutter-two-thousand-objects-one-material&quot;&gt;Clutter: two thousand objects, one material &lt;a class=&quot;anchor&quot; href=&quot;#clutter-two-thousand-objects-one-material&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The living-world&lt;/span&gt; pass&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; put crates, barrels, carts, market stalls and laundry lines into
every town and camp: 2,129 placements across the maps, 918 of them in the Vale. Drawn naively, that&#x27;s
thousands of draw calls and, after round two, an obvious descriptor risk. &lt;code&gt;Clutter&lt;/code&gt; reuses the plant
approach and goes one step further:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Drawn like Flora: everything of one model inside one CHUNK square is one MultiMesh, and every model
shares ONE material [...]: a model&#x27;s material colours are baked into its vertices at load, its
&amp;quot;Paint_&amp;lt;n&amp;gt;&amp;quot; parts take the instance&#x27;s colour, each instance gets a brightness jitter. So a dressed town
costs (models x chunks in view) draw calls and one material.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Chunks are 48 m, clutter is drawn to 130 m (small pieces to 70 m and without shadows), and only big pieces
like carts and stalls collide, with their boxes merged into one physics body per 64 m cell. The buildings
got the same treatment in their detail pass: weathering went into vertex colours so that every house
shares one material.&lt;/p&gt;
&lt;h2 id=&quot;sprites-a-video-memory-budget-enforced-by-tests&quot;&gt;Sprites: a video memory budget, enforced by tests &lt;a class=&quot;anchor&quot; href=&quot;#sprites-a-video-memory-budget-enforced-by-tests&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The switch to&lt;/span&gt; pre-rendered pixel sprites for players and NPCs&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; moved the cost from the GPU&#x27;s
vertex work to texture memory. A character&#x27;s sheets cover 8 directions and dozens of animation clips, in
several paper-doll layers. Two tests keep that in check. One dresses a character (body, a hairstyle, the
heaviest armor, eyes) and loads everything one weapon type plays: it must stay under 19 MB, and it&#x27;s 18.3
MB for the male body and 16.4 MB for the female. The other builds every NPC and townsperson of the Vale
plus 30 passers-by (63 people) and checks their sheets against a 128 MB budget: they need 92.7 MB.
The trick that made those numbers possible is packing the sheets per &amp;quot;bank&amp;quot; (the shared clips, then each
weapon set&#x27;s own), so a character only loads the pages of clips it can actually play.&lt;/p&gt;
&lt;h2 id=&quot;where-it-stands&quot;&gt;Where it stands &lt;a class=&quot;anchor&quot; href=&quot;#where-it-stands&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A fresh run&lt;/span&gt; of the scenic screenshot scenario on the current build, at 3840x2054 with everything on
(sprites, sprite-style shading, clutter, grass, volumetric fog, depth of field), measured between &lt;strong&gt;106
and 147 fps&lt;/strong&gt; across twelve spots in the Vale, with 389 to 1,451 draw calls. The low end is in the densest
woods and at the stone circle at night. That&#x27;s on the development machine, a fast desktop; nobody has
measured a modest PC yet, and that&#x27;s the next honest number to get.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Measure first.&lt;/strong&gt; We expected rendering; it was animation and AI on the CPU. Later, it was GPU memory
nobody was looking at.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cheapest monster is the one that doesn&#x27;t exist.&lt;/strong&gt; Proximity spawning with hysteresis beat every
per-monster optimization. The second cheapest is the one that doesn&#x27;t hold a model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#x27;t take the error message&#x27;s advice blindly.&lt;/strong&gt; &amp;quot;Increase the heap&amp;quot; would have hidden a leak that
grew with every teleport.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stagger periodic checks.&lt;/strong&gt; A thousand timers that fire on the same frame make a hitch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grid your big things and share materials&lt;/strong&gt;: plant batches, clutter, colliders, grass. Anything &amp;quot;one per
object&amp;quot; or &amp;quot;one per map&amp;quot; breaks on a big map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key caches on immutable inputs.&lt;/strong&gt; Check that your cache actually hits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put budgets in tests.&lt;/strong&gt; A video memory number in a test fails the day someone adds one clip too many.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;With frame time&lt;/span&gt; to spare, we spent it on looks. Next: chasing the Valheim look with pixel noise, leaf
cards, grass that parts around you and shadows of clouds that aren&#x27;t there, and then leaving it for
something else.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;When the Vale was later shrunk to about 1.4 x 1.8 km, the spawn radius came down with it, to 200 m, with
despawning at 260 m. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;It has &lt;a href=&quot;https://dekiro.d12r.io/16-making-the-world-feel-alive.html&quot;&gt;its own post&lt;/a&gt;. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;It has &lt;a href=&quot;https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html&quot;&gt;its own post&lt;/a&gt;. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>From Open World to Torii Portals, and Nine Maps with One Town Each</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/06-from-open-world-to-portals.html"/>
<id>https://dekiro.d12r.io/06-from-open-world-to-portals.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>We built a seamless continent and switched it off nine hours later, turned a hand sketch into a 2.6 x 3 km map and shrank it by two thirds, then rebuilt every other map to match.</summary>
<content type="html">&lt;h2 id=&quot;where-we-started&quot;&gt;Where we started &lt;a class=&quot;anchor&quot; href=&quot;#where-we-started&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The world began&lt;/span&gt; small and data-driven. A map is a JSON file in &lt;code&gt;data/maps/&lt;/code&gt;: zones (safe sanctuaries and
hunting grounds with level ranges), NPC placements, props, a seeded random scatter, and portals. The first
Sanctuary Vale was 480 x 480 m: a town in the middle, then rings of hunting grounds getting harder toward
the edge, and a portal north to the Shadow Marsh. All maps load into one scene tree, 5000 m apart, so
they never see each other.&lt;/p&gt;
&lt;p&gt;Zones were circles and rectangles at first. On day two they got a &lt;code&gt;polygon&lt;/code&gt; shape, for one reason that
the design doc states plainly:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Zones are circles, rectangles or &lt;strong&gt;polygons&lt;/strong&gt; (any outline, from the user&#x27;s map sketches: the user draws,
Claude translates into points; 1 sketch square = 25 m).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The human designs maps the way people have always designed maps: on a grid, with a pencil. Converting the
sketch into coordinates is the AI&#x27;s job.&lt;/p&gt;
&lt;h2 id=&quot;the-open-world-detour&quot;&gt;The open-world detour &lt;a class=&quot;anchor&quot; href=&quot;#the-open-world-detour&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On day three&lt;/span&gt; the plans got bigger. Seven more themed maps were drafted (Orc Valley, Duskmoor, Sunscar
Desert, Frostpeak, Ember Wastes, and the realms Hellmouth Rift and the Shadowlands), each with its own
sky, fog, weather, liquids and border mountains. A Python generator drew a world map from a layout spec,
and the idea came up to make it seamless. The backlog entry describes it as &amp;quot;regions that build onto each
other (like Where Winds Meet), joined by land bridges at passes&amp;quot;.&lt;/p&gt;
&lt;p&gt;We built it in an afternoon, in three commits:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Joined world&lt;/strong&gt;: the Vale and the Marsh meet at a land bridge called Willow Ford. Each map got an
organic coastline (a seeded wobbly shape plus land bridges), a sandy rim and sea around it. Walls follow
the coast but stay open where a neighbour&#x27;s land continues. Walking off one map moves you onto the
next in place, and sky, fog and light fade over three seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fixes&lt;/strong&gt;: you walk along the shore, joined maps wake together, and you can fight across the border.
Before that, Marsh monsters across the ford &amp;quot;stood frozen like a wall&amp;quot;, because only the player&#x27;s own
map was awake.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The open world&lt;/strong&gt;: all regions joined in a chain, each with named landmarks (&amp;quot;sights&amp;quot;) that announce
themselves with a banner when you arrive. Among them: the Old Mill, the Sand Ship, the Mammoth
Graveyard, the Gate of Teeth.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Terrain arrived in between: rolling hills from seeded noise, baked into a 2.5 m height grid. The grid is
flattened toward coasts, rivers, safe zones, portals and land bridges, so joined maps meet at the same
height.&lt;/p&gt;
&lt;p&gt;It worked. The commit message even notes the frame rate: &amp;quot;~140 fps again with the Vale and Marsh both
awake&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;switching-it-off&quot;&gt;Switching it off &lt;a class=&quot;anchor&quot; href=&quot;#switching-it-off&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Nine hours after&lt;/span&gt; the first joined map, this commit landed:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Back to separate maps linked by torii portals (open world switched off): purple portals along the chain,
a blue one from the Vale to Orc Valley&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It was the human&#x27;s call. CLAUDE.md records it with a date: &amp;quot;Open world is OFF (2026-10-09, the user&#x27;s
call): every map is its own place, linked by torii portals&amp;quot;. The code wasn&#x27;t deleted. &lt;code&gt;data/world.json&lt;/code&gt;
keeps an empty &lt;code&gt;&amp;quot;continent&amp;quot;&lt;/code&gt;, and the crossing, coastline and neighbour-sight code stays dormant for later.&lt;/p&gt;
&lt;p&gt;Looking back at what the open world needed, the trade is easy to see. Every map had to fit into its
neighbours&#x27; coastlines and match their heights at the border. Maps had to wake together, load together,
and share visibility across the border.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; Every map&#x27;s size
and shape was constrained by the chain. With portals, each map is its own place. It can be as big as it
needs to be, in any shape, and the next map only loads when you step through a gate.&lt;/p&gt;
&lt;p&gt;The switch itself was small (11 files, more lines removed than added), mostly because the earlier rules
held. Actors never cared which map they were on; &lt;code&gt;World.place()&lt;/code&gt; was already the only way to move between
maps; and portals already existed for dungeons. Fittingly, the portals became torii gates: a swirl in the
portal&#x27;s colour inside a gate model from the building generator.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-torii-portal.jpg&quot; alt=&quot;A torii portal: purple swirl, lanterns and guardian statues&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A torii portal: purple swirl, lanterns and guardian statues&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;map-1-from-a-sketch&quot;&gt;Map 1 from a sketch &lt;a class=&quot;anchor&quot; href=&quot;#map-1-from-a-sketch&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;With the size&lt;/span&gt; constraint gone, the human drew Map 1 by hand: a walled town with four gates, a coast to
the west, a river coming down from the northern mountains, lakes, roads to farmsteads, a harbour village,
a mountain border, and two portals in the far corners. Thirty-two minutes after the portal commit, this
landed:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Map 1 rebuilt from the user&#x27;s sketch: 2.6 x 3 km Sanctuary Vale with a walled town and four gates, coast,
river, lakes, mountain border, roads, farmsteads, harbor, new landmarks, torii portals to the Marsh and Orc
Valley&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The diff has 11,156 added lines, almost all of them generated map data.&lt;/p&gt;
&lt;h3 id=&quot;the-generator&quot;&gt;The generator &lt;a class=&quot;anchor&quot; href=&quot;#the-generator&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The map isn&#x27;t hand-written JSON. It comes from &lt;code&gt;tools/mapgen/maps/vale.py&lt;/code&gt;, a rerunnable script that
writes &lt;code&gt;data/maps/sanctuary_vale.json&lt;/code&gt;, and the JSON says so in its &lt;code&gt;_doc&lt;/code&gt;: &amp;quot;Generated by
tools/mapgen/maps/vale.py (edit that, not this file).&amp;quot; The sketch becomes a few named constants and
functions:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;TOWN_R&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;230.0&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;NORTH&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SOUTH&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;EAST&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1420.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1420.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;900.0&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;BLUE_PORTAL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;600.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1150.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;PURPLE_PORTAL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;560.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1110.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;HARBOR&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;980.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;150.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;CRYPT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;150.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1050.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;west_coast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;sd&quot;&gt;&quot;&quot;&quot;x of the west coast at z (wavy, a cape mid-way, an inlet where the river ends).&quot;&quot;&quot;&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1130&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;95&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;math&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;380&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;45&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;math&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;140&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;math&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;160&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# a cape around the harbor&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;x&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;160&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;math&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1050&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# the river mouth bay&lt;/span&gt;
	&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;From there the script lays out everything else: the coastline polygon, three rows of mountains along the
north, east and south edges (with a gap where the river comes through), the river Silverrun and three
lakes, a town wall in 12 m segments around a 230 m radius with four gatehouses, roads from the gates to
the farmsteads, the harbour and both portals, and the hunting grounds in rings:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;n&quot;&gt;hunting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whispering_fields&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;circle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;650&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;220&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;hunting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_woods&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;circle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1150&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;45&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;400&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
	&lt;span class=&quot;n&quot;&gt;hunting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ashen_reach&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;rect&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1500&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1500&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2500&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;14&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;35&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;300&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Levels 1-4 in the fields, 5-9 in the woods, 9-14 out to the mountains, with the Shadows and packs counted
per zone. The scatter is scaled to the area: 15,000 pines and 50,000 ferns in the Shadow Woods alone. The
script also keeps the old town square with all its NPCs, school halls and the forge, and it updates the
Marsh and Orc Valley portals so you arrive next to the matching torii.&lt;/p&gt;
&lt;p&gt;Because the generator is code, iterating on the map means changing a constant and rerunning it. &amp;quot;Move the
Old Mill&amp;quot;, &amp;quot;make the woods denser&amp;quot; or &amp;quot;three times the packs&amp;quot; are each a one-line change and a fresh JSON.&lt;/p&gt;
&lt;h3 id=&quot;checking-the-result-overviews-and-the-atlas&quot;&gt;Checking the result: overviews and the atlas &lt;a class=&quot;anchor&quot; href=&quot;#checking-the-result-overviews-and-the-atlas&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;figure class=&quot;fig margin&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-overview-vale.jpg&quot; alt=&quot;Top-down overview of the generated 2.6 x 3 km Sanctuary Vale&quot; width=&quot;874&quot; height=&quot;1055&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Top-down overview of the generated 2.6 x 3 km Sanctuary Vale&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;How do you check that a 2.6 x 3 km map matches a sketch? You draw it. Every generator run also writes a
top-down overview (&lt;code&gt;tools/mapgen/overview.py&lt;/code&gt;): zones with their level ranges, roads, water, landmarks and
portals, all drawn from the JSON the game will load. The overview is what the human compared with the
sketch, and what every later map change was reviewed with.&lt;/p&gt;
&lt;p&gt;On day three there was also a world atlas (&lt;code&gt;tools/mapgen/atlas.py&lt;/code&gt;) that asked Godot, headless, to export
every map&#x27;s real geometry and drew all the maps side by side. Its README says why it works that way:
&amp;quot;Rerun after changing maps; it can&#x27;t drift.&amp;quot; It drifted anyway, in a different sense: the atlas expects the
coastlines of the joined world, and after the rework below it no longer runs (a map without a coastline
gives it nothing to draw). The per-map overviews took its place, and fixing the atlas is on the list.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-world-atlas.jpg&quot; alt=&quot;The world atlas from day three, still drawing the joined open world (Willow Ford is the narrow bridge between the Vale and the Marsh)&quot; width=&quot;1600&quot; height=&quot;2000&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The world atlas from day three, still drawing the joined open world (Willow Ford is the narrow bridge between the Vale and the Marsh)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-town-gate.jpg&quot; alt=&quot;The north gatehouse of the walled town, with the road leading out&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The north gatehouse of the walled town, with the road leading out&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;new-problems-at-26-km&quot;&gt;New problems at 2.6 km &lt;a class=&quot;anchor&quot; href=&quot;#new-problems-at-26-km&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A map more&lt;/span&gt; than 30 times the area of the old one broke assumptions everywhere, and the next commits deal
with them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Grass streams&lt;/strong&gt; around the player instead of being built for the whole map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Peers only receive entities within 160 m&lt;/strong&gt;, not everything on the map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terrain cells grow&lt;/strong&gt; with the map size, and heights become exact along roads and coasts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mountains block at their foot&lt;/strong&gt;, and nothing grows or spawns on their slopes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monsters spawn only near players.&lt;/strong&gt; A map this size has more than a thousand monsters.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last point became its own performance story (the next post).&lt;/p&gt;
&lt;h2 id=&quot;too-big-too-empty&quot;&gt;Too big, too empty &lt;a class=&quot;anchor&quot; href=&quot;#too-big-too-empty&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Then the human&lt;/span&gt; played it. Their notes from that morning: &amp;quot;monster density or frequency is quite low, I
also feel like the map is a bit too big.&amp;quot; A big map is only impressive for the first walk across it.
After that it&#x27;s the time between fights.&lt;/p&gt;
&lt;p&gt;Because the map is a generator, shrinking it was a change of constants, not a redesign. Twelve hours after
the sketch became a map, the Vale shrank to about 1.4 x 1.8 km. The town stayed exactly as it was; the
fields, woods and outer ring moved closer. Packs now stand about every 35 m, Shadows are twice as dense,
roaming packs move more often, and pack spawns stay out of lakes. The proximity spawn radius dropped from
250 m to 200 m to match.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-vale-same-scale.png&quot; alt=&quot;Sanctuary Vale before and after the resize, drawn at the same scale&quot; width=&quot;1260&quot; height=&quot;720&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Sanctuary Vale before and after the resize, drawn at the same scale&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;every-map-at-vale-scale-one-town-each&quot;&gt;Every map at Vale scale, one town each &lt;a class=&quot;anchor&quot; href=&quot;#every-map-at-vale-scale-one-town-each&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The other eight&lt;/span&gt; maps were still the old small ones, mostly 300 x 300 m with two or three safe zones each.
The human&#x27;s brief for fixing that was one line: &amp;quot;Rework all other maps we have. Keep safe zones to one per
map + only other safe zones for portals.&amp;quot;&lt;/p&gt;
&lt;p&gt;That rule turned out to shape the whole game. With one town per map, the town is where you go back to, so
it has to be worth going back to. Everything else is hunting ground, and the portal shrines are small
islands of safety you pass through. The lore later gave it a reason: every town is built around a shard of
a broken sacred stone, and monsters can&#x27;t cross its song.&lt;/p&gt;
&lt;p&gt;The rework was one of the first big parallel jobs: four agents, two maps each, each writing a rerunnable
generator modelled on the Vale&#x27;s (&lt;code&gt;tools/mapgen/maps/&amp;lt;map&amp;gt;.py&lt;/code&gt;). Each map came out at roughly 1.2 to 1.5 km
across, with mountain borders, roads, packs about 35 m apart, eight to ten named landmarks,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; and waystones
at the town, the portals and the landmarks. The Vale&#x27;s own eleven landmarks were rebuilt as proper set
pieces with 17 new building models in the same commit. Because four generators wrote portals
independently, a small script (&lt;code&gt;tools/mapgen/link_portals.py&lt;/code&gt;) runs afterwards and points every portal&#x27;s
arrival at the partner map&#x27;s matching shrine.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-marsh-before-after.jpg&quot; alt=&quot;The Shadow Marsh before and after the rework: a ring of zones became a 1.5 km map with roads, landmarks and one town&quot; width=&quot;1600&quot; height=&quot;961&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Shadow Marsh before and after the rework: a ring of zones became a 1.5 km map with roads, landmarks and one town&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;exceptions-on-purpose&quot;&gt;Exceptions, on purpose &lt;a class=&quot;anchor&quot; href=&quot;#exceptions-on-purpose&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Two later decisions bent the rule, both from the human:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The desert got bigger and emptier.&lt;/strong&gt; &amp;quot;The desert I feel like should be larger and feel somewhat empty.
What are rewards we can have there to still make it valuable?&amp;quot; Sunscar is now about 2 x 2 km, with
islands of life between empty dunes and about 30% fewer packs. The emptiness pays: elite dune wanderers
with better loot, a Sandstorm event with storm elementals and treasure caches, rare spawns (a sand wyrm
that burrows, a raider warlord), lore tablets, a caravan merchant who keeps a wall-clock schedule, and
&lt;strong&gt;Sunstone&lt;/strong&gt;, the material the top three upgrade steps need.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Some maps lost their town.&lt;/strong&gt; &amp;quot;Some maps don&#x27;t have to have a town.&amp;quot; The Ember Wastes, the Hellmouth
Rift and the Shadowlands now keep only a gate camp inside the portal shrine, and their old houses stand
empty in the wilds. It reads right: these are the places where the land itself is the enemy.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-desert-storm.jpg&quot; alt=&quot;A Sandstorm in the Sunscar Desert: visibility drops, storm elementals roam, caches appear&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A Sandstorm in the Sunscar Desert: visibility drops, storm elementals roam, caches appear&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;getting-around-nine-big-maps&quot;&gt;Getting around nine big maps &lt;a class=&quot;anchor&quot; href=&quot;#getting-around-nine-big-maps&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Bigger maps with&lt;/span&gt; one town each need fast travel, or every quest turns into a walk. There are two layers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Waystones&lt;/strong&gt;: stone steles at the town, the portals and the landmarks. Walking up to one attunes it
(its rune lights up for good); at any attuned stone you can travel to any other you&#x27;ve attuned, for a
fee that grows with the target map&#x27;s level. The server checks every trip like any other request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gate Keepers&lt;/strong&gt;: the human asked for &amp;quot;a teleport NPC per larger safe zone that allows me to travel to
places I&#x27;ve been before and talked to the other teleport NPC&amp;quot;. They&#x27;re NPCs on the same waystone
network, so talking to one attunes it.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-gate-keeper.jpg&quot; alt=&quot;A Gate Keeper&amp;#x27;s window: attuned destinations per map, with fees&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A Gate Keeper&amp;#x27;s window: attuned destinations per map, with fees&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;each-map-plays-a-little-differently&quot;&gt;Each map plays a little differently &lt;a class=&quot;anchor&quot; href=&quot;#each-map-plays-a-little-differently&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The last layer&lt;/span&gt;, added on day six, is a small set of &lt;strong&gt;map mechanics&lt;/strong&gt; (data in &lt;code&gt;data/mechanics.json&lt;/code&gt;, run
on the server): the desert&#x27;s heat (stand in shade or water, or carry a water skin), Frostpeak&#x27;s cold
(campfires and shelter warm you; avalanches come down marked slopes after a warning), the Ember Wastes&#x27;
shifting lava and diving drakes, demon portals in the Hellmouth Rift that you close by channelling,
rituals in Duskmoor, and the Shadowlands&#x27; shade, which builds up away from shard light. The rule, in the
data file&#x27;s own words: &amp;quot;Keep it modest: flavour and reward, not frustration.&amp;quot;&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/06-avalanche.jpg&quot; alt=&quot;Frostpeak: an avalanche warning on a marked slope&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Frostpeak: an avalanche warning on a marked slope&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Separate places are simpler than a seamless world.&lt;/strong&gt; The open world worked, but it tied every map to
its neighbours. Portals give each map its freedom back. Keep the seamless code dormant if you might want
it later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let the human draw and the machine measure.&lt;/strong&gt; A sketch at 1 square = 25 m plus a generator script is a
surprisingly direct path from paper to a playable map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Big is not the same as good.&lt;/strong&gt; Density of things to do matters more than square kilometres. A
generator makes &amp;quot;smaller and denser&amp;quot; a change of constants.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One strong rule shapes a lot.&lt;/strong&gt; &amp;quot;One town per map&amp;quot; decided where NPCs live, what the lore says, where
waystones go and which maps feel safe.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Render what you generated, and keep the renderer running.&lt;/strong&gt; A tool that checks your maps is only
useful while it still runs against today&#x27;s maps.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A map with&lt;/span&gt; tens of thousands of plants and hundreds of animated monsters doesn&#x27;t run well by default, and
later a GPU heap ran out on a map that had rendered fine. Next: performance.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;Peers saw entities on a neighbour within 90 m. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;The desert later got thirteen. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/11-coordinator-and-subagents.html&quot;&gt;The coordinator post&lt;/a&gt; covers what went wrong with four
agents in one repository. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;A proposed digging-with-a-shovel feature was dropped: &amp;quot;not so much of a fan of the shovel thing&amp;quot;. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Everything from Code: 177 Monsters, 203 Items and a Town Built in Headless Blender</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/05-everything-from-code-headless-blender.html"/>
<id>https://dekiro.d12r.io/05-everything-from-code-headless-blender.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>No hand-modelling: every model in the game comes out of a Python script, and the first batch looked like wooden dolls.</summary>
<content type="html">&lt;h2 id=&quot;the-constraint&quot;&gt;The constraint &lt;a class=&quot;anchor&quot; href=&quot;#the-constraint&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Our developer is&lt;/span&gt; an AI working through a terminal. It can&#x27;t drag vertices around in Blender&#x27;s viewport,
and the human isn&#x27;t a 3D artist either. For the first few days every monster was a capsule. The design
doc planned for free low-poly asset packs later.&lt;/p&gt;
&lt;p&gt;Then the question changed: if Claude Code can write a Godot game, can it write a &lt;em&gt;Blender script&lt;/em&gt; that
builds a wolf? Blender runs headless (&lt;code&gt;blender -b -P script.py&lt;/code&gt;) and its Python API can do anything the UI
can. The first test was a single Giant Rat. Less than an hour later there was a commit titled &amp;quot;Low-poly
models for all 77 monsters: parametric Blender builders (quadruped, humanoid, critter) from one spec
table&amp;quot;. It changed 165 files.&lt;/p&gt;
&lt;h2 id=&quot;specs-builders-a-loop&quot;&gt;Specs, builders, a loop &lt;a class=&quot;anchor&quot; href=&quot;#specs-builders-a-loop&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The pipeline has&lt;/span&gt; three layers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. A spec table&lt;/strong&gt; (&lt;code&gt;tools/blender/monster_specs.py&lt;/code&gt;) says which body each monster gets and how it looks.
Base templates keep the variants short:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;WOLF&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;body&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;quadruped&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;length&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.92&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;leg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.46&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;girth&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.155&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;depth&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.165&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;head&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.125&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;snout&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;chest&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;s2&quot;&gt;&quot;wolf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;with_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WOLF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#7d7d80&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;belly&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#b4b4b6&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
	&lt;span class=&quot;s2&quot;&gt;&quot;grey_wolf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;with_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WOLF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#9a9a9e&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;belly&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#d0d0d2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.98&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
	&lt;span class=&quot;s2&quot;&gt;&quot;blue_wolf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;with_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WOLF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#4f6a9a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;belly&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#9ab0d8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;eye&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#80d0ff&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;eye_glow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;2.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
	&lt;span class=&quot;s2&quot;&gt;&quot;alpha_wolf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;with_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WOLF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#5e5e66&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;belly&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#8e8e96&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;extras&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;mane&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;eye&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#ffcc33&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;eye_glow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;2.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.05&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;2. Builders&lt;/strong&gt; turn a spec into a rigged, animated mesh. There are four: &lt;code&gt;quadruped&lt;/code&gt; (dogs, wolves,
cats, bears, boars, deer), &lt;code&gt;humanoid&lt;/code&gt; (goblins, orcs, cultists, skeletons, demons, shades), &lt;code&gt;critter&lt;/code&gt;
(rats, spiders and other small things) and &lt;code&gt;stone&lt;/code&gt; for the Shadow monoliths. Each takes a vocabulary of
options. The humanoid builder alone knows five body frames, 15 outfit pieces, 16 features from tusks to
wings, and 14 weapon types.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. A build loop&lt;/strong&gt; (&lt;code&gt;build_monsters.py&lt;/code&gt;) reads the game&#x27;s own &lt;code&gt;data/monsters.json&lt;/code&gt; for each monster&#x27;s
name and height, runs the right builder, and exports a &lt;code&gt;.glb&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;python&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;	&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mid&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;spec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monster_specs&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SPECS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BUILDERS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;body&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;height&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;known&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;height&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]))&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;known&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot; &quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;lp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clear_scene&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;arm&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ROOT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;assets&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;models&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mid&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;.glb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;makedirs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dirname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exist_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
		&lt;span class=&quot;n&quot;&gt;lp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;export_glb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The first 77 built in about ten seconds. With &lt;code&gt;--preview &amp;lt;dir&amp;gt;&lt;/code&gt; it also renders one image per monster, and
&lt;code&gt;contact_sheet.py&lt;/code&gt; puts them all on one sheet. That sheet is how the AI reviews a whole bestiary at a glance:
it opens a single PNG and looks for anything off.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-contact-sheet.jpg&quot; alt=&quot;Contact sheet of the four-legged monsters (after the organic rebuild)&quot; width=&quot;1600&quot; height=&quot;914&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Contact sheet of the four-legged monsters (after the organic rebuild)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-wolf-variants.jpg&quot; alt=&quot;Wolf, grey wolf, blue wolf and alpha wolf: one spec, different numbers&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Wolf, grey wolf, blue wolf and alpha wolf: one spec, different numbers&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;conventions-instead-of-fix-ups&quot;&gt;Conventions instead of fix-ups &lt;a class=&quot;anchor&quot; href=&quot;#conventions-instead-of-fix-ups&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The Godot side&lt;/span&gt;, &lt;code&gt;ModelVisual&lt;/code&gt;, needs no per-model tweaks because the builders follow fixed conventions:
meters, feet at z = 0, facing glTF forward, and four animations named &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;walk&lt;/code&gt;, &lt;code&gt;attack&lt;/code&gt; and &lt;code&gt;death&lt;/code&gt;.
Two conventions turned out to matter for feel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every attack animation lands its blow on frame 4.&lt;/strong&gt; &lt;code&gt;ModelVisual&lt;/code&gt; knows this
(&lt;code&gt;STRIKE_TIME := 4.0 / 24.0&lt;/code&gt;) and skips most of the wind-up for melee hits, so the blow lands right as
the damage number appears. For telegraphed attacks it does the opposite and stretches the wind-up over
the warning time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Walk speed travels with the model.&lt;/strong&gt; Each armature carries the speed its walk cycle covers as a
glTF extra (&lt;code&gt;export_extras=True&lt;/code&gt;), so feet don&#x27;t slide whether a rat scurries or a bear lumbers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Headless machines (dedicated servers, tests) skip the meshes and keep the capsule. That&#x27;s the &amp;quot;logic
separate from visuals&amp;quot; rule from the first day paying off: the server never needs to know a wolf has a
tail.&lt;/p&gt;
&lt;h2 id=&quot;shadow-stones-items-and-a-town&quot;&gt;Shadow stones, items and a town &lt;a class=&quot;anchor&quot; href=&quot;#shadow-stones-items-and-a-town&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same approach&lt;/span&gt; spread to everything else that has a shape.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shadow stones&lt;/strong&gt;: 11 carved monoliths, each stacked from three chunks on bones plus up to four floating
shards, so they can shudder when hit, surge when a wave spawns and crumble on death. The glowing runes use
a material named &lt;code&gt;Glow&lt;/code&gt;, which the game recolours per Shadow type and pulses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Items&lt;/strong&gt;: &lt;code&gt;build_items.py&lt;/code&gt; builds a model and an inventory icon for every item (139 then, 203 now). Godot exports the item
list first (including generated skillbooks and runes), so Blender and the game always agree on what exists.
Weapons get shapes from a spec table (swords became katanas with a curved blade, temper line, tsuba and
wrapped grip). The Fullmoon Sword, a nod to a popular Korean MMO, has its own hand-written builder.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Buildings&lt;/strong&gt;: East Asian low-poly houses (hanok, minka, Chinese gabled), temple halls, pagodas, torii
gates, stone lanterns, a roofed well and guardian lions. Each is built at a canonical size and scaled in
Godot to the footprint the map asks for, so map data keeps saying &amp;quot;a house, 7 x 6 m, here&amp;quot;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flora&lt;/strong&gt;: 27 variants of trees, plants and rocks at first (33 now, including snowy pines). Their materials are named by &lt;em&gt;role&lt;/em&gt; (&lt;code&gt;Leaf&lt;/code&gt;, &lt;code&gt;Bark&lt;/code&gt;,
&lt;code&gt;Needles&lt;/code&gt;, &lt;code&gt;Moss&lt;/code&gt;...), and Godot reads those names to decide what sways in the wind, what takes the map&#x27;s
colour and what glows. One mesh can serve a green Vale and a snowy Frostpeak.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Textures&lt;/strong&gt; follow the same idea. The leaf cards and grass blades are pixel art drawn by
&lt;code&gt;tools/flora_textures.py&lt;/code&gt; with Pillow, leaf by leaf.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-shadow-stones.jpg&quot; alt=&quot;Shadow stones at night, each with runes in its Shadow&amp;#x27;s colour&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Shadow stones at night, each with runes in its Shadow&amp;#x27;s colour&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-item-icons.png&quot; alt=&quot;Generated weapon icons for three of the four schools&quot; width=&quot;1370&quot; height=&quot;578&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Generated weapon icons for three of the four schools&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-town-buildings.jpg&quot; alt=&quot;The town: houses, the temple hall and stone lanterns from the building builder&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The town: houses, the temple hall and stone lanterns from the building builder&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong &lt;a class=&quot;anchor&quot; href=&quot;#what-went-wrong&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;the-roofs-you-could-see-through&quot;&gt;The roofs you could see through &lt;a class=&quot;anchor&quot; href=&quot;#the-roofs-you-could-see-through&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;After we added a pixel-texture shader to every model,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; roofs became transparent from
below and from some angles. The cause: Blender&#x27;s roof material was double-sided, and our material swap
only made single-sided shader materials. Thin roofs lost their back faces. The fix kept a two-sided variant
of the shader for any material that had culling disabled:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;two_sided&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cull_mode&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BaseMaterial3D&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CULL_DISABLED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A second commit closed the roofs underneath with a wooden soffit, so you never look up into an empty roof
even when the camera dips.&lt;/p&gt;
&lt;h3 id=&quot;the-pinocchio-dolls&quot;&gt;The Pinocchio dolls &lt;a class=&quot;anchor&quot; href=&quot;#the-pinocchio-dolls&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The bigger lesson is in the docstring of the helper library that every builder uses:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A creature is a set of simple primitives (spheres, cones, cylinders), each rigidly bound to one bone of
an armature (one vertex group per part), joined into a single skinned, flat-shaded mesh.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That approach was fast, robust and easy to parametrize, and it got 77 animated monsters into the game in
an evening. But rigid parts on bones move like a jointed wooden doll. Limbs are separate cones and
spheres, and there is no flesh that bends. They earned the nickname &lt;strong&gt;Pinocchio dolls&lt;/strong&gt;. Fine
as placeholders, not what the game should look like.&lt;/p&gt;
&lt;p&gt;The fix was one of the first jobs handed to a parallel subagent:&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; an organic rebuild, with continuous bodies skinned to their bones instead of separate primitives.
It landed in two commits. The four-legged animals got &amp;quot;one smooth skinned body per animal, painted 128 px
pixel fur, per-family shapes (canine, bear, boar, cat, toad, crocodile)&amp;quot;. The critters followed:
&amp;quot;segmented, smooth, painted: spiders, scorpions, beetle, bat, snake, eye, wisp&amp;quot;. The spec tables, the build
loop, the conventions and every test that checks &amp;quot;each monster has a model with its animations&amp;quot; stayed
the same. Only the builders changed. The humanoid monsters (goblins, orcs, cultists, demons) still use
the primitive builder; rebuilding them is queued, not done.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/05-pinocchio-vs-organic.jpg&quot; alt=&quot;The same wolf as a Pinocchio doll of primitives (left) and after the organic rebuild (right)&quot; width=&quot;1126&quot; height=&quot;460&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same wolf as a Pinocchio doll of primitives (left) and after the organic rebuild (right)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;six-days-later-the-pipeline-at-scale&quot;&gt;Six days later: the pipeline at scale &lt;a class=&quot;anchor&quot; href=&quot;#six-days-later-the-pipeline-at-scale&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same approach&lt;/span&gt; kept absorbing new content. At the time of writing, Blender scripts produce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;182 monster and Shadow stone models&lt;/strong&gt; (177 monster types and 11 Shadow types share them). Dungeon monsters mostly
reuse existing bodies, scaled, recoloured and given one accessory (a crown, horns, armor plates), which
was the human&#x27;s call when 20 dungeons were planned: &amp;quot;We can also just scale up some of the other models
we have and recolor them?&amp;quot; Each dungeon&#x27;s final boss gets its own silhouette instead: a crone under a
huge hat with a cauldron on her back, a sexton whose head is a bronze bell, a king with a furnace in
his back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;203 item models and icons&lt;/strong&gt;, including 41 armor pieces that a balance pass found missing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;97 building models&lt;/strong&gt;: five house styles plus a tall house, teahouse, shop, storehouse, granary,
farmhouse and guard tower, each in regional variants (desert, snow, swamp, ember), after a detail pass
that added tiled roofs, timber frames, trade signs and weathering in vertex colours, all on one shared
material.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;53 clutter models&lt;/strong&gt; (crates, barrels, carts, market stalls, laundry) and &lt;strong&gt;12 animal models&lt;/strong&gt; for the
living-world pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;458 dungeon kit pieces&lt;/strong&gt; in 20 themes, from crypt stone to a hedge maze to a palace of mirrors.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pipeline also gained a second output. The player characters are still modelled in Blender by a
script (&lt;code&gt;characters_v4.py&lt;/code&gt;), but they never ship as 3D models. They&#x27;re rendered into 8-direction pixel
sprites, which is its own post.&lt;/p&gt;
&lt;h2 id=&quot;why-generate-instead-of-buy&quot;&gt;Why generate instead of buy? &lt;a class=&quot;anchor&quot; href=&quot;#why-generate-instead-of-buy&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Asset packs would&lt;/span&gt; have been faster for the first few monsters. Generating had other advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Consistency&lt;/strong&gt;: one palette logic, one level of detail, one style across every monster, item and
building.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data alignment&lt;/strong&gt;: heights come from &lt;code&gt;monsters.json&lt;/code&gt;, item lists from &lt;code&gt;items.json&lt;/code&gt;. When a monster is
added to the data, its model is one spec line away, and a test notices if it&#x27;s missing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Change at scale&lt;/strong&gt;: &amp;quot;swords are katanas now&amp;quot; touched 181 files in one commit: every sword model and
icon, rebuilt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No licence questions&lt;/strong&gt;: everything is original.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cost is that quality is bound by what can be described in code. That&#x27;s exactly what the Pinocchio
lesson showed.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Blender is a library.&lt;/strong&gt; Headless Blender with a Python spec table is a real content pipeline, even for
animated characters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix conventions, not models.&lt;/strong&gt; Facing, scale, animation names and the strike frame are agreed once,
so the engine side never needs per-model fixes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review in bulk.&lt;/strong&gt; A contact sheet of every model is the fastest way for anyone, human or AI, to spot
the odd one out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast primitives are a placeholder, not a style.&lt;/strong&gt; Plan the step from rigid parts to skinned bodies.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;With models in&lt;/span&gt; place, the world needed space. Next: why we built a seamless open world, switched it off
nine hours later, and built Map 1 from a hand-drawn sketch.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;See &lt;a href=&quot;https://dekiro.d12r.io/08-chasing-the-valheim-look.html&quot;&gt;the art post&lt;/a&gt;. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;More on that setup in &lt;a href=&quot;https://dekiro.d12r.io/11-coordinator-and-subagents.html&quot;&gt;the coordinator post&lt;/a&gt;. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Testing a Game with 13,800 Checks</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/04-testing-a-game-with-13800-checks.html"/>
<id>https://dekiro.d12r.io/04-testing-a-game-with-13800-checks.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Headless unit tests, network bots, screenshot scenarios, a combat simulation, and the things only a human can test.</summary>
<content type="html">&lt;h2 id=&quot;why-so-many-tests&quot;&gt;Why so many tests &lt;a class=&quot;anchor&quot; href=&quot;#why-so-many-tests&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Our developer is&lt;/span&gt; an AI that starts every session without memory of the last one, and on a busy day it
commits 40 to 70 times. Later in the project, several AI agents edited the same repository at once.
Without a safety net, every one of those changes could quietly break something built two days earlier.
So the test suite isn&#x27;t an afterthought. It went into the very first commit together with movement and
the first arc attack, and every feature since has added to it.&lt;/p&gt;
&lt;p&gt;At the time of writing, &lt;code&gt;tests/run_tests.gd&lt;/code&gt; holds &lt;strong&gt;341 test functions&lt;/strong&gt;, which run about &lt;strong&gt;13,800
checks&lt;/strong&gt; in a little over a minute. Both numbers have a story, and the check count is the less
trustworthy of the two. We&#x27;ll come back to that.&lt;/p&gt;
&lt;h2 id=&quot;the-runner-a-few-dozen-lines-no-framework&quot;&gt;The runner: a few dozen lines, no framework &lt;a class=&quot;anchor&quot; href=&quot;#the-runner-a-few-dozen-lines-no-framework&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;We don&#x27;t use&lt;/span&gt; a test framework. The runner is a &lt;code&gt;SceneTree&lt;/code&gt; script that Godot runs headless:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_run_all&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_method_list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;begins_with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;test_&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;- &quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;call&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_cleanup&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; checks, &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; failed&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_checks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_failures&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;quit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_failures&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A test is any method named &lt;code&gt;test_*&lt;/code&gt;. It spawns what it needs and calls &lt;code&gt;_check(condition, message)&lt;/code&gt;.
&lt;code&gt;_cleanup()&lt;/code&gt; frees every actor, damage number, loot drop and world it left behind, so each test starts
clean. Two environment variables were added once the suite got big: &lt;code&gt;TEST_ONLY=&amp;lt;substring&amp;gt;&lt;/code&gt; runs the
matching tests, and &lt;code&gt;TEST_TIMES=1&lt;/code&gt; prints each test&#x27;s time and the slowest forty. The first test ever
written is still there and still typical:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;test_arc_hits_only_enemies_in_front_and_in_range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PLAYER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ZERO&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;front&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ENEMY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;front_edge&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ENEMY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# 45 deg, inside 120 arc&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;behind&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ENEMY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;too_far&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ENEMY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ally&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Team&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PLAYER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That this test is easy to write is a direct result of an architecture rule: &amp;quot;&lt;code&gt;Combat&lt;/code&gt; functions are pure
queries: no side effects, so they can be unit tested.&amp;quot;&lt;/p&gt;
&lt;h2 id=&quot;gotcha-1-engine-errors-dont-fail-tests&quot;&gt;Gotcha 1: engine errors don&#x27;t fail tests &lt;a class=&quot;anchor&quot; href=&quot;#gotcha-1-engine-errors-dont-fail-tests&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A GDScript runtime&lt;/span&gt; error prints &lt;code&gt;SCRIPT ERROR&lt;/code&gt; and carries on. The test that triggered it may still pass.
So the command in CLAUDE.md always greps the log as well:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;sh&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$G&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--headless&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;--path&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;.&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;-s&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;res://tests/run_tests.gd&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;&amp;gt;&lt;span class=&quot;p&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tee&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;/tmp/t.log&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;tail&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;-1&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;grep&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;-E&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;^(SCRIPT )?ERROR&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;/tmp/t.log
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&amp;quot;All passed&amp;quot; with an error line in the log counts as a failure. Several real bugs, for example
freed-object errors, first showed up only as an &lt;code&gt;ERROR&lt;/code&gt; line.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/04-test-run.png&quot; alt=&quot;The end of a test run and an empty error grep (from the day the count first passed 11,900)&quot; width=&quot;920&quot; height=&quot;421&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The end of a test run and an empty error grep (from the day the count first passed 11,900)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;gotcha-2-headless-runs-never-compile-shaders&quot;&gt;Gotcha 2: headless runs never compile shaders &lt;a class=&quot;anchor&quot; href=&quot;#gotcha-2-headless-runs-never-compile-shaders&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;This one cost&lt;/span&gt; a lot of embarrassment for one line of code. On day six,
an agent added a uniform called &lt;code&gt;shade&lt;/code&gt; to the sprite shader. A shared shader include for cloud shadows
already had a local variable called &lt;code&gt;shade&lt;/code&gt;. The sprite shader stopped compiling, and every sprite in
the game (the player and every NPC) rendered as a flat white rectangle.&lt;/p&gt;
&lt;p&gt;Every test passed. Every headless smoke run passed. A headless Godot never compiles a shader, so the
error never happened there. The human found it in their own running game and asked, dryly: &amp;quot;you do
realize that our player model is a white wall?&amp;quot; The fix was a rename (&lt;code&gt;shade_tint&lt;/code&gt;). The lesson went into
CLAUDE.md the same day:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Headless runs never compile shaders: after any shader edit (incl. shader strings in .gd files) take a
windowed screenshot of main.tscn and grep its log for &lt;code&gt;^ERROR&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agents working in parallel at the time noticed it too. One reported that its first screenshots of the
ice dungeon showed white walls where the player should be, and re-shot them after the fix. Screenshots
caught what tests couldn&#x27;t, but only because someone looked.&lt;/p&gt;
&lt;h2 id=&quot;where-13800-checks-come-from-and-why-the-number-lies&quot;&gt;Where 13,800 checks come from (and why the number lies) &lt;a class=&quot;anchor&quot; href=&quot;#where-13800-checks-come-from-and-why-the-number-lies&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Most checks come&lt;/span&gt; from tests that loop over &lt;strong&gt;data&lt;/strong&gt;:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;test_items_have_models_icons_and_upgrade_glow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemData&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemDb&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;items&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemLook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;icon&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%s&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; has an icon&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemLook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;make_model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%s&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; has a model&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That&#x27;s one function and hundreds of checks. Every item has an icon and a model. Every monster type has a
valid model with its animations. Every quest points at real NPCs and monsters. Every NPC, townsperson and
companion name is unique (the lore has a rule about that). Every armor slot has a tier at least every ten
levels. Every ranged monster&#x27;s telegraph is long enough to dodge. A busy town square (63 NPC sprites)
stays inside its video memory budget. When the AI adds 41 armor pieces or ten dungeons in one commit,
these tests check every one of them without anyone writing a new test.&lt;/p&gt;
&lt;p&gt;Now the honest part. On the morning of day four the suite had 11,933 checks and took 168 seconds. Most of
that time came from 38 tests that each built the real 2.6 x 3 km Sanctuary Vale, at about 3.9 seconds a
build. An agent moved those tests onto a small dev-only test map. The suite dropped to &lt;strong&gt;12 seconds&lt;/strong&gt;, and
the check count dropped to &lt;strong&gt;4,992&lt;/strong&gt;. Nothing was lost: two tests make one check per spawned monster or
spawn slot, and the big map simply had far more monsters (3,093 checks from one test alone, against 123
on the test map). It took two more days of new content to climb back to 13,800.&lt;/p&gt;
&lt;p&gt;So a check count measures how much data a test happens to walk over, not how much behaviour is covered.
We still print it because a sudden drop is a useful alarm. We don&#x27;t brag about it.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;h2 id=&quot;regression-tests-reproduce-the-bug-first&quot;&gt;Regression tests reproduce the bug first &lt;a class=&quot;anchor&quot; href=&quot;#regression-tests-reproduce-the-bug-first&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;When a bug&lt;/span&gt; is found, the fix comes with a test that reproduces the original failure. One of the first:
dead monsters are freed while a Shadow&#x27;s spawn list still holds them, and a typed lambda then errors.
The test does exactly that:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawned&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;free&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawned&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;die&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wave_size&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;freed and dead mobs no longer count (got &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawn_wave&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wave_size&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;next wave still spawns&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The lesson also went into CLAUDE.md, so the AI doesn&#x27;t write that kind of code again. The same pattern
held for a GPU problem later on: when big maps ran out of GPU descriptors,&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; the fix came with a test that a map&#x27;s props share a handful of materials and that a far-away
monster releases its model.&lt;/p&gt;
&lt;h2 id=&quot;tests-never-touch-the-players-files&quot;&gt;Tests never touch the player&#x27;s files &lt;a class=&quot;anchor&quot; href=&quot;#tests-never-touch-the-players-files&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The game writes&lt;/span&gt; options, window layouts and tutorial progress to &lt;code&gt;user://&lt;/code&gt;. A test run that overwrote the
human&#x27;s key bindings would be a nasty surprise, so the runner points every one of those at a throwaway
file before anything else runs:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_initialize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WindowDragger&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;layout_path&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user://test_ui_layout_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;.cfg&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;randi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# never touch the player&#x27;s layout&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LootHint&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;progress_path&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user://test_tutorial_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;.cfg&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;randi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GameSettings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user://test_options_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;.cfg&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;randi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# never the player&#x27;s options&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Save tests set &lt;code&gt;SaveSystem.root_dir&lt;/code&gt; to a temp directory, and the terrain disk cache can be switched off
for tests. The same care applies to processes: the human often has the editor or the game open while the
AI works, so test processes are always wrapped in &lt;code&gt;timeout&lt;/code&gt;, and the AI never kills a Godot process it
didn&#x27;t start.&lt;/p&gt;
&lt;h2 id=&quot;a-map-just-for-tests&quot;&gt;A map just for tests &lt;a class=&quot;anchor&quot; href=&quot;#a-map-just-for-tests&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Real maps get&lt;/span&gt; reshaped all the time. Map 1 has been rebuilt from scratch, then shrunk, then dressed with
nearly a thousand pieces of clutter. Tests that depended on &amp;quot;the pond at (45, -8) in the Vale&amp;quot; would break with
every redesign. So there&#x27;s a dev-only map, the &lt;strong&gt;Proving Grounds&lt;/strong&gt;, generated by a Python script: a safe
square with shops and every NPC role, training dummies, every kind of liquid, an arena, &amp;quot;The Wilds&amp;quot; (a
hunting strip with packs, Shadows, a road and mountains), and a &amp;quot;zoo&amp;quot; with one passive monster of every
type. Maps with a &lt;code&gt;dev_&lt;/code&gt; prefix load by id but stay out of the world, achievements and exploration.
CLAUDE.md says: &amp;quot;Prefer it for new tests and scenarios: the real maps get reshaped.&amp;quot;&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;A related rule came from an early surprise. Maps sit 5000 m apart, and their offsets come from sorted map
ids, so adding a new map file shifted every map after it and broke tests that used raw coordinates. Tests
now always place things with &lt;code&gt;map.to_global(local)&lt;/code&gt;. With 20 dungeons added as map files, that rule has
been worth a lot.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/04-proving-grounds.jpg&quot; alt=&quot;The Proving Grounds from above (fog switched off for this view): the test square, liquids, the zoo and the arena&quot; width=&quot;1600&quot; height=&quot;1369&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Proving Grounds from above (fog switched off for this view): the test square, liquids, the zoo and the arena&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;tests-outside-godot&quot;&gt;Tests outside Godot &lt;a class=&quot;anchor&quot; href=&quot;#tests-outside-godot&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Not everything runs&lt;/span&gt; in the engine. The dungeon generator&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; is plain Python, and it has its
own &lt;code&gt;selftest.py&lt;/code&gt;: pressure plates and beams built from templates, latching gates, guide mazes, shifting
walls, illusion walls, ice slides and hazard marks. More importantly, the generator &lt;em&gt;refuses to write a
dungeon its solver can&#x27;t finish&lt;/em&gt;, so an unsolvable layout never reaches the game at all. The balance model
is Python too and is checked the same way a spreadsheet is: by rerunning it after every data change and
reading the charts.&lt;/p&gt;
&lt;h2 id=&quot;a-combat-simulation-instead-of-a-playtest-almost&quot;&gt;A combat simulation instead of a playtest (almost) &lt;a class=&quot;anchor&quot; href=&quot;#a-combat-simulation-instead-of-a-playtest-almost&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Balancing 20 dungeons&lt;/span&gt; and 90 levels by hand would take weeks of play. So &lt;code&gt;tests/sim_combat.gd&lt;/code&gt; runs the
real game code headless as fast as the CPU allows (about 70 times real time): real Actors, real monster
AI and boss phases, real skills, real dungeon mechanics. &lt;strong&gt;Bot players&lt;/strong&gt; drive their characters through the
same &lt;code&gt;command_*&lt;/code&gt; methods as the player controller. They swing the combo, cast their school&#x27;s skills, drink
potions when low, and step out of every telegraphed attack after a 0.3 second &amp;quot;human&amp;quot; reaction time.
Whatever still hits them is what a careful player can&#x27;t avoid.&lt;/p&gt;
&lt;p&gt;It fights every hunting zone, every boss and every dungeon room, per school, solo, with companions and in
a party of four, and prints tables: time to kill, damage taken and by which attack, potions, deaths and
the biggest single hit. The first run found that nothing in the game could hurt you and nothing lived
(packs died to the opening skill burst in under 1.5 seconds), and that one boss took 150 to 230 seconds
because the bots kept hitting him while he was shielded.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; The bots
can&#x27;t walk mazes or solve puzzles, so dungeon run times still need a human with a stopwatch.&lt;/p&gt;
&lt;h2 id=&quot;smoke-runs-and-network-bots&quot;&gt;Smoke runs and network bots &lt;a class=&quot;anchor&quot; href=&quot;#smoke-runs-and-network-bots&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Unit tests check&lt;/span&gt; pieces. Two cheaper checks cover the whole:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Smoke runs&lt;/strong&gt;: start the start menu headless for 60 frames, and the game for 300, then grep for errors.
This catches problems that only happen when everything is live together: autoload order, scene
wiring, a missing resource.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network bots&lt;/strong&gt;: &lt;code&gt;tests/net_test.sh&lt;/code&gt; runs a host and a client that play on their own (fight, loot,
party up, enter the dungeon), then a passworded dedicated server. The multiplayer post covers them.
They now run on a random port with their own log folder, so parallel runs don&#x27;t collide.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;screenshots-how-an-ai-checks-visuals&quot;&gt;Screenshots: how an AI checks visuals &lt;a class=&quot;anchor&quot; href=&quot;#screenshots-how-an-ai-checks-visuals&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Tests can&#x27;t tell&lt;/span&gt; you that the grass is the wrong green or a window covers the hotbar. For that, the AI
takes screenshots and looks at them itself. &lt;code&gt;tests/screenshot.gd&lt;/code&gt; renders any scene for a few frames and
saves a PNG. On top of it there are &lt;strong&gt;scenario scripts&lt;/strong&gt;: scripted sessions that set something up and
photograph it. There were 15 when this series was first drafted. There are now &lt;strong&gt;44&lt;/strong&gt;: every skill of a
school, the inventory, the upgrade window, every NPC up close, the animals in town, the story&#x27;s finale,
the scenic spots of the Vale, and one per batch of dungeons that photographs the gate, every room, each
mechanic in action, the boss and a top-down overview.&lt;/p&gt;
&lt;p&gt;That last kind changed how visual review scales. Ten dungeons produce a few hundred screenshots, and the
coordinator can&#x27;t look at every one carefully. So each dungeon batch also produced contact sheets (all of a kit&#x27;s
pieces on one image, all of a dungeon&#x27;s monsters on another), and the review starts there.&lt;/p&gt;
&lt;p&gt;For visual changes the rule is before-and-after: a screenshot of the current state, the change, the same
shot again, and a look at both. Many visual commits show iteration in small steps. The lens effect, for
instance, got three follow-up commits within three minutes (&amp;quot;only at the outer corners&amp;quot;, &amp;quot;half the corner
blur&amp;quot;, &amp;quot;a bit more color fringing&amp;quot;).&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/04-dungeon-review-set.jpg&quot; alt=&quot;The Demon Tower&amp;#x27;s nine floors from one scenario run: every floor photographed at the default camera&quot; width=&quot;1600&quot; height=&quot;861&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The Demon Tower&amp;#x27;s nine floors from one scenario run: every floor photographed at the default camera&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/04-scenario-strip.jpg&quot; alt=&quot;Screenshots from the early scenario scripts and the network bots&quot; width=&quot;1600&quot; height=&quot;582&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Screenshots from the early scenario scripts and the network bots&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/04-before-after.jpg&quot; alt=&quot;A before/after pair as the AI sees it: the lower-left corner with the lens effect off and on&quot; width=&quot;1600&quot; height=&quot;599&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A before/after pair as the AI sees it: the lower-left corner with the lens effect off and on&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;bugs-found-along-the-way&quot;&gt;Bugs found along the way &lt;a class=&quot;anchor&quot; href=&quot;#bugs-found-along-the-way&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;From the commit&lt;/span&gt; messages and agent reports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ranged monsters never set their home position and walked with &lt;strong&gt;NaN directions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Monsters across a map border &lt;strong&gt;stood frozen like a wall&lt;/strong&gt;, because only the player&#x27;s map was awake.&lt;/li&gt;
&lt;li&gt;A moved window &lt;strong&gt;kept its biggest size&lt;/strong&gt;: the skill window got stuck at full height.&lt;/li&gt;
&lt;li&gt;Moving an actor between maps &lt;strong&gt;inside a physics callback segfaulted&lt;/strong&gt; Godot. That one is now a rule in
CLAUDE.md.&lt;/li&gt;
&lt;li&gt;Two agents each added a test helper named &lt;code&gt;_room_mid&lt;/code&gt; to the same test file, and the whole suite stopped
parsing until one of them renamed theirs.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;class_name&lt;/code&gt; scripts aren&#x27;t known until Godot refreshes its class cache, so a suite that suddenly
&amp;quot;can&#x27;t find&amp;quot; a class usually needs &lt;code&gt;--import&lt;/code&gt;, not a fix. That line is also in CLAUDE.md now.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-tests-cant-do&quot;&gt;What tests can&#x27;t do &lt;a class=&quot;anchor&quot; href=&quot;#what-tests-cant-do&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The CLAUDE&lt;/span&gt;.md line is honest about the limits: tests and smoke runs don&#x27;t cover visuals or game feel. &amp;quot;Ask
the user to playtest anything about feel.&amp;quot; Whether the combo has a good rhythm, whether a blow seems to
land when its damage number appears, whether a full sprint bar should last 5 seconds or 25 (it went from
one to the other two minutes after sprint landed): these are questions for a human with the game in their
hands. The combat sim narrows the questions; it doesn&#x27;t answer them.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No framework needed.&lt;/strong&gt; A few dozen lines of runner and a &lt;code&gt;_check&lt;/code&gt; function go a long way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loop over your data.&lt;/strong&gt; One test function per &lt;em&gt;rule&lt;/em&gt;, not per item, gives thousands of checks for free
and catches broken references between files. Just don&#x27;t read too much into the total.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grep for engine errors, and compile your shaders.&lt;/strong&gt; A green headless run with a broken shader isn&#x27;t
green.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Isolate the player&#x27;s files and processes.&lt;/strong&gt; Tests that touch real settings or kill a running editor
destroy trust quickly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simulate what you can&#x27;t play.&lt;/strong&gt; Bots that use the same commands as the player turn &amp;quot;is this boss too
hard?&amp;quot; into a table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the AI look at its work.&lt;/strong&gt; Screenshots turn &amp;quot;I changed the shader&amp;quot; into &amp;quot;I changed the shader and
here is what it looks like now&amp;quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Next is the&lt;/span&gt; content pipeline: how every monster, weapon, building and tree in the game is built by Python
scripts in headless Blender, and what went wrong with the first batch.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The title of this post is a small exception. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/07-performance-on-a-3km-map.html&quot;&gt;The performance post&lt;/a&gt; has the story. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;F10 takes you there in a debug build. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;It has &lt;a href=&quot;https://dekiro.d12r.io/13-a-dungeon-generator-with-solvers.html&quot;&gt;its own post&lt;/a&gt;. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/15-balancing-a-40-hour-grind.html&quot;&gt;The balance post&lt;/a&gt; has the numbers. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/11-coordinator-and-subagents.html&quot;&gt;The coordinator post&lt;/a&gt; has more on parallel agents. &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Server-Authoritative Multiplayer in Godot, Single Player Included</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/03-server-authoritative-multiplayer-in-godot.html"/>
<id>https://dekiro.d12r.io/03-server-authoritative-multiplayer-in-godot.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Four network roles, one spawn function and a bot that plays the game, all running in single player too.</summary>
<content type="html">&lt;h2 id=&quot;the-decision-single-player-is-a-local-server&quot;&gt;The decision: single player is a local server &lt;a class=&quot;anchor&quot; href=&quot;#the-decision-single-player-is-a-local-server&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The human set&lt;/span&gt; the multiplayer direction early: &lt;strong&gt;Valheim-style hosting&lt;/strong&gt;. Players host for friends from
the game or run a dedicated server. Characters live on each player&#x27;s PC and can join any world, and the
world lives with whoever hosts it. Up to 16 players.&lt;/p&gt;
&lt;p&gt;The key engineering choice followed from that. &lt;strong&gt;Single player is just a local server with one player.&lt;/strong&gt;
No separate offline code path exists that could drift apart from the online one. If something works
alone, it runs the same server code it would run online. The cost is that every gameplay system has to
be written with the network in mind from the start. The multiplayer commit arrived on day two, after
combat, skills, loot, maps and saving existed, and it changed 33 files. After that, every new system
was born networked.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/03-start-screen.png&quot; alt=&quot;The start screen: characters, worlds, Host / Join and the Steam status line (account name hidden)&quot; width=&quot;1600&quot; height=&quot;1219&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The start screen: characters, worlds, Host / Join and the Steam status line (account name hidden)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;four-roles-per-actor&quot;&gt;Four roles per actor &lt;a class=&quot;anchor&quot; href=&quot;#four-roles-per-actor&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every&lt;/span&gt; &lt;code&gt;Actor&lt;/code&gt; (players, monsters, Shadows, companions) has a network role:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;enum&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NetRole&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LOCAL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OWNER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PROXY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PUPPET&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LOCAL&lt;/strong&gt;: simulated here. Everything in single player, and monsters on the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OWNER&lt;/strong&gt;: a client&#x27;s own player. It moves locally so input feels instant; everything else goes to
the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PROXY&lt;/strong&gt;: the server&#x27;s copy of a client&#x27;s player. Combat, damage and loot happen here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PUPPET&lt;/strong&gt;: a client&#x27;s view of anyone else. It only shows what the server says.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CLAUDE.md states the rule: &amp;quot;Gameplay code runs only where the role says so; check &lt;code&gt;net_role&lt;/code&gt; /
&lt;code&gt;multiplayer.is_server()&lt;/code&gt; rather than assuming.&amp;quot; Movement is client-authoritative but sanity-checked by
the server. Combat, damage, loot, items and progression are decided by the server.&lt;/p&gt;
&lt;h2 id=&quot;one-way-to-create-things&quot;&gt;One way to create things &lt;a class=&quot;anchor&quot; href=&quot;#one-way-to-create-things&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Every dynamic entity&lt;/span&gt; (players, monsters, Shadows, training dummies, loot, books) is created through one
function on the server:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## Server: creates an entity (replicated to clients on its map). `data` needs&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## &quot;kind&quot;, &quot;map&quot; and &quot;pos&quot; ([x, z], map-local) plus kind-specific fields.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Dictionary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duplicate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;E&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;%d&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_next_id&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_next_id&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawner&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;spawn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That dictionary goes through Godot&#x27;s &lt;code&gt;MultiplayerSpawner&lt;/code&gt;, and &lt;code&gt;World._build_entity&lt;/code&gt; builds the same
node from it on every machine. The rule in CLAUDE.md is short: &amp;quot;Never &lt;code&gt;add_child&lt;/code&gt; gameplay entities
directly (tests excepted).&amp;quot; Spawning from a plain dictionary has a nice side effect: anything that can
be spawned can also be described in a map file, and the dungeon and proximity spawning code just builds
those dictionaries.&lt;/p&gt;
&lt;h2 id=&quot;state-versus-events&quot;&gt;State versus events &lt;a class=&quot;anchor&quot; href=&quot;#state-versus-events&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;We split what&lt;/span&gt; goes over the wire into two kinds:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;State&lt;/strong&gt; is replicated with &lt;code&gt;MultiplayerSynchronizer&lt;/code&gt;. The list is short on purpose:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ACTOR_SYNC&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;net_pos&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;net_facing&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;net_time&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;hp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;max_hp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;is_dead&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The boolean says whether the property goes out every tick or only when it changes. Players add a few more
fields (map, mana, level, title, weapon look).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Events&lt;/strong&gt; are things that &lt;em&gt;happen&lt;/em&gt;: a swing, a hit, a death, a skill effect, a Shadow wave. These go
through &lt;code&gt;Actor.net_event(kind, args)&lt;/code&gt;, which sends an RPC from the server to every peer that can see
this actor:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;only_peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;skip_peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_networked&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;multiplayer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;world&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;world&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peers_seeing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# incl. peers across a joined border&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;skip_peer&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;only_peer&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;only_peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;can_reach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_rpc_event&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rpc_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;can_reach&lt;/code&gt; is there because of a bug the network bots found on the first day: an RPC about an entity
whose spawn message hasn&#x27;t arrived yet fails on the client. Now an entity has to be at least 300 ms old
on that peer&#x27;s map before events about it are sent.&lt;/p&gt;
&lt;h2 id=&quot;the-ui-never-changes-anything&quot;&gt;The UI never changes anything &lt;a class=&quot;anchor&quot; href=&quot;#the-ui-never-changes-anything&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;On a client&lt;/span&gt;, the inventory window can&#x27;t move an item. It can only ask:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;net_role&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NetRole&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OWNER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_rpc_request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rpc_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;perform_action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;perform_action&lt;/code&gt; runs on the server and starts with the comment &amp;quot;Arguments come from the network, so
everything is validated here.&amp;quot; It checks indices, ownership, levels, gold and room in the bag, then
applies the action. The owner gets a fresh snapshot of its character back. In single player, &lt;code&gt;request&lt;/code&gt;
just calls &lt;code&gt;perform_action&lt;/code&gt; directly. It&#x27;s the same code with no network hop.&lt;/p&gt;
&lt;p&gt;Trading shows the pattern at its strictest. Both players put items and gold up, both lock, both accept.
Then the server checks that everything is still there and fits, and swaps it all at once. Any change
unlocks both sides.&lt;/p&gt;
&lt;h2 id=&quot;who-sees-what-interest-management&quot;&gt;Who sees what: interest management &lt;a class=&quot;anchor&quot; href=&quot;#who-sees-what-interest-management&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;At first&lt;/span&gt;, visibility was per map: a peer received everything on its player&#x27;s map and nothing else. That
was fine for 480 m maps. When Map 1 grew to 2.6 x 3 km, streaming every monster on the map to every
player stopped making sense. Now the synchronizers carry a visibility filter:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_peer_sees&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;players_by_peer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;peer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_instance_valid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node_map&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;map_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map_id&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node_map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Actor&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;players_by_peer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;has&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# other players: always (party, duels)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Node3D&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Node3D&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;global_position&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;distance_to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;global_position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;INTEREST_RANGE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Other players on your map are always visible (for party frames and duels). Everything else is visible
within &lt;code&gt;INTEREST_RANGE&lt;/code&gt;, 160 m. Maps without players sleep entirely: their monsters don&#x27;t think.&lt;/p&gt;
&lt;h2 id=&quot;smooth-movement-from-jittery-packets&quot;&gt;Smooth movement from jittery packets &lt;a class=&quot;anchor&quot; href=&quot;#smooth-movement-from-jittery-packets&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Remote players and&lt;/span&gt; monsters used to stutter on real connections. Packets arrive bunched up, especially
over Wi-Fi. The fix is classic snapshot interpolation in &lt;code&gt;NetSmoother&lt;/code&gt;: every sample carries the sender&#x27;s
timestamp, and we show the actor where it was 100 ms ago on the sender&#x27;s clock, interpolating between
the two samples around that moment.&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;## How far in the past we show things. Must cover a couple of send intervals plus jitter.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DELAY_MSEC&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;100.0&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## Past the newest sample we keep moving along the last velocity for at most this long.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_EXTRAPOLATE_MSEC&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;80.0&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;## A jump this big between two samples is a teleport (portal, respawn, dash): no gliding.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SNAP_DISTANCE&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;6.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The clock offset follows the fastest packets down immediately and drifts up slowly, so jitter doesn&#x27;t
shake it. Players sync at 30 Hz, everything else at 20 Hz. A unit test feeds the smoother bunched packet
arrivals and checks that playback stays even.&lt;/p&gt;
&lt;p&gt;One design consequence: &lt;strong&gt;hit-stop&lt;/strong&gt; (the brief freeze when the combo finisher connects) is single-player
only. Freezing the server would freeze everyone.&lt;/p&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/03-two-clients.jpg&quot; alt=&quot;Host and client in the same Crypt fight, both driven by the test bots&quot; width=&quot;1600&quot; height=&quot;448&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Host and client in the same Crypt fight, both driven by the test bots&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;dedicated-servers&quot;&gt;Dedicated servers &lt;a class=&quot;anchor&quot; href=&quot;#dedicated-servers&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A dedicated server&lt;/span&gt; is the same game started with &lt;code&gt;--server&lt;/code&gt;, or exported as its own build. It reads a
&lt;code&gt;server.cfg&lt;/code&gt; (name, world, port, password, max players, autosave interval, message of the day) and
creates one with comments on first start. A console takes &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;say&lt;/code&gt;, &lt;code&gt;kick&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt; and
&lt;code&gt;stop&lt;/code&gt;. A &lt;code&gt;STOP&lt;/code&gt; file in the save folder shuts it down cleanly, which is handy for service managers.&lt;/p&gt;
&lt;p&gt;Linux got the same treatment from the start. Exports for Windows and Linux, game and server, exist as
presets. The first Linux run under WSL found a real problem: a blocked stdin read deadlocked process exit
on Linux. The fix was &lt;code&gt;--no-console&lt;/code&gt; for when nothing reads stdin, plus a watchdog.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; A later fix made &lt;code&gt;World.shutdown&lt;/code&gt; free entities before the spawner on every quit
path, because the exported Linux server had logged one error per entity on exit.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/03-server-console.png&quot; alt=&quot;A dedicated server&amp;#x27;s console: joins, autosaves and the once-a-minute status line&quot; width=&quot;1064&quot; height=&quot;421&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A dedicated server&amp;#x27;s console: joins, autosaves and the once-a-minute status line&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;steam-lobbies&quot;&gt;Steam lobbies &lt;a class=&quot;anchor&quot; href=&quot;#steam-lobbies&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;For playing with&lt;/span&gt; friends, the game uses the GodotSteam GDExtension: friends-only lobbies, overlay invites,
&amp;quot;Join Game&amp;quot; from the friends list, and a &lt;strong&gt;Join &amp;lt;friend&amp;gt;&lt;/strong&gt; button on the start screen when a friend is
hosting. Dedicated servers stay on ENet.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The important constraint: everything must work with Steam off. Tests, headless runs and dedicated servers
never touch it. The Steam test itself needs two humans and two Steam accounts, so it&#x27;s a written checklist
in HOSTING.md. The human ran it with a friend the day after the feature landed, and it passed.&lt;/p&gt;
&lt;h2 id=&quot;the-bots&quot;&gt;The bots &lt;a class=&quot;anchor&quot; href=&quot;#the-bots&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Automated tests of&lt;/span&gt; a networked game are worth more than any amount of reasoning about it.
&lt;code&gt;tests/net_test.sh&lt;/code&gt; starts a headless host and a headless client, both driven by &lt;code&gt;tests/net_bot.gd&lt;/code&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Automated player for network tests (main.gd adds it with --bot). Takes over our own player: walks to the
nearest enemy, fights it, picks up loot, spends an attribute point through a server request, and prints
what it saw.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Over time the bots learned to form a party, accept a quest, receive a world event, enter the dungeon
together and hire a companion. The script then starts a &lt;strong&gt;passworded dedicated server&lt;/strong&gt;, checks that a
wrong password is refused and the right one plays, and that the world gets saved. At the end it prints
each side&#x27;s report and the number of engine errors.&lt;/p&gt;
&lt;p&gt;The first bot run found four bugs before any human connected: synchronizer visibility, RPCs sent before
spawns arrived, cyclic preloads, and the spawner on a reused local player node. A later one found that
owners lost their own player after a map transfer, because the visibility meta wasn&#x27;t updated.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/03-net-test.png&quot; alt=&quot;tests/net_test.sh: host + client bots, then a passworded dedicated server, with engine error counts&quot; width=&quot;1406&quot; height=&quot;605&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;tests/net_test.sh: host + client bots, then a passworded dedicated server, with engine error counts&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;what-doesnt-go-over-the-network&quot;&gt;What doesn&#x27;t go over the network &lt;a class=&quot;anchor&quot; href=&quot;#what-doesnt-go-over-the-network&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The newer systems&lt;/span&gt; taught a second lesson: the cheapest replicated state is the state you don&#x27;t replicate.
A lot of the world is &lt;em&gt;presentation&lt;/em&gt; that every machine can compute for itself from data it already has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Time of day.&lt;/strong&gt; &lt;code&gt;WorldClock&lt;/code&gt; reads the wall clock (a 36-minute day), so every player sees the same sun
without a single packet. The one catch is that a player&#x27;s PC clock can be wrong, so on join the server
sends its own wall clock and clients keep the difference (&lt;code&gt;server_skew&lt;/code&gt;). Everything timed by the clock
(the sky, the caravan merchant&#x27;s schedule, townsfolk routines) then matches the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Townsfolk and animals.&lt;/strong&gt; The 66 townsfolk walk their daily routines and the cats, dogs and goats wander
on schedules that are all derived from the same clock and a seeded hash, so everyone sees them about
where everyone else does. There is no spawn, no sync and no collision. The one exception is petting a
cat or dog: that is a server request, and the server checks it loosely against where the animal&#x27;s
schedule says it should be (a &amp;quot;leash&amp;quot; of 18 m), because nothing else may depend on where a
presentation-only actor happens to stand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dungeon devices&lt;/strong&gt; (levers, gates, rising lava, bells, light beams) &lt;em&gt;are&lt;/em&gt; gameplay, so the server runs
them. But they&#x27;re built on every machine from the map data, and only their state goes out: one
reliable message when something changes, plus a refresh every second. Moving parts like light beams are
drawn from a shared clock, so clients don&#x27;t need a stream of positions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule of thumb that came out of it: if it&#x27;s derived from data and a clock, compute it everywhere; if a
player&#x27;s choice can change it, the server owns it.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Make single player a local server&lt;/strong&gt; if you want multiplayer at all. One code path is easier to keep
right than two.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate state from events.&lt;/strong&gt; Replicate a few properties; send everything that happens as an event
to the peers who can see it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The UI asks, the server decides.&lt;/strong&gt; &lt;code&gt;request&lt;/code&gt; → &lt;code&gt;perform_action&lt;/code&gt; → snapshot. It also makes cheating
through edited clients much harder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write a bot.&lt;/strong&gt; It finds the race conditions you&#x27;d otherwise find with a friend on a Friday night.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The bots are&lt;/span&gt; one part of a larger safety net. The next post looks at the whole test setup: about 13,800
headless checks, screenshot scenarios, and how an AI developer checks its own visuals.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;HOSTING.md has a systemd unit example. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;During development the game uses Steam&#x27;s test app 480. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Skills, Items and Loot as Data</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/02-data-driven-skills-items-and-loot.html"/>
<id>https://dekiro.d12r.io/02-data-driven-skills-items-and-loot.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>Twenty-four skills, 203 items and a +9 upgrade track in the style of a popular Korean MMO, with almost none of it hard-coded.</summary>
<content type="html">&lt;h2 id=&quot;why-data-first&quot;&gt;Why data first &lt;a class=&quot;anchor&quot; href=&quot;#why-data-first&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;A grind game&lt;/span&gt; lives on its numbers. How wide is Whirlwind? How often does a Shadow drop a skillbook?
What does a +6 attempt cost, and how often does it fail? Those numbers change all the time while you
balance, so we decided early that they live in &lt;code&gt;data/*.json&lt;/code&gt; and not in code. CLAUDE.md says it bluntly:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New skills go in &lt;code&gt;data/skills.json&lt;/code&gt; only. If a skill needs a new kind of effect, add a building block
to SkillExecutor (and a test), never a one-off script per skill.&lt;/li&gt;
&lt;li&gt;Content is data: new NPCs, shops, mobs, Shadows, zones and maps go in &lt;code&gt;data/&lt;/code&gt;, not code.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This post walks through three systems built that way: skills, items with random bonuses, and the
blacksmith&#x27;s upgrade track.&lt;/p&gt;
&lt;h2 id=&quot;skills-are-effect-blocks&quot;&gt;Skills are effect blocks &lt;a class=&quot;anchor&quot; href=&quot;#skills-are-effect-blocks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;There are four&lt;/span&gt; schools with six skills each. Every skill is a JSON entry made from a small set of
&lt;strong&gt;building blocks&lt;/strong&gt;: a shape (&lt;code&gt;arc&lt;/code&gt;, &lt;code&gt;circle&lt;/code&gt;, &lt;code&gt;line&lt;/code&gt;, &lt;code&gt;ground&lt;/code&gt; or &lt;code&gt;self&lt;/code&gt;) plus modifiers like &lt;code&gt;hits&lt;/code&gt;,
&lt;code&gt;knockback&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;, &lt;code&gt;dash&lt;/code&gt;, &lt;code&gt;heal&lt;/code&gt; and &lt;code&gt;buffs&lt;/code&gt;. Here is the Blade school&#x27;s Whirlwind:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;whirlwind&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;motion&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;spin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;fx&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;hit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;block&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;spin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;block&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ring&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;scale&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.05&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;block&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;burst&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;kind&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;sparks&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;caster&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;block&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;hitstop&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;block&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shake&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;amount&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.08&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}]},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Whirlwind&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;mana&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;cooldown&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;description&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Spin, striking everything around you. The dark comes from every side; so does the Blade.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;effect&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;shape&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;circle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;radius&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;2.6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;damage_mult&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.6&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;mastery&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;radius&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;3.2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;grandmaster&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;radius&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;3.8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;knockback&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.6&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Two keys in that entry arrived later and are purely visual. &lt;code&gt;motion&lt;/code&gt; names the body motion the player&#x27;s
sprite plays (one of 13 shared motions such as spin, sweep, dash or slam, rendered per weapon type). &lt;code&gt;fx&lt;/code&gt; is
a recipe made from a pool of 17 effect blocks (slash, ring, pillar, beam, afterimages, shake, hitstop and
so on). The hit itself is still only the &lt;code&gt;effect&lt;/code&gt; block: what you see can change without touching what
gets hit.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;mastery&lt;/code&gt; and &lt;code&gt;grandmaster&lt;/code&gt; blocks are overrides. A skill&#x27;s parameters at a given rank are the base
effect with the overrides merged on top:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;params_for_rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;Dictionary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;effect&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duplicate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SkillRules&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MASTERY_RANK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;merge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mastery&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duplicate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SkillRules&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GRANDMASTER_RANK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;merge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;grandmaster&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duplicate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This makes tiers change &lt;em&gt;what a skill does&lt;/em&gt;, not just its numbers. Cleave&#x27;s arc widens from 160 to 210
degrees at Master and hits twice at Grandmaster. Charge dashes 6 m, then 8 m. The design doc asks for
exactly that: &amp;quot;Each tier changes the effect itself, not only the numbers&amp;quot;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SkillExecutor&lt;/code&gt; turns the merged parameters into gameplay. It has one branch per shape, and every branch
asks the pure &lt;code&gt;Combat&lt;/code&gt; queries what&#x27;s inside the shape:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;match&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shape&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;arc&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arc_deg&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;arc_deg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;90.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;targets&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Combat&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actors_in_arc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scope&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;here&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arc_deg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;team&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SkillFx&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fan&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;here&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arc_deg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;skill&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;circle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;targets&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Combat&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actors_in_circle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scope&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;here&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;team&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;			&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SkillFx&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;disc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caster&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;here&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;skill&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That&#x27;s the second architecture rule at work: &amp;quot;Combat is shape-based, never tab-target.&amp;quot; There is no
target selection anywhere in the game. You aim at the ground, and everything in the shape gets hit. For
a game about mowing down packs, that&#x27;s the whole point.&lt;/p&gt;
&lt;h3 id=&quot;progression-rules-also-in-one-place&quot;&gt;Progression rules, also in one place &lt;a class=&quot;anchor&quot; href=&quot;#progression-rules-also-in-one-place&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The rules for raising skills sit in &lt;code&gt;SkillRules&lt;/code&gt; as named constants, so they&#x27;re easy to find and test:&lt;/p&gt;
&lt;figure class=&quot;fig margin pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-skill-window.png&quot; alt=&quot;The skill window (K) for the Blade school: M3 = Master rank 3, G = Grandmaster&quot; width=&quot;1300&quot; height=&quot;1350&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The skill window (K) for the Blade school: M3 = Master rank 3, G = Grandmaster&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;
&lt;li&gt;One skill point per level. The 15th point in a skill turns it into &lt;strong&gt;M1&lt;/strong&gt; (Mastery).&lt;/li&gt;
&lt;li&gt;M1 to M10 can only be raised with &lt;strong&gt;skillbooks&lt;/strong&gt;, which drop from destroyed Shadows (40%) and never
from ordinary mobs.&lt;/li&gt;
&lt;li&gt;A read succeeds 70% of the time at M1, falling to 35% at M10. Each failure in a row adds 10% to the
next try (bad-luck protection), and you have to destroy a Shadow between reads.&lt;/li&gt;
&lt;li&gt;The 10th successful read makes the skill &lt;strong&gt;Grandmaster&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Damage grows 7% per rank, heals and buffs 3%.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The points math is deliberate. Taking all six of a school&#x27;s skills to M1 costs 90 points, so you have to
specialize.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-skill-tiers.jpg&quot; alt=&quot;The same disc, fan and rectangle effects at Normal, Master and Grandmaster, side by side&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The same disc, fan and rectangle effects at Normal, Master and Grandmaster, side by side&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;items-base-stats-plus-random-bonuses&quot;&gt;Items: base stats plus random bonuses &lt;a class=&quot;anchor&quot; href=&quot;#items-base-stats-plus-random-bonuses&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;data/items.json&lt;/code&gt; holds stat names, rarities, the random-bonus pool, rune rules, loot tables and the
items themselves. Rarity decides how many random bonuses a piece of gear rolls:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;rarities&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;color&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#d4d4d4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;min_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;max_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Magic&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;color&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#5aa2ff&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;min_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;max_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Rare&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;color&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#f2d64b&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;min_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;max_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Legendary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;color&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#ff8c2a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;min_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;max_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Unique&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;color&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;#ff3b5c&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;min_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;max_bonuses&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Bonuses come from a pool per slot (attack, HP, crit, attack speed, damage vs Shadows and more) and scale
with item level. Unique is reserved for the four &lt;strong&gt;Fullmoon&lt;/strong&gt; weapons, each school&#x27;s sacred level 30
weapon, which is named after that MMO&#x27;s Fullmoon Sword. They never come from normal rolls: a level 28+
Shadow drops one at 0.3% per weapon, a boss at 2%.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The item list itself has grown from 139 to 203 entries: 155 written in &lt;code&gt;items.json&lt;/code&gt;, plus 24 skillbooks
and 24 runes that &lt;code&gt;ItemDb&lt;/code&gt; generates from the skill list and the rune table at load. The biggest single
addition was a balancing pass that found a 56-level gap in armor&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; and filled it with 41 pieces in six sets. Models and icons came from
the same Blender script as everything else, so filling a gap in the data was mostly a data change.&lt;/p&gt;
&lt;h3 id=&quot;loot-that-respects-your-level&quot;&gt;Loot that respects your level &lt;a class=&quot;anchor&quot; href=&quot;#loot-that-respects-your-level&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Players of the old MMO know the trick of farming low monsters at a high level. We cut it off with one small,
testable function:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;static&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;drop_factor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;killer_level&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;victim_level&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;killer_level&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;victim_level&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DROP_FREE_GAP&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;maxf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DROP_LOSS_PER_LEVEL&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Up to 3 levels above a monster you get its full drops. Each level beyond that takes 25% off, so from 7
levels above nothing drops. In a party the gap counts from the highest-level member on the map, so a
strong friend can&#x27;t farm low monsters for the group.&lt;/p&gt;
&lt;p&gt;Gold followed the same &amp;quot;one small formula&amp;quot; idea, but the first formula was wrong. Gold was originally
the monster&#x27;s XP reward times a factor from its loot table. When the balance pass raised late-game XP a
lot, the model showed the median character finishing with about 16 million unspent gold. Gold now
follows the victim&#x27;s &lt;strong&gt;level&lt;/strong&gt; instead (&lt;code&gt;1.2 x (level + 2)&lt;/code&gt;, so 14 at level 10 and 110 at level 90),
times the table&#x27;s factor.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; Prices grow
linearly with level too, so the two stay in step.&lt;/p&gt;
&lt;h3 id=&quot;saves-cant-be-trusted-so-loading-validates&quot;&gt;Saves can&#x27;t be trusted, so loading validates &lt;a class=&quot;anchor&quot; href=&quot;#saves-cant-be-trusted-so-loading-validates&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Characters live on the player&#x27;s own PC and travel to any server they join. A server therefore has to
assume that a character file may have been edited. Every item goes through &lt;code&gt;ItemInstance.from_dict&lt;/code&gt; on
load, which clamps and checks everything against the data:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;gdscript&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;item_data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_gear&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rarity&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clampi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;rarity&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemDb&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_rarity_for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;# Unique: Fullmoon only&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upgrade&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clampi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb nb-Type&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;upgrade&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Upgrades&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_LEVEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;		&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;allowed&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ItemDb&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rarity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rarity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_bonuses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Bonuses that aren&#x27;t valid for that slot, values out of range and duplicate stats are all dropped. The
same goes for levels, attribute points and skill points in &lt;code&gt;CharacterSave.sanitize&lt;/code&gt;: impossible spends are
refunded. A handful of tests feed deliberately broken characters through it.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-tooltip.jpg&quot; alt=&quot;A legendary Steel Blade: rarity colour, empty sockets, random bonuses and the comparison to the equipped Iron Sword&quot; width=&quot;1400&quot; height=&quot;919&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;A legendary Steel Blade: rarity colour, empty sockets, random bonuses and the comparison to the equipped Iron Sword&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-blacksmith-0-to-9&quot;&gt;The blacksmith: +0 to +9 &lt;a class=&quot;anchor&quot; href=&quot;#the-blacksmith-0-to-9&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The upgrade track&lt;/span&gt; is pure data in &lt;code&gt;data/upgrades.json&lt;/code&gt;. Each step has a success chance, a gold factor
(multiplied by the item&#x27;s value) and materials, which are used up whether you win or lose:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;levels&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;chance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gold_factor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;materials&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_shard&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;chance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.95&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gold_factor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;materials&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_shard&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;	&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;chance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;gold_factor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;materials&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;item&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;shadow_shard&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&quot;count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each level adds 12% to the item&#x27;s base stats. The chance falls from 100% to 20%, and from +5 a failure
also drops the item a level. Shadow Shards drop from every Shadow, and from +6 the steps need rare Shadow
Cores from level 10+ Shadows. The last three steps also take &lt;strong&gt;Sunstone&lt;/strong&gt; (1, 2, then 3), glass fused by
an ancient light that only the Sunscar Desert yields in quantity, plus an 18% drop from level 55+ Shadows
anywhere. The top of the ladder sends you to the desert, which gives an otherwise empty map a reason to
exist.&lt;/p&gt;
&lt;p&gt;The Fullmoon weapons get their own &lt;strong&gt;track&lt;/strong&gt; in the same file: +36% per level instead of 12%, so a +9
Fullmoon beats a +9 level 60 weapon. Every step also needs the school&#x27;s &lt;strong&gt;Moon Seal&lt;/strong&gt;, earned through a
chain of nine seal quests unlocked from level 30 to 60. Here the data shows its value. A later design
change (&amp;quot;a failed Fullmoon upgrade keeps the Moon Seal but drains it: destroy Shadows to recharge&amp;quot;) was
mostly one new key:&lt;/p&gt;
&lt;div class=&quot;code&quot; data-lang=&quot;json&quot;&gt;&lt;pre class=&quot;hl&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&quot;recharge_shadows&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;18&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;22&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;25&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id=&quot;from-glow-to-sparks&quot;&gt;From glow to sparks &lt;a class=&quot;anchor&quot; href=&quot;#from-glow-to-sparks&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The first version followed a vague memory of the classic: a +7, +8 or +9 weapon glowed red, green or gold, with
a shine running up the blade. &lt;code&gt;UpgradeGlow&lt;/code&gt; laid an additive shader overlay on the weapon&#x27;s meshes. The
human corrected it the same day: &amp;quot;the +7, +8 and +9 upgrade shouldn&#x27;t glow but have particular sparks
around the weapon, not too many. In [that game] you can see the sparkling.&amp;quot; The rebuild draws a handful of
pixel-art sparkles along the blade in the step&#x27;s colour, and the +9 shine travels up the edge. Inventory
icons got tiny matching stars instead of a coloured glow behind them. Every machine shows it, because the
weapon&#x27;s look (&lt;code&gt;weapon_look&lt;/code&gt;) is part of each player&#x27;s replicated state.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-upgrade-window.jpg&quot; alt=&quot;Haro the Blacksmith&amp;#x27;s upgrade window: chance, gold and Shadow Shards&quot; width=&quot;1600&quot; height=&quot;824&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;Haro the Blacksmith&amp;#x27;s upgrade window: chance, gold and Shadow Shards&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig full&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-upgrade-sparks.jpg&quot; alt=&quot;+7, +8 and +9 katanas before and after the change: a full glow became a few pixel sparks&quot; width=&quot;1600&quot; height=&quot;670&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;+7, +8 and +9 katanas before and after the change: a full glow became a few pixel sparks&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/02-glow.jpg&quot; alt=&quot;The earlier glow version in hand (+7 red, +8 green, +9 gold)&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The earlier glow version in hand (+7 red, +8 green, +9 gold)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;runes-and-other-small-systems&quot;&gt;Runes and other small systems &lt;a class=&quot;anchor&quot; href=&quot;#runes-and-other-small-systems&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The same pattern&lt;/span&gt; repeats across the rest of the item game:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Runes&lt;/strong&gt;: every monster has a kind (beast, goblinoid, cultist, undead, elemental, spirit, demon, shadow).
Weapons have sockets by rarity, and a rune adds 2.5% to 10% damage against one kind. The tiers, bind
chances (70 / 45 / 25%) and drop rates are all a few lines in &lt;code&gt;items.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scroll of Renewal&lt;/strong&gt;: drag it onto gear to reroll its random bonuses. There are no &amp;quot;add bonus&amp;quot; scrolls;
the human decided against them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boost potions&lt;/strong&gt;, &lt;strong&gt;storage&lt;/strong&gt; shared between your characters, &lt;strong&gt;trading&lt;/strong&gt; with a two-step lock and
accept, and &lt;strong&gt;daily bounties&lt;/strong&gt;: each is data plus one validated server action.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;tested-from-the-data&quot;&gt;Tested from the data &lt;a class=&quot;anchor&quot; href=&quot;#tested-from-the-data&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Because content is&lt;/span&gt; data, a whole group of tests checks the data itself: every skill has a valid shape,
every item referenced by a loot table exists, every quest points at real NPCs and monsters, every upgrade
step names a real material. When someone (human or AI) edits a JSON file by hand, the suite says what is
broken before the game does. It doesn&#x27;t make mistakes impossible. Once, a map&#x27;s description text gained
unescaped quotes, the whole map file stopped parsing, and it took a fix commit three minutes later. This is
why &lt;code&gt;test_map_data_is_consistent&lt;/code&gt; loads every map file, including the ones no other test uses, and
checks its zones, spawns and references.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pick your building blocks and stick to them.&lt;/strong&gt; Five shapes and a handful of modifiers carry 24 skills
and their tiers. New effects become new blocks with tests, never one-off scripts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data makes design changes cheap.&lt;/strong&gt; &amp;quot;Make Rare and Legendary much scarcer&amp;quot; or &amp;quot;drain the seal instead
of losing it&amp;quot; becomes a data edit plus a test, not a refactor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate on load.&lt;/strong&gt; Portable characters are a lovely feature and a security hole. Clamp everything
against the rules.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;All of these&lt;/span&gt; systems run on the server and work the same for one player or sixteen. The next post is about
how that works: server-authoritative multiplayer in Godot.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;They have &lt;a href=&quot;https://dekiro.d12r.io/17-fullmoon-weapons-upgrades-and-awakening.html&quot;&gt;their own post&lt;/a&gt; near the end of the series. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Body armor stopped at level 30, the next set was the level 86 raid gear. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;Ordinary monsters 0.6-1.4, Shadows 10-20, final bosses 100-200. &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
<entry>
<title>Building a Grindy Martial-Arts ARPG with an AI Developer</title>
<link rel="alternate" type="text/html" href="https://dekiro.d12r.io/01-building-an-arpg-with-an-ai-developer.html"/>
<id>https://dekiro.d12r.io/01-building-an-arpg-with-an-ai-developer.html</id>
<published>2026-10-09T12:00:00Z</published>
<updated>2026-10-19T22:16:43+02:00</updated>
<summary>One person directs, an AI writes the code, and a rulebook keeps both of them honest.</summary>
<content type="html">&lt;h2 id=&quot;what-we-are-making&quot;&gt;What we are making &lt;a class=&quot;anchor&quot; href=&quot;#what-we-are-making&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Dekiro&lt;/span&gt;: Sanctuary of Shadows is a grindy hack-and-slash ARPG in the spirit of a popular Korean MMO. You mow down
packs of monsters, level up, gear up and clear dungeons. Its signature target is the &lt;strong&gt;Shadow&lt;/strong&gt;, our
take on that MMO&#x27;s famous stones: a corrupted monolith that stands in a hunting ground and keeps spawning
waves of enemies until you break it. You choose one of four schools at level 5 (Blade, Iron Fist,
Shadowstep, Sanctum). Skills are raised to Master and then Grandmaster with skillbooks. The blacksmith
takes gear from +0 to +9, and the failure chance at the top end feels as cruel as it should.&lt;/p&gt;
&lt;p&gt;The engine is Godot 4.7.2 with GDScript and the Forward+ renderer. The world is 3D with a third-person
orbit camera; the characters in it are pixel-art sprites rendered from 3D models.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; Multiplayer is server-authoritative (single player runs as a local server), with
dedicated servers and Steam lobbies.&lt;/p&gt;
&lt;p&gt;This series is about how it gets built. The team is unusual: one human developer, and Claude Code as the
developer who writes all of the code.&lt;/p&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/01-hero-now.jpg&quot; alt=&quot;The White Fox Shrine in Sanctuary Vale on the latest build: a sprite character in a 3D world&quot; width=&quot;1600&quot; height=&quot;856&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The White Fox Shrine in Sanctuary Vale on the latest build: a sprite character in a 3D world&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class=&quot;fig&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/01-shadow-fight.jpg&quot; alt=&quot;An early basic attack against a Lesser Shadow stone, back when the player was a placeholder capsule&quot; width=&quot;1600&quot; height=&quot;900&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;An early basic attack against a Lesser Shadow stone, back when the player was a placeholder capsule&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-roles&quot;&gt;The roles &lt;a class=&quot;anchor&quot; href=&quot;#the-roles&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The split has&lt;/span&gt; stayed the same since the first commit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The developer (human)&lt;/strong&gt; decides what the game is. They set direction (&amp;quot;[that MMO&#x27;s] grind, Valheim-style
hosting, no in-game chat, players use Discord&amp;quot;), make the design calls, sketch maps on paper, approve
visual designs and playtest. Anything about &lt;em&gt;feel&lt;/em&gt;, such as timing, responsiveness or whether a hit
lands, gets judged by a human with a mouse in hand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Claude Code (AI)&lt;/strong&gt; writes the code, the data files and the content generators. It runs the test suite,
renders screenshots and looks at them, fixes what it finds, and commits each step with a message that
says what changed and why.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every commit in the repository carries a &lt;code&gt;Co-Authored-By: Claude&lt;/code&gt; line. The human never writes code;
they steer.&lt;/p&gt;
&lt;h2 id=&quot;the-pace&quot;&gt;The pace &lt;a class=&quot;anchor&quot; href=&quot;#the-pace&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The first commit&lt;/span&gt;, &amp;quot;Milestone 1: movement, multi-target arc attack, training dummies&amp;quot;, landed on an
afternoon in early October. At the time of writing the repository has &lt;strong&gt;199 commits&lt;/strong&gt;. Here is how they
spread over the days:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th class=&quot;numeric&quot;&gt;Commits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 (afternoon only)&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td class=&quot;numeric&quot;&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The last two rows look like a slowdown. They aren&#x27;t. From day five on, most work was done by parallel
subagents and committed by a coordinating session in large, reviewed batches.&lt;sup class=&quot;fnref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; One day-six commit alone touches 639 files and adds about 51,000 lines: NPC
sprites, map mechanics and three dungeons. The days got fewer commits and much more content.&lt;/p&gt;
&lt;p&gt;The committed history now holds about 61,000 lines of GDScript (17,500 of them tests) and about 42,000
lines of Python for the content tools: model builders, map and dungeon generators, a sprite renderer,
a music synthesizer and a balance model. Those tools are covered in later posts.&lt;/p&gt;
&lt;p&gt;Speed is not the point of this post. It matters for one reason: at this pace, nobody can keep the whole
project in their head. Neither the human nor the AI, which starts every session cold. The project
has to remember itself. That is what CLAUDE.md is for.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/01-git-log.png&quot; alt=&quot;One day of commits (the 8th of October): 60 of them&quot; width=&quot;1064&quot; height=&quot;858&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;One day of commits (the 8th of October): 60 of them&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;claudemd-the-rulebook&quot;&gt;CLAUDE.md: the rulebook &lt;a class=&quot;anchor&quot; href=&quot;#claudemd-the-rulebook&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Claude Code reads&lt;/span&gt; a file called &lt;code&gt;CLAUDE.md&lt;/code&gt; at the root of the project at the start of every session.
Ours started with milestone 1 and has grown with every system since. It holds the commands
(how to run tests, smoke runs, exports, the Blender builders, the sprite renderer), a map of where things
live, and, most importantly, &lt;strong&gt;architecture rules&lt;/strong&gt;. Three of them go back to the first commit (quoted here
in their current wording):&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Controllers drive Actors only via &lt;code&gt;command_*&lt;/code&gt; methods&lt;/strong&gt; (&lt;code&gt;command_move&lt;/code&gt;, &lt;code&gt;command_attack&lt;/code&gt;, …).
PlayerController, future AIController (companions/mobs) and any future network input must be
interchangeable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Combat is shape-based, never tab-target.&lt;/strong&gt; Attacks query a shape (arc, circle, line) via &lt;code&gt;Combat&lt;/code&gt;
and hit everything valid inside it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logic is separate from visuals.&lt;/strong&gt; Actors hold no meshes; a child visual node (currently
&lt;code&gt;PlaceholderVisual&lt;/code&gt;) reads Actor state and listens to its signals.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;These rules paid off again and again. The command interface meant that AI companions (who
join your party and fight with you) could use the same player scene with &lt;code&gt;CompanionAI&lt;/code&gt; swapped in for
&lt;code&gt;PlayerController&lt;/code&gt;. Network input fit the same interface, and so did the bots of the combat simulation
that later balanced the game. Keeping logic apart from visuals meant that the capsules could be replaced
twice without touching gameplay: first by 3D monster models, then, for players and NPCs, by 8-direction
pixel sprites. Both times one visual node was swapped.&lt;/p&gt;
&lt;p&gt;The second kind of rule is the more interesting one: &lt;strong&gt;rules that come from bugs&lt;/strong&gt;. When something
breaks in a way that could happen again, the fix goes into code and the lesson goes into CLAUDE.md.
Examples from the current file:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Dead mobs are freed while other code may still reference them (Shadow spawn lists, AI targets,
delayed skill hits). Never pass a possibly-freed object into a typed parameter or typed lambda
(&lt;code&gt;func(a: Actor)&lt;/code&gt;): Godot errors with &amp;quot;Cannot convert argument 1 from Object to Object&amp;quot;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Never reparent or move bodies inside physics callbacks (&lt;code&gt;body_entered&lt;/code&gt; etc.): defer it. […] Doing
this inside a callback segfaulted Godot.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Headless runs never compile shaders: after any shader edit (incl. shader strings in .gd files) take a
windowed screenshot of main.tscn and grep its log for &lt;code&gt;^ERROR&lt;/code&gt; (a uniform named like a local in a shared
.gdshaderinc, e.g. &lt;code&gt;shade&lt;/code&gt; vs clouds.gdshaderinc, once turned every sprite into a white wall).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That last one is two days old. Each of these cost time once. Written down, they don&#x27;t cost time again,
because the next session reads them before writing a single line.&lt;/p&gt;
&lt;figure class=&quot;fig pixel&quot;&gt;&lt;picture&gt;&lt;img src=&quot;https://dekiro.d12r.io/assets/img/01-claude-md.png&quot; alt=&quot;The architecture rules in CLAUDE.md&quot; width=&quot;964&quot; height=&quot;522&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/picture&gt;&lt;figcaption&gt;The architecture rules in CLAUDE.md&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;the-loop&quot;&gt;The loop &lt;a class=&quot;anchor&quot; href=&quot;#the-loop&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The working loop&lt;/span&gt; is short and strict. It&#x27;s written into CLAUDE.md like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Run tests + smoke run after every change. […] Neither covers visuals or game feel, so after visual
changes take a screenshot and look at it; ask the user to playtest anything about feel.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In practice a step looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Implement&lt;/strong&gt; the feature, data first where possible (more on that in the next post).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add tests&lt;/strong&gt; to the headless suite. A test is a plain &lt;code&gt;test_*&lt;/code&gt; method that calls &lt;code&gt;_check()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run the suite.&lt;/strong&gt; It currently runs 341 test functions with about 13,800 checks. Godot engine errors
don&#x27;t fail a test on their own, so the log is grepped for &lt;code&gt;ERROR&lt;/code&gt; lines as well.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smoke-run&lt;/strong&gt; the game headless for a few hundred frames, to catch errors that only show up when
the whole scene is live.&lt;/li&gt;
&lt;li&gt;For networking changes, run &lt;strong&gt;bot clients&lt;/strong&gt; against a host and a dedicated server.&lt;/li&gt;
&lt;li&gt;For anything visual, &lt;strong&gt;render a screenshot&lt;/strong&gt;, look at it, fix it and look again. Claude Code reads
the PNGs itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Commit&lt;/strong&gt; with a descriptive message.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The commit messages became the project&#x27;s diary. A typical one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Fix errors from freed mobs in Shadow spawn tracking&lt;/p&gt;
&lt;p&gt;alive_count() filtered with a typed lambda; once a dead Shade was freed, Godot refused to pass it
(&#x27;Cannot convert argument 1 from Object to Object&#x27;) and the filter returned an untyped Array. Check
validity on untyped values instead; same hardening for MobAI targets and delayed skill hits.
Regression tests reproduce the original errors.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That message has everything: the symptom, the cause, the fix, the related places hardened at the same
time, and the regression test. We wrote most of this series by reading the git log.&lt;/p&gt;
&lt;h2 id=&quot;changing-course-is-cheap-if-the-rules-hold&quot;&gt;Changing course is cheap (if the rules hold) &lt;a class=&quot;anchor&quot; href=&quot;#changing-course-is-cheap-if-the-rules-hold&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The second commit&lt;/span&gt; set up a 3D world with a &lt;em&gt;fixed isometric&lt;/em&gt; camera. About seventy minutes later another
commit replaced it with a full perspective orbit camera (right-drag or Q/E to rotate, wheel to zoom). The
design doc records the reason: the game should feel like the old MMO, with a camera you rotate and zoom
yourself. Because movement input was already &amp;quot;screen-relative, converted to world space in
PlayerController&amp;quot;, the change touched six files.&lt;/p&gt;
&lt;p&gt;Bigger reversals followed. We built a seamless open world with land bridges between regions, then
switched it off nine hours later in favour of separate maps linked by torii portals. The first big map
was then shrunk by about two thirds twelve hours after it was built, because it felt too big and too
empty. The player
character went through six 3D drafts before the human picked a completely different technique: 3D
models pre-rendered into pixel sprites. Each of these turns has its own post. What matters here is that
the rules made them survivable. Nothing outside the world code knew whether maps were joined or
separate, and nothing outside the visual node knew whether a character was a capsule, a model or a
sprite.&lt;/p&gt;
&lt;h2 id=&quot;what-the-human-actually-decides&quot;&gt;What the human actually decides &lt;a class=&quot;anchor&quot; href=&quot;#what-the-human-actually-decides&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;From the outside&lt;/span&gt;, &amp;quot;the AI writes all the code&amp;quot; can sound like &amp;quot;the AI makes the game&amp;quot;. It doesn&#x27;t. Some
decisions from the history, all made by the human:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Valheim-style hosting: dedicated servers, characters stored on the player&#x27;s PC and portable between
servers, world events per server.&lt;/li&gt;
&lt;li&gt;No in-game chat, no player shops, no &amp;quot;add bonus&amp;quot; scrolls (rerolls only).&lt;/li&gt;
&lt;li&gt;The open world was switched off; every map keeps one town (&amp;quot;Keep safe zones to one per map&amp;quot;).&lt;/li&gt;
&lt;li&gt;Map 1 was drawn by hand on a grid (1 square = 25 m) and translated into data.&lt;/li&gt;
&lt;li&gt;The Bulwark school&#x27;s battleshields became fist weapons (&amp;quot;a shield to attack is hard to go for&amp;quot;), and the
school became Iron Fist.&lt;/li&gt;
&lt;li&gt;&amp;quot;Solid trees, but see in deep woods&amp;quot;. That one is preserved in a code comment in &lt;code&gt;flora.gd&lt;/code&gt;, which
dissolves the leaves between the camera and your character but keeps trunks opaque.&lt;/li&gt;
&lt;li&gt;Every visual direction: the Valheim-like pixel texture look at first, then the switch to HD-2D sprites,
keeping the grass because they liked it, and the decision that +9 weapons sparkle rather than glow.&lt;/li&gt;
&lt;li&gt;The target length: &amp;quot;about 40 hours of actual gameplay minimum&amp;quot; to the level cap, which drove a whole
balancing pass.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The AI proposes, implements and verifies. The human chooses. When a question is really a design question,
the AI asks; otherwise it keeps going. Asking too often would waste the human&#x27;s time. Asking too rarely
would build the wrong game. Some answers are short. The human&#x27;s verdict on one batch of AI-drawn sprite
mock-ups was unprintable, followed by: &amp;quot;the rendered 3d to 2d one look way better&amp;quot;. That sentence set the
art direction for everything after it.&lt;/p&gt;
&lt;h2 id=&quot;where-it-stands&quot;&gt;Where it stands &lt;a class=&quot;anchor&quot; href=&quot;#where-it-stands&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;Six days in&lt;/span&gt;, the game has nine world maps (each with one town, waystones and landmarks), 20 dungeons
from a generator with solvers, 177 monster types plus 11 kinds of Shadow stone, 203 items, 24 skills,
87 quests including a main questline that unfolds with your level, 43 NPCs and 66 townsfolk with daily
routines, 30 original music tracks and a progression model that aims at about 40 hours to level 90. None
of it has had a real playtest beyond the human&#x27;s own sessions yet, and the posts say so where it matters.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways &lt;a class=&quot;anchor&quot; href=&quot;#takeaways&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Write the rules down where the AI will read them.&lt;/strong&gt; CLAUDE.md is not documentation for later. It is
the memory that makes each session start where the last one ended.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turn every painful bug into a rule.&lt;/strong&gt; The fix stops the bug once. The rule stops the whole class of
bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tests are what make speed safe.&lt;/strong&gt; Without the suite, 40 to 70 commits a day would be 40 to 70 chances
to break something quietly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep feel for humans.&lt;/strong&gt; Tests can prove that the combo&#x27;s finisher does 1.8x damage. They can&#x27;t prove
that it feels good.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#x27;s next &lt;a class=&quot;anchor&quot; href=&quot;#whats-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span class=&quot;newthought&quot;&gt;The next posts&lt;/span&gt; go system by system: data-driven skills and loot, multiplayer, the test suite, the
Blender pipeline that builds every model from Python, the maps, performance, the art direction (and how
it changed), the synthesized music, fishing, and how the work moved from one AI session to a coordinator
with parallel subagents. After those come the newer pieces: the HD-2D sprite pipeline, the dungeon
generator, the story, the 40-hour balance model, the living towns and the Fullmoon weapons.&lt;/p&gt;
&lt;hr&gt;
&lt;ol class=&quot;endnotes&quot;&gt;&lt;li id=&quot;fn-1&quot;&gt;The &amp;quot;HD-2D&amp;quot; look, which has &lt;a href=&quot;https://dekiro.d12r.io/12-hd2d-sprites-from-3d-characters.html&quot;&gt;its own post&lt;/a&gt;. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;a href=&quot;https://dekiro.d12r.io/11-coordinator-and-subagents.html&quot;&gt;The coordinator post&lt;/a&gt; in this series explains how. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
</content>
</entry>
</feed>
