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.

QuestionIf yesIf 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.

SlotWhere it landsNotes
Event id stringEventType union in events.tsLowercase snake, e.g. pulse, vortex, comet_shower.
Charge durationchargeTime on each instanceSet by the global CHARGE_TIME (CFG EVENT_CHARGE_TIME × 1.5). Per-type override not currently supported.
Radius bucketradiusForEventType() switch in events.tsThree buckets only — see Step 1 table below.
Reward dispatchA branch in the for (const cev of completedEvents) loop in bridge.tsOne cev.type === '<id>' branch; reuse a primitive.
Pool weightbuildDefaultEventPool() in events.tsPush N copies of the id into the pool array. Drop rate = N / total.
Audio cueEVENT_AUDIO_CUE map (read by Juice.fire)Optional. Falls back to event_done if unmapped.
Completion burst tintEVENT_VFX_TINT mapOptional. No tint = no burst (e.g. wheel suppresses burst because it opens an overlay).
Camera shakeEVENT_SHAKE mapOptional. Only big-payoff types punch the camera.
Decay rulePermanent for hub-seeded, removed-after-flash for non-hubDecided by EventSpawner (hubId tagging), not by the type definition.
Biome gatingeventPool 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 gatingLevelTrigger 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.

BucketRadius (px)Used byIntent
Tight110starpower, regenRare sub-event — tight ring reads as potent and contained.
Small130levelupThe common filler (~38% of pool). Small so screens don’t fill with rings.
Large220magnet, weapon, artifact, wheel, cascade, forager, horde, comet_shower, pulse, vortexPremium 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).

TypeCountRate
levelup2738.0%
magnet1014.1%
weapon68.5%
regen57.0%
artifact45.6%
forager45.6%
horde34.2%
comet_shower34.2%
pulse34.2%
vortex34.2%
starpower23.1%
cascade23.1%

Two filters strip types at seed time:

  • artifact is removed unless the player owns ≥ 2 upgradeable artifacts.
  • regen is 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

FieldValueSource
Charge timeCFG.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 period8 secondsexitTimeMax
Exit warning lead time5 seconds before failure_exitWarning
Decay range (untouched)25–40 secondsEVENT_DECAY_RANGE (factory only; actual GC is distance-based via EventSpawner)
Completion flash2.0sset on ev.completeFlash at done
Failure flash1.0sset on ev.completeFlash at failed
Spark VFX thresholdcharge 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.

PassPer-hub countPositionLifetime
Hub center1Exactly at hub.x, hub.yIf the event is tagged with a hubId, it is permanent — completion illuminates the hub.
Hub extrasfloor(densityMult) + P(frac) (default densityMult = 1.0)Random angle, 40%–90% of hub.r from centerNormal — removed on completion or failure after flash.
Level-wide scatterOne per jittered grid cell, gated by SCATTER_ATTEMPT_CHANCEJittered grid covering the level disc, outside MIN_DIST_FROM_ORIGIN, inside 95% of levelRadiusNormal.

Constants used by the placement passes:

ConstantValue (px)Purpose
SCATTER_CELL600Grid cell size for the scatter pass. Smaller = denser coverage.
SCATTER_ATTEMPT_CHANCE0.75Probability that a cell attempts a placement at all.
SCATTER_TERRAIN_PAD40Buffer when rejecting placements inside terrain.
MIN_EVENT_SEPARATION500Minimum centre-to-centre distance between any two events.
MIN_DIST_FROM_ORIGIN500Keeps the starting area clear.
SPAWN_MIN_RADIUS600Inner edge of the per-tick spawn torus (event-spawner only spawns torus events from this radius outward).
SPAWN_RADIUS1500Outer edge of the spawn torus.
REVISIT_COOLDOWN3 (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):

