Skip to content

Level Lifecycle: Generation, Death & Reset

Up to this point we have been building terrain systems in isolation -- generate some hex data, render it, look at it with a camera. But terrain does not exist for its own sake. It exists inside a gameplay loop. Levels generate, the player fights through them, and on death or completion the terrain clears and a new one appears. The level lifecycle is the orchestrator that ties the terrain pipeline, biome system, and enemy spawning together into a coherent game.

This system answers the questions that matter for an auto-battle role-playing game (RPG): When does a level start? What happens when the player dies? What persists and what resets? How does progression work across deaths and completions?

Goal

Build the level lifecycle state machine: startLevel, completeLevel, handlePlayerDeath, and clearLevel. After this step, the game has a full loop -- generate terrain, play through it, die or complete, and transition to the next state with appropriate persistence.

Prerequisites

Key Concepts

What a level is

A level is a fixed-size hex chunk grid generated procedurally by the Wave Function Collapse (WFC) pipeline. Each level has:

  • A seed that determines its layout -- same seed always produces the same terrain
  • A level number that drives difficulty scaling, enemy types, and tier selection
  • A tier index that determines the biome and visual identity
  • A main road with a boss encounter at the end

The level number is the backbone of progression. It determines which enemies appear, how strong they are, and which depth tier's visuals apply.

The lifecycle state machine

                        +─────────────+
                        |  startLevel |
                        +──────┬──────+
                               |
                     generate terrain
                     load biome preset
                     spawn enemies
                     position player
                               |
                        +──────v──────+
                        |    Play     |  <── player auto-walks the road
                        +──────┬──────+
                               |
                    +──────────+──────────+
                    |                     |
             +──────v──────+       +──────v──────+
             | boss killed |       | player dies |
             +──────┬──────+       +──────┬──────+
                    |                     |
           +───────v───────+      +───────v───────+
           | completeLevel |      | handleDeath   |
           | unlock next   |      | new seed      |
           | transition    |      | same level #  |
           +───────┬───────+      +───────┬───────+
                   |                      |
                   v                      v
            startLevel(n+1)        startLevel(n)

Two critical design rules:

  1. The player entity is never destroyed. Experience points (XP), gold, equipment, inventory -- all persist across deaths and level transitions. Only terrain and enemies are cleared.
  2. Death randomizes the seed. Dying on level 5 regenerates level 5 with a completely different layout. The player faces the same difficulty tier but a fresh terrain arrangement.

LevelState world trait

This is the single source of truth for the current level. It lives on the world, not on an entity:

typescript
const LevelState = trait({
  seed: 0,               // random 31-bit integer
  levelNumber: 1,        // drives difficulty and tier lookup
  tierIndex: 0,          // 0 = Hollows, 1 = Verdant Deep, etc.
  chunksWide: 10,        // WFC grid width
  chunksLong: 10,        // WFC grid height
  isComplete: false,     // true after boss is defeated
  bossDefeated: false,   // flag for boss kill detection
});

Every system that needs to know about the current level reads from LevelState. The level manager functions are the only writers.

PlayerProgression world trait

Tracks what the player has unlocked across the entire session:

typescript
const PlayerProgression = trait({
  highestUnlockedLevel: 1,
  unlockedCheckpoints: [] as number[],
});

unlockedCheckpoints is a list of level numbers the player can return to. When the player completes level 5, checkpoint 6 is added. The player can then choose to replay any unlocked level from a selection screen. highestUnlockedLevel is the highest level the player has access to -- it only increases, never decreases.

Tier configuration

The tier system maps level number ranges to gameplay and visual parameters:

typescript
interface TierConfig {
  tierIndex: number;
  biomeId: string;            // which BiomeDefinition to load
  enemyTypes: string[];       // available enemy IDs for this tier
  clusterSizeRange: [number, number];  // min/max enemies per cluster
  statScaleFactor: number;    // multiplier for enemy stats
}

function getTierConfig(levelNumber: number): TierConfig

Example tier mapping:

