Skip to content
← Blog
kotlin-multiplatformcompose-multiplatformgame-development

Procedural Adventure Level Generation in Kotlin

September 11, 2026

A procedural adventure level generator needs to do more than produce a random puzzle. It needs to introduce game modes as players progress, control difficulty, and reproduce an earlier challenge when the player retries it.

This article shows how to build that structure in Kotlin: use seeded randomness to resolve a level specification, derive mode eligibility from the level index, and reset Compose attempt state without changing the puzzle.

The implementation example is Adventure mode in PickPerfect, a color puzzle app built with Kotlin Multiplatform. Its shared Android and iOS code separates the level’s identity, its generated content, and the state of the current attempt. Retrying a level should give the player the same challenge, subject to the same generator and resolved content inputs.

Diagram showing level inputs producing a remembered specification, while retrying resets only the attempt UI.

Deterministic mode selection from the level index

The generator starts with a level index. From that index, it determines which game types are eligible, selects a type, chooses a difficulty, and generates the round’s parameters.

A tempting implementation would choose from whatever the current player has unlocked. That would make replay unstable: level 6 could draw from more modes after the player reaches level 30.

Instead, unlockedTypesAt reconstructs the unlock state at the beginning of the requested level:

fun unlockedTypesAt(index: Int): List<AdventureLevelType> {
    val highestCleared = index - 1
    return AdventureLevelType.entries.filter { type ->
        val mode = TYPE_UNLOCK_MODE.getValue(type)
        mode == null || isModeUnlockedAtProgress(mode, highestCleared)
    }
}

Mix has no unlock requirement. The other modes use fixed progress thresholds. With the current rules:

ModeEligible in Adventure starting at
MixLevel 1
MatchLevel 6
Rush timing for MatchLevel 11
Odd One OutLevel 16
GradientLevel 21
Which OneLevel 26

Eligibility does not guarantee that the next level uses the newly unlocked mode. The generator makes a seeded selection from the eligible types. Rush is represented as a timed Match variant, rather than a separate entry in the level-type enum.

Seeded randomness with SplitMix64

Adventure reuses the small SplitMix64 mixing function behind PickPerfect’s daily color. Daily challenges pass an epoch day into the helper; Adventure uses a level index, with fixed offsets for different decisions.

This is the implementation from DeterministicRandom.kt:

fun splitmix64(seed: Long): Long {
    var z = seed + -0x61c8864680b583ebL
    z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
    z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
    return z xor (z ushr 31)
}
 
fun unitFloat(seed: Long): Float =
    ((splitmix64(seed) ushr 11).toDouble() /
        (1L shl 53).toDouble()).toFloat()

The helper has no advancing random state. Calling it for a hue does not consume a value that would otherwise have been used for a timer or tile position.

Different offsets assign different inputs to different decisions. For example, the mode selector uses 200_560_490_131L; the timed-Match selector uses 179_424_673L. A new use of randomness does not shift all subsequent draws in a shared sequence.

The mode selection itself is short:

fun typeFor(index: Int): AdventureLevelType {
    val options = unlockedTypesAt(index)
    val pick = (
        unitFloat(index.toLong() + TYPE_PICK_OFFSET) * options.size
    ).toInt().coerceIn(0, options.size - 1)
    return options[pick]
}

The bounds clamp matters. The helper scales a value through Double and then converts it to Float; rounding can reach the upper endpoint. The final index must still stay inside the list.

Why keep this helper in the app instead of relying on Random(seed)? Owning the mixing arithmetic makes it part of the content implementation. Kotlin’s seeded Random documentation guarantees repeatability within the same runtime version, while explicitly allowing the algorithm to change in future versions.

That still does not make a level immutable across every future app release. Offsets, enum order, unlock thresholds, color calculations, and content parameters all remain inputs to the result.

Difficulty gates and bounded parameter curves

