Skip to content

Dev Log: 2026-09-15

Mines and heal pickups are live in every stage from Chapter 1 through Chapter 6. Getting there meant fixing a mine that only worked if you stopped on it, and a kill that wasn't paying out when it should have.


Retiring the decoration, keeping the tile

Every stage has always had small 1x1 props scattered around: crates, barrels, the occasional warning sign. Purely decorative, no effect on play. A styleguide pass earlier this year retired most of them for being visual clutter that didn't earn its keep, but it left an obvious question behind: what if a tile like that actually did something?

The first answer was heal pickups. A stage-level field_objects list, separate from decoration, with a glowing medkit sprite loud enough to read as interactive on sight. A unit that ends a move on one restores 10% or 25% of its max HP (small or large), consumed once. Simple, and it immediately made careful routing feel rewarded instead of just safe.

Mines followed the same plumbing, with the opposite intent: 5% or 10% of max HP as damage, and unlike heals, they ignore defense entirely. A tank with a wall of defense still feels a mine exactly as much as anyone else. Enemy drones trigger them too, which turns them into more than a hazard to avoid. A pursuing drone chased onto a mine tile dies to it, no attack required. That's live across every chapter now: ten stages each, chapters 1 through 6.

A mine that only worked if you stopped on it

Both effects originally fired from the same place: a poll that runs once an action fully resolves, checking whether any unit or drone is now sitting on an unconsumed object's tile. That's the right mechanism for the final destination of a move. It is not the right mechanism for everything in between.

Walk a tank five tiles across a mine's cell and stop two tiles past it, and nothing happened. The mine only "worked" if you ended your move standing directly on top of it. Driving straight through was completely free, which undercuts the entire point of a hazard tile and meant the mine-avoidance pathfinding I'd already added was routing around cells that weren't actually dangerous to cross yet.

The fix is a second check that fires per step, as each cell of a route is actually reached, alongside the original end-of-action poll (still correct and still needed for a stationary attack or limit break, neither of which has a path to check):

# Per-step check: fires as soon as `unit` occupies a cell, whether or not
# the action ends there. Unlike check_field_object_pickups (which scans
# every unit against every object), this checks one specific mover against
# its current cell only.
func check_field_object_pickup_for_unit(sc: StageController, unit: Unit) -> void:
    if unit.current_hp <= 0:
        return
    for fo in field_objects:
        if is_instance_valid(fo) and not fo.consumed and fo.grid_cell == unit.grid_pos:
            _apply_field_object_unit(sc, fo, unit)
            fo.consume(unit.unit_id)
            return

The same change on the drone side means a path crossing two different mines now triggers both, independently, instead of only whichever one happened to be underfoot when the action ended. The Python solver, which mirrors the live combat and movement rules exactly so it can pre-verify that every stage is actually clearable, needed the identical fix, or its target scores would keep pricing in mine crossings that were about to start costing real damage.

The kill that wasn't paying out

Before calling the whole feature done, I asked for a full review of it end to end, mine damage, heal amounts, solver parity, all of it. It came back with one real bug: baiting a drone onto a mine wasn't awarding kill XP.

Live's kill-XP rule is simple and, as far as the code is concerned, unconditional: whatever destroys a drone, however it happens, credits the acting unit. The solver mirrors this in one function, _grant_kill_xp, and every kill path in the solver's engine calls it after a kill, except the mine-versus-drone path, which applied the damage and checked whether the drone died, but never made the follow-up call. A solver run that killed a drone by luring it onto a mine would under-award XP relative to what the same play would earn live, which is exactly the kind of quiet mismatch that doesn't fail loudly. It just makes the solver's carry-state numbers drift slightly away from what live actually produces, stage after stage, with nothing pointing at the cause.

state, killed = _damage_drone(state, drone.drone_id, dmg, "", balance, events)
# Mirrors live's _on_drone_destroyed: ANY damage source killing a
# drone (mine included - baiting one onto a mine is an intended
# tactic) awards kill XP to the acting unit.
if killed and acting_unit_id is not None:
    state = _grant_kill_xp(state, acting_unit_id, balance, drone_level)

