·7 min read

Difficulty rates a stage, progression places it

Sorting a level library easiest-to-hardest gives a campaign that is flat for a hundred stages and then a wall. What replaced the sort.

gamedevdifficultylevel-designpuzzle
The envelope, the rhythm and the tone windowsGist · IndieCoreDev/85b015318475143f35ee6df50fcf9d65

Part 4 of 10 on building a word-block puzzle engine. Part 3: rating difficulty by decisions.

Once every level has a difficulty rating, the campaign order looks solved. Sort ascending. Ship it.

I shipped that. What it produced was a campaign where all 92 Easy boards landed inside the first 138 stages and the first genuinely Tricky board did not appear until stage 133. A hundred levels of nothing happening, and then a wall.

The rule I ended up with, and the one thing worth taking from this post if you take nothing else:

Difficulty rates a stage. Progression places it. They are not the same job and the rating must not decide the position.

What a sort actually does wrong

Three things, and only the first is obvious.

No relief. After a hard board, a sort gives you a slightly harder one. There is nowhere for the player to catch their breath, so the difficulty curve is monotone and exhausting in exactly the way real level designers never make it.

Similar boards cluster. Similar boards sort together — same size, same piece count, same branching profile — so you get a run of eleven near-identical puzzles and the player learns nothing new for twenty minutes.

The easy tail piles up at the end. This is the one that catches you. Selection is never purely by rank, so the pool drifts, and any board that keeps losing has to still be asked for later. With a sort, the leftovers are all easy, and they are all at the end.

The pool, the slot, and the ask

The replacement is not a sort. It is a fill.

The library is a pool. Each slot in the campaign carries an ask — a target difficulty, a tone, and a set of constraints. The builder picks the best remaining board for that slot on difficulty, role, mechanic ramp and variety together, removes it from the pool, and moves on.

The target for a slot is a quantile of the boards still unplayed, following a designed curve across the campaign:

envelope.js Open in gist
// The envelope: what difficulty a campaign slot asks for.
//
// The central rule this implements: a level's difficulty rating must not decide
// its position. Difficulty RATES a level; progression PLACES it. The library is
// a pool, each slot asks for something, and the best level still in the pool is
// chosen on difficulty, role, ramp and variety together.
//
// Composed with the rhythm (beats.js) and the tone windows (tones.js) into one
// PROGRESSION object, so everything tunable is out of the algorithm.

// Bumping this changes WHICH level each stage number refers to. Level numbers
// are the player's saved progress, so they keep their completed count and those
// numbers now point at different levels. Only bump it alongside a pack change
// or a deliberate progress migration.
export const MODEL_VERSION = 'blocks-progression-2';

export const ENVELOPE = {
  version: MODEL_VERSION,

  // The opening levels teach and calibrate rather than challenge: small boards,
  // little deduction, strictly increasing so nothing surprises a new player.
  calibration: 6,

  // A slot's target is a QUANTILE of the levels still unplayed, and `anchor` is
  // the designed curve that quantile follows across the campaign.
  //
  // It has to be a quantile and not the minimum. Anchoring to the easiest level
  // still unplayed sounds equivalent and is not: the pool is consumed roughly
  // easiest-first, so the minimum creeps up one level at a time and the target
  // creeps with it. That is a sort wearing a rhythm, and it produced exactly
  // the failure this replaced — all 92 Easy boards inside the first 138 levels,
  // no Tricky board until level 133.
  //
  // A quantile sits above the easy tail instead of on it. The tail then gets
  // picked up later by the wave's dips and the recovery beats, which is where an
  // easy board actually belongs.
  //
  // `shape` below 1 rises fast early, which is what gets a new player out of the
  // shallow end. The counts still work out because the anchor reads what is
  // left, not what was there at the start.
  anchor: { from: 0.08, to: 0.82, shape: 1.1 },

  // Bands overlap between neighbouring regions by construction: a slot's band is
  // wide enough that consecutive slots share most of their candidates, so there
  // is no wall at any boundary.
  band: 1.5,
  bandGrowth: 0.9,
};

A quantile, not a minimum, and this distinction cost me an afternoon. Anchoring to the easiest board still in the pool sounds equivalent. It is not: the pool is consumed roughly easiest-first, so the minimum creeps up one board at a time and the target creeps with it. That is a sort wearing a rhythm, and it reproduces exactly the failure above.

