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(theACTIVATION_RADIUS_FRACconstant). - Player is considered “in range” when
distance(player, hub) <= activation radius. The lantern state’splayerInRangeflag 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
- The fill rate is derived from
LevelConfig.illuminationSpeed:
illuminationSpeed <= 0collapses to a near-instant fill (0.01seconds).- Otherwise
fillSeconds = lerp(0.5, 5, illuminationSpeed)— speed0.5produces the default 4-second fill, speed1.0slows 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(theDRAIN_RATEconstant), clamped to 0.- Drain only runs when the lantern is unactivated and
fill > 0; an already-lit lantern is locked atactivated = trueand 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 = trueandlantern.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.onActivatefire with(hubId, hub). The hub is also returned fromtickIlluminationas 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 = 0andactivatedAt = 0, buttickIlluminationshort-circuits on them via theif (!lantern || lantern.activated) continueguard, so they never re-fill or re-fire callbacks. hubsIlluminatedstarts 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 usegetIlluminatedHubIds(system).lengthor iterateilluminationMapdirectly (matches thesetIlluminationrecount).
This lets persisted runs, debug seeds, and the playground inject lit hubs without replaying the activation flow.
Related queries
engine/world/illumination.ts also exposes read-only helpers used by spokes,
spawn pools, and HUD code:
isHubLit(system, hubId)— direct lookup inilluminationMap.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 recountshubsIlluminatedfrom the full map.