docs
Writing a generator
The LevelGenerator contract, the constraints you must respect, and how levels are scored.
A generator is one method. Given a seed, a difficulty and a set of constraints, produce a level:
export interface LevelGenerator {
/** Deterministic: same (seed, difficulty, constraints) => identical LevelData. */
generate(seed: bigint, difficulty: number /* 1..10 */, c: Constraints): LevelData
}
The seed is a bigint because it is 64 bits and floats would round it. Nothing in the contract exposes a clock or an entropy source, for the same reason agents get none: your generator is re-run by the evaluator, and if two runs disagree the level is not a level.
export interface Constraints {
maxWidthTiles: number // per-difficulty caps, published
themePool: Theme[] // the evaluator may pin themes
featureBudget: FeatureBudget
requireSecretExit: boolean
}
export interface FeatureBudget {
maxAutoscrollPct: number
maxWaterPct: number
maxForcedFlightPct: number
maxSubAreas: number // 0 forbids bonus rooms outright
}
export type Theme = 'ground' | 'athletic' | 'underground' | 'water' | 'ghost' | 'castle'
Coming in under a budget is always fine. Exceeding one is a submission error, not a scoring penalty — you get told, you fix it, you resubmit.
The one hard requirement
Every level you serve must be finishable. Playability is a gate, not a score component: if any level in the evaluated sample fails certification, the generator scores zero for that evaluation.
Certification means an agent ensemble actually plays the level to the goal. You can run the same check locally before you ever submit — the reference A* solver in @subpixel/agents is the certifier, which is why it is open source:
import { certify } from '@subpixel/agents'
const level = myGenerator.generate(42n, 6, constraints)
const result = certify(level, { seed: 1 })
if (!result.solved) throw new Error(`unfinishable at difficulty 6: ${result.reason}`)
certify() walks a weight ladder of [3, 6, 12], each rung greedier than the last, and stops at the first that reaches the goal. Use maxNodes rather than a wall-clock cap when the answer has to be reproducible.
Before certification, run the linter — it catches the structural mistakes that make a level unplayable or unfair without needing a search:
import { lintLevel } from '@subpixel/sdk'
const violations = lintLevel(level).violations
It knows about things like an entrance that kills you before you can act, and sprite density in the spawn window that the 12-slot sprite engine cannot honour.
How generators are scored
playability = certified fraction over the sample (gate: must be 1.0)
discrimination = mean over difficulties of the rank correlation between a panel of agents
of known strength and the scores they get on your levels
calibration = 1 − mean |measuredDifficulty(d) − d| / 9
variety = chunk and theme entropy (published formula, anti-degenerate)
autoScore = 1000 × playability_gate × (0.45·discrimination + 0.35·calibration + 0.20·variety)
genScore = autoScore × humanMultiplier // [0.8, 1.25], needs ≥25 ratings
Read the weights as a design brief:
- Discrimination is 45% — the highest-value thing your generator can do is make good agents and great agents score differently. A level everyone finishes and a level nobody finishes both discriminate nothing.
- Calibration is 35% — difficulty 3 should be difficulty 3. This is the term that punishes a generator whose only trick is "make it harder".
- Variety is 20% and is explicitly anti-degenerate: shipping one great level shape with the numbers shuffled is detected and scored accordingly.
- The human multiplier is bounded to [0.8, 1.25] because fun matters and brigading should not. Visitors rate a random level from a random generator; nobody chooses their own target.
The house generator is the baseline to beat
@subpixel/gen-foundry implements exactly the interface above and is entered on the leaderboard like any other entry. Its structure is a reasonable starting shape for your own:
- Rhythm — plan the level as a sequence of beats before drawing a single tile.
- Stitch — pick a theme, then lay chunks that satisfy each beat, respecting entry and exit states so consecutive chunks connect.
- Populate — spend the difficulty budget on enemies and hazards at the anchors the chunks declared.
- Decorate — cosmetics, which never change geometry.
- Certify — run the ladder; on failure, retry with an offset RNG stream, then relax.
Two implementation habits from it are worth copying:
- One RNG stream per pass. Foundry draws each pass from its own stream, so editing the decoration table leaves every level's geometry byte-identical. When you change one pass, you want to see one pass change.
- Difficulty as a knob table. A published table maps difficulty 1–10 to concrete numbers (gap widths, enemy budget, time limit) rather than scattering
if (d > 7)through the code. It is what makes calibration measurable rather than vibes.
Determinism, concretely
Your bundle may import @subpixel/engine and @subpixel/sdk, and nothing else — no Math.random, no Date. Derive everything from the seed:
// splitmix64: three lines, deterministic, no dependencies.
function rngFrom(seed: bigint) {
let s = BigInt.asUintN(64, seed)
return () => {
s = BigInt.asUintN(64, s + 0x9e3779b97f4a7c15n)
let z = s
z = BigInt.asUintN(64, (z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n)
z = BigInt.asUintN(64, (z ^ (z >> 27n)) * 0x94d049bb133111ebn)
return Number(BigInt.asUintN(32, z ^ (z >> 31n))) / 2 ** 32
}
}
Two more traps specific to generators:
- Iteration order is part of your output. A
SetorMapkeyed by object identity will iterate in insertion order, which is fine — but building that collection in an order that depends on anything unseeded is not. - A slope only creates relief inside its own tile row. The foot sensor samples the row the feet are already in, so a slope placed in the row above flat ground is invisible and the solid beside it is just a wall. Terrain goes up by a step the player jumps; slopes are carved into the surface row to make valleys or to step the surface down. This one has bitten every level author on this project at least once.
Before you submit
- Generate a few hundred levels across all five evaluated difficulties, lint them, and certify them. A 1.0 playability rate on your own sample is the entry ticket.
- Look at them. The editor at
/editorloads level JSON, so you can walk through what your generator produced and find out whether it is fun or merely legal. - Check the width and feature budgets at every difficulty, not just the one you were tuning.