A quantile sits above the easy tail rather than on it. The tail then gets picked up later by the dips in the wave and by recovery beats, which is where an easy board actually belongs — as a breather two hundred stages in, not as stage 40.

The rhythm is a twenty-beat cycle

Every slot has a tone. The cycle:

beats.js Open in gist
// The rhythm: a twenty-beat cycle, and what each slot is for.
//
// Every slot has a `tone` saying what it is for and an `aim` placing it inside
// that tone's window (see tones.js).
//
// THE ORDER MATTERS AS MUCH AS THE COUNTS. Beat 11 is a challenge and beat 12
// the milestone, because the shape wanted is escalation *into* the major
// challenge. With a comfort beat in slot 11 the milestone had to climb out of a
// dip, the anti-oscillation cap held it down, and only four of eighteen
// milestones ended up being the hardest board anywhere near them. A milestone
// easier than the level before it is not a milestone, it is a lie with a badge.

export const RHYTHM = {
  // 12 comfort, 5 challenge, 2 recovery, 1 milestone = 60 / 25 / 10 / 5.
  beats: [
    { tone: 'comfort',   aim: 0.35 },
    { tone: 'comfort',   aim: 0.55 },
    { tone: 'challenge', aim: 0.40 },
    { tone: 'comfort',   aim: 0.15 },
    { tone: 'comfort',   aim: 0.60 },
    { tone: 'challenge', aim: 0.55 },
    { tone: 'recovery',  aim: 0.45 },
    { tone: 'comfort',   aim: 0.30 },
    { tone: 'comfort',   aim: 0.70 },
    { tone: 'challenge', aim: 0.75 },
    { tone: 'comfort',   aim: 0.25 },
    { tone: 'challenge', aim: 0.90 },
    { tone: 'milestone', aim: 0.60 },
    { tone: 'recovery',  aim: 0.15 },
    { tone: 'comfort',   aim: 0.40 },
    { tone: 'comfort',   aim: 0.65 },
    { tone: 'comfort',   aim: 0.55 },
    { tone: 'comfort',   aim: 0.20 },
    { tone: 'challenge', aim: 0.85 },
    { tone: 'comfort',   aim: 0.45 },
  ],

  // The target distribution the beat plan above has to produce. Worth asserting
  // in a report rather than trusting: it is easy to retune one beat and quietly
  // move the mix.
  mix: { comfort: 0.6, challenge: 0.25, recovery: 0.1, milestone: 0.05 },
};

12 comfort, 5 challenge, 2 recovery, 1 milestone — 60 / 25 / 10 / 5.

The order matters as much as the counts. Beat 11 is a challenge and beat 12 is the milestone, because the shape you want is escalation into the major challenge. My first version had a comfort beat in slot 11, so the milestone had to climb out of a dip, the anti-oscillation cap held it down, and only four of eighteen milestones ended up being the hardest board anywhere near them. A milestone that is easier than the level before it is not a milestone, it is a lie with a badge on it.

Tone windows, and the mistake that stacks easy boards

Each tone has a window expressed relative to the anchor:

tones.js Open in gist
// Tone windows, the wave, and the speed limits.
//
// Where each tone sits relative to the anchor, in multiples of `band`. Four
// different ranges, not one band around one number.

export const TONES = {
  // GETTING THIS WRONG IS WHAT STACKS THE EASY BOARDS. A single symmetric band
  // makes an easy level *ineligible* the moment the anchor climbs past it, so
  // the whole easy tail sits untouchable through the middle of the campaign and
  // then has nowhere to go but the end. Comfort and recovery reaching properly
  // below the anchor is what keeps draining it.
  //
  // `loEnd` tapers the downward reach across the campaign. Comfort has to reach
  // a long way below the anchor early — that is the drainage — but the same
  // reach late drops a Steady board into level 325, which reads as a bug rather
  // than a breather. Early it is drainage; late it closes.
  comfort:   { lo: -2.7, loEnd: -1.6, hi:  0.1  },
  challenge: { lo: -0.05,             hi:  0.95 },
  milestone: { lo:  0.7,              hi:  1.9  },
  recovery:  { lo: -2.0, loEnd: -1.4, hi: -0.5  },
};

