I raised the unlock bar without taking anything back
Characters in Tanubi open up one at a time as your score accumulates across runs. On this day I raised the line for the Fox, who was sitting furthest back on that list. The reason fits in a sentence: the last one did not feel like a last one. The trouble is that an unlock condition is a number in a config file and, at the same time, a record of everyone who has already passed that number and taken the character home. Move the line on its own and somebody who was playing as the Fox yesterday opens the page today to find the Fox locked. This entry is about the decision to raise the line, about how I preserved what people had already earned, and about the tests I wrote so that "preserved" would not stay a good intention. The numbers here are not the numbers you will see in the game right now. This is where things stood on 25 July 2026, and the line was raised again afterwards; I come back to that in the last section.
Written: 2026-07-25
The last character did not feel like a last one
At the time, the Fox sat at the far end of the list. It clings to walls, slides down them and kicks off the other way — a way of moving that nobody else in the cast has. In a game built around collecting, I want the thing you put at the very end to make arriving there an event in itself. It opens only after a long run of play, and that is exactly where its meaning comes from. Being last in the ordering and carrying the weight of being last are two different things.
All I had in hand was my own reading that those two had come apart. I did not count how many players were reaching the Fox, or how quickly, and then conclude anything from that. So this was not a measured finding; it came out of what I had intended the ordering to mean. Knowing that, I still decided to raise the line, so that the last character would read as a special last. And because I knew the reasoning was thin, I attached two conditions to it: leave the change in a shape I can undo, and do not touch the one thing that cannot be undone, which is what a person already holds.
Tuning a difficulty curve or an unlock threshold is the cheapest change there is in code. You edit one constant and you are done. It is only cheap for the person writing it. The bill for that change is paid by whoever happens to be standing near the line — in this case, people who had cleared the old threshold but not the new one. The cheaper a change looks, the more it is worth counting who pays for it first. That is the thing I took away from this one most strongly.
Moving a line moves the ground under the people who crossed it
Back then the unlock check was a single expression: is the accumulated score at or above the current threshold? In that shape, raising the threshold rewrites every past verdict retroactively. Nobody loses any of their own record — the accumulated score never goes down — and yet the answer flips, purely because the right-hand side of the comparison changed. To somebody sitting between the old line and the new one, it is indistinguishable from having something taken away for no reason they can see.
What worried me was not the complaint that would come back, but the silence that would. You open the page, the Fox you had is closed, you wonder for a moment whether something is broken, and you close the tab. Nothing about that reaches me. Collecting rests on the assumption that what you have collected keeps piling up. Break that assumption once and I suspect the reason to keep reaching for the next thing thins out along with it. That is a guess, not something I measured, but it seems a reasonable thing to expect if you stand where the collector stands.
So the change turned out to need two parts, not one. The first is the new line. The second is a safe place, outside the new line, to keep what people had already earned under the old one. My position was that without the second part the first part is not worth shipping.
You do not take back what somebody already has
The place I built for that is a permanent-unlock list inside the saved progress. Anything recorded there stays open no matter what the accumulated score says. The unlock check went from one expression to two branches: is this character on the permanent list, or is the current threshold met? The score route stays exactly as it was, open to anyone, and beside it now runs a second route whose whole job is to not revoke the past.
Moving the people who had passed the old line onto that list happens exactly once. The implementation keeps the pre-raise threshold around as its own constant; on the first load, if the accumulated score at that moment reaches the old line, the character is added to the list and saved. Whether that has happened is remembered by a flag in the saved progress, and once the flag is set nothing runs again. The absorption is wired into all three doors that read progress — the hub, the game itself, and the codex. I wanted the same outcome regardless of which door somebody came through; a rescue that behaves differently depending on where you entered is not a rescue I would trust.
I also had to fix the display that points at your next goal. If a character you already hold permanently is offered as the next thing to unlock, you end up with a progress bar filling toward somebody you are already playing as, which is nonsense on screen. Finishing quietly inside the data was not enough — the job only ended when the words on screen stopped contradicting it. All of this unlock state lives on the device; it does not reach level generation, scoring or the leaderboards. Keeping the blast radius small from the start means that being wrong stays small too.
Turning "nothing gets taken away" into tests
No loss of progress is, if you only say it, a good intention, and good intentions do not survive a refactor six months later. So in the same pull request I copied the promise across into tests, in the same words. In plain language there are two of them: somebody who had crossed the old line keeps the Fox, whatever happens next; and somebody starting fresh does not get it merely by reaching the old line.
What I put in place is a set of checks built around those two. A pre-existing record sitting exactly on the old line is absorbed and stays open. A newcomer with the identical accumulated score is not, and has to reach the new line before the Fox opens at all. Running the absorption twice changes nothing — feeding an already-migrated record back through returns the same thing untouched, reported as no change. I pinned the boundary from both sides: exactly on the old line is absorbed, one point short of it is not. Then persistence: the result survives being written and read back, and it survives even if the accumulated score is later lost entirely — the Fox stays open. And where the main save and its backup disagree about whether the absorption has run, merging them falls toward the side where nobody ends up holding less — the union of the two.
Writing them made something obvious that had not been obvious to me. These tests are not documentation of a feature. The threshold will change, the shape of the list will change. What is nailed down here is a single promise — that what you have earned does not shrink — and I named the tests so they read that way. If somebody arriving later turns one of them red, what they broke is not an implementation detail but the promise. A sentence in a design document might never be read; a failing test is, I expect, a good deal harder to walk past.
What is still broken, and where this entry sits in time
Everything above is 25 July 2026. The score you need for the Fox in Tanubi today is higher than the threshold this entry is about. Not long after this, the whole question of how much of the game is reachable got revisited, and the Fox line moved further up — to 5,500. The current values and the order the characters open in are laid out in the strategy guide, so if you are reading this looking for today's target, that is the page you want. This entry is only the record of the day I decided to move the line.
That second raise is also what exposed the weak spot in this design. The absorption remembers whether it has run using a single flag. But once a second raise arrived, anybody who had already spent that flag on the first one does not get the second round of the rescue automatically. Splitting the flag so that each raise carries its own would fix it, but that means reaching into the structure of the saved progress, so it is parked as separate work. For now the limitation is written down in a comment in the source and nowhere else.
I want to be clear that this is less a gap in the implementation than a mistake in how far ahead I was looking. Build a rescue as a one-off and it will work exactly once. Had I expected a second raise, I would have made it countable from the beginning. The wrong assumption here was the one that said the tuning would be finished in a single pass.
One last thing I cannot verify. I do not know how many people the absorption actually saved that day. The unlock record lives on the device, and I never built anything to go and count it from my side. Which means the result of this work can only show up as nobody noticing anything at all. Even with no way to confirm that nothing happened, I still think it was worth shipping — having something you earned disappear without explanation is not an experience anybody needs twice.