Skip to content

Dev Log: 2026-09-14

A "this score looks wrong, and it's not even saving" report turned into two separate leaderboard bugs. Testing the fix live found a third one hiding underneath both of them.


A score that was too good to be true

The report came in as two screenshots: Chapter 1, Stage 1, cleared in Challenge Mode for a score of 6,926 against a target of 3,366. 206%. On the very first, simplest tutorial stage in the game. The second screenshot, Stage 2, was a more believable 111%. And underneath both: the score never actually showed up on the live leaderboard, at all, even after a restart.

Two symptoms in the same report, and my first instinct was that they were probably the same bug wearing two faces. That instinct turned out to be wrong twice.

The target scores in this game come from a Python solver that plays each stage automatically and records what a competent clear looks like. So the first useful thing to do was just run it again, in isolation, for Stage 1 only:

stage 01: solving... CLEARED (1 att, 0.0s) score=3400 (k=2 d=42 a=4 hp=150)

Two kills, 42 damage taken, four actions, 150 max HP on the team. That's the reference the live target (3,366, the solver's number with a 1% margin) is built from. The player had exported their own action log from the same clear, so I could check their run against it directly, action by action: same two kills, same 42 damage (their tank went from 150 HP to exactly 108), and the same four actions once you merge a move immediately followed by its own attack into one, which is how the live game already scores it. Same numbers, on both sides, feeding the same formula.

Which meant the formula should have produced the same score. It produced 6,926.

The one number that didn't match

The score formula weighs damage taken as a fraction of the team's total max HP: less relative damage, higher score. Feed it the real numbers, 42 damage over a true team total of 150, and you get 3,400. Reproducing the reported 6,926 backward from the same formula only works with one specific denominator: 405. Which is suspiciously exactly 150 + 120 + 135, Katyusha's, Nadeshiko's, and Maria's base max HP added together.

Stage 1 only ever deploys Katyusha. The other two aren't even on the map. But the code computing this denominator wasn't looking at who was actually on the map:

_challenge_team_max_hp = 0
for uid in ["katyusha", "nadeshiko", "maria"]:
    if GameManager.is_unit_unlocked(uid):
        _challenge_team_max_hp += int(GameManager.units[uid].max_hp)

It was summing every unit the account had unlocked, regardless of whether that unit was in this particular fight. For a returning player replaying an early tutorial stage in Challenge Mode after having unlocked the full roster from later progress, that's every unit in the game, not the one tank actually taking the hits. The bigger the denominator, the smaller the damage penalty looks, and the more the score inflates, exactly proportional to how much of the account's roster wasn't deployed that stage.

It happened to be nearly invisible on Stage 2, because Stage 2 already deploys all three units, so "unlocked" and "deployed" coincide there and the bug has nothing to distort. Stage 1 is the one early stage that introduces a single unit while the account may already have the rest unlocked, which is exactly the condition the bug needed to become visible.

The fix moves this sum to after the stage actually spawns its units, and sums those instead:

func _sum_deployed_team_max_hp() -> int:
    var total := 0
    for u in units:
        total += int(u.max_hp)
    return total

Reran the same stage after the fix: 3,400, matching the solver to the exact digit.

The score that wasn't there at all

That left the second symptom unexplained: why hadn't either score, buggy or not, actually reached the leaderboard.

The client has an offline_mode flag, and it's deliberately a one-way latch for the whole session: the first time any request to the leaderboard server fails, times out, or comes back malformed, it flips permanently, and every submission after that just gets written to a local retry queue instead of attempted over the network, no matter how many stages get cleared afterward. The intent was reasonable: don't let a dead connection make every future clear hang trying to reach a server that isn't answering. What it didn't have was any way to tell the player it had happened. The only on-screen notice that ever fired was gated on being a dev/editor build, where submissions are deliberately disabled outright. A real build that quietly went offline mid-session looked exactly like a normal successful clear, "Bested!" and all, right up until the player went looking for their name on the board and it wasn't there.

