Skip to content

Dev Log: 2026-10-05

Two builds in one day, and a third batch of fixes right behind them. 0.1.9.107 is about keys: you can now play a whole stage without touching the mouse, and every key can be remapped. 0.1.9.108 is about an honest answer: a leaderboard rejection used to look like a network outage, and finding out why led through a score ceiling that was too tight and a solver that had quietly drifted. The next build covers a save bug that re-locked stages, and keyboard aiming for drones a unit cannot walk to.


Units that stayed damaged

It started with a screenshot of the world map: Maria at 3 of 224 HP, between stages, in a game that heals everyone when a stage ends. The report said it happened "sometimes", and maybe around retreating or a loss.

Every exit path (win, loss, quit, restart) does heal the roster, so I went looking for the paths that skip it. The screenshot had Challenge mode selected, and that mode works differently. It swaps your real units for fixed checkpoint units while the stage runs, and keeps your real ones in a backup. Two facts combined:

  • The mid-stage autosave writes the swapped-in, damaged units to disk.
  • The backup lived only in memory.

Restart the game in the middle of a Challenge stage and the backup is gone. At the end of the stage the "put the real units back" step found nothing to put back, and the Challenge branch never heals, so the damaged checkpoint units just stayed. It also meant the real roster was lost.

The fix has three parts. The backup now goes into the save file next to the units, restoring with no backup heals everyone instead of doing nothing, and as a failsafe every fresh stage entry starts at full HP.

# Failsafe: a fresh stage entry always starts at full HP, whatever left
# the roster damaged. Resumes keep their saved HP.
if not resuming:
    for uid in GameManager.units:
        GameManager.repair_unit(uid)

I could not reproduce the exact sequence from the report, so I want to be upfront that this is the one cause I found, not a proof it was the only one. The failsafe is there for the others.

Keyboard controls

With that out of the way, the request I had been wanting to do for a while: keys for everything. The starting list was short (K, N and M select a unit, E executes, Q queues, C cancels), and it grew as soon as I looked at what the mouse can do.

The rule that kept it small: a key never does anything a button could not do. Each shortcut calls the same handler as its HUD button and first checks that the button is on screen and enabled.

func shortcut_button_ready(kind: String) -> bool:
    var btn: Button = null
    match kind:
        "confirm": btn = _confirm_button
        "cancel": btn = _cancel_button
        "add_to_queue": btn = _add_to_queue_button
        # ...
    return btn != null and is_instance_valid(btn) and btn.is_visible_in_tree() \
            and not btn.disabled

That one check handles most of the edge cases for free: no queue key in No-Queue Challenge, no execute while a unit is mid-animation, nothing during a tutorial step that only allows one tap.

The more interesting part is that the arrow keys change meaning with context:

  • With a unit selected, each arrow adds one adjacent tile to its route, the same result as dragging a path. Step back and the last tile disappears; step into an enemy in range and it becomes the target. This reuses the drag-path code directly, so keys and mouse cannot disagree.
  • With nothing selected, left and right browse the action queue.
  • In the undo browser, left and right browse checkpoints.
  • On the world map, left and right switch chapters and up and down switch mode and difficulty, with the same confirm dialogs the buttons show.

One gotcha cost me an hour. The undo browser blocks all stage input while it is open, and my key handler sat behind that same gate, so the arrow keys never reached it. The browser is the one place that has to take keys while input is blocked, so it gets its own path in front of the gate.

Popups follow the same idea: C closes or cancels, and Enter presses OK, Yes or Continue. Enter is permanently a second Execute key, which is why it is reserved and cannot be bound to anything else. Neither key fires while you are typing in a text field, which I found out the obvious way by thinking about the player-name box.

The remapping page lives under Options and is a plain list of actions with the key next to each. Click one, press a key. Pick a key that is already taken and the two actions swap, with a line saying so. Keys are stored as physical keycodes, so someone on a QWERTZ or AZERTY layout keeps the same positions. It, and the new tip on the world map, are hidden on Android, where there is no keyboard.

"You are offline", said the server

Then a report from my own testing: no-queue Challenge on chapter 1 stage 3 would never submit a score. The result screen said "Score not saved yet (offline)", every time, through restarts. I assumed a network problem and was wrong.

