PURPOSE
Hub-centric event placement plus a level-wide jittered-grid scatter pass. Seeds events deterministically into world.events at run start (once per hub, once per level), and runs a per-frame chunk-tracking tick that maintains spawn-eligibility metadata for an off-screen torus around the player. Events are never garbage-collected — they persist until completed or failed. Dynamic crate spawning has been removed; the legacy crate hook is preserved as a no-op so future per-chunk systems can re-use the eligibility cadence.
OWNS
EventSpawnermodule-singleton (object literal withinit,seedHubs,tick).- Tuning constants:
SC(800 super-chunk),SPAWN_RADIUS(1500),SPAWN_MIN_RADIUS(600),REVISIT_COOLDOWN(3s),MIN_DIST_FROM_ORIGIN(500),MIN_EVENT_SEPARATION(500),SCATTER_CELL(600),SCATTER_ATTEMPT_CHANCE(0.75),SCATTER_TERRAIN_PAD(40). ChunkMetainterface (lastSeen,lastSpawn,evaluated).- Module state:
chunksmap keyed"scX_scY",eventPool(defaults to['magnet','weapon']),densityMult(default1.0),_seededHubsset keyed by hub id. - Internal helpers:
isTooClose,isInsideTerrain,clearTerrainAround(closures insideseedHubs),_chunkCenterMark(no-op placeholder).
READS FROM
WorldState:seed,hubs(legacy fallback),events(separation checks),terrain(overlap + clearance),levelRadius(via cast, default2500),biomeId(telemetry only),_levelData(via cast — preferred hub source fromLevelData.generation.hubs).ShipState:x,yfor spawn-torus origin.GameState:timefor chunk timestamps,tracking.lowestHpPercentto gate theregenevent type.camera(x,y,zoom) andW,Hfromcore/statefor viewport bounds.countOwnedArtifacts()from./artifactsto gate theartifactevent type.createEventandEventTypefrom./events.buildTerrainChunksfrom./generation.mulberry32from../core/utils.LevelDatatype from./chunk-manager.
PUSHES TO
world.events— appendscreateEvent(...)results duringseedHubs(main per hub, extras per hub, scatter pass).world.terrain— splices out pieces whose bounding disc overlaps a placed event (radius + 80px), viaclearTerrainAround.buildTerrainChunks(world)— invoked once at the end ofseedHubswhen any terrain was removed, to refresh the chunk-grid spatial index.- Sentry breadcrumbs:
[EventSpawner] init,[EventSpawner] seedHubs — no hubs, scatter-only,[EventSpawner] seedHubs placed(alllevel: 'info', taggedsystem: 'event-spawner'). - Internal
chunksmap mutated duringtick(entries created on first sight,lastSeen/lastSpawn/evaluatedupdated). - Internal
_seededHubsset,eventPool,densityMult(set byinit).
DOES NOT
- Does not spawn crates. The
ticktorus pass marks chunks eligible but the spawn branch is avoid _chunkCenterMark(...)no-op;world/crates.tsowns the new crate lifecycle. - Does not garbage-collect events. The header comment is explicit: events persist until completed.
- Does not garbage-collect chunk metadata. The
chunksmap grows for the lifetime of the run;initis the only path that clears it. - Does not spawn lantern / illumination events — that event type was removed; main hub events render as normal dotted-circle events.
- Does not reposition hub centers. Terrain yields to the event; the event never moves to dodge terrain.
- Does not retry the main hub event on
isTooCloserejection — it is skipped silently. - Does not pick from the raw
eventPoolat seed time.artifactis stripped when the player owns fewer than 2 upgradeable artifacts;regenis stripped until the ship has taken hull damage (tracking.lowestHpPercent < 1). If filtering empties the pool, it falls back to['weapon']. - Does not enforce a hard cap on weapon or artifact events. Comment notes both spawn at their natural pool rate (12%/12%).
- Does not re-seed hubs that already appear in
_seededHubs. Re-entering a hub after completing its events will not respawn them. - Does not run on a fixed tick interval.
tickis throttled by the caller (bridge) to 200px grid crossings, matchingexpandTerrain.
Signals
Sentry.captureMessage('[EventSpawner] init', ...)— fires oninit, includespoolRequested,poolActive,densityMult.Sentry.captureMessage('[EventSpawner] seedHubs — no hubs, scatter-only', ...)— fires when bothLevelData.generation.hubsandworld.hubsare empty, includeslevelDataPresent,levelDataHubCount,worldHubCount,biomeId,seed. Execution continues; the scatter pass runs anyway.Sentry.captureMessage('[EventSpawner] seedHubs placed', ...)— fires at the end of everyseedHubscall, includeshubsIterated,skippedAlreadySeeded,mainPlaced,extrasPlaced,scatterPlaced,eventsBefore,eventsAfter,densityMult,eventPool,biomeId,seed, plus level-data presence diagnostics.
Entry points
EventSpawner.init(pool: EventType[], density?: number)— call frominitRunbefore the first tick. Clearschunksand_seededHubs, replaceseventPool(empty input falls back to['magnet','weapon']), setsdensityMult(default1.0).EventSpawner.seedHubs(world, ship?, game?)— call once afterWorldGenerator.generateandEventSpawner.init, and again whenever level data changes (biome swap, sandbox config change). Idempotent per hub via_seededHubs.EventSpawner.tick(world, ship, game)— call per frame from bridge alongsideexpandTerrain, on the 200px grid-crossing throttle.
Pattern notes
- Placement: hub center + ring extras + level scatter. Each hub contributes one main event at hub center (
pickType(rng), separation-checked, terrain cleared) andfloor(densityMult) + P(densityMult - floor)extras placed at anglerng() * 2πand radiushub.r * (0.4 + rng() * 0.5). Extras retry up to 6 angles before giving up — they are not forced into terrain. Hub source preference:world._levelData.generation.hubs(new path) →world.hubswithradius ?? r ?? 300and syntheticlegacy_${i}ids (legacy biome fallback). - Scatter pass. Jittered grid over the level disc using
SCATTER_CELL(600px). Cell count =ceil((levelRadius * 2) / SCATTER_CELL)withlevelRadiusdefaulting to 2500. Per cell: skip with1 - SCATTER_ATTEMPT_CHANCE(25%); jitter the cell center by ±35% of cell size on each axis; reject if insideMIN_DIST_FROM_ORIGIN, outsidelevelRadius * 0.95, withinMIN_EVENT_SEPARATIONof an existing event, or inside a terrain bounding disc (withSCATTER_TERRAIN_PAD). - Determinism. All randomness inside
seedHubsruns from a singlemulberry32stream seeded(world.seed || 42) ^ 0x12345678. Same world seed + same pool filtering = same placements. - Pool filtering at seed time, not init time.
initstores the raw pool;seedHubsrecomputeseffectivePooleach call so re-entering a level after taking damage or earning an artifact unlocksregen/artifacttypes for new placements. - Terrain clearance. Main hub events: terrain yields unconditionally (
clearTerrainAround(hub.x, hub.y, mainEv.radius)witheventRadius + 80clear radius). Extras: pre-checked withisInsideTerrain, thenclearTerrainAroundruns as a belt-and-suspenders pass for rim overlap. Scatter: same as extras. AterrainModifiedflag short-circuits thebuildTerrainChunks(world)rebuild — it only runs if at least one piece was removed. - Spawn cadence (tick). Per-frame steps: mark on-screen chunks
lastSeen = now; scan chunks within the[SPAWN_MIN_RADIUS, SPAWN_RADIUS]torus that are off-screen and outsideMIN_DIST_FROM_ORIGIN; if previously evaluated, requirenow - lastSeen >= REVISIT_COOLDOWN; requirenow - lastSpawn >= REVISIT_COOLDOWNiflastSpawn > 0; markevaluated = true, setlastSpawn = now, and invoke the no-op_chunkCenterMark. The off-screen test is an AABB overlap against viewport bounds derived fromcameraandW/Hscaled bycamera.zoom. - Decay GC. Events: none — they live until completed/failed. Crates: not handled here (delegated to
world/crates.ts). Chunk metadata: not collected —chunksmap only resets oninit.REVISIT_COOLDOWN(3s) is not a GC interval; it is a re-spawn lockout preventing players from farming a small area by circling. - Telemetry-only fields. Sentry payloads include
biomeId(cast read) andseedfor cross-referencing placements with bug reports; these are not consumed elsewhere in the module. - Inline version stamps. Comments tagged
v5.156mark the terrain-clearance changes (hub-center clearance, extras retry loop, scatter rim clearance, post-passbuildTerrainChunksrebuild). Useful when grepping for the migration that introduced this behavior.