Level rangeTierBiomeEnemy typesScale
1 - 100Hollowsrats, goblins, skeletons (3+)1.0 - 1.9
11 - 201Verdant Deepmushroom folk, vine crawlers2.0 - 2.9
21 - 302(future)(future)3.0 - 3.9

The stat scale factor increases linearly within each tier, so level 1 enemies have a 1.0x multiplier and level 10 enemies have a 1.9x multiplier. The jump to tier 1 at level 11 starts at 2.0x, providing a noticeable difficulty step that matches the biome change.

Seed derivation

The seed determines the exact terrain layout. The derivation strategy:

typescript
function deriveSeed(levelNumber: number, attemptNumber: number): number {
  // Hash level number and attempt number into a 31-bit integer
  return hash(levelNumber * 10000 + attemptNumber) & 0x7FFFFFFF;
}
  • First attempt of each level uses attemptNumber = 0, producing a deterministic seed. If checkpoints let the player replay level 5, they get the same layout they saw the first time.
  • Death increments attemptNumber, producing a completely different seed. The player faces the same difficulty but a fresh map.
  • The hash ensures even adjacent level numbers produce uncorrelated seeds.

LevelRoadPath

After the WFC pipeline generates terrain, the road path is extracted from the zone grid as a sequence of world-space waypoints:

typescript
const LevelRoadPath = trait(() => ({
  waypoints: [] as Array<{ worldX: number; worldZ: number }>,
}));

The auto-walk system reads this path to move the player along the road. The waypoints are ordered from the level entrance to the boss chamber. The first waypoint is where the player spawns; the last is where the boss sits.

IsLevelBoss

typescript
const IsLevelBoss = trait();   // tag, no data

Tagged on the boss enemy entity spawned in the final road chunk. When the combat system detects that an entity with IsLevelBoss has been destroyed (health reaches zero), it triggers completeLevel.

Implementation Steps

Step 1: Define the level traits

Add LevelState, PlayerProgression, LevelRoadPath, and IsLevelBoss to src/traits/index.ts:

typescript
export const LevelState = trait({
  seed: 0,
  levelNumber: 1,
  tierIndex: 0,
  chunksWide: 10,
  chunksLong: 10,
  isComplete: false,
  bossDefeated: false,
});

export const PlayerProgression = trait(() => ({
  highestUnlockedLevel: 1,
  unlockedCheckpoints: [1] as number[],
}));

export const LevelRoadPath = trait(() => ({
  waypoints: [] as Array<{ worldX: number; worldZ: number }>,
}));

export const IsLevelBoss = trait();

LevelState and PlayerProgression are world-level traits (set on the world, not on entities). IsLevelBoss is an entity tag. LevelRoadPath is also world-level.

Step 2: Create the tier configuration module

Create src/level/tier-config.ts:

typescript
export interface TierConfig {
  tierIndex: number;
  biomeId: string;
  enemyTypes: string[];
  clusterSizeRange: [number, number];
  statScaleFactor: number;
}

const TIERS: TierConfig[] = [
  {
    tierIndex: 0,
    biomeId: 'grassland',         // placeholder until Hollows biome exists
    enemyTypes: ['rat', 'goblin'],
    clusterSizeRange: [1, 3],
    statScaleFactor: 1.0,
  },
  {
    tierIndex: 1,
    biomeId: 'forest',            // placeholder until Verdant Deep exists
    enemyTypes: ['mushroom_folk', 'vine_crawler'],
    clusterSizeRange: [2, 4],
    statScaleFactor: 2.0,
  },
];

export function getTierConfig(levelNumber: number): TierConfig {
  const tierIndex = Math.min(
    Math.floor((levelNumber - 1) / 10),
    TIERS.length - 1,
  );
  const tier = { ...TIERS[tierIndex] };

  // Scale stats within the tier
  const positionInTier = ((levelNumber - 1) % 10) / 10;
  tier.statScaleFactor += positionInTier;

  return tier;
}

Step 3: Implement clearLevel

Create src/level/clear-level.ts. This function removes all level-specific entities and data without touching the player:

typescript
import { World } from 'koota';
import { ChunkCoord, LevelRoadPath, MapManifest } from '../traits';
import { vertexRegistry } from '../utils/vertex-registry';

export function clearLevel(world: World): void {
  // Destroy all chunk entities
  world.query(ChunkCoord).forEach((entity) => {
    entity.destroy();
  });

  // Destroy all enemy entities (when combat exists)
  // world.query(IsEnemy).forEach((entity) => entity.destroy());

  // Clear vertex data
  vertexRegistry.clear();

  // Reset the road path (guard for first call before any level has loaded)
  const roadPath = world.has(LevelRoadPath) ? world.get(LevelRoadPath) : null;
  if (roadPath) roadPath.waypoints.length = 0;

  // Reset manifest
  const manifest = world.get(MapManifest);
  manifest.authored.clear();
  manifest.ready = false;
}

The critical rule: never query for IsPlayer here. The query targets ChunkCoord and (eventually) IsEnemy -- traits that only terrain and enemy entities have. The player's XP, gold, equipment, and inventory survive because the player entity is simply never touched.

Step 4: Implement startLevel

Create src/level/start-level.ts. This is the main entry point for level transitions:

typescript
import { World } from 'koota';
import { LevelState, LevelRoadPath, IsPlayer, Transform } from '../traits';
import { loadBiomePreset } from '../biomes/registry';
import { getTierConfig } from './tier-config';
import { clearLevel } from './clear-level';
import { generateLevel } from '../wfc/generate';
import type { LevelConfig } from '../wfc/types';
import { populateLevel } from './populate-level';

/** Shared across start/complete/death — tracks retry count per level. */
let attemptNumber = 0;
export function resetAttempts(): void { attemptNumber = 0; }
export function incrementAttempts(): void { attemptNumber++; }

export function startLevel(world: World, levelNumber: number): void {
  // 1. Derive seed
  const seed = deriveSeed(levelNumber, attemptNumber);

  // 2. Look up tier config
  const tier = getTierConfig(levelNumber);

  // 3. Set LevelState
  world.set(LevelState, {
    seed,
    levelNumber,
    tierIndex: tier.tierIndex,
    chunksWide: 10,
    chunksLong: 10,
    isComplete: false,
    bossDefeated: false,
  });

  // 4. Clear previous level
  clearLevel(world);

  // 5. Load biome preset for this tier
  loadBiomePreset(tier.biomeId);

  // 6. Generate and populate terrain
  const config: LevelConfig = {
    seed,
    gridWidth: 10,
    gridHeight: 10,
    chunkRadius: 4,
    biomeId: tier.biomeId,
  };
  const level = generateLevel(config);
  populateLevel(world, level);

  // 7. Extract road path from generated level
  const waypoints = extractRoadPath(level);
  world.set(LevelRoadPath, { waypoints });

  // 8. Reposition player at start of road
  repositionPlayer(world);
}

function repositionPlayer(world: World): void {
  const player = world.queryFirst(IsPlayer, Transform);
  if (!player) return;

  const roadPath = world.get(LevelRoadPath);
  if (roadPath.waypoints.length === 0) return;

  const start = roadPath.waypoints[0];
  const pos = player.get(Transform).position;
  pos.x = start.worldX;
  pos.z = start.worldZ;
  pos.y = 0;  // terrain height at this position

  // Reset health to max, clear combat state, enable auto-walk
  // (these depend on combat traits that will exist in a later phase)
}

Notice that repositionPlayer queries for the existing player entity and moves it. It never destroys and respawns the player. This is the mechanism that preserves all player state across level transitions.

Step 5: Implement completeLevel

Create src/level/complete-level.ts:

typescript
import { World } from 'koota';
import { LevelState, PlayerProgression } from '../traits';
import { resetAttempts, startLevel } from './start-level';

export function completeLevel(world: World): void {
  const state = world.get(LevelState);
  state.isComplete = true;
  state.bossDefeated = true;

  // Unlock next level
  const prog = world.get(PlayerProgression);
  const nextLevel = state.levelNumber + 1;

  if (nextLevel > prog.highestUnlockedLevel) {
    prog.highestUnlockedLevel = nextLevel;
  }

  if (!prog.unlockedCheckpoints.includes(nextLevel)) {
    prog.unlockedCheckpoints.push(nextLevel);
  }

  // Transition to next level
  resetAttempts();
  startLevel(world, nextLevel);
}