The server has a per-stage score ceiling, generated from the solver's own reference clear plus some headroom for a better player, to catch absurd scores. My run was 17,357. The ceiling for that stage was 17,163. The server answered with a clean HTTP 400 and the reason implausible_score.

The game already had a good screen for exactly that case: an honest message and a Report Score button that opens a bug-report email. It never appeared, because the client treated every non-2xx response as a failed connection. A 400 with a reason is the opposite of a failed connection; the server heard me and said no.

func _is_definitive_rejection(response: Dictionary) -> bool:
    if response.result_code != HTTPRequest.RESULT_SUCCESS:
        return false
    var status: int = response.status_code
    if status < 400 or status >= 500 or status == 408 or status == 429:
        return false
    var body: Variant = response.body
    return body is Dictionary and not body.get("ok", true) and body.has("error")

A rejection now skips the retry queue and the offline latch, and shows its real reason. 408 and 429 still count as "try again later". Re-running the same stage afterwards showed the correct screen, which is how I knew the fix was real.

That left the actual question: was the ceiling wrong? Yes. It was the second stage where hand play had beaten it. The default leeway assumed a better player could take half the damage and use three quarters of the actions. I widened it to a quarter and 0.6, regenerated the ceilings for both leaderboards, and pushed the new file to the server. Stage 1-3 went from 17,163 to 18,510, and the score that never submitted now fits with room to spare.

The solver had drifted

Regenerating the ceilings meant re-running the target computation, and that is where the day got longer. A dozen stages came back with different reference scores than the ones on file. Most moved a little. A few moved a lot, and the biggest one was a chapter 6 stage whose reference clear fell from about 34,000 to under 5,000, with only seven kills.

Regenerating the checkpoints produced byte-identical files, so the carry states were fine. The cause was the solver itself: it had received several parity fixes in recent weeks (the rocket launcher, the Iron Curtain interaction, the A-KAT gauge, the Nexus pulse), and each one changes what a good plan looks like. My working theory is that it is also struggling with the new mines, but I am leaving that for later.

The practical fallout was two finales that no longer cleared. Both have a hand-set starting-level bump so they stay solvable without mid-stage XP, and both needed re-bisecting. The chapter 6 finale is the stage where this is always uncomfortable, because the result is not monotonic:

bump 15: FAIL   bump 16: ok    bump 17: FAIL   bump 18-19: ok
bump 20: FAIL   bump 21-22: ok bump 23: FAIL   bump 24-26: 2-4 attempts
bump 27: FAIL   bump 28: 2 attempts    bump 29-40: ok, one attempt each

I took 29, the start of the first long stable run, rather than the narrow islands at 18 and 21 that sit between failures. A value that clears only because of luck on the surrounding numbers is a value that breaks on the next balance tweak. The chapter 5 finale was easier: 3 clears in one attempt, so 3.

Two ceilings I deliberately did not let drop. If the new reference clear is worse than the old one, a regenerated ceiling goes down with it, and a lower ceiling rejects a real player. One of those stages has a real score on the live board that would have been thrown out. So the ceilings only ever went up.

I also looked at who is on those boards. Other than my own test accounts, only one regular player has scores on the stages whose targets changed, and the rest are generated seed entries and my developer reference scores. Those reference scores now sit a little off from the new targets, which is cosmetic, so I am leaving them until the solver is in better shape.

Stages that locked themselves again

Late in the day a player reported that stages 3 through 7 of chapter 1 had locked again right after they cleared stage 10, watched the credits, and saw the Chapter Complete card. They had played Easy, then Challenge, then Easy, then Challenge. A stage that is open on one difficulty should be open on all of them, in both Challenge modes too.

The unlock rule itself was already global: a stage is open when the previous one is cleared on any difficulty or in Challenge. So I went looking for how a clear could stop counting, and found one real bug straight away. Challenge clears are saved as a list of stage numbers, and reading a save back turns every number into a float. In GDScript, 4 in [4.0] is false. After a restart, every Challenge clear stopped unlocking anything, and the game even added the same stages to the list a second time when they were replayed.

# JSON.parse_string turns every stage number into a float, and `4 in [4.0]` is
# false in GDScript. Rebuild each chapter's list as ints.
func _normalize_challenge_progress(raw: Variant) -> Dictionary:

That alone could not explain this player, though, and they sent me their save code, which settled it. Their Easy list read stages 1 and 7 to 10, with 2 to 6 missing, while star ratings and Challenge scores existed for all ten. Those stars are only written together with a normal clear, so the clears had been recorded once and then lost somewhere. I read every place that touches those lists and found only appends, so I do not know how it happened. A stale save restored over a newer one would look like this, but I cannot prove it.

Since the cause is open, I made the rule robust against it. Unlocking is now monotonic: a stage is open if the previous one is cleared, or if anything later in the chapter has a recorded clear on any difficulty, in Challenge, as a best score, or as a star rating. It only reads data, never edits the save, and this player's save now unlocks every stage. The cost is that stages 2 to 6 still show as not cleared on the map until they are replayed.

While I was there I found a related wrong message. After a Challenge clear of stage 10, the Chapter Complete card announced that Hard difficulty was now available. Challenge runs at the Normal tier and unlocks nothing, so the line was simply false. It now only names a tier that is really unlocked, says something about beating your score in Challenge, and goes through the translation files like everything else.

Aiming at drones you cannot walk to

The arrow keys build a route one cell at a time, which breaks down for Maria. She only moves on water, so a drone on land is never on her path, even when she can hit it from the shore with a range of 3. With the keyboard you could only target a drone standing directly next to her.

The fix has two parts. The first is an aiming cursor. When an arrow key points at a cell the unit cannot walk to, the route stays put and a yellow cursor appears on that cell. It roams over any cell within attack range of somewhere the unit can stand, and when it lands on a drone, that drone is locked in exactly as if I had clicked it. Stepping it back to where it started, or pressing C, drops it.

The second is a cycling key. T or Tab locks the nearest drone the selected unit can reach, pressing it again goes to the next one, and Shift goes back. With nothing selected it selects Katyusha first. The wrinkle is that a far target needs an approach waypoint, and cycling would stack one per press. Each press now remembers the waypoint it added and swaps it out on the next one.

Two small things came along. The range outline and tooltip that appear when the mouse hovers a drone now also work while a route is previewed, which is when the overlapping ranges are hardest to read, and a drone picked with the keys gets the same treatment. And three new keys zoom the camera: plus, minus, and 0 to reset, with numpad versions. The zoom keys match the typed character as well as the key position, so they also work on layouts where plus and minus sit elsewhere. The Controls page in Options now links to a keyboard guide, which I updated to cover all of this.


Bug fixes

  • Units could stay damaged after a stage, most often in Challenge mode after restarting the game mid-stage. Every new stage now starts at full HP, and the Challenge roster backup is saved with the game.
  • A rejected leaderboard score showed as "offline" and was retried forever. It now shows the real reason, with a Report Score button for a flagged genuine clear.
  • Challenge score ceilings were too tight and rejected a legitimate chapter 1 stage 3 run.
  • Enemy drones no longer pick up heal pickups meant for your units.
  • A reflex counter-attack no longer sets off the Nexus counter-pulse.
  • The chapter 5 and chapter 6 Challenge finales are clearable again after the solver changes.
  • Stages could re-lock after a restart because Challenge clears were read back as floats. They are converted back to whole numbers on load, and a later clear now keeps every earlier stage in that chapter unlocked.
  • The Chapter Complete card announced a locked difficulty after Challenge clears. It now only names a tier you can actually play.
  • Maria could not target drones on land from the keyboard unless they were right next to her.

Miscellaneous

  • New tests for the key bindings (defaults, conflicts, reserved keys, save and load), the popup keys, path stepping from the keyboard, the world map chapter and difficulty keys, the leaderboard rejection handling, and the stage-entry heal.
  • GDScript test suite: 1,513 passing (up from 1,419).
  • Python test suite: 680 tests, all passing (18 skipped: opt-in slow suites).
  • Builds 0.1.9.107 and 0.1.9.108 go out to Steam, Google Play, and itch.io. The unlock fix, the aiming keys, and the zoom keys land in the build after that.

Looking back at the day, the same mistake shows up twice. The offline notice and the damaged units were both cases where the game said something confident that was not true, and I believed it for longer than I should have, because the message sounded plausible. An honest "the server said no, and here is why" cost one small function. The more dangerous version of that error is the one with no message at all, which is why the failsafe heals everyone on stage entry regardless of how they got hurt.