Tuning an endless runner difficulty curve
My Echo Bat test bot had a median of 52 points. Changing only the cave's rules, never the bot, took it to 1,757 and its best run to 4,608.
Contents
Echo Bat is a one-touch cave flyer. Tap to flap, and the bat's sonar pings light the rock for a moment before the cave goes dark again. It is an endless runner, so its difficulty curve is the whole game: speed and obstacles rise with the score, and the run ends when the player can't keep up.
I set one target for that curve: the best player should get past 2,000 points. At roughly a point a second that is a half-hour run, so the hardest cave has to stay flyable for a very long time.
My stand-in for "the best player" is an autopilot. It sees only rock that is lit and on screen, reacts in 0.15 s, lands its taps 50 ± 20 ms late, and can't tap faster than every 0.12 s. When I set the target, it was flying the game on an emulator in a loop. After 15 runs its median was 52 and its best was 140. Roughly forty times short.
The rule I tuned under
The obvious lever is the bot. Make it aim better and the median goes up. It also stops meaning anything, because then I am measuring my bot and not my cave. So the bot was frozen for the whole exercise, and every gain had to come from a rule in the cave generator. If a real player couldn't have done better than the bot at some spot, the cave was wrong there.
That needs a fast loop. The game's rules are plain JavaScript with no rendering, so the bot can fly them headless in Node, frame by frame at 60 steps a second, as fast as the CPU goes. The first measurement, 12 runs, took 4 seconds. By the end, one command flew twenty runs of up to an hour each:
npm run playtest -- 20 3600
That took 46 seconds while the runs were short and about three minutes once they were long, and it prints the median, the best, and which section of cave each run died in. The death report is what did the work. A median tells you the cave is too hard. A list of where and on what the bat died tells you which rule to read.
Where it was dying
The emulator runs died between 45 and 55 points more often than anywhere else, and a first headless report of 40 runs had 28 deaths on stalactites, 11 on stalagmites and one on the ceiling. The bunching was not a mystery once I looked at the curve. Forward speed rose by a fixed amount per point until 50 points, then stopped. The obstacle settings slid from easy to hard by 60 points, then stopped. So the whole curve was a ramp into a wall: 50 points of climbing, then maximum difficulty for the rest of the run.
I wrote about the same shape in a puzzle game, where sorting a level library by difficulty gives you a flat campaign and then a wall. An endless runner gets there faster because the ramp is the only progression it has.
The changes, in the order they landed
Every row below is the same frozen bot. The first row is the 15 emulator runs. The next three are 12 headless runs each, capped at 40 or 50 minutes, and every one of them died well before its cap. The rest are 20 runs of up to an hour.
| Change to the cave | Median | Best |
|---|---|---|
| None (the emulator runs) | 52 | 140 |
| Eased curve, honest reach rule | 108 | 221 |
| Ping when rock enters the screen, bat moved left | 553 | 1,324 |
| A jump must fit between seeing a gap and reaching it | 737 | 1,917 |
| Tunnel climbs and dives held to the same limit | 696 | 2,025 |
| The bat starts from the last gap's middle | 851 | 1,578 |
| Cavern gaps kept near the tunnel's middle | 966 | 2,674 |
| Every screen at least 15% rock | 1,757 | 4,608 |
The curve. Speed and difficulty now follow score / (score + half), so they rise fast at
first and then ever more slowly, and never quite arrive. A simplified version:
// before: a straight ramp, flat from 50 points on
const speedAt = (score) => 158 + Math.min(score, 50) * 1.8;
// after: half the gain by 150 points, still rising at 2,000
const eased = (score, half) => score / (score + half);
const speedAt = (score) => 158 + 100 * eased(score, 150);
At 2,000 points the eased value is 0.93, so a long run still has something left to get harder. I changed the curve and the reach rule together, so the first row can't say which of the two the gain came from.
The reach rule. The generator places each gap within reach of the last one, and it had been generous about time. It counted the whole distance between two rocks as time to move, but the bat has to stay inside one gap until it is past that rock. Now only the open stretch counts, a drop starts from rest instead of at full fall speed, and the climb rate assumes 190 units a second, below what a thumb flapping flat out gets.
Seeing the rock. This was the biggest single jump, 108 to 553. A phone screen in portrait shows only about a second of cave ahead of the bat, and a rock could come onto the screen and stay dark until it was nearly on top of it. The sonar now also pings the moment rock enters the screen, and the bat sits 18% in from the left edge instead of 28%, so more cave shows ahead of it. Those two went in together too.
Sight time. A jump between gaps now has to fit inside the time between the gap being visible and the bat reaching it, less 0.3 s for the ping to arrive and 0.2 s to react. At speed, on a narrow screen, that is shorter than the open stretch, and the old rule never asked. The tunnel's own climbs and dives are now held to the same limit, which is the next row.
Where the bat starts. The reach rule had credited the bat with being anywhere in the last gap, which past a lone rock meant anywhere from the gap to the wall. It is where the player put it, so the rule now starts from the gap's middle. The median went up and the best run went down, from 2,025 to 1,578. I kept it anyway: the old rule assumed a position the player didn't have.
Caverns. After that, caverns caused 8 of 20 deaths, many of them early, between 236 and 414 points. Caverns were tall and dark with lone rocks, and their gaps could sit up to 260 units from the last one. A player waiting in the dark holds the bat near the middle, so cavern gaps now sit within 90–130 units of the tunnel's middle instead. Two of twenty runs passed 2,000, and a third reached 1,958.
The change I didn't make for the score
The last row came from a complaint about how the game looked. Playing it, I noticed the sonar wave often hit nothing: the camera showed open cave and no wall at all, so a ping expanded into an empty screen. I asked for a minimum share of every frame to be rock.
Measuring it first, over 58,284 frames of bot flight, 25% of frames were under 10% rock and 41% were under 15%. In caverns and rests it was worse: 62% and 63% of frames under 10%. The cause was simple once counted. The screen is 640 units tall, caverns were 920 to 980 units, and rests were 720. With the bat in the middle of a cavern, both walls were off screen.
A screen column can't show more open cave than the tunnel is tall at that point. So the tunnel is now capped at what the screen can frame with 15% to spare: 640 × 0.85, less twice the walls' 32-unit waver, which is 480. The generator enforces the cap whatever the section table asks for, and a test fails if any frame the bot flies drops below 15% wall.
After: no frame under 15%, the emptiest at 21% and the median frame at 34% rock. The median score went from 966 to 1,757 and the best from 2,674 to 4,608. Seventeen of twenty runs ended inside the hour, and none of them on rock the bot had never been shown.
I can't tell you which part of that change did it. It put more wall on screen for every ping to light, and it halved the height of the caverns, and I didn't separate the two. The deaths moved, which is the part I trust: before it, the ordinary pairs section caused 13 of 20 deaths; after it, forest led with 7 of 17.
What "the bot never changed" covers
All of the above, from 52 to 1,757. The next round added tight tunnel sections with no rocks, where the walls are the obstacle, and the bot died in 33 of 40 runs there because it aimed at the middle of the screen in open tunnel. That one I did change, so it aims at the middle of the lit tunnel ahead like a player would. Tests check that it still only sees walls that are on screen and lit. The median after that round was 2,638, but that number mixes a cave change with a bot change, so I don't count it as the cave getting fairer.
Each check got the same treatment as the bot. When the generator's seven checks over 200 generated caves all passed first time, I switched the reach rule off on purpose to make sure the test noticed. It did. A check that has never failed has not been shown to check anything.
The steepest slope a player can fly
A later version of the cave is built from pieces: straight pipes, bends that climb or dive, and reducers between three tunnel heights, 30%, 50% and 68% of the screen. A bend needs a maximum steepness. I wanted the real maximum, not a safe margin under it.
The back-of-envelope figure: flapping as fast as a thumb can, every 0.12 s, the bat climbs at most about 340 units a second. At top speed it moves forward at 258. So the steepest climb it can follow is about 340 ÷ 258 ≈ 1.3, around 53°.
Then the measurement. For each height and each steepness in steps of 0.025, the bot flew every piece followed by every other piece, at start speed and near top speed, over ten seeds. On the final set of pieces that was 1,280 flights per value. The cap is the steepest value with zero crashes, as at every step below it:
| Tunnel height | Cap | Angle | First crashes |
|---|---|---|---|
| tight (30%) | 0.675 | 34° | 3 of 1,280 at 0.700 |
| normal (50%) | 1.1 | 48° | 1 of 1,280 at 1.125 |
| large (68%) | 1.5 | 56° | 1 of 1,280 at 1.525 |
The first passes said normal topped out at 0.775 and large at 0.825. In the larger pass, every crash at those two heights was a pairing with a reducer down to a narrower tunnel, mostly "narrow, then dive". The probe made every bend steeper, including the bends in the narrower tunnel after the reducer, so crashes in that tunnel were being billed to the height the flight started in. Once only the measured height's bends were steepened, normal flew clean to 1.1 and large to 1.5.
Large beats the 1.3 estimate. My reading, which I haven't tested, is that a bend is only that steep at its middle, and a tall tunnel gives the bat room to fall behind a climb and catch up. Tight being the strictest fits the same idea from the other side: each flap bobs the bat about 62 units, and a tight tunnel has little room for that.
The edge is not clean. Tight had 3 crashes in 1,280 at 0.700, then 2 at 0.725, then 18 at 0.750. That mattered for the tests.
Testing both sides of the limit
Two tests per height. The bot must fly every pairing at the cap without a crash, which proves the cap is playable. And it must crash once the bends are steeper, which proves the cap is the limit and not something comfortably inside it.
I first wrote the second test at the cap plus one step, 0.025. It failed for tight and normal: not one crash. One step past the cap, crashes come a few per thousand flights or fewer, and the test's flights happened to miss them. The steeper side now sits at 1.25 times the cap, where crashes are reliable rather than rare. The "too steep" side of a both-sides test has to live where failure is reliable, not where it first appears, or the test is flaky. The two tests add about two minutes to the suite.
Fair is not the same as hard
With every piece at its measured limit, the bot flew 80 three-minute runs, 40 at start speed and 40 at top speed, and never died once. The score was stuck at 0 in all of them, because only rocks and moths scored and the new cave had neither. Speed rose with the score, so it never rose either.
Scoring one point per 200 units flown fixed the counter: 170 points in three minutes, with speed reaching 211 units a second by the end. The bot still survived all 40 runs. Every limit in the cave was now right, and nothing in it got harder over time. A curve needs something to climb, and setting each piece to its measured maximum doesn't give you one.
Takeaways
- Freeze the model player before tuning. If the bot changes, the numbers measure the bot.
- Record where each run died, not just its score. The median says "too hard"; the death list says which rule to read.
- When two changes land together, say so. Three of my rows are pairs, and I can't split them.
- Ask what a real player could have seen and done at the moment of death. Most of my fixes were the generator assuming time or information the player didn't have.
- Set limits at the measured maximum, and test both sides: playable at the cap, failing well past it.
- The emulator can lie about all of this. Device runs of the same build died early until I traced it to a starved emulator, not the cave.