Step 6: Implement handlePlayerDeath

Create src/level/handle-death.ts:

typescript
import { World } from 'koota';
import { LevelState } from '../traits';
import { incrementAttempts, startLevel } from './start-level';

export function handlePlayerDeath(world: World): void {
  const state = world.get(LevelState);

  // Increment attempt for a new seed
  incrementAttempts();

  // Regenerate the same level number with a different layout
  startLevel(world, state.levelNumber);
}

The key behavior: same levelNumber, different seed. The player faces the same difficulty tier and enemy types but a completely fresh terrain layout. All accumulated XP, gold, and equipment persist because startLevel never destroys the player entity.

Step 7: Wire boss defeat detection

The boss defeat hook connects the combat system to the level lifecycle. When an entity with IsLevelBoss is destroyed, call completeLevel:

typescript
// In the combat/death system (future phase):
function handleEntityDeath(world: World, entity: Entity): void {
  if (entity.has(IsLevelBoss)) {
    completeLevel(world);
  }
  entity.destroy();
}

Until the combat system exists, you can trigger completeLevel manually from a debug button.

Step 8: Wire player death detection

Similarly, when the player's health reaches zero:

typescript
// In the combat/damage system (future phase):
function applyDamage(world: World, entity: Entity, amount: number): void {
  const health = entity.get(Health);
  health.current -= amount;

  if (health.current <= 0 && entity.has(IsPlayer)) {
    handlePlayerDeath(world);
  }
}

Step 9: Add debug controls for testing

Until combat exists, add debug buttons that trigger lifecycle transitions:

typescript
// In a debug panel or Leva control
function DebugLevelControls(): JSX.Element {
  return (
    <>
      <button onClick={() => startLevel(world, 1)}>Start Level 1</button>
      <button onClick={() => handlePlayerDeath(world)}>Simulate Death</button>
      <button onClick={() => completeLevel(world)}>Simulate Complete</button>
    </>
  );
}

This lets you test the full lifecycle loop without a working combat system: start a level, see terrain, simulate death (terrain clears and regenerates with a different seed), simulate completion (next level loads with potentially different biome).

Step 10: Verify persistence across transitions

The most important thing to test is that player state survives level transitions. After calling handlePlayerDeath or completeLevel:

  • The player entity still exists (world.queryFirst(IsPlayer) returns non-null)
  • The player's Transform has been updated to the new road start position
  • Any traits on the player (health, inventory, XP) retain their values from before the transition
  • Only terrain chunks have been destroyed and regenerated

Visual Checkpoint

With the level lifecycle wired up and debug controls available:

  • Start a level -- terrain generates and appears on screen with biome-appropriate textures
  • Simulate death -- the screen clears (all chunks disappear briefly), then new terrain generates with a different layout but the same visual style (same biome, same tier). The key verification: the terrain looks different from the previous run.
  • Simulate completion -- terrain clears and a new level loads. If the new level crosses a tier boundary (e.g., level 10 to 11), the biome should change visually.
  • Player persists -- after death and completion, the player entity still exists in the ECS. Any state you set on it before the transition (position, health, inventory) is still there after the transition (except position, which is explicitly repositioned).
  • Multiple deaths -- trigger death 3-4 times in a row. Each time, the terrain layout should be different (different seed). No memory leaks -- check that old chunk entities are properly destroyed and vertex registry entries are cleaned up.

If the terrain does not change on death, check the seed derivation -- attemptNumber must be incrementing and feeding into the hash. If the player entity disappears after a transition, check that clearLevel is not querying for traits that the player also has.

What's Next

The game loop works: generate, play, die, regenerate. But the terrain is still flat. Ribbon Projection wraps the flat hex grid around a 3D helical ribbon, giving the game its signature spiraling tower visual where the player descends deeper into the earth.