The first 100 Adventure levels resolve to the Easy difficulty label. Beyond that gate, another seeded decision chooses Easy, Medium, or Hard.

That does not mean every parameter stays flat for the first 100 levels. The default two-color Mix generator has its own bounded curve: over 60 level increments, its pass score moves from 65 to 88, and its hue-separation range narrows. Other generators can keep their Easy parameters at a fixed baseline.

This distinction matters when describing or changing the system. A mode’s difficulty label selects a branch; its parameters determine the actual puzzle inside that branch. Increasing a level number forever does not have to keep shrinking a timer or increasing a pass score forever.

The repository wraps the result in an AdventureLevelSpec: Mix, Match, Odd One Out, Gradient, or Which One. Each variant carries the data its screen needs, including the round’s coin reward. The UI receives a resolved specification instead of deciding how to generate its own content.

Resetting Compose attempt state with remember and key

A deterministic generator solves replay across separate visits. Compose state handles the immediate retry inside the screen.

AdventureScreen remembers the specification using the level index:

var levelIndex by remember {
    mutableStateOf(replayLevel ?: PlayerStats.adventureCurrentLevel())
}
val spec = remember(levelIndex) {
    AdventureContentRepository.resolveAdventureLevel(levelIndex)
}
var attempt by remember(levelIndex) { mutableStateOf(0) }

When an attempt fails, the result action increments attempt and switches back to the play phase. The play subtree is wrapped in key(levelIndex, attempt).

Changing that key gives the next attempt a fresh composition for its picker, tile arrangement state, and timer. The remembered specification sits outside that subtree, so it remains the source of the challenge. Advancing to another level changes levelIndex, which resolves a new specification.

This uses Compose’s distinction between remembered state and composition identity. remember retains a value while its composition remains; it is not persistent storage across process death.

The reward path is separate too. An explicit replay of a completed level calls recordAdventureReplay and awards half the round’s reward, using integer division. A successful progression attempt calls recordAdventureClear. Replaying an old puzzle does not advance the current level.

Local level generation with optional cached content

The procedural generator can create rounds from local defaults. The current app also starts AdventureContentRepository.refreshFromNetwork() from App.kt to check a manifest and content pack hosted in Supabase Storage.

These are compatible parts of the design. The repository can use cached parameters for Easy Mix levels covered by a downloaded pack. If no applicable entry is available, it uses defaultParamsFor(index). Medium and Hard Mix, Match, Odd One Out, Gradient, and Which One currently use their procedural generators directly.

The download supplies parameters; it is not a server request to create each puzzle. Gameplay does not wait for the startup refresh to finish.

There is a replay boundary here: two installations with different cached Mix parameters can resolve different content for the same index. The remembered specification keeps an active attempt stable, but leaving and reopening the level can pick up changed content. The accurate repeatability promise is the same generator and resolved inputs produce the same challenge.

If preserving old levels across content updates becomes a requirement, the design needs an explicit generator or pack version attached to that level identity. That is a next step, not something the current cache already guarantees.

Versioning and testing a procedural level generator

Once players remember “level 42,” generator changes become content changes. Reordering eligible types or changing an offset can alter an existing level even if the UI is untouched.

Useful regression checks for this design would pin representative seed outputs and level specifications, test the unlock boundaries, and confirm that a retry changes attempt state without changing the specification. Cached-content fixtures should be separate from procedural-default fixtures so those two input paths stay visible.

Adventure’s architecture gives those checks a clear place to live: generation in AdventureLevels.kt, content resolution in AdventureContentRepository.kt, unlock rules in PlayerStats.kt, and attempt state in AdventureScreen.kt.

For the player, the result is straightforward: another attempt at the puzzle they were learning. For the implementation, it comes from making level identity and attempt identity explicit.


Explore the games in PickPerfect, or read the earlier technical post about keeping Compose color-slider callbacks up to date.

← All poststudor.deviza@zarzara.app