How to design a new event
A sequential recipe for adding a new world event to Starship Survivors. Events are timed charge-up beacons scattered across the map. The player stands inside the event’s radius to fill a charge meter; on completion a reward dispatch fires. Every existing event type shares the same charge state machine — the new event you author is data plus a reward branch, not a new mechanic.
Before you start
Events are not enemies, props, or pickups. They are a third spawnable class with their own lifecycle (idle → active → completing → done | failed) driven by EventManager.update() and placed at world generation by EventSpawner.seedHubs().
Read this table first; the answers decide the shape of the work.
| Question | If yes | If no |
|---|---|---|
| Does your new reward only need to fire one signal handler? | Data-only — add the type to EventType and a reward branch in the bridge.ts completed-events loop. | You may need a new primitive call (e.g. a new VFX kit, a new world-state list). Plan it before step 4. |
| Should the event appear at every hub center, or scatter between hubs? | Hub-seed mode places one at each hub center (illuminates the hub on completion). Scatter mode distributes copies via the jittered grid pass. Both modes share the same factory. | If neither — see step 5 (trigger-script spawn). |
| Should the reward only apply to artifact owners (e.g. Datacore / Bacta Tank)? | Fire the event_complete signal and let the artifact effect graph pick it up. No bespoke wiring needed. | Add a direct reward branch in bridge.ts alongside the existing types (magnet, levelup, weapon, etc.). |
| Does the event spawn enemies, props, or a hazard field? | Combat-stakes pattern — model after horde (4 elites in a ring) or comet_shower (5 props in a ring). | Passive-reward pattern — model after magnet, levelup, forager. |
Data-only with reused VFX is the default path. New visual primitives are a separate task.
Step 1: Identity
Fill this table on paper before you write code. Every cell decides one field downstream.
| Slot | Where it lands | Notes |
|---|---|---|
| Event id string | EventType union in events.ts | Lowercase snake, e.g. pulse, vortex, comet_shower. |
| Charge duration | chargeTime on each instance | Set by the global CHARGE_TIME (CFG EVENT_CHARGE_TIME × 1.5). Per-type override not currently supported. |
| Radius bucket | radiusForEventType() switch in events.ts | Three buckets only — see Step 1 table below. |
| Reward dispatch | A branch in the for (const cev of completedEvents) loop in bridge.ts | One cev.type === '<id>' branch; reuse a primitive. |
| Pool weight | buildDefaultEventPool() in events.ts | Push N copies of the id into the pool array. Drop rate = N / total. |
| Audio cue | EVENT_AUDIO_CUE map (read by Juice.fire) | Optional. Falls back to event_done if unmapped. |
| Completion burst tint | EVENT_VFX_TINT map | Optional. No tint = no burst (e.g. wheel suppresses burst because it opens an overlay). |
| Camera shake | EVENT_SHAKE map | Optional. Only big-payoff types punch the camera. |
| Decay rule | Permanent for hub-seeded, removed-after-flash for non-hub | Decided by EventSpawner (hubId tagging), not by the type definition. |
| Biome gating | eventPool argument to EventSpawner.init() | Each biome’s level data passes a curated pool. Omit the id to lock it out of that biome. |
| Level-script gating | LevelTrigger with type: 'event' | For scripted spawns at fixed coords — separate path from the random pool. |
Radius buckets
The radius is fixed by event type via radiusForEventType() in events.ts.
| Bucket | Radius (px) | Used by | Intent |
|---|---|---|---|
| Tight | 110 | starpower, regen | Rare sub-event — tight ring reads as potent and contained. |
| Small | 130 | levelup | The common filler (~38% of pool). Small so screens don’t fill with rings. |
| Large | 220 | magnet, weapon, artifact, wheel, cascade, forager, horde, comet_shower, pulse, vortex | Premium reward / combat sub-events that need a visible footprint. |
Pool weight reference
The default pool is built by buildDefaultEventPool() as a 71-entry array of duplicates. Each event’s drop rate = (count / 71).
| Type | Count | Rate |
|---|---|---|
levelup | 27 | 38.0% |
magnet | 10 | 14.1% |
weapon | 6 | 8.5% |
regen | 5 | 7.0% |
artifact | 4 | 5.6% |
forager | 4 | 5.6% |
horde | 3 | 4.2% |
comet_shower | 3 | 4.2% |
pulse | 3 | 4.2% |
vortex | 3 | 4.2% |
starpower | 2 | 3.1% |
cascade | 2 | 3.1% |
Two filters strip types at seed time:
artifactis removed unless the player owns ≥ 2 upgradeable artifacts.regenis removed until the run has registered hull damage (lowestHpPercent < 1).
If your event has a similar prerequisite, add the filter inside EventSpawner.seedHubs() alongside those two.
Lifecycle constants
| Field | Value | Source |
|---|---|---|
| Charge time | CFG.EVENT_CHARGE_TIME × 1.5 (≈6s) | CHARGE_TIME in events.ts |
| Charge acceleration | +10% speed per second continuously inside | _updateCharge in events.ts |
| Drain rate (outside zone) | 0.15× per second | _updateCharge |
| Exit grace period | 8 seconds | exitTimeMax |
| Exit warning lead time | 5 seconds before failure | _exitWarning |
| Decay range (untouched) | 25–40 seconds | EVENT_DECAY_RANGE (factory only; actual GC is distance-based via EventSpawner) |
| Completion flash | 2.0s | set on ev.completeFlash at done |
| Failure flash | 1.0s | set on ev.completeFlash at failed |
| Spark VFX threshold | charge fraction > 0.1 | _updateCharge |
Hub-tagged events (ev.hubId set) never fail and never despawn — they revert to idle on exit and persist as a permanent beacon after completion.
Step 2: Hub events vs scatter events
EventSpawner.seedHubs() places events in three passes during world generation. The decision of where an event lands is not part of the event definition — every type can spawn in any pass. Pick the placement intent by registering the event in the right biome pool.
| Pass | Per-hub count | Position | Lifetime |
|---|---|---|---|
| Hub center | 1 | Exactly at hub.x, hub.y | If the event is tagged with a hubId, it is permanent — completion illuminates the hub. |
| Hub extras | floor(densityMult) + P(frac) (default densityMult = 1.0) | Random angle, 40%–90% of hub.r from center | Normal — removed on completion or failure after flash. |
| Level-wide scatter | One per jittered grid cell, gated by SCATTER_ATTEMPT_CHANCE | Jittered grid covering the level disc, outside MIN_DIST_FROM_ORIGIN, inside 95% of levelRadius | Normal. |
Constants used by the placement passes:
| Constant | Value (px) | Purpose |
|---|---|---|
SCATTER_CELL | 600 | Grid cell size for the scatter pass. Smaller = denser coverage. |
SCATTER_ATTEMPT_CHANCE | 0.75 | Probability that a cell attempts a placement at all. |
SCATTER_TERRAIN_PAD | 40 | Buffer when rejecting placements inside terrain. |
MIN_EVENT_SEPARATION | 500 | Minimum centre-to-centre distance between any two events. |
MIN_DIST_FROM_ORIGIN | 500 | Keeps the starting area clear. |
SPAWN_MIN_RADIUS | 600 | Inner edge of the per-tick spawn torus (event-spawner only spawns torus events from this radius outward). |
SPAWN_RADIUS | 1500 | Outer edge of the spawn torus. |
REVISIT_COOLDOWN | 3 (seconds) | Stops circle-farming a single chunk. |
Hub-center events also call clearTerrainAround(x, y, eventRadius) to remove any terrain whose bounding disc overlaps the event zone (with an 80px buffer). After that pass, if any terrain was removed, the spawner rebuilds the terrain chunk index. New event types inherit this automatically because the clearing is keyed off radius, not type.
Each hub is seeded exactly once per run — completed events never re-spawn on hub revisit. The seeded-hub ledger is _seededHubs.
Step 3: Reward dispatch
When EventManager.update() returns a completed event, bridge.ts runs three universal beats and then a type-specific branch.
Universal beats (run for every completed event):
| Beat | Source | Notes |
|---|---|---|
| Audio cue | EVENT_AUDIO_CUE[cev.type] via Juice.fire; falls back to event_done | One sound; doesn’t block the visual. |
| Completion burst | EVENT_VFX_TINT[cev.type] via Particles.burst (count, kind, RGB, speed) | Tintless types get no burst. |
| Camera shake | EVENT_SHAKE[cev.type] via Camera.shake(amp, dur) | Optional. Only big payoffs punch. |
event_complete signal | Sig.fire('event_complete', 0, 0, cev.x, cev.y, cev.type) | Always fires. Passes event centre in num1/num2 so signal-centered artifact effects can place their hitbox at the event location (Tick 72 contract). |
Sandbox runs (runDef.sandbox === true) stop here — events still play VFX and audio but skip the reward branch entirely. This is intentional: Nate walks through events during ship-playground layout testing without artifact picks firing.
Existing reward branches
This is the load-bearing reference. Your new event registers a branch here.
| Event type | Primitive called | What the player receives |
|---|---|---|
magnet | xpOrbs.triggerGlobalMagnet() | Map-wide XP vacuum; every orb flips to exponential lock-on. |
levelup | Direct mutation of game.xp + LevelingSystem.update() | Instant +1 level, fractional progress preserved within the new level band. |
weapon | Pushes a weapon box at cev.x, cev.y (radius: 15) | Existing weapon-chest collect flow handles reward selection on pickup. |
artifact | Queues { type: 'artifact_box' } on game.rewardQueue | Upgrade-only: gated by countOwnedArtifacts() >= 2 and canRollArtifactUpgrade(); both fail → drops a weapon chest instead. |
wheel | Initialises game._wheelState, sets game.timeDilation = 0, low-passes music | Opens the Wheel of Fortune overlay; sim freezes until exit. |
starpower | applyExclusiveState(ship, 'starpower', 15) | 15 seconds of star-power state; stacks with other star-power timers. |
regen | Pushes a regen station onto world.regenStations (radius = event radius, 30s timer) | 30s healing zone; 1% HP/sec while inside (30% max if parked the full duration). |
cascade | getPropPool().triggerSupplyPodCascade(cev.x, cev.y) | 5-prop ring (2 Scrap, 1 Mineral, 1 Comet, 1 Magnetar by default). |
forager | spawnXPOrbs(world, { x, y, xp: 12 }, 8, game) | Ring of 8 XP orbs at the event centre. |
horde | 4 × GameMaster.spawnEnemy(world, ship, 'charger_rare', ...) in a 110px ring | Combat-stakes — fight through for XP + affix loot drops. |
pulse | Loops damageEnemy with 250 flat damage, source 'player', on every enemy in a 600px radius | Screen-clear smart-bomb; one-shots commons, chips elites; kills count toward streak / XP / debris. |
vortex | getPropPool().triggerMagnetarPull(cev.x, cev.y, world) | Gravity-pull cluster — yanks every enemy within 220px to the centre. No damage; the pull is the reward shape. |
comet_shower | 5 × pool.forceSpawnAt(sx, sy, 'comet_fragment') in a 110px ring | Speed-build payoff via Comet’s 35% speed-boost-on-ram synergy. |
Artifacts that listen on event_complete
| Artifact | id | Effect summary |
|---|---|---|
| Datacore | event_reward | Grants $upgrades random ship upgrades (tiered: 2, 2, 3, 4) and bumps eventSpawnMult at higher tiers. Tier 2+ also heals and grants invulnerability. |
| Bacta Tank | event_healer | Heals ($healPct), grants invulnerability ($invulnTime), and stacks a refreshing weaponDamagePct buff ($dmgBuff for $buffDuration). Tier 3+ also restores shield; tier 4 also spawns 8 crates. |
Both artifacts fire on every completed event — including hub-illumination events — because the signal payload is type-agnostic. If your new event type should NOT trigger these artifacts, you need a conditional in the artifact’s effect block, not in the event itself.
Step 4: Trigger-script events
The level-script path is separate from the random pool. LevelTrigger is a proximity primitive in engine/world/trigger-system.ts. When the player enters its radius, the trigger fires its callback.
| Field | Type | Purpose |
|---|---|---|
id | string | Unique trigger id; tracked in firedIds for one-shot enforcement. |
type | `‘dialogue' | 'spawn_wave' |
x, y | number | World-space centre. |
radius | number | Player must enter this radius to fire. |
oneShot | boolean (default true) | If false, fires on every entry. |
payload | TriggerPayload | For event triggers, set payload.eventType to the event id you want to spawn. |
For event triggers, the onEvent(trigger) callback in bridge.ts reads payload.eventType and is responsible for placing the event. Currently both onEvent registrations in bridge.ts are no-ops (onEvent() {}) — wiring a level-script-spawned event up means implementing that callback to push a createEvent(...) onto world.events at the trigger coords.
Trigger-fired events are not auto-tagged with a hubId. If you need them to behave like a hub event (permanent, illuminates something on completion), set hubId on the created event manually.
Step 5: VFX
Events ship a standard three-part VFX kit. You don’t author new shaders to add a new event type.
| Layer | Source | Authoring hook |
|---|---|---|
| Charge ring | Dotted-circle render at ev.radius, fill arc grows with charged / chargeTime | Implicit — every event gets this for free. |
| Radial fill sparks | Particles.add('spark', ...) at radius × 0.9, scaled by progress; green-gold RGB (60, 255, 180) | Threshold: charged / chargeTime > 0.1. Spawn chance = dt × 3 × pct × 0.6. |
| Completion burst | Particles.burst(ev.x, ev.y, 25, 'spark', { r: 80, g: 255, b: 120 }, 100) baseline + optional EVENT_VFX_TINT override | The baseline burst always fires from EventManager.update. The per-type burst in bridge.ts is additive — register a tint to layer reward-coloured sparks on top. |
| Exit-warning flash | _exitWarning flag, set 5s before failure | Read by the renderer; no extra wiring. |
| Failure flash | completeFlash = 1.0 on failed | Implicit. |
If your event needs a unique completion VFX, register an EVENT_VFX_TINT entry (count, RGB, particle speed; optional kind override — defaults to spark, with smoke used by cascade / comet_shower for drift / meteor-trail reads). Anything beyond the tint map is a custom primitive and lives outside the event system.
Step 6: Validate
Run this checklist after wiring. Every step is observable in-game.
- The event id appears in
EventTypeand there are no remaining type errors inevents.tsorbridge.ts. - A fresh run spawns at least one of the new event in the seeded biome (search the Sentry
[EventSpawner] seedHubs placedbreadcrumb foreventPoolcontaining the new id). - The event renders a dotted ring and charges while the player is inside.
- Charge acceleration is visible: standing still inside the ring fills faster after 5+ seconds than the first second.
- Charge drains and the 5-second exit warning fires when the player leaves an active non-hub event.
- A non-hub event fails after 8 seconds outside, plays the 1.0s failure flash, and is spliced from
world.events. - Hub events do not fail — they revert to
idleand the dotted ring re-fills cleanly when the player returns. - On completion the universal beats fire: audio cue, completion burst, optional camera shake,
Sig.fire('event_complete', ...). - The type-specific branch in
bridge.tsruns and produces the reward effect for the player. Sentry.captureMessage('[EventManager] first-tick snapshot', ...)shows the new event included inaliveon first tick.- Datacore and Bacta Tank (if owned) trigger on completion — confirms the signal payload is wired correctly.
- No Sentry errors during a 5-minute run with the new event in pool.
Custom-element structure rule
A new event type is data + a signal handler, not a new system.
- The lifecycle (idle → active → completing → done | failed), charge mechanic, decay, exit grace, hub-permanence, scatter placement, and three-layer VFX kit are owned by
EventManagerandEventSpawner. You do not re-implement any of them. - The new behaviour you add is two things:
- A reward branch in the
bridge.tscompleted-events loop (or, equivalently, a new artifact whose effect listens on theevent_completesignal and inspectscev.typefrom the payload). - Optional entries in the
EVENT_AUDIO_CUE,EVENT_VFX_TINT, andEVENT_SHAKEtables for sensory feedback.
- A reward branch in the
- Reward routing is by signal payload (
event_complete+cev.typein the source string), not by inline code that special-cases the new event in unrelated systems. If you find yourself addingif (cev.type === 'myevent')checks outsidebridge.ts’s completed-events loop or an artifact effect block, you are adding coupling — back out and route through the signal instead. - If your event needs a new world-state list (e.g.
regenStationsforregen), declare it once onWorldStateand tick it in its own dedicated update — not inside the event itself. The event branch inbridge.tsonly pushes the entry; ownership of the lifecycle lives in a sibling module. - Every number you introduce (radius, charge time override, pool weight, reward count, AoE radius, damage, buff duration) must trace to a named constant in the event’s branch or a sibling table. No magic values in the branch body.