// The rhythm barely opens up across the campaign. Damping it early turned out to
// be the wrong protection — it flattened the first hundred levels into one long
// shallow run. Beginners need no SPIKES, not a flat curve, and `firstMilestone`
// does that on its own by treating an early milestone slot as a comfort one.
export const WAVE = { from: 1.0, to: 1.15, firstMilestone: 16 };

// How far difficulty may move between consecutive levels, which is what stops
// the 8.0 -> 3.0 -> 8.0 oscillation a naive rhythm produces.
//
// Asymmetric on purpose: dropping into a recovery level should be allowed to
// feel like a drop, while a jump upward has to be earned by the rhythm rather
// than arriving because the pool happened to be thin.
export const STEP = { up: 2.2, down: 3.4 };

// NOT BUILT, and the seams are marked rather than left to be found again.
//
// The model this follows describes a runtime engine fed by telemetry: a player
// skill model, win/loss streaks, session shaping, difficulty updated from
// observed behaviour. None of it can exist before there are players, and level
// numbers are saved progress, so the order has to be identical for everyone and
// stable across launches.
//
//   targetFor()  takes the envelope — where a skill target would blend in
//   score()      where a skill-dimension fit would be added
export const SEAMS = ['targetFor', 'score'];

Four different ranges, not one band around one number. I had one symmetric band first, and it is the direct cause of the piled-up easy tail: a symmetric band makes an easy board ineligible the moment the anchor climbs past it. The whole easy end of the library becomes untouchable through the middle of the campaign and then has nowhere to go but the end.

Comfort and recovery reaching properly below the anchor is what keeps draining it.

loEnd is the other half. Comfort has to reach a long way down early, because that is the drainage — but the same reach at stage 325 drops a Steady board into the late campaign, which reads as a bug rather than a breather. So the downward reach tapers: early it is drainage, late there is nothing down there worth showing and it closes.

Speed limits, asymmetric on purpose

STEP above — 2.2 up, 3.4 down — is how far difficulty may move between consecutive stages, and it is what stops the 8.0 → 3.0 → 8.0 oscillation a naive rhythm produces.

Asymmetric deliberately: dropping into a recovery stage should be allowed to feel like a drop, while a jump upward has to be earned by the rhythm rather than arriving because the pool happened to be thin.

Damping the beginner curve was the wrong protection

The instinct is to flatten the rhythm for new players and open it up over time. I did that, and it turned the first hundred stages into one long shallow run — the exact thing I was trying to escape.

What beginners need is not a flat curve, it is no spikes. So the wave barely opens up at all (1.0 → 1.15), and the protection is a single line instead: a milestone slot before stage 16 is treated as a comfort slot. Six calibration stages at the front are strictly increasing, small, and low on deduction, so nothing surprises anyone in the first five minutes.

Every slot records how it was filled

The part I would build first next time. When a slot cannot be filled — the band is too narrow, every candidate violates an anti-repetition window — the builder climbs a relax ladder, loosening one constraint at a time. Every step is recorded on the slot.

That turns "why is stage 214 a 3.1?" from an afternoon of print statements into reading one object. It says which constraint was dropped and what the alternatives were. There is a report script that prints the whole campaign's shape — the tone mix against target, the difficulty trace, where the ladder was used — and it is the only reason the tuning above converged at all.

The version number is a migration

MODEL_VERSION, at the top of the envelope above, is one line that is easy to miss and expensive to get wrong.

Stage numbers are the player's saved progress. The order is deterministic and seeded, so everyone gets the same campaign — but bumping this changes which board each stage number refers to. A player who completed 137 stages still has 137 completed stages, and they now point at different boards.

That is fine when the pack changes anyway. It is not fine as a casual tuning bump. Anything that changes selection has to be a deliberate decision about existing players, and having the constant sitting there with that comment on it is what makes it one.

What is not built

The model this follows describes a runtime engine fed by telemetry: a player skill model, short-term win/loss streaks, session shaping, difficulty updated from observed behaviour. None of that exists here, and it cannot until there are players.

What is built is the offline half — the envelope, the rhythm, roles, the target distribution, anti-repetition windows, seeded randomness, and the debugging record. The seams for the rest are marked in the code: the function that computes a slot's target is where a skill target would be blended in, and the scoring function is where a skill-dimension fit would be added.

Writing those two comments took a minute. Finding those two places again in six months would have taken a day.


Next: part 5, the opening layout is part of the puzzle — where the loose pieces sit when a level opens, and why it is a packing problem with a surprising objective.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.