Skip to content

Dev Log: 2026-09-19

A playtester's real Challenge Mode score didn't match a freshly computed target. Chasing that down found a real solver bug, and underneath it, a second bug in a fixture file nobody had touched in months. Also in this build: a tutorial and UI polish pass, and leaderboard scores that finally come back online on their own.


A target that didn't match a real clear

Earlier this week I extended a tutorial-teaching mechanic (softened enemy stats and a pre-charged limit gauge on a handful of early Chapter 1 stages) from Easy difficulty to Easy and Normal, since guided tutorials had already been running on both for a while and the combat tuning that supports them hadn't caught up. That meant re-solving the whole campaign and recomputing every stage's Challenge Mode target, since the solver plays through Normal-difficulty rules to build its carry states.

I asked a playtester to check the new numbers against a real run. Chapter 1 Stage 4's freshly computed target came out at 13,000. Their actual, legitimate clear scored about 25,000, nearly double. Something was very wrong.

The bug was in exactly the kind of place that doesn't show up unless you go looking for it. Computing a Challenge Mode target means running the solver's search as if it were a real Challenge Mode run, no tutorial hand-holding, full-strength enemies, gauge zeroed. The code that does that accounting already knew about a challenge_mode flag and used it correctly. The code that does the actual planning, deciding what the solver should do turn by turn, never had that flag at all:

def solve_stage_with_replay(
    stage_num: int,
    campaign_units: dict[str, UnitState],
    balance: BalanceDB,
    stages_dir: Path,
    *,
    per_layer_budget: float = 60.0,
    cross_layer_top_k: int = DEFAULT_CROSS_LAYER_TOP_K,
    replay_max_attempts: int = DEFAULT_REPLAY_MAX_ATTEMPTS,
    status_callback=None,
    layer_complete_callback=None,
    attempt_start_callback=None,
    chapter: int = 1,
    field_repair_available: bool | None = None,
    challenge_mode: bool = False,
) -> StageReplayOutcome:

Before that flag existed here, the search planned its moves against the tutorial-tuned drone stats, because nothing told it not to. A stage where a sentinel drone is scripted down to 20 HP for a teaching moment looked, to the solver, like a stage where sentinels always have 20 HP. It found the easiest possible path through weakened enemies and called that the target. The post-hoc accounting layer never got a chance to correct the plan itself, only to describe it after the fact.

Threading the flag through the actual search fixed it outright: Chapter 1 Stage 4's target came back at 24,587, matching the real clear almost exactly. Only six stages in the whole game (the ones with tutorial overrides at all) were ever affected, and all sixty stages still verified as clearable once the fix was in.

The dev score was never supposed to equal the target

While I was regenerating that data, something else looked off. The self-hosted leaderboard seeds every board with a "dev reference" score, plus a spread of fake-but-beatable filler scores under names like Kestrel89 and Bramblefox, so a new player never sees an empty board. My first pass at fixing the drift set that dev score to exactly the solver's target.

A very reasonable objection came back almost immediately: the dev score is supposed to be beatable, not sitting at the exact ceiling. Checking the math against production settled it in one line: the live dev score for Stage 1 was 3,366, and 3400 * 0.99 is 3366.0, exactly. The convention had always been 99% of the target, just never written down anywhere as code, only computed once by hand years ago and left to quietly drift out of sync with every balance pass since.

That gets a real generator now instead of a one-off spreadsheet moment:

DEV_FACTOR = 0.99

def main() -> None:
    for row in rows:
        targets = json.loads((_CHALLENGE_DIR / f"ch{row['chapter']:02d}_targets.json").read_text())
        row["score"] = round(targets[str(row["stage"])]["target"] * DEV_FACTOR)

Re-running it against the corrected targets landed two of the affected stages back on their exact live values with zero change needed, and the rest within the kind of small drift you'd expect from months of intervening balance work elsewhere in the campaign, not from anything wrong with the fix.

Rolling it out without guessing

