Generating RuneGrid Levels


RuneGrid levels are not hand-authored one at a time.

Instead, each level is produced from a deterministic generator that chooses a mathematical structure, selects a puzzle style, places clues, validates the result, and then carefully removes information until the puzzle becomes interesting.

The aim is not randomness for its own sake. The aim is controlled variation: levels should feel different, but still belong to the same quiet puzzle language.

This post focuses on generation. Validation deserves its own separate write-up.

A level is a plan before it is a puzzle

The generator begins by turning a level index into a LevelPlan.

private static func makePlan(for index: Int) -> LevelPlan {
    let group = groupForLevel(index)
    let ordinal = ordinalForLevel(index, groupName: group.name)
    let progress = difficultyProgress(for: index)
    let phase = phase(for: progress, ordinal: ordinal, group: group)
    let generatedTemplate = template(for: group, ordinal: ordinal, phase: phase)
    let template = openingTemplate(for: index, group: group) ?? generatedTemplate
    let visualShape: VisualWorldShape =
        visualWorldsEnabled ? visualShape(for: group, ordinal: ordinal, phase: phase) : .none

    return LevelPlan(
        ordinal: ordinal,
        group: group,
        phase: phase,
        targetDifficulty: progress,
        template: template,
        visualShape: visualShape
    )
}

This separates the idea of a level from the final grid.

The plan answers questions like:

  • which group should this level use?
  • is this an introductory, reinforcing, expanding, or challenge level?
  • which clue-placement template should shape the puzzle?
  • how far through the difficulty curve are we?

Only after those choices are made does the generator build a concrete board.

Difficulty as a slow curve

RuneGrid does not simply make each level harder than the last by removing a fixed number of clues.

Instead, difficulty progresses gradually on a logarithmic curve:

private static func difficultyProgress(for index: Int) -> Double {
    let levelNumber = max(1, index + 1)
    return min(1.0, log(Double(levelNumber) + 1.0) / log(140.0))
}

This gives early levels room to breathe.

The first levels change slowly because that is where players are still forming expectations. Later, the curve continues to increase, but without abruptly turning the game into something hostile.

This matters because the game is not just teaching rules. It is teaching pattern sensitivity.

Choosing the mathematical world

Each level is built from a group table.

Early levels are deliberately restricted:

private static let openingGroups: [GroupDefinition] = [
    Groups.cyclic4,
    Groups.cyclic4,
    Groups.klein4,
    Groups.cyclic4,
    Groups.s3
]

The opening sequence is not trying to show off the full mathematical range of the system. It is trying to establish the basic grammar of play:

  • each symbol appears once per row and column
  • identity rows and columns matter
  • patterns can be inferred
  • order can eventually matter

After that, the generator cycles through broader families:

case 31...60:
    pattern = [
        Groups.cyclic5, Groups.klein4, Groups.s3, Groups.d4,
        Groups.cyclic6, Groups.cyclic4, Groups.d4, Groups.s3,
        Groups.cyclic5, Groups.cyclic8, Groups.c4xc2
    ]

The structure underneath becomes richer over time, but the player’s surface interaction remains the same: place the correct symbol in the grid.

Templates give levels personality

A group table alone does not make an interesting puzzle.

The clue pattern matters.

RuneGrid uses templates such as:

case .identityHeavy:
    addIdentityLine(limitFraction: 0.5, minimumExtra: 3)
    add(inverseCells, limit: desired)
    add(edges, limit: desired)

case .diagonalEcho:
    add(diagonals, limit: desired)
    addIdentityLine()
    add(inverseCells, limit: desired)

case .pairGuided:
    add(pairCells, limit: desired)
    addIdentityLine()
    add(inverseCells, limit: desired)

These templates are not cosmetic. They change what kind of reasoning the player is invited to do.

An identity-heavy level says: start from the most stable reference line.

A diagonal-echo level says: look for mirrored or repeating behaviour.

A pair-guided level says: compare related cells and notice when order matters.

The player may never use those words, but the board quietly shapes that attention.

Onboarding overrides the generator

The early levels are generated, but they are not left entirely to chance.

For the first few levels, RuneGrid forces specific templates:

private static func openingTemplate(for index: Int, group: GroupDefinition) -> PuzzleTemplate? {
    guard isOnboardingLevel(index) else { return nil }

    switch index + 1 {
    case 1:
        return .identityHeavy
    case 2:
        return .generatorTrail
    case 3:
        return .inversePairs
    case 4:
        return .diagonalEcho
    case 5:
        return .pairGuided
    default:
        return nil
    }
}

This lets the game behave like a handcrafted tutorial while still using the same generation machinery as the rest of the game.

That matters philosophically. The onboarding levels are not separate teaching exercises. They are real RuneGrid levels with more deliberate scaffolding.

Determinism instead of pure randomness

RuneGrid uses a seeded generator so that a level index always produces the same result.

struct SeededGenerator {
    private var state: UInt64

    init(seed: UInt64) {
        self.state = seed == 0 ? 0x9E3779B97F4A7C15 : seed
    }

    mutating func nextUInt64() -> UInt64 {
        state = state &* 6364136223846793005 &+ 1442695040888963407
        return state
    }
}

This is important for a puzzle game.

A generated level should still be stable enough to:

  • share with other players
  • return to later
  • compare times
  • debug when something feels wrong

Procedural generation here is not a dice roll. It is more like a compact way of describing a large library of possible puzzles.

Relabelling keeps familiar structures fresh

Even when the underlying group is the same, the symbols and table positions can be relabelled.