Checked the live database directly for the account in question: the last score that had actually landed was from the day before. Neither of the two scores from this session, buggy or fixed, had ever arrived. The submission was going into the local queue every single time, silently.

Fixed the visibility problem directly: whenever offline_mode is set on a build where submissions are otherwise enabled, the result screen now says so, "Score not saved yet (offline), will retry next time you launch the game," instead of just not mentioning it.

Deleting the evidence

Once the calculation fix was confirmed, the two scores the buggy formula had produced were still sitting live on the leaderboard, the Stage 1 entry as a false #1. Since neither had actually needed to be re-earned (the underlying clears were legitimate, only their scoring was wrong), and since Chapter 1's leaderboard is small enough that a wrong #1 is exactly the kind of thing a new player would see first, I pulled both rows directly from the production database rather than leave a number up that no formula could ever produce again.

A live test on itch.io found the third bug

With both fixes in, I ran an actual live test on itch.io before calling it done. The score displayed correctly this time, 3,400, matching the solver exactly. And then the same "not saved (offline)" notice showed up anyway, even though the session had been online the entire time. An itch.io game only exists inside a browser tab with a live connection; there's no real "offline" state to fall into the way a phone losing signal has one.

It did eventually sync, after a relaunch. Which was reassuring (the retry-on-next-launch path genuinely works) and also the clue to what had actually happened: the first network request of that session had failed for some one-off reason, tripped the latch, and every following request that session, correctly or not, was written off as "we're offline" without another attempt ever being made. The most likely candidate is the very first leaderboard call of the whole session, a rank fetch that fires at boot, racing a web build's own page load. A single blip there, and the entire rest of the session inherits it.

First fix: give the player a way out without waiting for a relaunch. A Retry button next to the offline notice, wired to a new retry_now() that clears the latch and re-flushes whatever's queued.

func retry_now() -> void:
    if not _authed:
        return
    offline_mode = false
    flush_sync_queue()

That's a real improvement, but it still puts the work of noticing and clicking on the player. The actual fix belongs one level down, in the shared request helper all four leaderboard calls (submit, fetch scores, fetch ranks, set nickname) already go through:

func _request(url: String, method: int, payload: Dictionary = {}) -> Dictionary:
    var response := await _request_once(url, method, payload)
    if _is_transport_failure(response):
        await get_tree().create_timer(RETRY_DELAY_SECONDS).timeout
        response = await _request_once(url, method, payload)
    return response

One retry, after a one-second pause, on a connection error or a non-2xx status specifically, not on a malformed-but-successful response, which is a different failure that won't fix itself by asking twice. Every caller gets this for free without any of them knowing it happened. offline_mode now only latches after two consecutive failures instead of one, which is exactly the "any API hiccup tips the whole session offline" complaint the self-hosted leaderboard was built to fix about the third-party service it replaced, quietly reintroduced by the replacement for the same underlying reason: one strike, no retry.


Miscellaneous

  • Added tests for the deployed-vs-unlocked team HP sum, the offline notice (including the case where the notice has to appear retroactively, after the screen is already showing, because the failure only resolves after the result screen renders), the Retry button's visibility rules, and the transport-failure predicate the new automatic retry decides on.
  • GDScript test suite: 1279 of 1282 passing (3 pending: sprite assets unavailable in the headless test environment, not failures).
  • Python solver test suite: 597 passing (17 skipped).
  • Builds 0.1.8.102 (the score and visibility fixes) and 0.1.8.103 (the retry improvements) go out to Steam, Google Play, and itch.io.

Two of these three bugs were the same shape from opposite directions: a value meant to describe "the units in this fight right now" got approximated by "the units this account happens to have," and a decision meant to describe "we're genuinely offline" got approximated by "one request just failed." Both approximations were reasonable the day they were written, and both quietly stopped matching reality once the game had players further along than the stage in front of them, or a platform where "offline" doesn't really mean anything. Neither would have shown up in a fresh playthrough. It took someone replaying an early stage with a leveled-up roster, on a browser tab, to find either one.