Most developers place platforms where they look right. Level designers measure what the player can reach first, because an impossible level is a bug no unit test will catch.
Stage 4 of my game UpArrow was called Spike Pit: zigzag platforms going up, spikes along the edges, and a queue of Jumps to get you to the top. I finished the layout one evening, loaded it on the emulator and started climbing.
Jump. Land. Walk. Jump.
Then I reached a gap six tiles wide and fell short. I restarted and fell short again. So I opened the level file and did the math I should have done before drawing anything. A regular jump in UpArrow carries you about 4.2 tiles. I had drawn a gap of six. Nobody could beat that level, me included.
I'm an app developer. For years my screens had forms, lists and API calls, and I designed my first levels the way I would lay out a screen: by eye. The gap looked fine, and looking fine is how a screen gets approved. A level has to pass a stricter test, because the physics engine checks every gap you draw and won't make exceptions.
The fix for Spike Pit was one extra platform in the middle of the gap. The bigger fix was changing how I design every level. This post walks through that process with the real numbers and files from UpArrow, a puzzle platformer that's now on Google Play.
(If you want the story of why UpArrow became a platformer in the first place, it's in There's an Obvious Reason Your Side Project Feels Like Homework.)
Step 1: Measure the player before you draw a platform
Everything the player can do in UpArrow comes from a few constants in GameEngine.kt. The comments are mine:
const val GRAVITY = 800f // px per second, per second
const val MOVE_SPEED = 200f // px per second, walking
const val JUMP_VELOCITY = -400f // px per second, negative is up
const val DASH_SPEED = 500f // px per second, for 0.3 s
// One tile is 48 px. Double Jump uses twice JUMP_VELOCITY.
// Gravity is switched off while a Dash is running.
From those you can work out the reach of every move with school physics:
Jump height 400² / (2 × 800) = 100 px ≈ 2 tiles up
Jump air time 2 × 400 / 800 = 1.0 s
Jump while walking 200 px/s × 1.0 s = 200 px ≈ 4.2 tiles across
Double Jump height 800² / (2 × 800) = 400 px ≈ 8 tiles up
Dash 500 px/s × 0.3 s = 150 px ≈ 3 tiles, in a flat line
That little table is the spec sheet I design against now. A gap wider than about four tiles needs a Dash or a Double Jump in the queue. A ledge more than two tiles up needs a Double Jump. If neither is in the queue, the level is broken, however good it looks.
If you can't say how far your player can jump, you're guessing where the platforms go.
Step 2: Keep levels in plain text you can read and diff
Every UpArrow level is a JSON file. This is Level 1, "First Jump", exactly as it ships:
{
"id": 1, "name": "First Jump",
"width": 12, "height": 8, "tileSize": 48,
"grid": [
["#","#","#","#","#","#","#","#","#","#","#","#"],
["#",".",".",".",".",".",".",".",".",".",".","#"],
["#",".",".",".",".",".",".",".",".",".",".","#"],
["#",".",".",".",".",".",".",".",".",".",".","#"],
["#",".",".",".",".",".",".",".",".",".","P","#"],
["#",".",".",".",".",".",".",".","=","=","=","#"],
["#",".",".",".","=","=",".",".",".",".",".","#"],
["#","#","#","#","#","#","#","#","#","#","#","#"]
],
"startX": 72, "startY": 296,
"actionQueue": ["JUMP", "JUMP"],
"coins": [{"x": 216, "y": 264, "type": "SMALL"}]
}
| Character | Tile |
|---|---|
# | Wall |
. | Empty space |
= | Platform you can stand on |
^ | Spike |
P | Portal, the exit |
L / l | Laser on / off |
[ / ] | Teleport entry / exit gate |
You can read the whole level without running the game. Two platforms, two Jumps, an exit on the right. A text format also means every change to a level shows up in git diff the way a code change does, so you can review a moved platform the same way you'd review a changed line in a pull request. I didn't need a visual level editor, and for a solo project that's one less tool to build.
If you can't review a level change in a diff, nobody is really reviewing it.
Step 3: Give the player a budget, then reward spending less of it
In UpArrow the actionQueue is the move budget for the level. Walking costs nothing. Every action costs one move. The star rating compares the moves you used with that budget, which the code calls par:
object StarRating {
const val MAX_STARS = 3
fun starsFor(used: Int, par: Int): Int = when {
par <= 0 -> MAX_STARS
used * 2 <= par -> 3
used * 4 <= par * 3 -> 2
else -> 1
}
}
Use half the budget or less and you get three stars. Use up to three quarters and you get two. Level 24, The Summit, is 76 tiles tall and has 38 actions in its queue, so three stars there means reaching the top with 19 moves or fewer.
I learned why the budget matters the hard way. Early in development, some of my levels had 15 or 16 actions. With that many moves you never have to think. You can waste three Jumps going nowhere and still reach the exit, which turns a puzzle into busywork. I cut those queues hard, and the game got harder by taking things away. Later I rebuilt every level as a tall tower and the queues grew with them, so today the stars do that job. Reaching the portal is the first goal. Reaching it on half the moves is the puzzle.
A puzzle needs a way to run out.
Step 4: Beat every level yourself before you call it done
Spike Pit loaded fine, rendered fine and never crashed. It was still broken. The only test that catches that is a person playing the level from the first tile to the portal.
This is the checklist I run on every level now, on the emulator, before it goes anywhere:
- Can I reach the portal with the queue exactly as written?
- Is every gap within the reach table from Step 1, or does the queue include the Dash or Double Jump that covers it?
- Is every jump achievable, start to finish?
- Can a new player land on each platform without pixel-perfect timing? Narrow platforms at full walking speed belong in the expert tower, not the first one.
It's the same discipline as testing a pull request before you merge it, except the tester has to be you, with the controller in your hands.
If you can't beat your own level, nobody can.
Step 5: Design one level at a time, then play them back to back
Two of my worst problems were invisible as long as I looked at one level at a time.
The first was width. Level 1 was 12 tiles wide. The levels after it were 10. I never noticed because I never played them in a row, and the spacing that worked at 12 felt cramped at 10. The second was layout. When I finally played every level in order, almost all of them used the same pattern: platform on the left, platform on the right, repeat. By level 8 you could predict the next platform before it scrolled into view.
The fix for the zigzag was to give layouts their own shapes. I now mix in wide platforms of five or six tiles where the player can breathe, platforms in the center column, staircases that drift in one direction, hourglass shapes that go wide, narrow, then wide again, and lopsided floors with a wide platform on one side and a narrow one on the other.
Each level can look fine on its own. Whether the game feels varied only shows up when you play them as a set.
Step 6: If players can't predict an action, redesign the action
My first Teleport turned the player into a ghost for a short time so they could walk through special walls. I liked it because it was my own idea. Every tester did the same thing with it: activated it, then wandered around not knowing where to go or how long the ghost mode would last. The result of the action depended on what you did after pressing it, which made planning impossible in a game that is all about planning.
The replacement is a plain warp. Each teleport level has an orange exit gate that's visible on screen, and using Teleport puts you on it. The current code is short:
internal fun GameEngine.executeTeleport(): Boolean {
val exitPos = level.findTeleportOut() ?: return false
player = player.copy(
position = Vector2(exitPos.first, exitPos.second),
velocity = Vector2.ZERO,
isGrounded = false
)
return true
}
The in-game description is five words: "Warp to the orange ring." If you can't describe an action's result that briefly, players won't be able to plan around it either. (The old ghost wall character W still parses, by the way. It's just a plain wall now.)
Every action should show its result before the player spends a move on it.
Step 7: Make planning the first move of every level
In the early builds the queue played in a fixed order. If you needed a Dash and the next three actions were Jumps, you had to burn the Jumps to get to it. Then I built a popup editor that let you reorder the queue, and made it open on every level load and every restart. I annoyed myself with it within a few levels, because a retry in a platformer has to be instant.
Today the planning step is the core of the game instead of an interruption. Before each climb you get a "Pack your actions" screen with a fixed number of slots. The hint on that screen says: "Tap + in the order you'll use them. Fewer moves used = more stars." The order you tap is the exact order you'll play, so every climb starts with a decision.
Let the player plan before the clock starts, and keep anything optional out of the retry loop.
The part nobody warns you about: you will edit every level by hand, more than once
Plain JSON is easy to read and painful to change in bulk. When I fixed the width problem, I widened 23 levels from 10 tiles to 12. That meant moving every platform, every spike, every coin, every laser and every teleport gate, by hand, in 23 files. A while later I rebuilt all 24 levels as vertical towers with a scrolling camera, and did a lot of that work again.
Counting tiles by hand is also how a six-tile gap got into Spike Pit in the first place. Text files are easy to review, but they don't show you a jump arc.
The constraint I'd adopt from day one: lock the grid width and the physics constants before you build level 3. Every change to either one after that gets paid for in every level file you already have.
A realistic workflow for one new level
- Pick the one action the level introduces or the one combination it tests.
- Write the reach numbers from Step 1 where you can see them.
- Draw the grid in JSON at the locked width.
- Write the action queue as a budget, with one move for every gap or ledge that needs it.
- Play it start to finish on the emulator.
- Play it again on half the moves to confirm three stars are possible.
- Fix any gap the reach table says is too wide, then play it again.
- Play it right after the level before it and right before the level after it.
- Commit it with a message that says what changed, so the diff explains itself later.
The five habits that separate level designers from programmers
- They measure first. Jump height and reach are numbers on a sticky note before the first platform exists.
- They keep levels reviewable. A level change should be as easy to read in a diff as a code change.
- They treat moves as a budget. The tension in a puzzle comes from what the player can run out of.
- They play everything, in order. A level that passes on its own can still fail as part of the set.
- They cut their own clever ideas. A mechanic players can't predict goes, even if it took days to build.
Programmers ask whether the level loads. Level designers ask whether a player can beat it, and whether they'll want to.
Next time you draw a gap, check whether you know the number or you're eyeballing it.
Here's the game all of this went into. UpArrow has 24 levels across three towers and six actions, from Jump to Teleport, and it's free on Google Play.
Get UpArrow free on Google Play
UpArrow is my own game. It's free and contains ads: a video plays between levels unless you buy Remove ads ($2.99). In the video above, the gameplay is real footage, the narration is a synthetic voice, and the character art was made with AI (Meshy).
How do you check that a level is beatable before anyone else plays it? I'd like to know if you have a better test than playing it yourself. Leave it in the comments.

Comments