None of this is worth much if the migration that applies it can't be trusted to touch only what it's supposed to. Before running anything against the live database, I checked the touched-ID set was fully enumerable ahead of time (every row is either a npc_* filler name or a D### dev-reference ID, never a real player), then cross-checked it against the actual production leaderboard with a plain read-only request:

curl -H "x-game-id: panzer-island" \
  "https://scores.keksdose.org/index.php?action=board&chapter=1&stage=4&mode=challenge&limit=50"

Real players (a couple of long-time regulars near the top of that board) sat exactly where they should, untouched by anything the migration was about to do. Dry run matched expectations, apply matched the dry run, and the corrected Chapter 1 leaderboard ceilings went out right after.

Scores that never come back online

Separately, a recurring complaint: a brief connection hiccup would tip the game into offline mode for the rest of the session, and Challenge Mode scores earned after that point would just sit unsubmitted until someone thought to relaunch. The retry button on the result screen worked, but only if a player noticed something was wrong and tapped it.

Two changes. A single failed request now retries twice more before giving up, at 1 and 3 second delays, instead of just once, so a slightly-longer blip has a real chance to clear on its own. And if the game does end up offline anyway, an ambient timer now retries every 30 seconds for the rest of the session, using the exact same recovery path the manual Retry button always used:

func _on_offline_retry_timeout() -> void:
    if not offline_mode:
        return
    retry_now()

A real reconnect no longer depends on a player noticing anything at all.

Also in this build

A batch of smaller fixes and playtester-reported polish landed alongside all of the above:

  • Guided tutorials now automatically turn off in Challenge Mode and on Hard difficulty and above, with a warning if you manually turn them back on there.
  • Android gets a "Lock Landscape" toggle on the world map.
  • Leaving drones behind at an extraction zone now pulses the zone outline and asks for confirmation first.
  • A drone that would die to your currently previewed or queued attack now shows a kill marker.
  • The world map's tip box is clickable and cycles through the full tip list.
  • Dialog boxes auto-size to fit their text instead of adding a scrollbar.
  • A confusing "no heal" note on Iron Curtain's tooltips and tutorials is gone. It never implied otherwise; the callout only raised the question.
  • Chapter 1 Stage 6 now calls out mines the moment they first appear.
  • The in-game "How to Play" screen got a real accuracy pass: button labels now match the default Queue-mode controls (it was still describing the old non-queue MOVE/ATTACK convention), the limit gauge is correctly described as filling from dealing or taking damage, and active skill unlocks now list all four levels instead of two. The separate in-depth Guides reference had its own, older version of that same skill-level mistake, plus a leftover mention of a passive-skill-unlock mechanic that was fully removed months ago. Both fixed.

Bug fixes

  • Challenge Mode targets on six early Chapter 1 stages were computed against weakened tutorial-only enemy stats instead of full Challenge-strength ones. The solver's search now runs in genuine Challenge Mode, not just its scoring afterward.
  • The leaderboard's dev-reference scores had drifted from their intended 99%-of-target value, some by a rounding error, a couple by a wide margin after months of unrelated balance changes. Regenerated from a real, checked-in formula instead of a one-off manual computation.
  • A brief connection hiccup could tip the game into offline mode for the rest of a session, leaving Challenge Mode scores unsubmitted until the player noticed and manually retried or relaunched. Requests now retry twice more before giving up, and the game keeps trying to reconnect and flush pending scores every 30 seconds on its own.
  • A move that completed an extraction objective without an accompanying attack didn't register the win.
  • World-map rank badges didn't appear for a stage that earned its first rank while the map was already open.
  • Garbled or missing characters in tutorial text on the web (itch.io) build.
  • A queue-mode bug where an unexecuted queued action could carry over incorrectly across a sector or stage transition.

Miscellaneous

  • Added regression tests for the offline-retry timer (idempotent creation, correct no-op/reconnect behavior), the Challenge-mode solver planning fix, and the tutorial-tuning-on-Normal exclusion logic.
  • Python solver test suite: 632 passing, 1 skipped (up from 625).
  • GDScript test suite: 1,363 passing.
  • Build 0.1.9.105 goes out to Steam, Google Play, and itch.io.

The solver bug and the dev-score bug were found back to back, twenty minutes apart, both by the same instinct: don't trust a freshly computed number just because the code that produced it looks reasonable, check it against something real. One playtester's honest "wait, that doesn't look right" caught a bug that would have quietly undersold six stages' worth of Challenge Mode to every player who tried them. That's worth more than any amount of staring at the solver's own output in isolation.