private static func relabeledGroup(from group: GroupDefinition, seed: UInt64) -> GroupDefinition {
    let size = group.table.count
    guard size > 2 else { return group }

    var rng = SeededGenerator(seed: seed)
    var perm = Array(0..<size)
    var movable = Array(1..<size)
    rng.shuffle(&movable)

    for (offset, value) in movable.enumerated() {
        perm[offset + 1] = value
    }

    let inversePerm = inverse(of: perm)
    var symbolPerm = Array(0..<size)
    rng.shuffle(&symbolPerm)
    let newSymbols = symbolPerm.map { group.symbols[$0] }

    ...
}

The mathematical structure is preserved, but its appearance changes.

This helps avoid the feeling that a player is simply memorising a fixed table. The puzzle remains structurally related, but visually and symbolically refreshed.

Clue count depends on structure and phase

Different groups need different amounts of information to feel fair.

A small cyclic group can tolerate fewer clues than a larger or non-abelian structure. The generator accounts for this:

private static func fixedCount(for group: GroupDefinition, phase: LevelPhase, difficulty: Double) -> Int {
    let size = group.table.count
    let totalCells = size * size

    let baseline: Double
    let minimum: Double

    switch group.name {
    case "C4":
        baseline = 0.50; minimum = 0.28
    case "D4":
        baseline = 0.70; minimum = 0.46
    case "D6":
        baseline = 0.68; minimum = 0.44
    default:
        baseline = 0.62; minimum = 0.38
    }

    ...
}

This is one of the places where the generator has to behave less like a pure mathematical system and more like a puzzle designer.

The same clue density does not mean the same thing in every group.

Pruning creates the actual puzzle

The generator first creates a clue-rich level, then removes clues one by one.

private static func pruneClues(
    of level: Level,
    allowAssisted: Bool,
    protectedPositions: Set<GridPosition>? = nil,
    minimumFixedCount: Int? = nil
) -> Level {
    var fixed = fixedPositions(of: level)

    let candidates = fixed
        .filter { !($0.row == level.identityRow && $0.col == level.identityColumn) }
        .sorted { lhs, rhs in
            let l = clueRemovalPriority(for: lhs, in: level, protectedPositions: protectedPositions)
            let r = clueRemovalPriority(for: rhs, in: level, protectedPositions: protectedPositions)
            return l < r
        }

    ...
}

This is a useful inversion.

Rather than trying to place exactly the perfect sparse clue pattern from scratch, RuneGrid begins with too much information and removes clues while the puzzle remains valid.

The result tends to feel more intentional than purely random placement.

Some clues are more valuable than others

Not every clue is equally disposable.

The pruning system gives different positions different removal priorities:

let isIdentityLine = pos.row == level.identityRow || pos.col == level.identityColumn
let isInverse = value == level.identityValue && pos.row != pos.col
let isNonAbelianWitness =
    level.isNonAbelian &&
    pos.row != pos.col &&
    level.solution[pos.row][pos.col] != level.solution[pos.col][pos.row]

var score = 0
if protectedPositions.contains(pos) { score += 350 }
if isIdentityLine { score += 100 }
if isInverse { score += 100 }
if isNonAbelianWitness { score += 100 }

Identity lines, inverse relationships, and non-abelian witnesses are often pedagogically useful. They give the player a foothold into the structure.

So the generator is allowed to prune aggressively, but not blindly.

Generation attempts and fallback

A level must survive several checks before it is accepted.

The generator makes multiple attempts with different seeds:

for attempt in 0..<120 {
    let relabeled = relabeledGroup(from: plan.group, seed: baseSeed &+ UInt64(attempt) &* 3571)

    let fixed = generateFixedPositions(
        group: relabeled,
        template: plan.template,
        visualShape: plan.visualShape,
        targetCount: targetCount,
        seed: baseSeed &+ UInt64(attempt) &* 8191
    )

    ...
}

If all attempts fail, the system falls back to a simpler generation path.

That fallback is not glamorous, but it is important. Procedural systems need failure modes. Without them, rare edge cases eventually become player-facing bugs.

Titles and subtitles are procedural too

RuneGrid also generates level names from the same structural information:

private static func title(for plan: LevelPlan, levelIndex: Int) -> String {
    if plan.ordinal <= 2 || plan.ordinal.isMultiple(of: 25) {
        return "\(plan.group.familyName) \(roman(plan.ordinal))"
    }

    let descriptor = proceduralDescriptor(for: plan, levelIndex: levelIndex)
    return "\(plan.group.familyName)\(descriptor)"
}

This gives levels a sense of identity without requiring every name to be hand-authored.

Subtitles do a different job. They nudge the player toward a useful way of looking:

case ("D4", .introduce, _):
    return "Turning and flipping both matter"

case ("Q8", .challenge, _):
    return "The pairs are familiar, but the order twists"

The aim is not to explain the algebra. It is to give the player a small perceptual invitation.

Controlled surprise

The generator is trying to balance several tensions:

  • mathematical correctness
  • puzzle fairness
  • visual variation
  • gradual challenge
  • player intuition
  • replayability
  • shareability

The most important design choice is that RuneGrid does not generate arbitrary puzzles.

It generates puzzles within a constrained language.

The player should feel surprise, but not noise. Variation, but not chaos. Challenge, but not betrayal.

That is the heart of RuneGrid’s procedural level generation: not producing endless content, but producing coherent structure that can be explored through play.

The next layer is validation: how the game decides whether a generated puzzle is actually fair, solvable, and unique.