One line missing, one line added. I checked the fix actually mattered by reverting it, rerunning the regression test, and confirming it failed the way I expected (an XP total that should have grown staying exactly flat) before putting the fix back.

Re-solving the whole campaign

A change to XP awarding is a change to how much XP the solver ends every stage with, which means the saved carry states, the officially recorded entering roster for the next stage in every chapter, needed to be regenerated from scratch rather than left stale. Ran the full six-chapter, sixty-stage campaign again from a clean start.

All sixty stages still cleared. Fifty-four of the sixty saved carry states came out different from before, every one of them a stage at or after the first mine placement, which is exactly the shape you'd expect: nothing changes on a stage with no mines to bait a kill from. The drift was small and net positive across the campaign, XP a solver run was previously leaving on the table now actually landing in the carry state that feeds the next stage.

One stage needed a manual re-tune afterward. Challenge Mode solves every stage a second time under harsher rules (XP disabled mid-fight, entering limit gauge always at zero), and stages that are balanced assuming some mid-stage leveling get a small manual entering-level bump to compensate, tracked in one table and periodically re-bisected whenever something upstream shifts the numbers. Chapter 4, Stage 10 dropped out of clearing cleanly at its old bump of +3 once the refreshed carry states came through; +5 sits in the middle of a stable range that clears every time, so that's the new value.

Leaderboard scores that were too good to be true, again

Separately, and prompted by a look at real leaderboard data: two very old accounts, migrated over from before the current ratio-based score formula existed, had Chapter 1 Stage 1 Challenge Mode scores of 6,926 and 4,426. The actual ceiling for that stage, confirmed by both the solver and a fresh manual clear, is exactly 3,400. Real players, real clears, just scored under a formula that no longer exists.

Rather than only ever reacting to whichever bad score someone happens to screenshot, the leaderboard server now checks every submission against a real per-stage ceiling: the solver's own reference clear, with a little headroom added for a better-than-solver human run, rather than a flat number that goes stale the moment anything gets rebalanced. The two old scores got capped down to the real maximum rather than deleted outright, since the clears themselves were genuine.

A rejection used to be completely silent. The stage-clear screen now says outright when a score wasn't saved and why, and for the one outcome where a player might actually have a legitimate case, a score flagged as implausible specifically, there's a Report Score button that opens an email with the details pre-filled, asking the player to attach their exported action log for a real look. Older app versions can no longer submit scores at all now, since they predate this whole check; wiring that version requirement through also turned up that the exact same silent-rejection gap existed on the separate reddit-lite leaderboard, one JS bridge function was discarding the entire error response on any non-success result before it ever reached the game, so that got the identical fix.


Bug fixes

  • Walking through a mine without stopping on it was free. Field objects (mines and heals) now trigger the instant a unit's or drone's path crosses their cell, not only when the move ends there.
  • A drone killed by a mine didn't award kill XP to the unit that baited it. The solver's mine-damage path now calls the same kill-XP function every other kill path already used.
  • Two legacy leaderboard entries on Chapter 1 Stage 1 were scored under a formula that no longer exists. Capped down to the real maximum rather than left standing.
  • A rejected leaderboard score vanished with no explanation. The stage-clear screen now shows why, with a way to report one you believe was flagged by mistake.

Miscellaneous

  • Added regression tests for kill XP on a mine-killed drone, the ceiling-leeway math behind the new plausibility check, and the reddit-lite rejection wiring.
  • Python solver test suite: 625 passing (up from 597).
  • GDScript test suite: 1,302 of 1,305 passing (3 pending: sprite assets unavailable in the headless test environment, not failures).
  • Server-side PHP test suite: 29 passing (up from 26).
  • Build 0.1.9.104 goes out to Steam, Google Play, and itch.io.

The mine and the kill-XP bug were the same kind of miss from opposite directions: a rule that was correct for the common case (land on a tile, finish a fight the ordinary way) and silently wrong for the edge the feature was actually built to reward (cross a tile without stopping, win by baiting rather than fighting). Neither one would show up unless someone specifically tried the trick the feature was designed to allow. That's exactly the review I'd asked for before calling it done, and exactly why I asked for it.