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

  • EventSpawner module-singleton (object literal with init, 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).
  • ChunkMeta interface (lastSeen, lastSpawn, evaluated).
  • Module state: chunks map keyed "scX_scY", eventPool (defaults to ['magnet','weapon']), densityMult (default 1.0), _seededHubs set keyed by hub id.
  • Internal helpers: isTooClose, isInsideTerrain, clearTerrainAround (closures inside seedHubs), _chunkCenterMark (no-op placeholder).

READS FROM

  • WorldState: seed, hubs (legacy fallback), events (separation checks), terrain (overlap + clearance), levelRadius (via cast, default 2500), biomeId (telemetry only), _levelData (via cast — preferred hub source from LevelData.generation.hubs).
  • ShipState: x, y for spawn-torus origin.
  • GameState: time for chunk timestamps, tracking.lowestHpPercent to gate the regen event type.
  • camera (x, y, zoom) and W, H from core/state for viewport bounds.
  • countOwnedArtifacts() from ./artifacts to gate the artifact event type.
  • createEvent and EventType from ./events.
  • buildTerrainChunks from ./generation.
  • mulberry32 from ../core/utils.
  • LevelData type from ./chunk-manager.

PUSHES TO

  • world.events — appends createEvent(...) results during seedHubs (main per hub, extras per hub, scatter pass).
  • world.terrain — splices out pieces whose bounding disc overlaps a placed event (radius + 80px), via clearTerrainAround.
  • buildTerrainChunks(world) — invoked once at the end of seedHubs when any terrain was removed, to refresh the chunk-grid spatial index.
  • Sentry breadcrumbs: [EventSpawner] init, [EventSpawner] seedHubs — no hubs, scatter-only, [EventSpawner] seedHubs placed (all level: 'info', tagged system: 'event-spawner').
  • Internal chunks map mutated during tick (entries created on first sight, lastSeen / lastSpawn / evaluated updated).
  • Internal _seededHubs set, eventPool, densityMult (set by init).

DOES NOT

  • Does not spawn crates. The tick torus pass marks chunks eligible but the spawn branch is a void _chunkCenterMark(...) no-op; world/crates.ts owns 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 chunks map grows for the lifetime of the run; init is 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 isTooClose rejection — it is skipped silently.
  • Does not pick from the raw eventPool at seed time. artifact is stripped when the player owns fewer than 2 upgradeable artifacts; regen is 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. tick is throttled by the caller (bridge) to 200px grid crossings, matching expandTerrain.

Signals

  • Sentry.captureMessage('[EventSpawner] init', ...) — fires on init, includes poolRequested, poolActive, densityMult.
  • Sentry.captureMessage('[EventSpawner] seedHubs — no hubs, scatter-only', ...) — fires when both LevelData.generation.hubs and world.hubs are empty, includes levelDataPresent, levelDataHubCount, worldHubCount, biomeId, seed. Execution continues; the scatter pass runs anyway.
  • Sentry.captureMessage('[EventSpawner] seedHubs placed', ...) — fires at the end of every seedHubs call, includes hubsIterated, skippedAlreadySeeded, mainPlaced, extrasPlaced, scatterPlaced, eventsBefore, eventsAfter, densityMult, eventPool, biomeId, seed, plus level-data presence diagnostics.

Entry points

  • EventSpawner.init(pool: EventType[], density?: number) — call from initRun before the first tick. Clears chunks and _seededHubs, replaces eventPool (empty input falls back to ['magnet','weapon']), sets densityMult (default 1.0).
  • EventSpawner.seedHubs(world, ship?, game?) — call once after WorldGenerator.generate and EventSpawner.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 alongside expandTerrain, 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) and floor(densityMult) + P(densityMult - floor) extras placed at angle rng() * 2π and radius hub.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.hubs with radius ?? r ?? 300 and synthetic legacy_${i} ids (legacy biome fallback).
  • Scatter pass. Jittered grid over the level disc using SCATTER_CELL (600px). Cell count = ceil((levelRadius * 2) / SCATTER_CELL) with levelRadius defaulting to 2500. Per cell: skip with 1 - SCATTER_ATTEMPT_CHANCE (25%); jitter the cell center by ±35% of cell size on each axis; reject if inside MIN_DIST_FROM_ORIGIN, outside levelRadius * 0.95, within MIN_EVENT_SEPARATION of an existing event, or inside a terrain bounding disc (with SCATTER_TERRAIN_PAD).
  • Determinism. All randomness inside seedHubs runs from a single mulberry32 stream seeded (world.seed || 42) ^ 0x12345678. Same world seed + same pool filtering = same placements.
  • Pool filtering at seed time, not init time. init stores the raw pool; seedHubs recomputes effectivePool each call so re-entering a level after taking damage or earning an artifact unlocks regen / artifact types for new placements.
  • Terrain clearance. Main hub events: terrain yields unconditionally (clearTerrainAround(hub.x, hub.y, mainEv.radius) with eventRadius + 80 clear radius). Extras: pre-checked with isInsideTerrain, then clearTerrainAround runs as a belt-and-suspenders pass for rim overlap. Scatter: same as extras. A terrainModified flag short-circuits the buildTerrainChunks(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 outside MIN_DIST_FROM_ORIGIN; if previously evaluated, require now - lastSeen >= REVISIT_COOLDOWN; require now - lastSpawn >= REVISIT_COOLDOWN if lastSpawn > 0; mark evaluated = true, set lastSpawn = now, and invoke the no-op _chunkCenterMark. The off-screen test is an AABB overlap against viewport bounds derived from camera and W/H scaled by camera.zoom.
  • Decay GC. Events: none — they live until completed/failed. Crates: not handled here (delegated to world/crates.ts). Chunk metadata: not collected — chunks map only resets on init. 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) and seed for cross-referencing placements with bug reports; these are not consumed elsewhere in the module.
  • Inline version stamps. Comments tagged v5.156 mark the terrain-clearance changes (hub-center clearance, extras retry loop, scatter rim clearance, post-pass buildTerrainChunks rebuild). Useful when grepping for the migration that introduced this behavior.