Illumination System

Per-hub lantern activation drives the core run progression. Each hub on a level carries one lantern; the player must stand inside its inner activation circle to fill a meter and permanently light the hub. Illumination is one-way — once a lantern fires, the hub stays lit for the rest of the run.

Activation circle

Every hub exposes an outer radius hub.r. The lantern’s activation circle is an inner disc centered on the hub:

  • Radius = hub.r * 0.35 (the ACTIVATION_RADIUS_FRAC constant).
  • Player is considered “in range” when distance(player, hub) <= activation radius. The lantern state’s playerInRange flag flips on each frame from this check.

Anywhere outside that inner disc counts as out of range, even if the player is still inside the hub’s broader footprint.

Fill meter

While the player is in range, the lantern’s fill value (0..1) charges toward

  1. The fill rate is derived from LevelConfig.illuminationSpeed:
  • illuminationSpeed <= 0 collapses to a near-instant fill (0.01 seconds).
  • Otherwise fillSeconds = lerp(0.5, 5, illuminationSpeed) — speed 0.5 produces the default 4-second fill, speed 1.0 slows the lantern to ~5 seconds.
  • Per-frame charge: fill += (1 / fillSeconds) * dt, clamped to 1.

The default illuminationSpeed produces the canonical 4-second fill, matching DEFAULT_FILL_SECONDS.

Drain

If the player steps out of the activation circle before the meter fills, the lantern leaks progress instead of holding it:

  • fill -= 0.5 * dt (the DRAIN_RATE constant), clamped to 0.
  • Drain only runs when the lantern is unactivated and fill > 0; an already-lit lantern is locked at activated = true and is skipped on subsequent ticks.

Stepping back into the circle resumes charging from whatever fill remains.

Activation event

When fill reaches 1 inside tickIllumination, the hub flips permanently:

  • lantern.activated = true and lantern.activatedAt = time (used by VFX for transition timing).
  • system.illuminationMap.set(hub.id, true) — the canonical run state shared with the chunk manager and spawn logic.
  • system.hubsIlluminated++ — the run’s progression counter.
  • All callbacks in system.onActivate fire with (hubId, hub). The hub is also returned from tickIllumination as the “just activated” hub for the frame so the caller can trigger one-shot cascade effects (spawn-pool swaps, audio stingers, screen VFX).

Only one hub can be reported as justActivated per frame, because the activation circles are disjoint inner discs.

Pre-seeded illumination

createIlluminationSystem walks every hub in levelData.generation.hubs and seeds each lantern from levelData.illuminationMap:

  • activated = levelData.illuminationMap.get(hub.id) === true.
  • Pre-lit hubs start with fill = 0 and activatedAt = 0, but tickIllumination short-circuits on them via the if (!lantern || lantern.activated) continue guard, so they never re-fill or re-fire callbacks.
  • hubsIlluminated starts at 0 even when pre-lit hubs exist; the counter only ticks up for in-run activations. Callers that need the true lit count should use getIlluminatedHubIds(system).length or iterate illuminationMap directly (matches the setIllumination recount).

This lets persisted runs, debug seeds, and the playground inject lit hubs without replaying the activation flow.

engine/world/illumination.ts also exposes read-only helpers used by spokes, spawn pools, and HUD code:

  • isHubLit(system, hubId) — direct lookup in illuminationMap.
  • isSpokeLit(system, spoke, hubs) — true if either endpoint hub is lit.
  • getLanternFill(system, hubId) — current fill 0..1 for HUD rings.
  • isPlayerInLanternRange(system, hubId) — proximity flag for prompts.
  • getIlluminatedHubIds(system) — for spawn-pool queries.
  • getConnectedSpokes(hubIdx, spokes) — graph helper for cascade VFX.
  • setIllumination(system, hubId, lit, time) — playground / debug override that also recounts hubsIlluminated from the full map.