swarm
The THREATS slice of ANVIL CROWN. Registers the `threats` system with `game.anvil` (order 30) the moment it is required; the world entrypoint requires every `/source/game/<slice>/<name>.module` at load, so dropping this folder in is the whole of joining the game.
swarm — what comes down out of the cloud
The THREATS slice of ANVIL CROWN. Registers the threats system with
game.anvil (order 30) the moment it is required; the world entrypoint
requires every /source/game/<slice>/<name>.module at load, so dropping this
folder in is the whole of joining the game.
local A = require("game.anvil")
A.threats.damageAt(x, y, radius, amount) -- the cannon killed something
A.threats.list() -- every live threat, as plain data
The five reads
Everything is a silhouette first. In a storm you often get one lightning flash to look at a shape, so what a thing does has to be legible from its outline before it is close enough to do it.
| shape | what it does | hits to kill | |
|---|---|---|---|
| SLEET | a dart, nose down, barbs swept back, white-hot tip | falls straight and fast; aims itself at you from CORE up | 1 |
| MITE | a small pale hail pellet, always in an arc of them | weaves across its own line as it falls | 1 |
| STALKER | a wide manta with one red eye | steers across the shaft onto your lateral position | 3 |
| SPITTER | a squat drum, warning band, three barrels | stops at hover height and shoots aimed bursts | 5 |
| HAILMASS | a big rock with no light anywhere on it | cannot be killed. Fly around it. | — |
The one with no hot accent is the one your gun does nothing to. That is the read, and it is why the hail is the only wholly dark archetype: rounds strike a pale spark off it and it keeps coming.
Escalation
The wave director keys off state.bandIndex and A.rules.progress(). The gap
between waves is 1.05 s ÷ (band.threat × (1 + progress × 0.55)), so SHELF
runs about one wave every 1.7 s and ANVIL about one every 0.5 s. What spawns
changes with the band too:
| band | sleet | mite | hail | stalker | spitter |
|---|---|---|---|---|---|
| SHELF | 40 | 34 | 18 | 8 | 0 |
| CORE | 30 | 22 | 14 | 22 | 12 |
| ANVIL | 26 | 16 | 10 | 26 | 22 |
Mites arrive as an arc of 3 + bandIndex; from CORE up a sleet is aimed at
where you are rather than at a lane, and in ANVIL it arrives as a volley of
three. Spitters fire two-round bursts in CORE and three in ANVIL. At most 26
threats are alive at once, so the shaft escalates in pressure rather than in
sheer count.
How the runner's cannon kills things
The flight slice owns the rounds. It reports a hit with
A.threats.damageAt(x, y, radius, amount) -> hits
Until something calls that, this slice keeps shadow rounds: it listens for
the fire event, integrates a round from the muzzle along the nose at
K.GUN.muzzleSpeed out to K.GUN.range, and resolves the hits itself. The
first damageAt call stands the shadow rounds down for three seconds and each
call renews it, so the two paths never both count a kill — and the shaft is
dangerous and killable whether or not the flight slice is loaded.
The same self-standing-down applies to damage: this slice emits hit and lets
flight own the hull, but if nothing is listening to hit it calls
A.rules.applyDamage itself, so a run can be lost on its own.
What it owns
Writes state.threatsAlive, state.kills, state.score. Fires hit (contact
and threat rounds), kill, and shake. Reads runnerX / runnerY / alive
from flight, airMul / wind / gust from storm, bandIndex / phase from
the director. Sets A.claims.threats.
Threats spawn during climb. During title and opening a thinner, harmless
trickle falls through the shaft so the screen behind a menu is alive; nothing
in it can shoot or hurt you. During crown the existing threats fall out and
no new wave starts — the fight at the top is the crown slice's.
The public surface
| call | what it does |
|---|---|
A.threats.damageAt(x, y, r, amount) | register a point of damage; returns how many threats it touched |
A.threats.list() | every live threat as { x, y, radius, kind, hp, killable } |
A.threats.count() | how many are alive |
A.threats.spawn(kind) | force one of sleet / mite / stalker / spitter / hail |
A.threats.showcase() | park one of each across the shaft, frozen, to look at the silhouettes |
A.threats.stats() | pool occupancy, rounds, sparks, shadow rounds — the numbers to watch for a leak |
How it is built
Every threat is one entity per material tier, parented to the scene's Threats
anchor, pooled and recycled — never spawned and despawned per wave, because
attaching a Model costs about thirty times what creating the entity does.
Pool entities are setTemporary(true) (so they never bake into scene.json)
and setSynced(false) (so each pilot's shaft is their own).
The meshes are built at runtime by game.threats.geo: a 2D outline extruded
toward the camera with a chamfered front face. In a 2.5D game seen from one
station, the outline is the read, and the chamfer is what stops a shape
looking like a flat card when the lightning catches it.
A rebuild — a scene reload, an edit↔play flip, a hot reload of this module —
clears every Threat… entity off the anchor before it allocates a new pool,
so a reload never stacks a fresh pool on an abandoned one.
Scoped to this part · feeds back into the world's score.