{
    "componentChunkName": "component---src-templates-blog-detail-tsx",
    "path": "/blog/godot-for-web-developers/",
    "result": {"data":{"site":{"siteMetadata":{"siteTitleShort":"Developer Portfolio"}},"markdownRemark":{"id":"fca26b12-e423-5d6f-a29e-f7130040fc61","excerpt":"A genre I had no business starting with I picked Godot 4, which I had never used, and GDScript, which I had never written. Eight days later the repository has…","html":"<h2>A genre I had no business starting with</h2>\n<p class=\"lead\">On the 23rd of July I opened an empty folder and decided to build a real-time strategy game. Not a prototype of a mechanic, an actual RTS: base building, an economy, fog of war, an AI opponent that has to find you. I have written a lot of software. None of it had a frame budget.</p>\n<p>I picked Godot 4, which I had never used, and GDScript, which I had never written. Eight days later the repository has 316 commits, 91 test files and a match you can lose. This post is what a web developer learns in the first week, written while it’s still fresh and I still remember what confused me.</p>\n<p>The game is called <a href=\"https://github.com/FinalAngel/hammerfest\">Hammerfest</a>. It’s flat, isometric and single player, in the spirit of the Command &#x26; Conquer games I grew up on and the Kingdom Rush look I still like. The plan is that it eventually ships on Steam. The plan for the first slice is much smaller: one level, and an honest answer to whether expanding a base under pressure is fun for twenty minutes.</p>\n<h3>Why Godot and not a browser</h3>\n<p>My instinct was TypeScript and a canvas library, because that is what I know and because everything would be text. I talked myself out of it in an afternoon.</p>\n<p>Godot hands you the parts I would otherwise write badly: an editor for maps, audio, particles, a profiler, and an export to desktop that is one button. I estimated two to four months of plumbing saved, and given how slow I am at anything involving a GPU, that is probably optimistic in my favour. The cost is an editor that a lot of your work goes through, which I’ll come back to, because it turned out to matter less than I feared.</p>\n<p>GDScript over C# was easier. It has no compile step, it’s what almost all Godot 4 material is written in, and with static typing switched on it stops being the loose scripting language people remember from Godot 3.</p>\n<div class=\"gatsby-highlight\" data-language=\"gdscript\"><pre class=\"language-gdscript\"><code class=\"language-gdscript\"><span class=\"token keyword\">var</span> speed<span class=\"token punctuation\">:</span> <span class=\"token class-name\">float</span> <span class=\"token operator\">=</span> <span class=\"token number\">3.0</span>\n<span class=\"token keyword\">var</span> goal_cell<span class=\"token punctuation\">:</span> <span class=\"token class-name\">Vector2i</span> <span class=\"token operator\">=</span> Vector2i<span class=\"token punctuation\">.</span><span class=\"token constant\">ZERO</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">apply</span><span class=\"token punctuation\">(</span>world<span class=\"token punctuation\">:</span> <span class=\"token class-name\">World</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">void</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span></code></pre></div>\n<p>Type every declaration. The project settings have warnings for untyped declarations and unsafe property access, and I set both to error on day one. GDScript’s type inference is fine, but an untyped <code class=\"language-text\">var</code> in a function that runs thirty times a second for every unit is a Variant box being allocated and torn down thirty times a second for every unit, and the profiler showed me that within a day. More on that below.</p>\n<p>One thing that surprised me: GDScript is indented with tabs by convention, and the editor inserts them. I gave in. It’s a tab project now.</p>\n<h3>The one rule the whole thing hangs on</h3>\n<p>If I had to keep a single decision from the first week it would be this: <strong>the simulation does not know Godot exists.</strong></p>\n<p>Everything in <code class=\"language-text\">sim/</code> is a plain <code class=\"language-text\">RefCounted</code> class. No <code class=\"language-text\">Node</code>, no scene tree, no physics, no input, no wall clock, no unseeded randomness. Godot’s node classes read from the simulation and draw it. They never own state. In web terms it’s “the business logic knows nothing about React”, which every React developer nods at and few of us actually enforce.</p>\n<p>I enforce it with a shell script in CI. It greps <code class=\"language-text\">sim/</code> for the banned symbols (<code class=\"language-text\">PhysicsServer</code>, <code class=\"language-text\">CharacterBody</code>, <code class=\"language-text\">NavigationServer</code>, <code class=\"language-text\">Input.</code>, <code class=\"language-text\">Time.get_ticks</code>, <code class=\"language-text\">randi</code>, and so on), and it checks that every <code class=\"language-text\">extends</code> in that folder resolves to <code class=\"language-text\">RefCounted</code>, <code class=\"language-text\">Object</code>, or a class declared inside <code class=\"language-text\">sim/</code> itself. Anything else fails the build. Banning <code class=\"language-text\">Node</code> subclasses by name is a losing battle, Godot has dozens, so the allowlist runs the other way. There’s an escape hatch comment, <code class=\"language-text\"># purity-ignore</code>, and it has been used zero times.</p>\n<p>An invariant nobody enforces is a promise, and this one is very easy to break by reflex. <code class=\"language-text\">Area2D</code> overlap queries are right there, they are how every tutorial finds “what is near this unit”, and they are exactly the thing you cannot use if you want the next section to be true.</p>\n<h4>What the split buys</h4>\n<p>It sounds like architectural fussiness. It is the cheapest thing in the project, and it bought four concrete things in week one:</p>\n<ul>\n<li>Tests run headless, in milliseconds, with no window. The whole suite runs on every commit on a Linux runner with no GPU.</li>\n<li>Save and load is “serialise the world”. There is no per-feature save code, and there never will be.</li>\n<li>Two AIs can play each other at a few hundred times real time, which turns “is the AI any good” from an opinion into a number.</li>\n<li>The renderer got swapped twice, and <code class=\"language-text\">sim/</code> did not change by one line either time. I’ll get to that.</li>\n</ul>\n<h3>Fixed timestep, and why your game is otherwise a different game on every machine</h3>\n<p>Web developers meet <code class=\"language-text\">requestAnimationFrame</code> and learn to multiply movement by the delta since last frame. That works for a spinner. It’s wrong for a simulation.</p>\n<p>If the world advances by however much real time has passed, a 144 Hz machine and a 40 Hz machine run different games. Floating point doesn’t associate, so the positions drift apart, so the fights resolve differently, so a save from one laptop loads into a slightly different match on another. Replays are impossible. Bugs are irreproducible by construction.</p>\n<p>The simulation in Hammerfest advances in fixed ticks, thirty per second, and never by a delta. A <code class=\"language-text\">Node</code> in the presentation layer accumulates real elapsed time and spends it in whole ticks:</p>\n<div class=\"gatsby-highlight\" data-language=\"gdscript\"><pre class=\"language-gdscript\"><code class=\"language-gdscript\"><span class=\"token keyword\">class_name</span> <span class=\"token class-name\">MatchRunner</span>\n<span class=\"token keyword\">extends</span> <span class=\"token class-name\">Node</span>\n\n<span class=\"token keyword\">const</span> <span class=\"token constant\">MAX_TICKS_PER_FRAME</span><span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span> <span class=\"token operator\">=</span> <span class=\"token number\">8</span>\n\n<span class=\"token keyword\">var</span> world<span class=\"token punctuation\">:</span> <span class=\"token class-name\">World</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">null</span>\n<span class=\"token keyword\">var</span> _pending<span class=\"token punctuation\">:</span> <span class=\"token class-name\">Array</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span>\n<span class=\"token keyword\">var</span> _accumulator<span class=\"token punctuation\">:</span> <span class=\"token class-name\">float</span> <span class=\"token operator\">=</span> <span class=\"token number\">0.0</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">_process</span><span class=\"token punctuation\">(</span>delta<span class=\"token punctuation\">:</span> <span class=\"token class-name\">float</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">void</span><span class=\"token punctuation\">:</span>\n\t_accumulator <span class=\"token operator\">+=</span> delta\n\t<span class=\"token keyword\">var</span> ticks<span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span> <span class=\"token operator\">=</span> <span class=\"token number\">0</span>\n\t<span class=\"token keyword\">while</span> _accumulator <span class=\"token operator\">>=</span> World<span class=\"token punctuation\">.</span><span class=\"token constant\">TICK_DELTA</span> <span class=\"token keyword\">and</span> ticks <span class=\"token operator\">&lt;</span> <span class=\"token constant\">MAX_TICKS_PER_FRAME</span><span class=\"token punctuation\">:</span>\n\t\tworld<span class=\"token punctuation\">.</span><span class=\"token function\">step</span><span class=\"token punctuation\">(</span>_pending<span class=\"token punctuation\">)</span>\n\t\t_pending<span class=\"token punctuation\">.</span><span class=\"token function\">clear</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\t\t_accumulator <span class=\"token operator\">-=</span> World<span class=\"token punctuation\">.</span><span class=\"token constant\">TICK_DELTA</span>\n\t\tticks <span class=\"token operator\">+=</span> <span class=\"token number\">1</span></code></pre></div>\n<p>The pending array is the commands issued since the last tick, which is the only way anything gets into the world. More on that in a moment.</p>\n<p>The cap on ticks per frame is not tidiness. Without it one long stall queues a burst of ticks, which itself takes long enough to queue more, and the game freezes forever trying to catch up. The usual name for this is the spiral of death. Dropping simulated time instead means a bad frame turns into a moment of slow motion, which nobody can see.</p>\n<p>The rendering runs at whatever rate the machine manages and interpolates between ticks. At thirty ticks a second you do notice if it doesn’t.</p>\n<h3>Everything is a command</h3>\n<p>The second rule: <strong>nothing mutates the simulation except a command.</strong></p>\n<p>A click becomes a <code class=\"language-text\">MoveCommand</code>. The AI’s decision becomes a <code class=\"language-text\">MoveCommand</code>, the same class, through the same door. The debug panel that spawns a thousand units for a stress test issues <code class=\"language-text\">SpawnEntityCommand</code>s. There is no back door for tooling, because a back door added “just for debugging” is a back door the AI could use, and I’d never be sure it wasn’t.</p>\n<div class=\"gatsby-highlight\" data-language=\"gdscript\"><pre class=\"language-gdscript\"><code class=\"language-gdscript\"><span class=\"token keyword\">class_name</span> <span class=\"token class-name\">Command</span>\n<span class=\"token keyword\">extends</span> <span class=\"token class-name\">RefCounted</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">type_code</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token function\">push_error</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"Command.type_code() must be overridden\"</span><span class=\"token punctuation\">)</span>\n\t<span class=\"token keyword\">return</span> <span class=\"token number\">0</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">apply</span><span class=\"token punctuation\">(</span>_world<span class=\"token punctuation\">:</span> <span class=\"token class-name\">World</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">void</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token function\">push_error</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"Command.apply() must be overridden\"</span><span class=\"token punctuation\">)</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">to_array</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">Array</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token function\">push_error</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"Command.to_array() must be overridden\"</span><span class=\"token punctuation\">)</span>\n\t<span class=\"token keyword\">return</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span></code></pre></div>\n<p>Every command is serialised to a positional array with an integer type id, appended to a log with its tick number. <code class=\"language-text\">seed + ordered command log</code> reconstructs any match exactly, and there is a test that plays a match, replays the log into a fresh world, and asserts the two state hashes are equal. That test is the backbone of the project, because determinism breaks silently. A stray <code class=\"language-text\">randi()</code> or an iteration over a Dictionary whose order you assumed will not throw. It will quietly make every save, replay and benchmark wrong, and you find out weeks later.</p>\n<p>Two habits keep it honest. All randomness goes through one seeded generator owned by the world, and I never iterate an unordered collection where the order affects the result. Entities are processed in ascending slot order, always.</p>\n<p>The AI drives the same command interface as the player and reads the world through a filtered view that only contains what its units can currently see. No cheating, and more usefully, no <em>accidental</em> cheating, which is the kind that actually happens.</p>\n<h3>Handles, not references</h3>\n<p>Every RTS hits the dangling-target bug. Unit A is attacking unit B. B dies, its slot is reused for a harvester, and A cheerfully keeps shooting at a harvester it has never seen, possibly its own.</p>\n<p>The fix is old and simple: entities are stored in dense arrays and every cross-entity reference is a generational handle, an index plus a generation counter packed into one 64-bit integer. When a slot is reused, the generation increments. A stale handle resolves to null instead of resolving to the wrong thing.</p>\n<div class=\"gatsby-highlight\" data-language=\"gdscript\"><pre class=\"language-gdscript\"><code class=\"language-gdscript\"><span class=\"token keyword\">static</span> <span class=\"token keyword\">func</span> <span class=\"token function\">make</span><span class=\"token punctuation\">(</span>index<span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">,</span> generation<span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>generation <span class=\"token operator\">&amp;</span> <span class=\"token number\">0xFFFFFFFF</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">&lt;&lt;</span> <span class=\"token number\">32</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">|</span> <span class=\"token punctuation\">(</span>index <span class=\"token operator\">&amp;</span> <span class=\"token number\">0xFFFFFFFF</span><span class=\"token punctuation\">)</span>\n\n<span class=\"token keyword\">static</span> <span class=\"token keyword\">func</span> <span class=\"token function\">index_of</span><span class=\"token punctuation\">(</span>handle<span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">int</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token keyword\">return</span> handle <span class=\"token operator\">&amp;</span> <span class=\"token number\">0xFFFFFFFF</span></code></pre></div>\n<p>Generations start at 1 so that zero is never a valid handle. So far it has done its job, which for a first RTS feels like cheating.</p>\n<h3>Pathfinding is where RTS projects die</h3>\n<p>The obvious approach is A* per unit. Order fifty units to one spot and you get fifty near-identical searches, then fifty units trying to walk the same line and shoving each other into walls.</p>\n<p>A flow field inverts it. Once per destination, compute a direction arrow for every passable cell, flooding outward from the goal. Any number of units then read the arrow in their cell and follow it. One computation serves the whole group, and it gets relatively cheaper the more units share it. The flood is integer breadth-first search, so the routing input is exactly reproducible. Units bumping into each other is a separate, local problem, and for now it’s a plain “push away from close neighbours” force.</p>\n<p>The first version built the field at full resolution, 256 by 256 cells. That is 65,536 cells, and GDScript took <strong>330 ms</strong> to flood them. Inlining the hot loop got it to 117 ms. Against a 5 ms tick budget that is still unusable by a factor of twenty, and it is not an algorithm bug. The search is correctly linear in cells. It’s the interpreter’s per-operation cost over a lot of cells, and inlining is the floor.</p>\n<p>The fix was a coarse routing grid, 64 by 64, decoupled from the fine collision grid: about 7 ms per build, inside a frame. Routing coarse and collision fine is how actual RTS engines separate the two problems, which I did not know until I’d rediscovered it the expensive way.</p>\n<h3>The performance gate that lied to me</h3>\n<p>This is the part of the week worth reading twice, because the bug was in the instrument.</p>\n<p>I had a gate that spawned 600 units, sent them across the map, and checked the tick against the 5 ms budget. It printed <strong>4.18 ms, PASS</strong>. Comfortable.</p>\n<p>But look at what those 600 units were. Bare entities: no player, no weapons, no cargo. So combat hit its early-out and returned. Vision stamped one player’s fog. The economy found nothing to do. The gate measured movement honestly and everything else not at all, then printed a verdict about the whole tick. The same 600 units actually fighting cost <strong>17.1 ms</strong>, three and a half times the budget.</p>\n<p>A gate that measures a subset and reports on the whole is worse than no gate, because a green light gets believed. The fix was not to optimise. It was a probe that runs the real systems on the real level, two armies meeting mid-map with both economies running, and times each system separately:</p>\n<table>\n<thead>\n<tr>\n<th>System</th>\n<th align=\"right\">Before</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Combat</td>\n<td align=\"right\">8.16 ms</td>\n</tr>\n<tr>\n<td>Economy</td>\n<td align=\"right\">2.40 ms</td>\n</tr>\n<tr>\n<td>Vision</td>\n<td align=\"right\">2.36 ms</td>\n</tr>\n<tr>\n<td>Movement</td>\n<td align=\"right\">1.94 ms</td>\n</tr>\n</tbody>\n</table>\n<p>Combat costing four times what movement cost was not what I would have guessed, and every instinct I had about where a simulation spends its time was wrong about half the time. The biggest single win was embarrassing: iterating the world allocated a fresh <code class=\"language-text\">Array[Entity]</code> on every call, and the systems call it about seventeen times a tick. Caching it took the economy from 2.40 ms to 0.86 ms.</p>\n<p>What GDScript actually costs, in one sentence: the wins come from deleting calls and lookups, not from cleverer arithmetic. <code class=\"language-text\">Packed*Array</code> is the only unboxed storage the language has, and anything a loop touches every tick belongs in one.</p>\n<h3>A rock was scouting for you</h3>\n<p>My favourite bug of the week. Harvesters, freshly spawned, walked straight to the nearest contested resource node even though nobody had scouted it yet.</p>\n<p>The obvious cause was the AI’s target search ignoring what its owner had seen. Adding that check did nothing. The real cause was one layer down: resource nodes are entities, entities need an owner to spawn, so the nodes were owned by player 0 “because nothing reads it”. Vision read it. The fog pass stamped a sight disc for every entity, rocks included, so every node on the map revealed itself to player 0 from tick zero, permanently. Player 0 could see every deposit for free. Player 1, the AI, could see none.</p>\n<p>Silently asymmetric, and in the play scene player 0 is the human. It showed up in the numbers the moment it was fixed: contested ore mined across a match went from 170 against 20 to 170 against 155. Deposits now have a vision range of zero. A rock is scenery, not a scout.</p>\n<h3>The renderer changed twice and the simulation never noticed</h3>\n<p>On the 26th the game rendered in 2D. On the 27th I ported it to 3D under an orthographic isometric camera, because I thought I wanted real height and painted buildings as billboards. On the 28th a tileset I had bought settled the question the other way: cliffs are tiles, buildings are placeables, the whole thing is a 2D tilemap. So the 3D renderer was deleted. 7,157 lines removed, 305 added.</p>\n<p><code class=\"language-text\">sim/</code> did not change. Not one line, in either direction. The entire UI layer used exactly three things from the camera, a ground-point lookup, a to-screen projection and the zoom, so the minimap, HUD and overlays each needed one type name changed. That’s the bill a narrow interface pays, and it is legible in the diff. I wrote the invariant down on day one hoping it would hold. It has now been tested twice, which is better than asserted.</p>\n<h3>Testing</h3>\n<p>gdUnit4 is the test framework, vendored into <code class=\"language-text\">addons/</code>. Tests mirror the source layout, extend <code class=\"language-text\">GdUnitTestSuite</code>, and run headless:</p>\n<div class=\"gatsby-highlight\" data-language=\"gdscript\"><pre class=\"language-gdscript\"><code class=\"language-gdscript\"><span class=\"token keyword\">extends</span> <span class=\"token class-name\">GdUnitTestSuite</span>\n\n<span class=\"token keyword\">func</span> <span class=\"token function\">test_stale_handle_does_not_resolve</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-></span> <span class=\"token class-name\">void</span><span class=\"token punctuation\">:</span>\n\t<span class=\"token keyword\">var</span> store<span class=\"token punctuation\">:</span> <span class=\"token class-name\">EntityStore</span> <span class=\"token operator\">=</span> EntityStore<span class=\"token punctuation\">.</span><span class=\"token function\">new</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\t<span class=\"token keyword\">var</span> handle<span class=\"token punctuation\">:</span> <span class=\"token class-name\">int</span> <span class=\"token operator\">=</span> store<span class=\"token punctuation\">.</span><span class=\"token function\">spawn</span><span class=\"token punctuation\">(</span>Entity<span class=\"token punctuation\">.</span><span class=\"token function\">new</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n\tstore<span class=\"token punctuation\">.</span><span class=\"token function\">despawn</span><span class=\"token punctuation\">(</span>handle<span class=\"token punctuation\">)</span>\n\t<span class=\"token function\">assert_object</span><span class=\"token punctuation\">(</span>store<span class=\"token punctuation\">.</span><span class=\"token function\">get_entity</span><span class=\"token punctuation\">(</span>handle<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">is_null</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span></code></pre></div>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">./tools/ci/run_tests.sh</code></pre></div>\n<p>One sharp edge is worth knowing about. Godot’s registry of <code class=\"language-text\">class_name</code> declarations lives under the gitignored <code class=\"language-text\">.godot/</code> folder and is populated by a filesystem scan that <code class=\"language-text\">godot --headless -s script.gd</code> does <em>not</em> perform. On a fresh clone the test runner failed with “Could not find type GdUnitTestCIRunner”, and worse, Godot’s script runner still exited 0, silently running zero tests. The runner script now imports the project first, unconditionally, and it also fails the run if no tests actually executed. Green with zero tests is the worst colour.</p>\n<p>Most of the suite is simulation tests that run in milliseconds. The ones I trust most are the determinism tests: play a match, replay its command log into a fresh world, compare the hashes. Every system that landed this week landed with one of those.</p>\n<h3>Every mechanic must be visible</h3>\n<p>Headless tests prove correctness. They don’t find the problems you didn’t think to test for, and in a real-time game that’s most of them.</p>\n<p>So every system ships with a debug overlay you can toggle at runtime (flow-field arrows, paths, collision radii, aggro ranges, the AI’s current stance) and with a sandbox scene where that one mechanic can be poked in isolation. Spawn units, click, drop a wall, watch. The single most useful key in the whole project is <code class=\"language-text\">.</code>, which advances the simulation by one tick while paused. Most gameplay bugs are one bad tick, invisible at thirty a second.</p>\n<p>They feel like extra credit next to the “real” work. They aren’t. The rock above was found by watching harvesters walk, not by a test.</p>\n<h3>About the editor</h3>\n<p>The thing I feared most about Godot was that a chunk of the work would live inside a GUI, away from text. In practice, almost everything is a <code class=\"language-text\">.gd</code> file and the scenes are plain text <code class=\"language-text\">.tscn</code> files that diff acceptably. The editor is where I lay out a level and look at the result. It is not where the logic lives, and it would be a bad place for it.</p>\n<p>Two things worth knowing. Godot writes <code class=\"language-text\">.uid</code> sidecar files next to every script and they belong in version control, which the commit history shows me forgetting at least once. And it will happily rewrite <code class=\"language-text\">project.godot</code> when you touch a setting, so commit that separately or you’ll never find your own change in the diff.</p>\n<h3>Who typed all this</h3>\n<p>Not all me, and I’d rather say so. I’ve written about <a href=\"/blog/claude-code/\">Claude Code</a> twice this year, and this project is where I’ve leaned on it hardest. The repository has a <code class=\"language-text\">CLAUDE.md</code> whose first instruction is “you are a critical collaborator, not a cheerleader”, with a standing order to argue the case against my plan before helping with it. There are three advisor agents, a designer, an engineer and a director who resolves the two, and I go to the director for decisions. Each day ends with a journal entry in <code class=\"language-text\">docs/journal/</code>, and one of the branches merged this week is literally called <code class=\"language-text\">overnight-autonomous</code>.</p>\n<p>What I do is decide, review, play and reject. The sim purity script, the perf gate that lied and the probe that replaced it, the rock: all of those came out of me reading what was built and pushing back. The 316 commits are a measure of how fast the loop runs when one side of it never sleeps, not of how much I typed.</p>\n<h3>What’s next</h3>\n<p>The 20-minute fun gate is still open. Matches currently last about fourteen minutes against a target of twenty, and the AI needs to attack rather than trickle. The map editor exists but is two days old. I have not drawn a single piece of art I like.</p>\n<p>I’m also aware that a lot of this reads like confidence. It’s not. I’ve been in this engine for eight days and I have a folder of daily journal entries whose most common heading is some variation of “what I got wrong”. Godot has been pleasant, GDScript is faster to write and slower to run than I expected, and the architecture is holding. Ask me again after the fun gate.</p>\n<p>‘Till next time!</p>","rawMarkdownBody":"\n## A genre I had no business starting with\n\n<p class=\"lead\">On the 23rd of July I opened an empty folder and decided to build a real-time strategy game. Not a prototype of a mechanic, an actual RTS: base building, an economy, fog of war, an AI opponent that has to find you. I have written a lot of software. None of it had a frame budget.</p>\n\nI picked Godot 4, which I had never used, and GDScript, which I had never written. Eight days later the repository has 316 commits, 91 test files and a match you can lose. This post is what a web developer learns in the first week, written while it's still fresh and I still remember what confused me.\n\nThe game is called [Hammerfest](https://github.com/FinalAngel/hammerfest). It's flat, isometric and single player, in the spirit of the Command & Conquer games I grew up on and the Kingdom Rush look I still like. The plan is that it eventually ships on Steam. The plan for the first slice is much smaller: one level, and an honest answer to whether expanding a base under pressure is fun for twenty minutes.\n\n### Why Godot and not a browser\n\nMy instinct was TypeScript and a canvas library, because that is what I know and because everything would be text. I talked myself out of it in an afternoon.\n\nGodot hands you the parts I would otherwise write badly: an editor for maps, audio, particles, a profiler, and an export to desktop that is one button. I estimated two to four months of plumbing saved, and given how slow I am at anything involving a GPU, that is probably optimistic in my favour. The cost is an editor that a lot of your work goes through, which I'll come back to, because it turned out to matter less than I feared.\n\nGDScript over C# was easier. It has no compile step, it's what almost all Godot 4 material is written in, and with static typing switched on it stops being the loose scripting language people remember from Godot 3.\n\n```gdscript\nvar speed: float = 3.0\nvar goal_cell: Vector2i = Vector2i.ZERO\n\nfunc apply(world: World) -> void:\n\t...\n```\n\nType every declaration. The project settings have warnings for untyped declarations and unsafe property access, and I set both to error on day one. GDScript's type inference is fine, but an untyped `var` in a function that runs thirty times a second for every unit is a Variant box being allocated and torn down thirty times a second for every unit, and the profiler showed me that within a day. More on that below.\n\nOne thing that surprised me: GDScript is indented with tabs by convention, and the editor inserts them. I gave in. It's a tab project now.\n\n### The one rule the whole thing hangs on\n\nIf I had to keep a single decision from the first week it would be this: **the simulation does not know Godot exists.**\n\nEverything in `sim/` is a plain `RefCounted` class. No `Node`, no scene tree, no physics, no input, no wall clock, no unseeded randomness. Godot's node classes read from the simulation and draw it. They never own state. In web terms it's \"the business logic knows nothing about React\", which every React developer nods at and few of us actually enforce.\n\nI enforce it with a shell script in CI. It greps `sim/` for the banned symbols (`PhysicsServer`, `CharacterBody`, `NavigationServer`, `Input.`, `Time.get_ticks`, `randi`, and so on), and it checks that every `extends` in that folder resolves to `RefCounted`, `Object`, or a class declared inside `sim/` itself. Anything else fails the build. Banning `Node` subclasses by name is a losing battle, Godot has dozens, so the allowlist runs the other way. There's an escape hatch comment, `# purity-ignore`, and it has been used zero times.\n\nAn invariant nobody enforces is a promise, and this one is very easy to break by reflex. `Area2D` overlap queries are right there, they are how every tutorial finds \"what is near this unit\", and they are exactly the thing you cannot use if you want the next section to be true.\n\n#### What the split buys\n\nIt sounds like architectural fussiness. It is the cheapest thing in the project, and it bought four concrete things in week one:\n\n- Tests run headless, in milliseconds, with no window. The whole suite runs on every commit on a Linux runner with no GPU.\n- Save and load is \"serialise the world\". There is no per-feature save code, and there never will be.\n- Two AIs can play each other at a few hundred times real time, which turns \"is the AI any good\" from an opinion into a number.\n- The renderer got swapped twice, and `sim/` did not change by one line either time. I'll get to that.\n\n### Fixed timestep, and why your game is otherwise a different game on every machine\n\nWeb developers meet `requestAnimationFrame` and learn to multiply movement by the delta since last frame. That works for a spinner. It's wrong for a simulation.\n\nIf the world advances by however much real time has passed, a 144 Hz machine and a 40 Hz machine run different games. Floating point doesn't associate, so the positions drift apart, so the fights resolve differently, so a save from one laptop loads into a slightly different match on another. Replays are impossible. Bugs are irreproducible by construction.\n\nThe simulation in Hammerfest advances in fixed ticks, thirty per second, and never by a delta. A `Node` in the presentation layer accumulates real elapsed time and spends it in whole ticks:\n\n```gdscript\nclass_name MatchRunner\nextends Node\n\nconst MAX_TICKS_PER_FRAME: int = 8\n\nvar world: World = null\nvar _pending: Array = []\nvar _accumulator: float = 0.0\n\nfunc _process(delta: float) -> void:\n\t_accumulator += delta\n\tvar ticks: int = 0\n\twhile _accumulator >= World.TICK_DELTA and ticks < MAX_TICKS_PER_FRAME:\n\t\tworld.step(_pending)\n\t\t_pending.clear()\n\t\t_accumulator -= World.TICK_DELTA\n\t\tticks += 1\n```\n\nThe pending array is the commands issued since the last tick, which is the only way anything gets into the world. More on that in a moment.\n\nThe cap on ticks per frame is not tidiness. Without it one long stall queues a burst of ticks, which itself takes long enough to queue more, and the game freezes forever trying to catch up. The usual name for this is the spiral of death. Dropping simulated time instead means a bad frame turns into a moment of slow motion, which nobody can see.\n\nThe rendering runs at whatever rate the machine manages and interpolates between ticks. At thirty ticks a second you do notice if it doesn't.\n\n### Everything is a command\n\nThe second rule: **nothing mutates the simulation except a command.**\n\nA click becomes a `MoveCommand`. The AI's decision becomes a `MoveCommand`, the same class, through the same door. The debug panel that spawns a thousand units for a stress test issues `SpawnEntityCommand`s. There is no back door for tooling, because a back door added \"just for debugging\" is a back door the AI could use, and I'd never be sure it wasn't.\n\n```gdscript\nclass_name Command\nextends RefCounted\n\nfunc type_code() -> int:\n\tpush_error(\"Command.type_code() must be overridden\")\n\treturn 0\n\nfunc apply(_world: World) -> void:\n\tpush_error(\"Command.apply() must be overridden\")\n\nfunc to_array() -> Array:\n\tpush_error(\"Command.to_array() must be overridden\")\n\treturn []\n```\n\nEvery command is serialised to a positional array with an integer type id, appended to a log with its tick number. `seed + ordered command log` reconstructs any match exactly, and there is a test that plays a match, replays the log into a fresh world, and asserts the two state hashes are equal. That test is the backbone of the project, because determinism breaks silently. A stray `randi()` or an iteration over a Dictionary whose order you assumed will not throw. It will quietly make every save, replay and benchmark wrong, and you find out weeks later.\n\nTwo habits keep it honest. All randomness goes through one seeded generator owned by the world, and I never iterate an unordered collection where the order affects the result. Entities are processed in ascending slot order, always.\n\nThe AI drives the same command interface as the player and reads the world through a filtered view that only contains what its units can currently see. No cheating, and more usefully, no *accidental* cheating, which is the kind that actually happens.\n\n### Handles, not references\n\nEvery RTS hits the dangling-target bug. Unit A is attacking unit B. B dies, its slot is reused for a harvester, and A cheerfully keeps shooting at a harvester it has never seen, possibly its own.\n\nThe fix is old and simple: entities are stored in dense arrays and every cross-entity reference is a generational handle, an index plus a generation counter packed into one 64-bit integer. When a slot is reused, the generation increments. A stale handle resolves to null instead of resolving to the wrong thing.\n\n```gdscript\nstatic func make(index: int, generation: int) -> int:\n\treturn ((generation & 0xFFFFFFFF) << 32) | (index & 0xFFFFFFFF)\n\nstatic func index_of(handle: int) -> int:\n\treturn handle & 0xFFFFFFFF\n```\n\nGenerations start at 1 so that zero is never a valid handle. So far it has done its job, which for a first RTS feels like cheating.\n\n### Pathfinding is where RTS projects die\n\nThe obvious approach is A* per unit. Order fifty units to one spot and you get fifty near-identical searches, then fifty units trying to walk the same line and shoving each other into walls.\n\nA flow field inverts it. Once per destination, compute a direction arrow for every passable cell, flooding outward from the goal. Any number of units then read the arrow in their cell and follow it. One computation serves the whole group, and it gets relatively cheaper the more units share it. The flood is integer breadth-first search, so the routing input is exactly reproducible. Units bumping into each other is a separate, local problem, and for now it's a plain \"push away from close neighbours\" force.\n\nThe first version built the field at full resolution, 256 by 256 cells. That is 65,536 cells, and GDScript took **330 ms** to flood them. Inlining the hot loop got it to 117 ms. Against a 5 ms tick budget that is still unusable by a factor of twenty, and it is not an algorithm bug. The search is correctly linear in cells. It's the interpreter's per-operation cost over a lot of cells, and inlining is the floor.\n\nThe fix was a coarse routing grid, 64 by 64, decoupled from the fine collision grid: about 7 ms per build, inside a frame. Routing coarse and collision fine is how actual RTS engines separate the two problems, which I did not know until I'd rediscovered it the expensive way.\n\n### The performance gate that lied to me\n\nThis is the part of the week worth reading twice, because the bug was in the instrument.\n\nI had a gate that spawned 600 units, sent them across the map, and checked the tick against the 5 ms budget. It printed **4.18 ms, PASS**. Comfortable.\n\nBut look at what those 600 units were. Bare entities: no player, no weapons, no cargo. So combat hit its early-out and returned. Vision stamped one player's fog. The economy found nothing to do. The gate measured movement honestly and everything else not at all, then printed a verdict about the whole tick. The same 600 units actually fighting cost **17.1 ms**, three and a half times the budget.\n\nA gate that measures a subset and reports on the whole is worse than no gate, because a green light gets believed. The fix was not to optimise. It was a probe that runs the real systems on the real level, two armies meeting mid-map with both economies running, and times each system separately:\n\n| System | Before |\n| --- | ---: |\n| Combat | 8.16 ms |\n| Economy | 2.40 ms |\n| Vision | 2.36 ms |\n| Movement | 1.94 ms |\n\nCombat costing four times what movement cost was not what I would have guessed, and every instinct I had about where a simulation spends its time was wrong about half the time. The biggest single win was embarrassing: iterating the world allocated a fresh `Array[Entity]` on every call, and the systems call it about seventeen times a tick. Caching it took the economy from 2.40 ms to 0.86 ms.\n\nWhat GDScript actually costs, in one sentence: the wins come from deleting calls and lookups, not from cleverer arithmetic. `Packed*Array` is the only unboxed storage the language has, and anything a loop touches every tick belongs in one.\n\n### A rock was scouting for you\n\nMy favourite bug of the week. Harvesters, freshly spawned, walked straight to the nearest contested resource node even though nobody had scouted it yet.\n\nThe obvious cause was the AI's target search ignoring what its owner had seen. Adding that check did nothing. The real cause was one layer down: resource nodes are entities, entities need an owner to spawn, so the nodes were owned by player 0 \"because nothing reads it\". Vision read it. The fog pass stamped a sight disc for every entity, rocks included, so every node on the map revealed itself to player 0 from tick zero, permanently. Player 0 could see every deposit for free. Player 1, the AI, could see none.\n\nSilently asymmetric, and in the play scene player 0 is the human. It showed up in the numbers the moment it was fixed: contested ore mined across a match went from 170 against 20 to 170 against 155. Deposits now have a vision range of zero. A rock is scenery, not a scout.\n\n### The renderer changed twice and the simulation never noticed\n\nOn the 26th the game rendered in 2D. On the 27th I ported it to 3D under an orthographic isometric camera, because I thought I wanted real height and painted buildings as billboards. On the 28th a tileset I had bought settled the question the other way: cliffs are tiles, buildings are placeables, the whole thing is a 2D tilemap. So the 3D renderer was deleted. 7,157 lines removed, 305 added.\n\n`sim/` did not change. Not one line, in either direction. The entire UI layer used exactly three things from the camera, a ground-point lookup, a to-screen projection and the zoom, so the minimap, HUD and overlays each needed one type name changed. That's the bill a narrow interface pays, and it is legible in the diff. I wrote the invariant down on day one hoping it would hold. It has now been tested twice, which is better than asserted.\n\n### Testing\n\ngdUnit4 is the test framework, vendored into `addons/`. Tests mirror the source layout, extend `GdUnitTestSuite`, and run headless:\n\n```gdscript\nextends GdUnitTestSuite\n\nfunc test_stale_handle_does_not_resolve() -> void:\n\tvar store: EntityStore = EntityStore.new()\n\tvar handle: int = store.spawn(Entity.new())\n\tstore.despawn(handle)\n\tassert_object(store.get_entity(handle)).is_null()\n```\n\n```bash\n./tools/ci/run_tests.sh\n```\n\nOne sharp edge is worth knowing about. Godot's registry of `class_name` declarations lives under the gitignored `.godot/` folder and is populated by a filesystem scan that `godot --headless -s script.gd` does *not* perform. On a fresh clone the test runner failed with \"Could not find type GdUnitTestCIRunner\", and worse, Godot's script runner still exited 0, silently running zero tests. The runner script now imports the project first, unconditionally, and it also fails the run if no tests actually executed. Green with zero tests is the worst colour.\n\nMost of the suite is simulation tests that run in milliseconds. The ones I trust most are the determinism tests: play a match, replay its command log into a fresh world, compare the hashes. Every system that landed this week landed with one of those.\n\n### Every mechanic must be visible\n\nHeadless tests prove correctness. They don't find the problems you didn't think to test for, and in a real-time game that's most of them.\n\nSo every system ships with a debug overlay you can toggle at runtime (flow-field arrows, paths, collision radii, aggro ranges, the AI's current stance) and with a sandbox scene where that one mechanic can be poked in isolation. Spawn units, click, drop a wall, watch. The single most useful key in the whole project is `.`, which advances the simulation by one tick while paused. Most gameplay bugs are one bad tick, invisible at thirty a second.\n\nThey feel like extra credit next to the \"real\" work. They aren't. The rock above was found by watching harvesters walk, not by a test.\n\n### About the editor\n\nThe thing I feared most about Godot was that a chunk of the work would live inside a GUI, away from text. In practice, almost everything is a `.gd` file and the scenes are plain text `.tscn` files that diff acceptably. The editor is where I lay out a level and look at the result. It is not where the logic lives, and it would be a bad place for it.\n\nTwo things worth knowing. Godot writes `.uid` sidecar files next to every script and they belong in version control, which the commit history shows me forgetting at least once. And it will happily rewrite `project.godot` when you touch a setting, so commit that separately or you'll never find your own change in the diff.\n\n### Who typed all this\n\nNot all me, and I'd rather say so. I've written about [Claude Code](/blog/claude-code/) twice this year, and this project is where I've leaned on it hardest. The repository has a `CLAUDE.md` whose first instruction is \"you are a critical collaborator, not a cheerleader\", with a standing order to argue the case against my plan before helping with it. There are three advisor agents, a designer, an engineer and a director who resolves the two, and I go to the director for decisions. Each day ends with a journal entry in `docs/journal/`, and one of the branches merged this week is literally called `overnight-autonomous`.\n\nWhat I do is decide, review, play and reject. The sim purity script, the perf gate that lied and the probe that replaced it, the rock: all of those came out of me reading what was built and pushing back. The 316 commits are a measure of how fast the loop runs when one side of it never sleeps, not of how much I typed.\n\n### What's next\n\nThe 20-minute fun gate is still open. Matches currently last about fourteen minutes against a target of twenty, and the AI needs to attack rather than trickle. The map editor exists but is two days old. I have not drawn a single piece of art I like.\n\nI'm also aware that a lot of this reads like confidence. It's not. I've been in this engine for eight days and I have a folder of daily journal entries whose most common heading is some variation of \"what I got wrong\". Godot has been pleasant, GDScript is faster to write and slower to run than I expected, and the architecture is holding. Ask me again after the fun gate.\n\n'Till next time!\n","frontmatter":{"title":"Godot for Web Developers","date":"31. July, 2026","description":"A week into building an RTS in Godot 4 as a web developer. GDScript with static typing, headless tests, and why the simulation must not know the engine exists.","category":"Develop","cover":{"childImageSharp":{"gatsbyImageData":{"layout":"fixed","backgroundColor":"#181828","images":{"fallback":{"src":"/static/237f3ed6f86f9e2d1193f1e518ab6e70/1619f/godot-for-web-developers.png","srcSet":"/static/237f3ed6f86f9e2d1193f1e518ab6e70/1619f/godot-for-web-developers.png 960w","sizes":"960px"},"sources":[{"srcSet":"/static/237f3ed6f86f9e2d1193f1e518ab6e70/0a27d/godot-for-web-developers.webp 960w","type":"image/webp","sizes":"960px"}]},"width":960,"height":653}}}},"fields":{"slug":"/2026-07-31_godot-for-web-developers/"}}},"pageContext":{"slug":"/2026-07-31_godot-for-web-developers/","previous":{"fields":{"slug":"/2026-06-20_ai-skills/"},"frontmatter":{"title":"AI Skills"}},"next":{"fields":{"slug":"/2026-08-22_tauri-desktop-app/"},"frontmatter":{"title":"A Desktop App with Tauri 2"}}}},
    "staticQueryHashes": ["1711471402","674253978"]}