BeatSourceNotes
Audio cueEVENT_AUDIO_CUE[cev.type] via Juice.fire; falls back to event_doneOne sound; doesn’t block the visual.
Completion burstEVENT_VFX_TINT[cev.type] via Particles.burst (count, kind, RGB, speed)Tintless types get no burst.
Camera shakeEVENT_SHAKE[cev.type] via Camera.shake(amp, dur)Optional. Only big payoffs punch.
event_complete signalSig.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 typePrimitive calledWhat the player receives
magnetxpOrbs.triggerGlobalMagnet()Map-wide XP vacuum; every orb flips to exponential lock-on.
levelupDirect mutation of game.xp + LevelingSystem.update()Instant +1 level, fractional progress preserved within the new level band.
weaponPushes a weapon box at cev.x, cev.y (radius: 15)Existing weapon-chest collect flow handles reward selection on pickup.
artifactQueues { type: 'artifact_box' } on game.rewardQueueUpgrade-only: gated by countOwnedArtifacts() >= 2 and canRollArtifactUpgrade(); both fail → drops a weapon chest instead.
wheelInitialises game._wheelState, sets game.timeDilation = 0, low-passes musicOpens the Wheel of Fortune overlay; sim freezes until exit.
starpowerapplyExclusiveState(ship, 'starpower', 15)15 seconds of star-power state; stacks with other star-power timers.
regenPushes 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).
cascadegetPropPool().triggerSupplyPodCascade(cev.x, cev.y)5-prop ring (2 Scrap, 1 Mineral, 1 Comet, 1 Magnetar by default).
foragerspawnXPOrbs(world, { x, y, xp: 12 }, 8, game)Ring of 8 XP orbs at the event centre.
horde4 × GameMaster.spawnEnemy(world, ship, 'charger_rare', ...) in a 110px ringCombat-stakes — fight through for XP + affix loot drops.
pulseLoops damageEnemy with 250 flat damage, source 'player', on every enemy in a 600px radiusScreen-clear smart-bomb; one-shots commons, chips elites; kills count toward streak / XP / debris.
vortexgetPropPool().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_shower5 × pool.forceSpawnAt(sx, sy, 'comet_fragment') in a 110px ringSpeed-build payoff via Comet’s 35% speed-boost-on-ram synergy.

Artifacts that listen on event_complete

ArtifactidEffect summary
Datacoreevent_rewardGrants $upgrades random ship upgrades (tiered: 2, 2, 3, 4) and bumps eventSpawnMult at higher tiers. Tier 2+ also heals and grants invulnerability.
Bacta Tankevent_healerHeals ($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.

FieldTypePurpose
idstringUnique trigger id; tracked in firedIds for one-shot enforcement.
type`‘dialogue''spawn_wave'
x, ynumberWorld-space centre.
radiusnumberPlayer must enter this radius to fire.
oneShotboolean (default true)If false, fires on every entry.
payloadTriggerPayloadFor 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.

LayerSourceAuthoring hook
Charge ringDotted-circle render at ev.radius, fill arc grows with charged / chargeTimeImplicit — every event gets this for free.
Radial fill sparksParticles.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 burstParticles.burst(ev.x, ev.y, 25, 'spark', { r: 80, g: 255, b: 120 }, 100) baseline + optional EVENT_VFX_TINT overrideThe 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 failureRead by the renderer; no extra wiring.
Failure flashcompleteFlash = 1.0 on failedImplicit.

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 EventType and there are no remaining type errors in events.ts or bridge.ts.
  • A fresh run spawns at least one of the new event in the seeded biome (search the Sentry [EventSpawner] seedHubs placed breadcrumb for eventPool containing 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 idle and 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.ts runs and produces the reward effect for the player.
  • Sentry.captureMessage('[EventManager] first-tick snapshot', ...) shows the new event included in alive on 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 EventManager and EventSpawner. You do not re-implement any of them.
  • The new behaviour you add is two things:
    1. A reward branch in the bridge.ts completed-events loop (or, equivalently, a new artifact whose effect listens on the event_complete signal and inspects cev.type from the payload).
    2. Optional entries in the EVENT_AUDIO_CUE, EVENT_VFX_TINT, and EVENT_SHAKE tables for sensory feedback.
  • Reward routing is by signal payload (event_complete + cev.type in the source string), not by inline code that special-cases the new event in unrelated systems. If you find yourself adding if (cev.type === 'myevent') checks outside bridge.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. regenStations for regen), declare it once on WorldState and tick it in its own dedicated update — not inside the event itself. The event branch in bridge.ts only 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.