Dev Log: 2026-07-19¶
Two leaderboards, two backends, and neither of them got to be simple.
Why there are two leaderboard systems, not one¶
Challenge Mode and No-Queue Challenge have always produced a score. Until now that score only ever went one place: the player's own save file. This build adds real competitive leaderboards, but not one leaderboard system, two, because the two builds that needed one had almost nothing in common to build it on.
The Steam, itch.io, and Android builds have no backend of my own, so they talk to CheddaBoards, a third-party leaderboard REST API, and the score is genuinely global across every player of the game. The Reddit app runs inside Devvit, which already gives every installed app its own Redis instance scoped to the subreddit, so the reddit-lite build's leaderboard is per-subreddit and reads and writes straight to that. Different players, different infra, different problems.
Fitting the whole game into one shared board¶
CheddaBoards' custom scoreboards turned out to work nothing like the mental model I started with. A score submitted with an explicit scoreboardId is rejected outright, "submit without a scoreboardId so it gets the normal fan-out", and a score submitted without one lands on every board in the game. There is no per-board targeting at the API level, confirmed by just trying it. That rules out the obvious design (one board per chapter, stage, and mode) entirely: the game has one shared board, all-time, whether I want it to or not.
That leaves one board carrying entries for every chapter, every stage, both Challenge and No-Queue mode, all mixed together, with the API only able to sort by a single numeric score field. The nickname field looked like a way out, tag the entry with a stage code there, until nicknames turned out to be capped at 12 characters server-side. Twelve characters is not enough room for a readable player name and a stage code with a delimiter, and the player's real name is the one thing that has to survive intact for "is this my entry?" matching.
So the stage code is packed into the score itself:
# Packs chapter+stage+mode into the submitted score (see the file-level
# comment). mode_bit distinguishes the two CheddaBoards-tracked modes;
# "easy" never reaches here (submit_score() returns early for it).
func _encode_score(chapter: int, stage: int, mode: String, score: int) -> int:
var mode_bit := 1 if mode == "noqueue" else 0
var suffix := chapter * 1000 + stage * 10 + mode_bit
return score * SCORE_ENCODE_BASE + suffix
func _decode_score(encoded: int) -> Dictionary:
var suffix := encoded % SCORE_ENCODE_BASE
var mode_bit := suffix % 10
return {
"chapter": suffix / 1000,
"stage": (suffix % 1000) / 10,
"mode": "noqueue" if mode_bit == 1 else "challenge",
"score": encoded / SCORE_ENCODE_BASE,
}
SCORE_ENCODE_BASE (10000) just has to exceed the largest possible suffix, and the real score occupies everything above that. The trick that makes this safe rather than merely clever: sorting the shared board by the encoded number still sorts by the real score first within any single chapter+stage+mode subset, because the suffix is constant there. Fetching the board, filtering entries down to one stage's suffix, and decoding is enough to reconstruct a normal per-stage leaderboard, one HTTP call recovers all of it since CheddaBoards already returns entries pre-sorted.
The rank CheddaBoards reports back on submit is meaningless here too, it is the rank on the whole mixed board, not within one stage, so _filter_entries() re-ranks from scratch after decoding rather than trusting anything the API says about position.
Nicknames, collisions, and going offline mid-run¶
CheddaBoards' nickname rules turned out to hide a second trap: nicknames must be globally unique across every player of the game, and a collision is not rejected, it is silently replaced server-side with an auto-generated Player_XXXX name. Submit a score under a name someone else already has, and the game has no error to react to, just an entry that will never again match the self-comparison the leaderboard view relies on. The player-name modal now round-trips the chosen name through CheddaBoards before committing to it locally, so a collision shows up as a same-turn "that name is taken" instead of a leaderboard entry silently going anonymous later:
if BuildConfig.cheddaboards_enabled:
_confirm_button.disabled = true
_name_input.editable = false
var result: Dictionary = await LeaderboardClient.set_nickname(name)
_confirm_button.disabled = false
_name_input.editable = true
if not is_instance_valid(self) or not visible:
return # modal was dismissed while the check was in flight
if result.taken:
_show_error(tr("LBL_PLAYER_NAME_TAKEN"))
return
The other constraint was never being allowed to lose a score to a bad connection. A player finishing a stage offline, or mid-flight to a subway tunnel, still gets their run scored locally and the submission queued to disk (user://leaderboard_queue.json); the next successful connection flushes the whole queue. Queueing de-duplicates by board id and keeps the higher of two scores rather than stacking both, so replaying the same stage offline several times before reconnecting does not spam the board with stale attempts once it comes back.
Ties, done properly, on the Reddit side¶
The Reddit app's leaderboard has the opposite problem from CheddaBoards: instead of one shared board needing to be carved into per-stage slices, Devvit hands each subreddit its own Redis, so each stage and mode gets a real sorted set (lb:{chapter}:{stage}:{mode}) with no encoding needed at all. The interesting problem there was ties. Early, easy stages routinely have dozens of players sharing the literal top score, and Redis's own zRank breaks those ties by member name, handing out an arbitrary #1/#2/#3 split among players who did exactly as well as each other.
// "Competition ranking" (1224, not 1234): rank 1 is shared by every member
// with the top score, and the next distinct score's rank skips ahead by the
// number of ties. Deliberately NOT zRank (Redis breaks ties by member name,
// giving each tied member a distinct rank). Early/easy stages routinely
// have many players sharing the exact top score, and all of them should
// show as #1, not an arbitrary #1/#2/#3 split.
export async function competitionRank(key: string, score: number): Promise<number> {
const higher = await redis.zRange(key, `(${score}`, '+inf', { by: 'score' });
return higher.length + 1;
}
Rank is recomputed as "how many entries beat this score, plus one" instead of trusting Redis's ordinal position, which is what gives every member of a tie the same number. The leaderboard modal then does one more pass purely for display: within a tied group, the viewer's own row moves to the front so their name is not lost scanning a wall of identical ranks, without touching anyone's stored score or the order any other viewer sees.
Bug fixes¶
- The Reddit leaderboard modal's mode toggle looked identical whether Easy or Challenge was selected. Disabling the inactive button was the entire signal, so which mode was currently showing was not actually legible at a glance. It now reuses the world map's own mode-selector recipe (tinted background, colored left accent bar, dimmed text on the inactive one) so the "currently selected" state reads the same way here as it does on the map the player picked it from.
- A connectivity indicator was missing from the leaderboard view. Offline data (built from the local best score and the stage's dev target, so the view is never empty) now shows a disconnected icon instead of looking like a live global board.
Miscellaneous¶
- Chapter 2 through 6 challenge score targets were regenerated after
challenge_score.py, the solver's mirror ofchallenge_manager.gd's score formula, moved to ratio-based scoring (damage and action count as fractions of team max HP / expected actions instead of flat point deductions). The flat version was calibrated for Chapter 1 and went as far as -160,000 on later chapters' much larger HP pools, clamping every target to a meaningless 0. - GDScript test suite: 1158/1161 passing (3 pending: sprite assets unavailable in the headless test environment, not failures).
- Python solver test suite: 536/536 passing (17 skipped).
- Build 89 shipped to Steam, Google Play, and itch.io.
Neither leaderboard backend was the one I would have designed on a blank page, CheddaBoards' fan-out-only boards and Devvit's per-subreddit Redis are both constraints I found out about by hitting them, not by reading a design doc up front. The score-encoding trick and the competition-ranking function end up solving the same underlying question, "what is this player's rank among just the people who played this exact stage in this exact mode", but they get there from opposite directions: one packs structure into a number because the backend only gives you one number, the other throws structure away because the backend gives you too much of its own opinion about what "rank" means.