# sync
Proof that every player sees the same enemies. Each peer that is not in charge sends the host a small digest every 2 s: the active enemies' ids, kinds, spawn spots and positions, each spawner's wave counters, and how many enemies it created itself. The host compares the digest with its own state and sends the verdict back.
Read it with `SK.syncReport()`: on the host, one report per other peer; elsewhere, the host's latest verdict about this peer. Each report counts `missing`, `extra` (a duplicate, or an enemy still shown after the host put it back), `spawnSpot`, `drift` (live position more than `tolerance` m apart), `wave` and `peerSpawned` problems, with the last few as text. Tune or switch it off with `SK.syncCheck({ enabled, interval, grace, tolerance })`. The Spawner runs it every frame on every peer, and the PoolReadout shows the verdict on both screens.
# bus
The kit's event bus on each peer: exactly five events (`waveStarted`, `waveCleared`, `spawned`, `despawned`, `allWavesDone`) plus `"*"` for all of them. Games listen through `SK.on`, which is `bus.on`; the Spawner fires events on the host, and the Spawner and Enemy components fire them on the other peers from synced state, so every event fires on every peer. Listeners and counters live in `modules.state()` and survive a hot reload; `counts()` says how often each event fired and lists the last 40.
# api
The one interface to spawnerKit: `local SK = require("spawnerKit.api")`. Scene builds call `SK.addSpawnPoint`, `SK.addSpawner`, `SK.addTurret` and `SK.addReadout`; game code drives spawners with `SK.start`, `SK.stop`, `SK.reset`, `SK.nextWave` and `SK.spawn`, swaps settings with `SK.setStyle`, `SK.setEffect`, `SK.setCurve` and `SK.setWaves`, listens with `SK.on` and picks options with `SK.find`. It also exposes the curve and wave planners (`SK.curve`, `SK.planWave`, `SK.validateWaves`), the pool (`SK.poolStats`, `SK.prewarm`), custom enemies (`SK.defineEnemy`) and `SK.VERSION`. The package README's Reference lists every function.
# placement
Pure maths for where each of the ten spawn patterns puts an enemy and how it enters. `solve(pattern, ctx)` returns the position, where the entrance starts, the entrance kind (`grow`, `drop`, `rise`, `walkIn`) and its duration, plus a route for the path pattern; `entranceAt` gives the position and scale part-way through an entrance. The path pattern's route is a smooth curve: `smoothRoute(points, spacing)` builds a Catmull-Rom spline through a path's points with the engine's spline module (`@builtin::modules.spline`) and samples it about every `spacing` metres, and the Spawner draws the same curve on the ground. The Spawner gathers the context on the host (spawn point records, players, the host's camera) and calls it for every spawn, and the Enemy component plays the entrance and walks the route. `DEFAULT_EFFECT` maps each pattern to the effect `auto` picks.
# juice
The juiceKit front door: `require("juiceKit.juice")`. `play(name, target, opts)` resolves a preset into its pieces, applies the global intensity, the look and reduce motion, routes each piece by scope (player pieces stay with their player, world pieces go to every peer through `net`), and returns what ran. It also holds the preset registry (`list`, `names`, `get`, `has`, `define`, `remove`), the settings (look, intensity, reduce motion), `stopAll`, `stats` and `_receive`, the handler other peers' messages arrive at. Gameplay code and the gallery call it; the package README documents every call.
# catalog
Pure data: every spawn pattern (catalog kind `style`; `find` also takes `kind = "pattern"`), spawn effect, difficulty curve, sample enemy and look, each labelled with `kind`, `family`, `genres` and `tags`, plus the event list and the label vocabulary. The api serves it through `SK.catalog()`, `SK.find(query)` and `SK.vocabulary()`, and the Spawner, enemies and effects modules read their id lists and enemy stats from it. `find({ kind, family, genre, tag, id })` filters by any label, each key taking one value or a list.
# effects
The seven spawn effects (smoke, portal, beam, dirt, impact, sparkle, none): juiceKit presets played with `scope = "local"`, plus spinning rings, beams and dirt clods from a small local shape pool, and the telegraphs (a shadow, a dirt mound) before sky drops and burrows. The Enemy component calls `play` and `telegraph` on every peer when an enemy's synced spawn counter changes, and the Spawner calls `step` each frame. Games play one directly with `SK.playEffect` and read its counters with `SK.effectStats`.
# util
Small shared helpers for the kit: a seeded xorshift random stream (so the host's choices repeat), vector maths on plain `{x, y, z}` arrays, and safe engine queries. `owns(e)` decides which peer acts on an entity (offline: this one; an entity nobody owns: the room host), and the module also keeps the spawn point registry (`registerPoint`, `points`), `playerBodies()` and `splitList()`. Every other module and component of the kit requires it; games can reach it as `SK.util`.
# api
The one interface to SecondWind: `local HK = require("SecondWind.api")`.
- **Damage:** `damage`, `heal`, `kill`, `revive`, `explode`.
- **Configure:** `attach`, `configure`, `setModel`, `setDeathStyle`, `applyPreset`, `setRules`, `endRound`.
- **Query:** `state`, `isAlive`, `isDowned`, `config`, `get`, `lifeline`, `all`, `rules`, `ruleValues`, `nameOf`, `isPlayer`, `catalog`, `killFeed`.
- **Hooks:** `on` (events) and `setDamageFilter`.
Calls on a target this peer does not own are sent to its owner, who checks them (attacker ownership, teams, the damage cap, config permission) before applying them. The package README lists every argument.
# waves
Waves as data. `normalize` and `validate` check a wave table (a Lua table or a resolved `spawnerKit.waveTable` .data value), name every error by wave and field, and fill in defaults (delay 2, gap 0.8, order inOrder); `plan(table, n, opts)` turns wave n into a timed list of spawns (time, kind, spawn point, look) scaled by the curve. It is pure, with no engine calls, so the plan the host follows is the plan tests read. The Spawner calls it for every wave; games use it through `SK.validateWaves`, `SK.planWave` and `SK.loadWaves` (`fromData`).
# curves
Pure maths for the five difficulty curves (steady, steep, stepped, breather, flat). `at(name, wave, strength)` turns a wave number into `count`, `pace`, `speed`, `health` and `damage` multipliers plus a `rest` flag for breather waves; `series` gives a whole curve for graphs and `scaleCount` rounds a scaled enemy count up. The waves module and the Spawner call it to scale every wave and the trickle; games read it through `SK.curve` and `SK.curveSeries`.
# enemies
The enemy kinds and their looks: the five sample kinds (grunt, runner, brute, flyer, spitter) built from builtin shapes in the `toy` or `neon` look. The Enemy component calls `build(root, kind, look)` on every peer to make the look as local children of the synced enemy entity, and `setVisible` to hide it while it is pooled; the Spawner and pool read stats with `get`. Games register their own kinds, optionally with their own `build`, through `SK.defineEnemy` (`define`), on every peer.
# pool
The shared enemy pool. `acquire(kind, look)` takes a free enemy of that kind or, when none is free, creates one (a synced entity with the Enemy component and SecondWind Vitals); `release` hides it and parks it underground with its health switched off; `restore` revives and heals it to its new maximum for the next spawn. The Spawner calls it on the host for every spawn and despawn; `stats()` gives the created/reused counters the PoolReadout shows, `prewarm` fills it ahead of time, and `adopt` rebuilds its lists when a peer becomes host. Its state lives in `modules.state()`, so it survives a hot reload.
# spawnerKit 1.0.0
Enemy spawners, waves and pooling for any multiplayer game: put a Spawner on a synced entity, give it waves as a data file, and enemies arrive in one of 10 spawn patterns (the `pattern` setting) with one of 7 effects, get harder along one of 5 curves, and are reused from a pool instead of piling up.
Version **1.0.0** (`SK.VERSION`). The version, this README and `package.yaml` move together.
## Install
spawnerKit is built on **SecondWind** (health, damage, death), **Lifeline** (health bars, under SecondWind), **MatchFlow** (rounds) and **juiceKit** (effects). A package cannot declare its dependencies yet (engine issue #2612), and an install that drags them along leaves them under `/source/deps`, where your scenes cannot reach them by name. So install the kits **first**, spawnerKit **last**, every call with `at=/source`:
1. Lifeline, MatchFlow and juiceKit:
`zeromind_install guid=7b85ce05-3903-4374-b654-2d4ffea295a5,31cf13b4-df7d-4888-85e2-7947a7301614,eed55f6d-afaf-4767-83c5-539ef91e0dc5 at=/source`
2. SecondWind (it builds on Lifeline and MatchFlow, so it finds them from step 1):
`zeromind_install guid=5da0a53c-5953-4adc-80fb-f5afb02f6568 at=/source`
3. spawnerKit (it finds all four from steps 1 and 2):
`zeromind_install guid=866b48e5-deed-4e7f-84a8-94ec29134c81 at=/source`
Afterwards your world has `SecondWind.package`, `Lifeline.package`, `MatchFlow.package`, `juiceKit.package` and `spawnerKit.package` side by side in `/source`, and `require("spawnerKit.api")` and `require("SecondWind.api")` both work. Step 2 also leaves a `/source/deps` folder with older copies of a few MatchFlow modules that SecondWind pins; leave it, its errors are harmless (see [Known limits](#known-limits)).
**Installed spawnerKit first?** Then its kits went only into `/source/deps`, and installing them again without `at=/source` just reports them as already there. Delete the `/source/deps` folder, then run steps 1 and 2 above. spawnerKit itself can stay: it finds the kits in `/source` without being installed again. (Don't install the kits into `/source` while those `deps` copies are still there: two copies of the same kit make its name ambiguous and its components stop loading.)
This order was checked in brand-new worlds: the three steps, and the repair after a spawnerKit-first install, each followed by the example below running all three waves.
## Use it
Add this to your scene's `build.luau`. It makes four spawn points around an arena, a spawner that plays three waves from a data file, a turret that shoots them, the pool readout, and a player whose body has SecondWind health so the enemies can hurt it.
```lua
local SK = require("spawnerKit.api")
local HK = require("SecondWind.api")
function content()
local ground = entity.spawn("ground")
ground.component.add("Model", { model = asset.resolve("@builtin::meshes.cube", "mesh"),
material = asset.resolve("@builtin::materials.lit_checkerboard", "material") })
ground.component.add("BoxCollider", {})
ground.localPosition = { 0, -0.1, 10 }
ground.localScale = { 60, 0.2, 60 }
-- SecondWind's world rules: one per scene, on a synced entity.
local rules = entity.spawnSynced("HealthRules")
rules.component.add(HK.RULES, { preset = "arcadeShooter", playerDeathStyle = "timed" })
-- Spawn points: plain entities (only the host reads them).
local A = { 0, 0, 20 }
local dirs = { ArenaNorth = { 0, 9 }, ArenaSouth = { 0, -9 }, ArenaEast = { 9, 0 }, ArenaWest = { -9, 0 } }
for name, d in dirs do
local p = entity.spawn(name, { position = { A[1] + d[1], 0, A[3] + d[2] } })
SK.addSpawnPoint(p, { group = "Arena", kind = "portal" })
end
-- The spawner: on a SYNCED entity, playing the waves in arenaWaves.data.
local spawner = entity.spawnSynced("Arena", { position = A })
SK.addSpawner(spawner, {
pattern = "portal", effect = "auto", curve = "steady",
waves = asset.resolve("arenaWaves", "data"),
maxAlive = 10, behavior = "chase", aggroRadius = 13, prewarm = 6,
})
-- A turret in the middle of the arena that reaches every portal, and the readout.
SK.addTurret(entity.spawn("ArenaTurret", { position = A }), { range = 12, only = "Arena" })
SK.addReadout(entity.spawn("PoolReadout"), { spawner = "Arena", corner = "topLeft" })
-- The player, with a SecondWind body (as in the gallery).
local setups = entity.spawn("PlayerSetups")
local prototype = entity.spawn("Player", { parent = setups })
prototype:setParticipation("PrototypeOnly")
local body = entity.spawn("body", { parent = prototype })
body.component.add("Asset", { source = asset.resolve("@builtin::avatars.humanoid", "avatar") })
HK.attach(body, { team = "players" })
local rig = entity.spawn("CameraRig", { parent = prototype })
rig:setNetworkScope("OwnerOnly")
rig.component.add("Camera", { behavior = asset.resolve("@builtin::controller.orbital_follow", "component") })
rig.localPosition = { 0, 2.2, -4.5 }
rig.localRotation = { 0, -0.9961332, -0.0878564, 0 }
prototype.component.add("PlayerPrototype", { body = body, camera = rig })
-- The player starts inside the arena, in the turret's reach.
local spawn = entity.spawn("Start", { parent = entity.spawn("Spawns"), position = { A[1], 0, A[3] - 6 } })
spawn.component.add("PlayerSpawn", { prototype = prototype })
end
```
Then make the waves: a folder `/source/arenaWaves.data` holding this `values.yaml` (word for word the kit's own `threeWaves.data`), plus a `.metadata` with a description and tags. The point names match the spawn points above. The data leaves `curve` empty on purpose, so the Spawner's `curve = "steady"` decides how much harder each wave gets.
```yaml
contract: spawnerKit.waveTable
values:
curve: ""
loop: false
waves:
- name: Scouts
delay: 3
gap: 1.0
points: [ArenaNorth, ArenaSouth]
enemies:
- { kind: grunt, count: 4 }
- { kind: runner, count: 2, gap: 0.5 }
- name: Pincer
delay: 4
gap: 0.7
points: [ArenaEast, ArenaWest]
order: mixed
enemies:
- { kind: grunt, count: 4 }
- { kind: flyer, count: 3 }
- name: The Brute
delay: 5
gap: 0.6
points: [ArenaNorth, ArenaEast, ArenaSouth, ArenaWest]
enemies:
- { kind: runner, count: 6, gap: 0.3 }
- { kind: brute, count: 1 }
- { kind: spitter, count: 2 }
```
Press play: after 3 s the Scouts come out of the north and south portals, then the Pincer out of east and west, then the Brute out of all four. On the steady curve the three waves bring 6, 9 and 13 enemies (as written they are 6, 7 and 9; steady adds 15% a wave, rounded up per entry).
To react to what happens, listen in code that runs while the game plays, such as the scene's `entrypoint.luau` (a `build.luau` runs while you author, not in play). Events fire on every peer, so every player's screen can react:
```lua
local SK = require("spawnerKit.api")
local conns = {}
function onLoad()
table.insert(conns, SK.on("waveStarted", function(e)
print(`{e.spawnerName}: wave {e.wave} of {e.waves}, "{e.name}", {e.total} enemies`)
end))
table.insert(conns, SK.on("despawned", function(e)
if e.reason == "killed" then print(`a {e.kind} went down`) end
end))
table.insert(conns, SK.on("allWavesDone", function(e)
print(`all {e.waves} waves done in {e.seconds} s: {e.killed} killed, {e.escaped} escaped`)
end))
end
function onUnload()
for _, c in conns do c:Disconnect() end
end
```
## Swap one setting
Every choice is one field. Nothing else needs to change.
```lua
-- In the build: a different arrival, effect, curve or look.
SK.addSpawner(spawner, { pattern = "sky", effect = "impact", curve = "breather", look = "neon", waves = ... })
-- While playing (on the host, see Multiplayer):
SK.setPattern("Arena", "burrow") -- next spawn rises out of the ground
SK.setEffect("Arena", "beam") -- "auto" goes back to the pattern's own effect
SK.setCurve("Arena", "steep")
```
- A pattern change applies from the next spawn. With `effect = "auto"` the effect follows the pattern (see the Patterns table).
- `pattern` is the setting's name. `style` is its older name and still works: `style = "sky"` in `SK.addSpawner` options sets the pattern, `SK.setStyle` is `SK.setPattern`, and a pattern id written straight into the Spawner's `style` field is used instead of `pattern` (leave `style` empty to let `pattern` decide).
- **Swapping the curve changes the waves.** In the [Use it](#use-it) example, change `curve = "steady"` to `curve = "steep"` in the build and the same three waves bring 6, 10 and 17 enemies instead of 6, 9 and 13, with more health and a faster pace; `breather` gives 6, 9 and 14, and `flat` or `stepped` give 6, 7 and 9 (stepped's first step up comes at wave 4). `SK.setCurve` while playing applies from the next wave that starts. `SK.planWave(t, n, { curveOverride = "steep" })` shows a wave's plan on any curve without playing it.
- **A curve named in the wave data wins** over the Spawner's `curve` field: a wave's own `curve`, then the table's `curve`, then the field. That is why the example data (like the kit's `threeWaves.data`) leaves `curve` empty (`""`): the Spawner's `curve` field and `SK.setCurve` decide. Write a curve into the data only when that table should always use it, and then neither of them changes its waves.
- Unknown ids are refused with an error that lists the known ones.
## Catalog
Everything the kit offers is labelled. `SK.catalog()` returns it all; `SK.find(query)` filters it:
```lua
SK.find({ kind = "pattern", genre = "towerDefense" }) -- point, path, burrow, trickle, scripted
SK.find({ kind = "effect", tag = "zombies" }) -- dirt
SK.find({ kind = { "pattern", "effect" }, genre = "bulletHell" })
SK.vocabulary() -- the kinds, families per kind and genres the labels use
```
Query keys are `kind` (pattern, effect, curve, enemy, look; the entries of the pattern list carry `kind = "style"`, and `kind = "style"` finds them too), `family`, `genre`, `tag` and `id`; each takes one value or a list (any of them matches), and every key given must match. An unknown key or kind is an error. Genres: waveShooter, hordeMode, towerDefense, arena, survival, bulletHell, platformer, roguelike, coop, boss.
### Spawn patterns (`pattern`)
| id | label | family | what it does | effect on auto | settings | tags | genres |
|---|---|---|---|---|---|---|---|
| `point` | Spawn point | placed | Each enemy appears exactly on one of the spawner's spawn points, picked in turn or at random. | smoke | points, pointOrder | classic, predictable, doors | waveShooter, arena, towerDefense, hordeMode, coop |
| `area` | Random in area | placed | Each enemy appears at a random spot inside a spawn point's box (sizeX by sizeZ), so a field fills up unevenly. | sparkle | points | scattered, organic | hordeMode, survival, arena, roguelike |
| `ring` | Ring around player | aroundPlayer | Enemies appear on a circle of ringRadius metres around a player and close in from every side. | smoke | ringRadius, target | surrounded, pressure, vampire-survivors | hordeMode, survival, bulletHell, roguelike |
| `offscreen` | Just off screen | aroundPlayer | Enemies appear just beyond the edge of the target player's view and walk in, so nobody sees them pop in. | none | offscreenMargin, target | endless, seamless, never-seen-appearing | hordeMode, survival, bulletHell, roguelike |
| `path` | Along a path | route | Enemies enter at the start of a path and walk a smooth curve through the spawn points of one group, in order, to the end: a Catmull-Rom spline from the engine's spline module (`@builtin::modules.spline`), drawn on the ground on every peer (`showPath`). | none | points, pathSpread, showPath | lanes, conveyor, marching, curved | towerDefense, platformer, arena |
| `portal` | Out of a portal | entrance | A swirling portal opens on a spawn point and each enemy steps out of it, growing from nothing. | portal | points | magic, rift, gate | arena, waveShooter, boss, roguelike |
| `sky` | Drop from the sky | entrance | A shadow marks the spot, then the enemy falls from skyHeight metres and lands with an impact. | impact | skyHeight, points | drop-pod, airborne, telegraphed | arena, waveShooter, survival, coop |
| `burrow` | Burrow up from the ground | entrance | The ground bulges, dirt bursts out and the enemy rises out of the floor from burrowDepth metres down. | dirt | burrowDepth, points | zombies, underground, creepy | hordeMode, survival, arena, towerDefense |
| `trickle` | Endless trickle | timing | No waves: one enemy every 1/rate seconds, forever (or until `total`), never more than maxAlive at once. Difficulty climbs every `trickleStep` spawns. | smoke | rate, maxAlive, total, trickleStep | endless, steady-stream, no-waves | survival, hordeMode, towerDefense |
| `scripted` | Scripted wave table | timing | Every spawn is written down: in each wave's `script` list, the second it happens, the enemy and the spawn point. Nothing is random. | beam | waves | authored, exact, set-piece | waveShooter, towerDefense, boss, arena |
How they enter: `point`, `area`, `ring`, `path`, `trickle` and `scripted` grow in over 0.35 s; `portal` grows over 0.8 s; `sky` falls (for 0.85 s from the default 9 m); `burrow` rises over 1.1 s; `offscreen` simply walks in. `area`, `sky` and `burrow` pick a random spot in a spawn point's box when it has a `sizeX`/`sizeZ`. With no spawn points at all, `ring`, `trickle` and `path` fall back to a ring around a player, and the others use the spawner's own position.
### Spawn effects (`effect`)
Each effect is juiceKit presets plus, for some, a few pooled shapes of the kit's own.
| id | label | family | what it does | juiceKit presets | tags | genres |
|---|---|---|---|---|---|---|
| `smoke` | Puff of smoke | classic | A round puff of smoke and a pop-in squash. | vfxSmoke, popIn | cartoon, ninja, poof | arena, waveShooter, platformer, coop |
| `portal` | Portal swirl | magic | A purple ring spins open on the ground, magic sparks burst out, and the enemy grows out of it. | vfxMagic, popIn | magic, purple, swirl | arena, roguelike, boss |
| `beam` | Sci-fi beam-in | scifi | A tall cyan beam of light drops onto the spot, crackles, and the enemy materialises inside it. | vfxZap, popIn | teleport, cyan, energy | waveShooter, arena, bulletHell |
| `dirt` | Dirt burst | ground | Clods of dirt and dust burst up out of the ground where the enemy surfaces. | vfxDust, jellyWobble | earth, rubble, zombies | hordeMode, survival, towerDefense |
| `impact` | Landing impact | impact | A dust ring, a ground shockwave and a short shake that nearby players feel. | landingHard | heavy, shockwave, shake | arena, survival, boss, coop |
| `sparkle` | Sparkle pop | magic | A bright pop with a burst of magic sparkles, juiceKit's own spawn combo. | spawn | cute, friendly, light | platformer, arena, coop |
| `none` | No effect | none | Enemies simply appear. Cheapest, for very large hordes. | none | quiet, performance | towerDefense, survival, bulletHell |
`sky` and `burrow` also telegraph: a shadow grows where a drop will land, a mound of dirt rises before a burrow, and the effect plays when the enemy arrives.
### Difficulty curves (`curve`)
| id | label | family | what it does | tags | genres |
|---|---|---|---|---|---|
| `steady` | Steady | linear | Every wave a little harder than the last: +15% enemies, +12% health, +6% pace per wave. | gentle, fair, predictable | waveShooter, towerDefense, coop, arena |
| `steep` | Steep | exponential | Grows by a percentage each wave (x1.32 enemies, x1.28 health), so wave 8 is a wall. | brutal, runaway, short-runs | hordeMode, survival, roguelike, bulletHell |
| `stepped` | Stepped plateaus | stepped | Three waves at the same level, then a big jump: room to learn each tier before the next. | tiers, chapters, learnable | towerDefense, arena, waveShooter |
| `breather` | With breather waves | rhythm | Climbs a little faster than steady, but every fourth wave is a breather: half the enemies at a gentle pace. | pacing, rest, intense | coop, hordeMode, waveShooter, survival |
| `flat` | Flat | flat | Every wave exactly as written in the data. For hand-tuned tables. | exact, tuning, tests | towerDefense, boss, arena |
### Sample enemies (`kind`)
| id | label | family | what it does | health / speed / damage / size | tags | genres |
|---|---|---|---|---|---|---|
| `grunt` | Grunt | melee | The everyday foot soldier: walks at the nearest player and hits on contact. | 40 / 2.6 / 10 / 0.9 | basic, walker | waveShooter, hordeMode, arena, towerDefense |
| `runner` | Runner | fast | Small and quick: twice as fast as a grunt with half the health. | 20 / 5.2 / 6 / 0.6 | fast, fragile, swarm | hordeMode, survival, arena |
| `brute` | Brute | heavy | Big, slow and hard to put down: five grunts' worth of health and a heavy hit. | 200 / 1.5 / 25 / 1.6 | tank, slow, big | arena, towerDefense, boss, coop |
| `flyer` | Flyer | flying | Hovers two metres up and swoops at players, ignoring the ground. | 30 / 3.6 / 8 / 0.7 | hover, airborne, drone | waveShooter, bulletHell, arena, survival |
| `spitter` | Spitter | ranged | Stops eight metres away and lobs damage at the nearest player every two seconds. | 35 / 2.2 / 7 / 0.85 | ranged, keeps-distance | waveShooter, arena, survival |
Melee enemies hit once a second on contact (physical damage, knockback 4); the spitter hits every 2 s from 8 m (poison damage). The flyer's body is drawn raised and bobs, but like every enemy it moves on the spawner's ground height (see Known limits).
Your own kinds: `SK.defineEnemy("goblin", { health = 30, speed = 4, damage = 5, size = 0.8, label = "Goblin", build = function(root, look) ... return parts end })`. Leave `build` out to borrow the grunt's look, or spawn local (`temporary = true`) children under `root` and return them. Every peer builds looks locally and checks wave data against the known kinds, so call `defineEnemy` in code that runs on every peer in play (a module your components require, or the scene's `entrypoint.luau`), not in `build.luau`.
### Looks (`look`)
| id | label | what it does | tags | genres |
|---|---|---|---|---|
| `toy` | Toy | Bright plastic shapes with big round eyes. | bright, plastic, friendly | platformer, arena, coop, towerDefense |
| `neon` | Neon | Dark bodies with glowing edges and eyes, readable at night and on dark arenas. | glowing, dark, synthwave | waveShooter, bulletHell, arena, survival |
The Spawner's `look` can also be `mixed` (alternating toy and neon); a wave entry's or script line's `look` overrides it.
## Waves as data
Waves live in a `.data` file of the `spawnerKit.waveTable` contract, given to the Spawner's `waves` field (`asset.resolve("<name>", "data")`). The same shape works as a Lua table through `SK.setWaves(sp, t)` or the Spawner's `waveTable` field (used when `waves` is empty); in Lua a bare list of waves is accepted too. Check a table with `SK.validateWaves(t)` and see exactly what a wave will do with `SK.planWave(t, n)` or `sp:planWave(n)`.
### Fields
| field | default | meaning |
|---|---|---|
| `curve` | `""` (none) | Difficulty curve for the whole table: steady, steep, stepped, breather, flat. Beats the Spawner's `curve`. |
| `loop` | `false` | After the last wave, start over from wave 1, harder each time (the wave number and so the curve keep climbing). The Spawner's `loop` field forces it on. |
| `waves[]` | required | The waves, in order. At least one. |
| `waves[].name` | `"Wave <n>"` | Shown in events and the readout. |
| `waves[].delay` | `2` | Seconds of waiting before this wave starts (see below for when the wait begins). |
| `waves[].gap` | `0.8` | Seconds between two spawns of this wave, divided by the curve's pace. |
| `waves[].points` | spawner's `points` | Spawn point names (or group names) the wave's spawns cycle through, in turn. |
| `waves[].order` | `inOrder` | `inOrder`: all of the first entry, then all of the next. `mixed`: one of each entry in turn. |
| `waves[].curve` | `""` | A curve for this wave only; beats the table's and the Spawner's. |
| `waves[].maxAlive` | `0` (use the Spawner's) | Most of this spawner's enemies alive at once while this wave spawns; above 0 it replaces the Spawner's `maxAlive`. |
| `waves[].enemies[].kind` | required | Enemy kind id: grunt, runner, brute, flyer, spitter or one you defined. |
| `waves[].enemies[].count` | `1` | How many; multiplied by the curve's count and rounded up (never below 1 when above 0). |
| `waves[].enemies[].gap` | the wave's `gap` | Seconds before each of this entry's spawns (divided by pace). |
| `waves[].enemies[].points` | the wave's `points` | Spawn points for this entry only. |
| `waves[].enemies[].look` | the Spawner's `look` | `toy` or `neon`. |
| `waves[].enemies[].health` | `1` | A **multiplier** on the kind's health (on top of the curve and `healthScale`). |
| `waves[].enemies[].speed` | `1` | A **multiplier** on the kind's speed (on top of the curve and `speedScale`). |
| `waves[].script[].at` | `0` | Seconds after the wave starts (after its delay). Not scaled by the curve's pace. |
| `waves[].script[].kind` | required | Enemy kind id. |
| `waves[].script[].point` | the wave's `points`, in turn | Spawn point name (or group name). |
| `waves[].script[].look` | the Spawner's `look` | `toy` or `neon`. |
A wave needs `enemies` or a `script`. When a wave has a non-empty `script`, it is played instead of `enemies`, whatever the pattern, and the curve does not change its count or timing (it still scales health, speed and damage). Unknown keys, unknown kinds or curves, negative delays and gaps, and counts that are not whole numbers are errors that name the wave and field. A Spawner whose table is missing or invalid reports the error on the component (and in `SK.state(sp).error`) and runs an endless trickle of its `enemy` instead.
### How delay, gap, advance and maxAlive work together
1. `start()` (or `autoStart`, or the MatchFlow round starting) waits wave 1's `delay`, then wave 1 begins and fires `waveStarted`.
2. The wave's spawns happen on a timetable: the first at 0 s, each next one a `gap` later (the entry's `gap` if it has one), divided by the curve's pace.
3. `maxAlive` caps how many of this spawner's enemies are alive at once (the wave's own `maxAlive` replaces it while that wave spawns; 0 means no cap). When the cap is reached the wave's clock **holds** at the next spawn until one is gone, so the remaining spawns shift later and keep their gaps.
4. When the last spawn is out, `advance` decides when the next wave's `delay` starts counting:
- `cleared` (default): once every enemy of this wave is gone. Waves never overlap.
- `timer`: right away. Waves can overlap; each still fires its own `waveCleared` when its enemies are gone.
5. A wave is cleared when all its spawns are out and none of its enemies is alive; that fires `waveCleared` (and calls MatchFlow's `clearWave()` when `matchEntity` is set). Clearing the last wave fires `allWavesDone`.
6. How many waves run: the table's length, or `wavesToRun` when it is above 0. A looping table with `wavesToRun = 0` is endless (its `waves` count is 0 in events and `allWavesDone` never fires).
7. After `allWavesDone`, `restartDelay` (≥ 0) starts the spawner again from wave 1 after that many seconds; -1 never does. With a `matchEntity`, the MatchFlow round restarts it instead.
## Difficulty curves
A curve turns a wave number into five multipliers: **count** (each entry's count, rounded up), **pace** (gaps are divided by it), **speed**, **health** and **damage** (each enemy's), plus `rest = true` on a breather wave. With `n = (wave - 1) × curveStrength`:
| curve | count | pace | speed | health | damage |
|---|---|---|---|---|---|
| `steady` | 1 + 0.15n | 1 + 0.06n | 1 + 0.04n | 1 + 0.12n | 1 + 0.05n |
| `steep` | 1.32^n | 1.12^n | 1.06^n | 1.28^n | 1.15^n |
| `stepped` (level L = floor((wave - 1) / 3) × strength) | 1 + 0.6L | 1 + 0.25L | 1 + 0.12L | 1 + 0.5L | 1 + 0.2L |
| `breather`, normal wave | 1 + 0.2n | 1 + 0.08n | 1 + 0.05n | 1 + 0.15n | 1 + 0.06n |
| `breather`, every 4th wave (rest) | 0.5 + 0.05n | 0.7 | 1 | 0.9 + 0.08n | 1 |
| `flat` | 1 | 1 | 1 | 1 | 1 |
Limits: count and pace stay within 0.1 to 12, speed 0.1 to 4, health 0.1 to 24, damage 0.1 to 12.
Count multiplier per wave at strength 1 (health in brackets):
| wave | steady | steep | stepped | breather | flat |
|---|---|---|---|---|---|
| 1 | 1.00 (1.00) | 1.00 (1.00) | 1.00 (1.00) | 1.00 (1.00) | 1 |
| 2 | 1.15 (1.12) | 1.32 (1.28) | 1.00 (1.00) | 1.20 (1.15) | 1 |
| 3 | 1.30 (1.24) | 1.74 (1.64) | 1.00 (1.00) | 1.40 (1.30) | 1 |
| 4 | 1.45 (1.36) | 2.30 (2.10) | 1.60 (1.50) | 0.65 (1.14) rest | 1 |
| 5 | 1.60 (1.48) | 3.04 (2.68) | 1.60 (1.50) | 1.80 (1.60) | 1 |
| 8 | 2.05 (1.84) | 6.98 (5.63) | 2.20 (2.00) | 0.85 (1.46) rest | 1 |
| 10 | 2.35 (2.08) | 12.00 (9.22) | 2.80 (2.50) | 2.80 (2.35) | 1 |
`curveStrength` scales how hard any curve bites (0 = flat, 2 = twice as steep). The trickle pattern uses level `1 + floor(spawns / trickleStep)` as its wave number. On top of the curve, the Spawner's `healthScale`, `speedScale` and `damageScale` multiply every enemy. Read any curve with `SK.curve(name, wave, strength)` or `SK.curveSeries(name, waves, strength)`.
## Pooling
Enemies are made once and reused. Each spawn takes an enemy of that kind out of the pool; when it dies, escapes, expires or is cleared, it goes back: hidden on every peer, parked underground at y = -400, its health components switched off so it costs nothing while it waits. A new entity is only **created** when the pool has no free enemy of that kind; every other spawn is **reused**, revived and healed to its new maximum by SecondWind.
So the entity count stays **flat**: it settles at the most enemies of each kind that were ever alive together (plus any prewarm), and wave 20 makes no more entities than wave 2. One synced entity per enemy is all that crosses the network; its look (one entity per shape) is built locally on each peer as temporary children. Effect shapes come from a small local pool too, so effects never grow the entity count either. The pool is shared by every Spawner in the scene and keyed by enemy kind.
- **Prewarm:** the Spawner's `prewarm` field makes that many enemies of the first wave's first kind (or the trickle's `enemy`) when it starts, so the first wave reuses instead of creating. `SK.prewarm(kind, n, look)` tops the free pool of a kind up to `n`, on the host.
- **Counters:** `SK.poolStats()` gives `{ created, reused, released, active, pooled, peakActive, entities }` on this peer; the host's are the real ones, and every peer reads them from `SK.state(sp).pool`.
- **The readout:** `SK.addReadout(e, opts)` shows, in a screen corner, how many enemies were created versus reused and the share of spawns reused, how many are in play and pooled, the scene's entity count (with its range over the last 30 s), script memory, each spawner's wave (`wave 2/3 next in 4s`, `all 3 waves done`) and, with `match`, the MatchFlow round. It reads the Spawners' synced snapshots, so every player sees the host's numbers.
## Events
```lua
local conn = SK.on("waveCleared", function(e) print("cleared", e.name) end)
SK.on("*", function(name, e) print(name, e.wave) end) -- every event: fn(name, payload)
conn:Disconnect()
```
Exactly five events, plus `"*"` for all of them. An unknown name is an error. Every payload carries `spawner` (the Spawner's entity id) and `spawnerName` (its entity name).
| event | when | payload keys |
|---|---|---|
| `waveStarted` | A wave began. | `spawner, spawnerName, wave, waves, name, total, rest, scale` (scale = `{ count, pace, speed, health, damage }`) |
| `waveCleared` | Every enemy of a wave was spawned and is gone. | `spawner, spawnerName, wave, waves, name, killed, escaped, seconds` |
| `spawned` | An enemy appeared. | `spawner, spawnerName, enemy, kind, look, pattern, style (the same as pattern), effect, wave, position, reused` |
| `despawned` | An enemy left play and went back to the pool. | the `spawned` keys plus `reason`: `killed`, `escaped`, `expired`, `cleared` or `reset` |
| `allWavesDone` | The last wave was cleared. | `spawner, spawnerName, waves, killed, escaped, seconds` |
- `enemy` is the enemy's entity id; `position` is where it appeared (`{x, y, z}`), in `despawned` too; `reused` is true when it came from the pool; `wave` is 0 for a trickle or a manual spawn.
- `waves` is the number of waves the spawner will run, 0 for an endless one. `seconds` is from the wave's start (or, in `allWavesDone`, from `start()`) to the clear.
- In `waveCleared`, `escaped` counts every enemy of the wave that was not killed. In `allWavesDone`, `killed` and `escaped` are the run's totals (`escaped` there counts escaped and expired enemies).
- **Events fire on every peer.** The host fires them as it decides. Other peers fire the wave events when the Spawner's synced snapshot says so, and `spawned`/`despawned` when an enemy's synced spawn counter or `active` flag changes. A player who joins later does not get a replay of what already happened.
- `SK.eventCounts()` tells how often each fired on this peer and the last 40.
## Multiplayer
- **One peer is in charge of each spawner: the owner of its synced entity** (normally whoever created it; this README calls it the host). Only it runs the waves, picks positions with its seeded random stream and spawns. Put the Spawner on an entity made with `entity.spawnSynced`; `SK.addSpawner` warns when it is not.
- **Enemies are synced entities owned by the host.** The host moves them, applies their damage and decides when they die. Every peer builds each enemy's look locally from its synced `kind` and `look`, shows or hides it from `active`, and plays the spawn effect locally (juiceKit `scope = "local"`) when the enemy's spawn counter `seq` changes, so every player sees the same effect at the same spot without an extra message. Turrets aim and draw their zaps locally on every peer; only the host's damage counts.
- **The snapshot:** the host publishes what each spawner is doing (phase, wave, counts, pool, last events) in its synced `snapshot` field whenever something changes and at least twice a second. `SK.state(sp)` reads it, the same on every peer.
- **Calls from any peer:** `start`, `stop`, `reset`, `nextWave` and `spawn` called on a peer that is not in charge are sent to the host on the channel `spawnerKit:<entityId>` (the Spawner's entity id) and carried out there. `despawnAll`, `SK.despawn` and `SK.prewarm` act only on the host; the setters (`setPattern`, `setStyle`, `setEffect`, `setCurve`, `setWaves`) write the Spawner's synced fields and are not forwarded, so call them where `SK.isAuthority(sp)` is true.
- **Host migration:** every other peer checks the spawner's owner twice a second. When the owner has left (or nobody owns it), the lowest remaining peer claims the entity after 0.5 s (any peer after 4 s). The new host adopts the enemies it now owns from the pool and carries on from the snapshot: the same wave, the next spawn in the plan, the same kill counts.
- **What the kit does against duplicate or missing enemies:** exactly one peer spawns. An entity no peer owns yet counts as the room host's (`multiplayer.isHost()`), so even then only one peer acts on it. Enemies are real synced entities, so every peer is meant to see the same set, and looks and effects come from their synced fields rather than from messages that could be lost. A new host resumes the current wave at the spawn after the last one the snapshot recorded; the one gap is a spawn made in the frame between the old host's last snapshot and its leaving. No duplicate enemy was ever seen in testing. Missing and late enemies on another player's screen were seen, from engine problems the kit cannot work around: see [Known limits](#known-limits) (#19353, #19280).
## MatchFlow
Set the Spawner's `matchEntity` to the name of an entity carrying `MatchFlow` (format `waves` fits best) and the spawner plays inside its rounds:
- the waves start when the round enters `playing` (with `autoStart` on);
- each cleared wave calls the match's `clearWave()`, so a `waves` round with `waves = 3` ends in victory when the third wave is cleared;
- when the round leaves `playing`, the spawner resets: every enemy back to the pool (`despawned` with reason `reset`), ready for the next round. `restartDelay` is ignored.
- If no peer runs the MatchFlow (its entity is owned by nobody and not in charge anywhere), the host plays the waves without it after 5 s and logs a warning.
```lua
local match = entity.spawnSynced("Match")
match.component.add("MatchFlow", { format = "waves", waves = 3, roundSeconds = 0, minPlayers = 1 })
SK.addSpawner(entity.spawnSynced("Arena", { position = A }), { waves = asset.resolve("arenaWaves", "data"), matchEntity = "Match" })
```
Give the readout `match = "Match"` to show the round's phase and cleared waves, and SecondWind's Rules `matchEntity = "Match"` so its round ends with MatchFlow's.
## SecondWind
Health, damage and death are SecondWind's. Every enemy gets `SecondWind.Vitals` (and Lifeline's health) through `HK.attach` with model `hitPoints`, death style `stay`, team `enemies` and no fall damage; on every spawn its maximum health is set to kind health × curve × `healthScale` × the entry's `health`. So:
- **Hurt enemies with SecondWind:** `HK.damage(enemyEntity, 25, { by = attacker, type = "fire" })` from your weapons. A killed enemy lies there for 0.7 s, then goes back to the pool (`despawned`, reason `killed`). `HK.explode`, traps and SecondWind's hit effects all work on them.
- **Enemies hurt players** with `HK.damage` (contact hits, the spitter's shots) and only chase players SecondWind says are alive, so give player bodies health: `HK.attach(body, { team = "players" })`, and one synced `SecondWind.Rules` entity in the scene.
- `healthBars` draws Lifeline's bar over each enemy.
## juiceKit
The spawn effects are juiceKit presets (see the Effects table) played with `scope = "local"` on every peer, plus the kit's own pooled shapes. The turret's sparks are local too; the spitter's shot plays juiceKit's `vfxZap` at the player it hits. juiceKit's global intensity and reduce-motion settings apply to all of them. Play an effect yourself, on this peer only, with `SK.playEffect("portal", { x, y, z }, { size = 1.5 })`.
## Reference
### Spawner component (on a synced entity)
All fields are synced.
| field | default | meaning |
|---|---|---|
| `pattern` | `point` | The spawn pattern, how enemies arrive: point, area, ring, offscreen, path, portal, sky, burrow, trickle or scripted. Swap it and nothing else changes. |
| `style` | `""` | Older name for `pattern`. Empty lets `pattern` decide; a pattern id here is used instead of `pattern`. `SK.addSpawner`, `SK.setPattern` and `SK.setStyle` always write `pattern` and leave this empty. |
| `effect` | `auto` | Spawn effect every player sees: smoke, portal, beam, dirt, impact, sparkle or none; auto picks the pattern's own. |
| `curve` | `steady` | Difficulty curve: steady, steep, stepped, breather or flat. A curve named in the wave data wins. |
| `curveStrength` | `1` | How hard the curve bites: 1 as designed, 0 flat, 2 twice as steep. |
| `waves` | none | A .data file of the waveTable contract: the waves this spawner plays. |
| `waveTable` | `{}` | Waves as a Lua table (set by SK.setWaves); used when `waves` is empty. |
| `wavesToRun` | `0` | How many waves to play: 0 = every wave in the table (endless when it loops). |
| `loop` | `false` | After the last wave, start the table over, harder each time (the curve keeps climbing). |
| `enemy` | `grunt` | Enemy kind for a trickle, and for manual spawns that name none. |
| `enemies` | `""` | A trickle's mix: comma list of kinds it picks from in turn (empty = `enemy`). |
| `look` | `toy` | How the enemies look: toy, neon, or mixed (alternating). |
| `points` | `""` | Spawn points this spawner uses: comma list of point names or group names. Empty = the points whose group is this spawner's name, else every point. |
| `pointOrder` | `cycle` | When a spawn names no point: take the points in turn, or at random. |
| `target` | `nearest` | Whom ring and offscreen spawn around: nearest, random, each (in turn), or the name of an entity. |
| `rate` | `1` | Trickle: spawns per second. |
| `maxAlive` | `12` | Most enemies of this spawner alive at once; a spawn waits until one is gone. 0 = no limit. |
| `total` | `0` | Trickle: stop after this many spawns (0 = endless). |
| `trickleStep` | `10` | Trickle: the difficulty curve goes up one level every this many spawns. |
| `advance` | `cleared` | When the next wave's delay starts: once this wave is cleared, or once it has finished spawning. |
| `autoStart` | `true` | Start by itself when play begins (or when the MatchFlow round starts); when false, call start(). |
| `restartDelay` | `-1` | Seconds after the last wave is cleared before the spawner starts again from wave 1; -1 = never (a MatchFlow round restarts it instead). |
| `behavior` | `chase` | What spawned enemies do: chase players, wander near where they appeared, march (path pattern), or hold still. |
| `aggroRadius` | `0` | Enemies only chase players within this many metres of the spawner; 0 = any distance. |
| `wanderRadius` | `4` | How far a wandering enemy roams from where it appeared, in metres. |
| `goal` | `""` | Name of a spawn point (or any entity) enemies head for when nobody is in reach; reaching it counts as escaping. |
| `lifetime` | `0` | Seconds an enemy stays before leaving by itself (reason 'expired'); 0 = never. |
| `healthBars` | `true` | Draw Lifeline's health bar over each enemy. |
| `healthScale` | `1` | Multiplies every enemy's health, on top of the curve. |
| `speedScale` | `1` | Multiplies every enemy's speed, on top of the curve. |
| `damageScale` | `1` | Multiplies every enemy's damage, on top of the curve. |
| `ringRadius` | `9` | Ring pattern: distance from the player, in metres. |
| `offscreenMargin` | `4` | Offscreen pattern: how far past the edge of the view, in metres. |
| `offscreenDistance` | `14` | Offscreen pattern: distance from the player, in metres. |
| `skyHeight` | `9` | Sky pattern: drop height, in metres. |
| `burrowDepth` | `1.6` | Burrow pattern: how deep they start, in metres. |
| `pathSpread` | `0.6` | Path pattern: sideways jitter so a column does not walk in single file, in metres. |
| `showPath` | `true` | Path pattern: draw the smooth curve the enemies walk on the ground, on every peer (small local dots, rebuilt when the pattern or the points change). |
| `prewarm` | `0` | Make this many enemies of the first kind into the pool before the first spawn. |
| `seed` | `1` | Seed for every random choice the host makes. |
| `matchEntity` | `""` | Name of an entity with MatchFlow: waves start when its round starts, each cleared wave calls clearWave(), and a round ending resets the spawner. |
| `snapshot` | `{}` | Written by the host only: what the spawner is doing, for every other peer. Read it through getState(). |
Enemies that `wander` roam around the spot where they appeared; `chase` enemies measure `aggroRadius` from the spawner, head for `goal` when nobody is in reach, and wander when there is no goal. The `path` pattern always makes its enemies march their route; reaching the end counts as escaping.
### Spawner methods
| method | what it does |
|---|---|
| `sp:start()` | Start from wave 1 (or start the trickle). On a peer that is not in charge the request goes to the host. |
| `sp:stop()` | Stop spawning; enemies already out stay. |
| `sp:reset()` | Stop, send every enemy of this spawner back to the pool (reason 'reset') and go back to before wave 1. |
| `sp:nextWave()` | Skip the wait before the next wave. |
| `sp:spawn(kind?, opts?) -> entity?` | Spawn one enemy now with this spawner's pattern and effect. opts: `{ point, look, health, speed }`. Returns the entity on the host, nil elsewhere (the host spawns it). |
| `sp:despawnAll(reason?)` | Send every enemy of this spawner back to the pool now (default reason `cleared`). Host only. |
| `sp:setWaves(t) -> (ok, errors)` | Put waves in as a Lua table (the same shape as the waveTable data), replacing the data file. Takes effect at the next start(). |
| `sp:getState()` | What this spawner is doing, the same on every peer: `phase` (idle, waiting, spawning, clearing, trickle, done, stopped), `running, wave, waves, name, spawned, total, alive, waveAlive, killed, escaped, spawnedTotal, nextIn, pool, entities, memoryKb, pattern, style (the same), curve, effect, host, error` and the last event payloads. |
| `sp:isAuthority() -> boolean` | Whether this peer is the one deciding and spawning for this spawner. |
| `sp:alive() -> { id }` | The entity ids of this spawner's enemies in play. |
| `sp:planWave(n)` | The wave plan this spawner would follow for wave n (who, when, from where), without spawning anything. |
### SpawnPoint component (on any entity; not synced)
Its transform is the spot. Every peer has the same scene and only the host reads it.
| field | default | meaning |
|---|---|---|
| `group` | `""` | Group name. A Spawner's `points` can name a group instead of single points; the points of one group, sorted by `order`, make a path. |
| `order` | `0` | Position along its group's path (lowest first). |
| `kind` | `point` | Which marker it draws: a pad, an area outline, a standing portal ring or a path dot. Every pattern can use every kind. |
| `sizeX` | `0` | Width of the area in metres (patterns area, sky and burrow pick a random spot inside); 0 = a single spot. |
| `sizeZ` | `0` | Depth of the area in metres. |
| `showMarker` | `true` | Draw the marker in play as well as in the editor. |
| `color` | orange | Colour of the marker. |
Method: `point:record()` returns `{ name, pos, sizeX, sizeZ, yaw, group, order, kind }`, what the placement maths reads.
### Turret component (on any entity; not synced)
| field | default | meaning |
|---|---|---|
| `range` | `10` | How far the turret reaches, in metres. |
| `interval` | `0.7` | Seconds between shots. |
| `damage` | `15` | Damage per shot (SecondWind damage). |
| `damageType` | `electric` | SecondWind damage type (physical, fire, ice, electric, poison), which picks the hit effect. |
| `only` | `""` | Only shoot enemies of this spawner (its entity name); empty = any enemy. |
| `color` | cyan | Colour of the turret's head and zap. |
### PoolReadout component (on any entity; local)
| field | default | meaning |
|---|---|---|
| `spawner` | `""` | Name of one Spawner to show; empty shows every spawner in the scene. |
| `corner` | `topLeft` | Which corner of the screen it sits in (topLeft, topRight, bottomLeft, bottomRight). |
| `title` | `spawnerKit pool` | Heading of the readout. |
| `maxSpawners` | `4` | Most spawner rows to list. |
| `match` | `""` | Name of a MatchFlow entity whose round to show (phase and wave); empty shows none. |
Method: `readout:numbers()` returns what it shows: `{ created, reused, active, pooled, entities, memoryKb, peer }`.
### Enemy component (added by the pool; you don't add it)
All fields are synced and written by the host.
| field | default | meaning |
|---|---|---|
| `kind` | `grunt` | Enemy kind id (grunt, runner, brute, flyer, spitter, or one a game defined). |
| `look` | `toy` | How it looks: toy or neon. |
| `active` | `false` | Whether it is in play; false means it is waiting in the pool, hidden. |
| `seq` | `0` | Bumped by the host each time this entity is spawned; every peer plays the spawn effect when it changes. |
| `uses` | `0` | How many times this entity has been spawned; above 1 means it was reused from the pool. |
| `spawner` | `""` | Entity id of the Spawner that spawned it. |
| `spawnerName` | `""` | Name of that Spawner, for events. |
| `wave` | `0` | The wave it belongs to (0 for a trickle). |
| `style` | `point` | The spawn pattern that placed it (the field keeps the older name). |
| `effect` | `smoke` | The spawn effect every peer plays when it appears. |
| `entrance` | `{}` | How it enters: `{ pos, from, entrance, duration }`. |
| `route` | `{}` | For the path pattern: the points of the smooth curve it walks, about 0.6 m apart. |
| `behavior` | `chase` | What it does once in play: chase players, wander near where it appeared, march its route, or hold still. |
| `speed` | `2.6` | Walking speed in metres per second (already scaled by the wave's difficulty). |
| `damage` | `10` | Damage per contact hit (or per shot for a spitter). |
| `aggroRadius` | `0` | Only chase players within this many metres of `home`; 0 = any distance. |
| `home` | `{}` | Centre of the area it guards or wanders in (`{x, y, z}`). |
| `wanderRadius` | `4` | How far from `home` a wandering enemy roams, in metres. |
| `goal` | `{}` | Where it heads when it has nobody to chase; reaching it counts as escaping (`{x, y, z}`, empty for none). |
| `lifetime` | `0` | Seconds before it leaves by itself (reason 'expired'); 0 = never. |
| `goneReason` | `""` | Why it last left play: killed, escaped, expired, cleared or reset. |
Method: `enemy:isAlive()`, whether it is in play and alive.
### `spawnerKit.api` (`local SK = require("spawnerKit.api")`)
Wherever a function takes a spawner (`sp`), it accepts the Spawner's entity, its entity name, or the component.
| function | what it does |
|---|---|
| `SK.addSpawnPoint(e, opts?) -> SpawnPoint` | Make an entity a spawn point. opts: group, order, kind (point/area/portal/path), sizeX, sizeZ, showMarker, color. |
| `SK.addSpawner(e, opts?) -> Spawner` | Make an entity a spawner. Use a SYNCED entity (entity.spawnSynced). opts are any Spawner fields. |
| `SK.addTurret(e, opts?) -> Turret` | Add a defence turret that zaps enemies through SecondWind. opts: range, interval, damage, damageType, only, color. |
| `SK.addReadout(e, opts?) -> PoolReadout` | Add the on-screen pool readout. opts: spawner, corner, title, maxSpawners, match. |
| `SK.spawner(x) -> Spawner?` | The Spawner component of an entity, an entity name, an id or a component; nil when there is none. |
| `SK.start(sp)` | Start a spawner from wave 1 (asks the host when called elsewhere). |
| `SK.stop(sp)` | Stop a spawner; enemies already out stay. |
| `SK.reset(sp)` | Reset a spawner: every enemy back to the pool, back before wave 1. |
| `SK.nextWave(sp)` | Skip the wait before a spawner's next wave. |
| `SK.spawn(sp, kind?, opts?) -> entity?` | Spawn one enemy now from a spawner, with its pattern and effect. opts: `{ point, look, health, speed }`. The entity on the host, nil elsewhere. |
| `SK.setPattern(sp, pattern)` | Swap a spawner's spawn pattern; takes effect from the next spawn. |
| `SK.setStyle(sp, pattern)` | Older name of `SK.setPattern`; does the same. |
| `SK.setEffect(sp, effect)` | Swap a spawner's spawn effect ("auto" follows the pattern). |
| `SK.setCurve(sp, curve)` | Swap a spawner's difficulty curve. |
| `SK.setWaves(sp, t) -> (ok, errors)` | Give a spawner waves as a Lua table (replacing its data file); takes effect at its next start. |
| `SK.state(sp)` | What a spawner is doing (the same on every peer). |
| `SK.isAuthority(sp) -> boolean` | Whether this peer decides and spawns for a spawner (the host). |
| `SK.despawn(enemy, reason?) -> boolean` | Send an enemy back to the pool now. reason: "killed", "escaped", "expired" or "cleared" (default). Host only; returns false elsewhere. |
| `SK.alive(sp?) -> { id }` | The ids of enemies in play: of one spawner, or of every spawner when sp is nil. |
| `SK.isEnemy(e) -> boolean` | Whether an entity (or id) is a spawnerKit enemy (pooled or in play). |
| `SK.validateWaves(t) -> (ok, errors)` | Check a wave table (Lua table or resolved waveTable data). Unknown enemy kinds are errors. |
| `SK.planWave(t, n, opts?)` | Plan wave n of a table without spawning: `{ wave, name, delay, total, duration, rest, spawns = { { t, kind, point, look, index } }, scale }`. opts: `{ curve = "steady", strength = 1, points = { "North" } }`. |
| `SK.loadWaves(ref)` | Read a waveTable .data asset (from asset.resolve("<name>", "data") or a Field.dataRef) into a plain table. |
| `SK.curve(name, wave, strength?)` | A difficulty curve's multipliers for one wave: `{ count, pace, speed, health, damage, rest, level }`. |
| `SK.curveSeries(name, waves, strength?)` | A curve's multipliers for waves 1..n, for graphs. |
| `SK.defineEnemy(id, def)` | Register an enemy kind for wave data. def: `{ health, speed, damage, size, hover, range, label, build = function(root, look) -> parts }`. |
| `SK.enemyKinds() -> { id }` | Every enemy kind id, sorted. |
| `SK.poolStats()` | The pool's counters on this peer: `{ created, reused, released, active, pooled, peakActive, entities }`. |
| `SK.prewarm(kind, n, look?)` | Make enemies of a kind into the pool ahead of time, until n wait there. Host only. |
| `SK.on(name, fn) -> conn` | Listen to a kit event on this peer; `conn:Disconnect()` stops it. |
| `SK.eventCounts()` | How many times each event fired on this peer, and the most recent ones. |
| `SK.catalog()` | Everything the kit offers, labelled: patterns (`styles`), effects, curves, enemies, looks, events, vocabulary and version. |
| `SK.find(query?) -> { entry }` | Catalog entries matching labels. Keys: kind, family, genre, tag, id; each a value or a list. |
| `SK.vocabulary()` | The words the catalog's labels use: kinds, families per kind, genres. |
| `SK.playEffect(id, pos, opts?)` | Play a spawn effect on this peer at a point. opts: `{ size, target }`. |
| `SK.effectStats()` | Effect counters on this peer: played, shapes made, shapes reused, live, pooled. |
| `SK.syncReport()` | Whether every player sees the same enemies. On a non-host peer, its own verdict against the host's latest digest; on the host, the verdict each other peer sent back. Counts missing, extra (duplicates included), duplicate, spawnSpot, drift, wave and peerSpawned problems, each once, with the last few as text. |
| `SK.syncCheck(opts)` | Tune the cross-peer check: `{ enabled = true, interval = 2, grace = 1.5, tolerance = 1.5 }`. |
Constants: `SK.VERSION` (`"1.0.0"`), the component assets `SK.SPAWNER`, `SK.SPAWN_POINT`, `SK.ENEMY`, `SK.TURRET`, `SK.READOUT`, and the id lists `SK.EVENTS`, `SK.PATTERNS` (also `SK.STYLES`), `SK.EFFECTS`, `SK.CURVES`, `SK.LOOKS`. The pure modules are reachable as `SK.waves`, `SK.curves`, `SK.placement`, `SK.pool` and `SK.util`.
## Tests
The kit's test suite is `spawnerKit`. Run it from Luau with:
```lua
tools.use("tests", "run", { suite = "spawnerKit" })
```
## Known limits
- **Install order.** A package cannot declare its dependencies yet (#2612), and kits an install drags along land only under `/source/deps`. That is why the kits go in first and spawnerKit last, each with `at=/source` ([Install](#install)). Putting all five ids in one call in that order also gave the right layout in a test, but the call answered with an error (HTTP 503) although the files landed, so the README gives three calls.
- **Errors from `/source/deps` (#19260).** The older MatchFlow modules SecondWind pins, left under `/source/deps`, log errors such as `module ~.rules ... has no root to resolve against` or `module ~.api ... has no root to resolve against` when scripts load and on scene rebuilds. They are harmless: the kit and its kits use the copies in `/source`.
- **Relay ownership fault (#19323).** In the editor, after many play restarts in one session, the relay can record a non-existent "peer 0" as the owner of the scene's synced entities and refuse every claim, so no peer is in charge of them. spawnerKit treats an entity nobody owns as the room host's (`multiplayer.isHost()`), so the waves still run, and a Spawner with `matchEntity` plays without MatchFlow after 5 s. MatchFlow itself cannot run its round then: it stays in `waiting` and its wave counter and victory screen never appear. Once, leaving and rejoining the room (`multiplayer.leaveRoom` then `multiplayer.joinRoom`) cleared it; another time it did not, and giving the entities new ids did (in edit mode, despawn the scene's synced entities and rebuild the scene). A fresh session, such as a visitor opening the world, starts in a new room and is not affected.
- **Offscreen knows only the host's own camera.** Other players' cameras live on their own machines and are never sent. When the target is the host's own player, the offscreen pattern uses the host's real camera (its direction, field of view and aspect). For any other target (another player, or a named entity) it assumes a 60-degree, 16:9 camera looking along the target's facing, so with a free-orbiting camera an enemy can now and then appear inside that player's view. Raise `offscreenMargin` for more room, or use the `ring` pattern.
- **Cost per live enemy.** SecondWind's Vitals costs roughly 0.05–0.3 ms per live enemy per frame on desktop. Pooled enemies have their health components switched off and cost nothing. Keep `maxAlive` modest, especially in a browser tab.
- **Simple movement.** Enemies move kinematically on the spawner's ground height: no physics, no gravity, no pathfinding around walls. Use open arenas, `path` curves through gaps, or `behavior = "hold"` with your own movement. A path curve passes through every one of its points and swings round sharp corners; space the points out for gentle bends.
- **Damage numbers are all or nothing.** Lifeline draws floating damage numbers for every enemy on screen or for none (its HUD's `showNumbers`); a number can drift across a 3D label behind it. The gallery turns them on only while you are inside the arena, so the station labels stay clear.
- **New enemies and setting changes can reach a second player very late, or never (#19353).** When a second player joins, what the host creates or changes (a newly spawned enemy, an enemy coming back out of the pool with its new kind, look and `active` flag, a Spawner's settings and its snapshot) can reach that player about a minute and a half late, or not at all, while the movement of enemies they already have arrives at once. On that player's screen, enemies then appear late or not at all, a reused enemy can stay hidden (or show its old look) while the host has it in play, a pattern, effect or curve change shows up late, and the wave counter and wave events lag behind the host's. It happened again in this release's two-player testing. The kit cannot work around it: every enemy is one synced entity, and its look, entrance and events come from its synced fields.
- **Enemies spawned during play can fail to reach other players (#19280).** A synced entity created while the game runs can fail to reach the other players at all, and the log then fills with a warning for every such enemy, every frame. spawnerKit creates a new entity only when the pool has no free enemy of that kind (prewarmed ones included, since prewarm also runs in play); every later spawn reuses one of those. So this hits the first enemies of each kind, and those enemies can then be missing on other players' screens for the rest of the session.
- **What a second player sees (two-player checks).** The kit's side holds: in every session only the host created enemies (the other player created none) and no duplicate was ever seen. In the best session (desktop app and a browser tab, the arena alone, three waves from `threeWaves.data`), the other player saw the same enemies with the same ids at the same spawn spots, live positions 0.1–0.6 m apart on average (worst 1 m, about one network update), and the same wave counters: 6, 9 and 13 on the steady curve. Other sessions were not that clean, so expect any of these on a second player's screen: enemies that arrive late or never, and settings that change late (#19353, #19280); enemies a few seconds behind on a slow machine (a browser tab at 6–10 fps with the full gallery); no spawners at all after a dropped connection (see Lost connection); and no one in charge of the round (#19323). Two kit bugs these checks found are fixed: a peer that was not in charge prewarmed pool enemies of its own when a MatchFlow round started, and a reused enemy could flash at its previous place on the other screen. To see what is happening, `SK.syncReport()` (and the readout, on both screens) counts missing, extra, duplicate, spawnSpot, drift, wave and peerSpawned problems: every 2 s the host sends every other peer a digest, and each peer compares it with its own view. The readout turns red only for a difference that is still there now (a duplicate or a non-host spawn at once); a difference counts only when it shows in two digests in a row. `multiplayer.explain(enemyId, "spawnerKit.Enemy", "active")` says why a value is not arriving.
- **Lost connection.** When both peers' relay connections dropped at once, the play room came back without the scene's synced objects (spawners, MatchFlow, health rules), and every later Play into that room dropped them again, even with new entity ids. The gallery guards against it: its entrypoint has the room's host recreate any missing shared objects at play start (`galleryShared`). A game that builds its spawners with `entity.spawnSynced` in a scene build can do the same.
- **Bandwidth.** The gallery, with 23 spawners and 30 to 40 enemies in play, sends about 110 KB/s and 43 messages a second from the host. Most of that is moving enemies' positions and Lifeline's one message per hit (a limit of the health kit, see SecondWind's README); the live curve pens keep their enemies standing still and their turrets slow to keep their share small. A game with one or two spawners and fewer enemies sends far less. Each Spawner publishes its state at most 5 times a second, and otherwise every 2 s.
- **Small screens.** From the gallery's start position, the label titles are readable on a phone held sideways, but the one-line subtitles and the live lines under the curve boards are only a few pixels tall there; walk towards a row to read them. On a desktop window every label is readable from the start, below MatchFlow's timer and wave banner.
- **MatchFlow's screens cover the middle between rounds.** In the gallery, when the arena's third wave is cleared, MatchFlow's results screen and then its "next match in" panel fill the middle of the screen for about 10 s (its `resultsSeconds` and `breakSeconds`), then the next round starts. Set `showUi = false` on your MatchFlow to draw your own from its state.
- **Web browser.** The gallery runs in a browser tab: the models and labels are built and running there, the same as on desktop, and the tab was the host of the two-player session. It runs much slower (about 10 fps against 30 to 40 on desktop with the full gallery), so keep `maxAlive` modest on the web. In the editor, the Scene view's camera menu must read **Active camera** in play: if it is set to the editor camera, play keeps drawing from the editor's fly camera and the gallery appears to be empty.
- **Scene rebuilds and old entities.** When a build re-places an entity whose component values were changed outside the build, the scene builder can keep the old values or refuse the add. `SK.addSpawner`, `SK.addSpawnPoint`, `SK.addTurret` and `SK.addReadout` update a component that is already there. If a rebuild still reports `already has a 'spawnerKit.…' component`, rename that entity in your build.
## Version history
- **1.0.0** — First release: 10 spawn patterns picked with the Spawner's `pattern` setting (`style` is its older name and still works), the path pattern walking a smooth curve built with the engine's spline module, 7 spawn effects, 5 difficulty curves, 5 sample enemies in 2 looks and custom kinds, waves as `spawnerKit.waveTable` data, a shared enemy pool with prewarm and the created-vs-reused readout, five events on every peer, host-authoritative multiplayer with host migration, turrets, and MatchFlow, SecondWind and juiceKit integration. A gallery world shows every pattern, effect and curve live, with a live pen per curve and an arena playing three waves inside a MatchFlow round with its screens on.
# waveTable (dataType contract)
The contract a spawnerKit waves .data file is checked against: an ordered list of waves, each with a name, a delay before it, a gap between spawns, spawn points, an order and a per-wave cap, and either enemy entries (kind, count, gap, points, look, and health and speed multipliers) or an exact `script` of timed spawns, plus a difficulty `curve` and `loop` for the whole table. A Spawner plays one through its `waves` field, and the Lua-table form given to `SK.setWaves` has the same shape. The kit ships `threeWaves.data` as an example; the package README's "Waves as data" section documents every field, its default and how delay, gap, advance and maxAlive work together.
# juiceKit
**One call makes hits, explosions, pickups and level-ups feel great: `Juice.play("explosionLarge", someEntity)` shakes the camera, flashes the screen, freezes the action for a beat, squashes the target and plays an effect at the spot, all together and correctly for every player.**
Version **1.2.2** (`Juice.VERSION`). The version, this README and `package.yaml` move together.
## First example
1. Install juiceKit into your world.
2. Create a component named `juiceDemo` (a `juiceDemo.component` folder with this `init.luau`).
3. Add `juiceDemo` to any entity in your scene, press Play and press **E** (Interact). Each press plays the next of the three effects.
```lua
local Juice = require("juiceKit.juice")
declare {}
public = {
inputMap = Field.assetRef("inputMap", "@builtin::inputMaps.default", NoSync, "The controls the Interact press comes from."),
}
local controls = nil
local presses = 0
function start()
controls = public.inputMap:activate("juiceDemo")
controls.interact:onPressed(function()
presses += 1
local here = public.entity
if presses % 3 == 1 then
-- an explosion at this entity: everyone sees it, and each player
-- feels the shake as strongly as their distance to it warrants
Juice.play("explosionLarge", here)
elseif presses % 3 == 2 then
-- a coin pickup at a point in the world
Juice.play("coinPickup", { 4, 1, 2 })
else
-- the local player got hurt by an attacker standing at this entity:
-- red vignette, a short freeze, a kick away from it (only this player)
Juice.play("hurt", nil, { from = here })
end
end)
end
function onDestroy()
if controls then public.inputMap:deactivate(controls) end
end
```
Nothing else is needed. Only the player who pressed E calls `play`, so the default routing is right: the explosion and the coin reach every player, and the hurt stays with this one. Requiring `juiceKit.juice` starts listening for other peers' effects, and the first shake attaches the shake components to the camera on screen. To make sure a peer whose own scripts never call `play` still receives world effects, put the **JuiceNet** component on any scene entity. Its `look` field also sets the starting look.
## Swapping things
Everything goes through the same call, so a different preset, look or strength is a one-word change:
```lua
Juice.play("hitLight", enemy) -- -> "hitHeavy", "hitCritical"
Juice.play("vfxFire", pos, { look = "pixel" }) -- one effect in another look
Juice.setLook("realistic") -- every effect from now on
Juice.play("shakeExplosion", pos, { intensity = 0.5 }) -- half strength, this call only
```
## Presets (51)
Kinds: `combo`, `shake`, `hitstop`, `flash`, `squash` and `vfx`. Strengths: `light`, `medium` and `heavy`. Scope is `player` (it happens only for the player it is about) or `world` (every peer sees it). `hurt`, `heal`, `death`, `levelUp` and `landingHard` also carry `personal = "player"`: their screen parts play only for a player who is the target, never for whoever called `play`. See the multiplayer rules below.
| name | kind | strength | scope | pieces | what it is |
|---|---|---|---|---|---|
| `coinPickup` | combo | light | world | squash + vfx | A coin sparkle and pulse. |
| `death` | combo | heavy | world | shake + hitstop + flash + squash + vfx | A death: crushed flat, smoke, a slow-motion freeze, and a dark fade for the player who died. |
| `explosionLarge` | combo | heavy | world | shake + hitstop + flash + squash + vfx | A big blast: fireball, debris, a dust ring, a flash and a shake everyone feels by distance. |
| `explosionSmall` | combo | medium | world | shake + squash + vfx | A small blast: fireball, smoke and a distance-scaled shake. |
| `heal` | combo | light | world | flash + squash + vfx | Healing: rising green motes and a soft green pulse for the healed player. |
| `hitCritical` | combo | heavy | world | shake + hitstop + flash + squash + vfx | A critical: a long freeze into slow motion, a hot flash, a kick away from the attacker and zap sparks. |
| `hitHeavy` | combo | medium | world | shake + hitstop + flash + squash + vfx | A heavy hit: bigger sparks, a freeze, a strong shake and a flash. |
| `hitLight` | combo | light | world | shake + hitstop + squash + vfx | A light hit: sparks, a tiny freeze, a small shake and a wobble. |
| `hurt` | combo | medium | player | shake + hitstop + flash | Taking damage: a red vignette, a kick away from the attacker and a tiny freeze. |
| `landingHard` | combo | medium | world | shake + squash + vfx | A hard landing: a dust ring, a landing squash and a short heavy shake. |
| `levelUp` | combo | heavy | world | shake + flash + squash + vfx | Level up: a golden column, a ring, confetti, a golden glow and a stretch. |
| `spawn` | combo | light | world | squash + vfx | Something appears: a pop-in with a magic burst. |
| `shakeEarthquake` | shake | heavy | world | shake | A rolling earthquake that swells, peaks and fades over four seconds for everyone nearby. |
| `shakeExplosion` | shake | heavy | world | shake | A blast shake that every player feels, weaker the farther they are from it. |
| `shakeHeavy` | shake | heavy | player | shake | A strong jolt for big hits and slams. |
| `shakeKick` | shake | medium | player | shake | A directional kick that pushes the view away from where the hit came from (pass `from` or `direction`). |
| `shakeLight` | shake | light | player | shake | A quick, small jitter for light hits and footfalls. |
| `shakeRecoil` | shake | light | player | shake | A weapon recoil: the view snaps back and up, then settles. |
| `shakeRumble` | shake | medium | player | shake | A long, low rumble for engines, collapsing rock or a boss approaching. |
| `hitstopCritical` | hitstop | heavy | player | hitstop | A 180 ms freeze that eases out through slow motion, for critical hits and finishers. |
| `hitstopHeavy` | hitstop | medium | player | hitstop | A 120 ms freeze for heavy blows. |
| `hitstopLight` | hitstop | light | player | hitstop | A 50 ms freeze that lands a light hit. |
| `hitstopSlowmo` | hitstop | medium | player | hitstop | Half a second of 25% slow motion with no freeze, for near misses and dramatic moments. |
| `flashBlackout` | flash | heavy | player | flash | A fade to black and back, for death, respawn or a scene cut. |
| `flashCritical` | flash | heavy | player | flash | A blinding white-orange burst with hot edges. |
| `flashDamage` | flash | medium | player | flash | A red vignette that closes in from the screen edges when you take damage. |
| `flashExplosion` | flash | medium | world | flash | A bright blast flash every player near the explosion sees. |
| `flashFreeze` | flash | light | player | flash | An icy blue vignette for frozen, chilled or shielded. |
| `flashHeal` | flash | light | player | flash | A soft green pulse from the edges. |
| `flashHit` | flash | light | player | flash | A sharp white flash across the screen. |
| `flashLevelUp` | flash | medium | player | flash | A warm golden glow that blooms and fades. |
| `flashPoison` | flash | light | player | flash | A sickly purple-green vignette for poison and curses. |
| `flatten` | squash | heavy | world | squash | Crushed flat, held, then springs back up, for deaths and stomps. |
| `jellyWobble` | squash | light | world | squash | A jelly wobble when something is hit. |
| `popIn` | squash | medium | world | squash | Grows from nothing with an overshoot, for spawns and pickups appearing. |
| `pulse` | squash | light | world | squash | A quick uniform bump for collect, select or a heartbeat. |
| `punch` | squash | heavy | world | squash | A hard punch inward that rings out, for heavy impacts. |
| `squashLand` | squash | medium | world | squash | Flattens on landing and springs back. |
| `stretchJump` | squash | medium | world | squash | Stretches tall on take-off and settles. |
| `vfxCoin` | vfx | light | world | vfx | A coin sparkle. |
| `vfxConfetti` | vfx | medium | world | vfx | A confetti burst. |
| `vfxDust` | vfx | light | world | vfx | A dust puff. |
| `vfxExplosionLarge` | vfx | heavy | world | vfx | A large explosion: fireball, debris and a smoke ring. |
| `vfxExplosionSmall` | vfx | medium | world | vfx | A small explosion. |
| `vfxFire` | vfx | medium | world | vfx | A burst of fire. |
| `vfxHeal` | vfx | medium | world | vfx | Rising healing motes. |
| `vfxLevelUp` | vfx | medium | world | vfx | A level-up column and ring. |
| `vfxMagic` | vfx | medium | world | vfx | A magic burst. |
| `vfxSmoke` | vfx | medium | world | vfx | A smoke cloud. |
| `vfxSparks` | vfx | light | world | vfx | Impact sparks. |
| `vfxZap` | vfx | medium | world | vfx | An electric zap. |
Every `vfx` preset and every combo's effect is drawn in the current look: **cartoon** (solid toon shapes, the default), **realistic** (soft additive glow, smoke and dark debris), **pixel** (chunky cubes snapped to a grid) or **scifi** (cyan and magenta energy). Effect kinds: `sparks`, `dust`, `explosionSmall`, `explosionLarge`, `magic`, `heal`, `levelUp`, `coin`, `smoke`, `fire`, `zap` and `confetti`.
## Interface (`require("juiceKit.juice")`)
### Playing
`Juice.play(name, target?, opts?) -> result` plays a preset. It raises on an unknown preset, look or target, and otherwise returns a table saying what ran on this peer: `{ shake = strength, hitstop = true, flash = alpha, squash = true, vfx = particleCount, sent = true, id = playId }`. Every play gets an `id`, even one that intensity 0 turned into nothing, so the result can always be handed to `Juice.stop`.
- `target`: an entity, an entity id, an entity name, a position (`{x,y,z}`, `{x=,y=,z=}` or a vector), or nil for the local player.
- `opts`:
| option | meaning |
|---|---|
| `intensity` | Multiplier for this call (multiplied by the global intensity). |
| `look` | Draws this call's effect in a look other than the current one. |
| `from` | The attacker: an entity or a position. Kicks push away from it, and when the target is a thing (not a player) and `from` is another player's avatar, the personal parts land on that attacking player's screen. |
| `direction` | World direction the hit travels, used instead of `from`. |
| `scale` | Effect size multiplier. |
| `axis` | Squash axis: `"y"` (default), `"x"` or `"z"`. |
| `scope` | `"auto"` (default): world parts go to every peer, personal parts to the one player they belong to. `"local"`: play here only what belongs on this peer and send nothing, for code that already runs on every peer. `"world"`: also send a player preset's world parts to every peer. |
| `seed` | Seed for a repeatable effect. |
`Juice.stop(what) -> boolean` stops one play early and leaves everything else running. `what` is the table `play` returned, its `id`, or a preset name; a name stops every play of that preset on this peer, its own and ones received from other peers.
```lua
local charge = Juice.play("shakeRumble")
-- the charge was cancelled:
Juice.stop(charge)
```
- The shake comes off the camera with no drift; other shakes keep going.
- The hitstop lets go: normal speed comes back at once unless another hitstop still holds.
- The flash disappears, the squash lands on the exact base scale, and the effect's particles and light vanish.
- Stopping a play this peer sent to others stops it on their screens too.
- It returns false for a play that has already finished or was stopped, and for nil. An unknown preset name raises.
`Juice.isPlaying(what) -> boolean` says whether any part of a play (same `what` as `stop`) is still running here.
`Juice.stopAll()` stops every shake, hitstop, flash, squash and effect at once, puts squashed things back at their exact base scale, and releases the time scale. It acts on this peer only and forgets every play id.
### Finding presets
- `Juice.list(filter?) -> rows` returns every preset as `{ name, kind, strength, scope, personal, looks, pieces, effects, tags, custom, description }`, sorted by kind and then name.
- A **kind name** string (`"shake"`, `"combo"`, …) keeps that kind.
- Any other string matches a tag or a name (`"heavy"`, `"world"`, `"explosion"`, `"custom"`).
- A table `{ kind?, strength?, scope?, look?, piece?, tag?, custom? }` requires all of its fields.
- `Juice.names(filter?) -> { string }` returns the same rows as `list`, names only.
- `Juice.get(name) -> row?` returns one row plus `spec`, the resolved pieces. Both are copies: changing them never changes the preset.
- `Juice.has(name) -> boolean` says whether a preset by that name exists.
- Constants: `Juice.KINDS`, `Juice.STRENGTHS`, `Juice.SCOPES` and `Juice.PIECES`.
### Making your own
`Juice.define(name, spec) -> row` adds a preset, or replaces one you defined earlier. Each piece in `spec` is either the name of an existing preset of that kind or a table of its own:
```lua
Juice.define("bossSlam", {
shake = "shakeEarthquake", hitstop = "hitstopHeavy", flash = "flashExplosion",
squash = "punch", vfx = { kind = "explosionLarge", scale = 1.6 },
strength = "heavy", scope = "world", tags = { "boss" },
description = "The boss hits the floor.",
})
Juice.play("bossSlam", boss)
```
Besides the pieces, `spec` takes `kind`, `strength`, `scope` (`"player"` or `"world"`, worked out from the pieces when left out), `personal` (`"target"`, the default, or `"player"` for moments that only make sense for a player; see the multiplayer rules), `tags` and `description`.
Your presets can be built on each other (`Juice.define("bossSlamBig", { shake = "bossSlam", vfx = { "bossSlam", "confetti" } })`), and changing one never breaks another:
- `Juice.remove(name) -> boolean` removes a preset you defined (false when there was none). Presets built on it keep working exactly as before: each keeps its own copy of the pieces it took from it.
- Redefining a preset changes what the presets built on it play, except for a piece the new version no longer has: those keep the piece they had.
- `define` and `remove` refuse a change that would leave another preset broken, such as two presets naming each other in a loop. They raise `juiceKit.define: 'a' would break 'b': …` (or `'a': …` when `a` itself would break), and every preset stays as it was.
### Settings
| call | what it does |
|---|---|
| `Juice.setLook(name)` / `getLook()` / `looks()` / `hasLook(name)` / `vfxKinds()` | The one look switch every effect follows. |
| `Juice.setIntensity(x)` / `getIntensity()` | Global intensity, clamped to 0–2. It scales shake, flash, hitstop and squash strength and effect density; 0 turns the kit off. |
| `Juice.setReduceMotion(on)` / `getReduceMotion()` | Motion-sickness comfort: no camera shake at all, and gentler, shorter flashes. Hitstop, squash and effects still play. |
| `Juice.stats()` | Counters and live counts from every piece, plus `recent`, the last 12 plays and receives with who got the personal parts, for HUDs, tests and debugging. |
Settings are per peer, so each player chooses their own look, intensity and comfort.
## Multiplayer
The world is always multiplayer, and `play` routes each piece for you. Every preset is made of two kinds of parts:
- **World parts** happen at a spot: the visual effect, the squash, a blast shake (one with distance falloff), a blast flash (one with a radius) and a blast's hitstop. For a `world`-scope preset they run here and on every other peer. Each peer works out **its own** shake, flash and freeze from **its own** distance to the spot. A far player sees the blast without being shaken. Only players within roughly the inner third of the shake's range freeze.
- **Personal parts** belong to one player's screen: a fixed camera shake, a screen flash and a hitstop. They play for **exactly one player**, never for everyone:
| target | who gets the personal parts |
|---|---|
| nil, or your own avatar | you |
| another player's avatar | only that player (it is sent to them) |
| a thing (an NPC, a prop) or a point | you, or the attacking player when `opts.from` is another player's avatar |
| a thing or a point, for a preset with `personal = "player"` | nobody: these moments only make sense for a player |
The presets with `personal = "player"` are `hurt`, `heal`, `death`, `levelUp` and `landingHard`. A healer never sees the healed player's green pulse, and a killer never gets the victim's blackout. A hit (`hitLight`, `hitHeavy`, `hitCritical`) on an NPC lands on the attacker's screen; on another player it lands on theirs.
- **Squash on a shared object**: a synced entity owned by another peer is squashed by its owner, who receives the same play, and its scale reaches everyone from there. Peers never write to an entity they don't own.
- `scope = "local"` plays here only what belongs on this peer and sends nothing. Use it from code that already runs on every peer (for example, reacting to a synced health field): each peer then gets exactly its own share.
- `Juice.stats().recent` shows, for the last few plays and receives, who got the personal parts (`"here"`, `"nobody"` or the recipient avatar's id) and what ran.
- **Stopping travels like playing:** `Juice.stop` on a play this peer sent also sends a small stop message, and each peer that received the play stops exactly that one (matched by sender and play id). Stopping by name stops received plays of that name on your own screen only. A peer on a version before 1.1.2 ignores the stop and lets its share finish.
## Guarantees
- **No camera drift:** the shake is added after every camera controller has run and removed before they run again. Measured on all nine builtin controllers (orbital follow, third person follow, first person, orbit, chase, side scroller, bird's eye, isometric and RTS): the camera returns to exactly where it would have been (0.000000 m of drift).
- **Hitstop never sticks:** it is one owned time-scale hold. The slowest live request wins, and every span is capped (no freeze longer than 0.6 s). It always releases on end, on `stopAll`, on interruption and on reload.
- **Cheap and self-cleaning:** particles are pooled instanced draws, capped at 700 live, with the oldest stolen first. Lights come from a pool of 4. Nothing is spawned per particle, and everything drains to zero and idles when quiet.
- **Flashes are part of the picture:** they are drawn by one post-process pass over the lit scene, before the UI, so your HUD stays on top and untinted. The pass is switched off whenever no flash is playing. A copy of the module left behind by a hot reload can never switch it back on.
- **Never touches the editor:** the shake only moves the gameplay camera in play mode, never the editor's fly-camera. A peer drops plays sent by other peers until its own scene has arrived.
## Pieces (for advanced use)
The `juice` module is the front door. The modules under it can be used directly:
| module | what it holds |
|---|---|
| `juiceKit.shake` | Additive camera shake layers (noise, kick, rumble, quake) with distance falloff. |
| `juiceKit.hitstop` | Owned, capped time-scale holds. |
| `juiceKit.flash` | Full-screen flashes, vignettes and glows, drawn into the rendered picture by one post-process pass under the UI. |
| `juiceKit.squash` | Squash-and-stretch springs that always return to the base scale. |
| `juiceKit.vfx` | Pooled particle effects: 12 kinds in 4 looks. |
| `juiceKit.net` | The `juiceKit.play` message channel. |
| `juiceKit.presets` | The data: presets, looks, palettes and effect recipes. |
| `juiceKit.util` | Small vector, quaternion and targeting helpers. |
Components: **JuiceNet** (optional; listens from scene load and sets the starting look), **JuiceShakeApply** and **JuiceShakeRestore** (attached to the camera automatically). Assets: `juiceParticle.shader`, `juiceFlash.shader` and the `juiceSolid`, `juiceGlow` and `juiceSmoke` materials.
## Tests
Run them the standard way, in edit mode: `tools.use("tests", "run", "juiceKit")` (the shell form is `zero tests run juiceKit`). The suite is named `juiceKit`, not `juiceKit.testSuite`. You can also run it directly with `asset.resolve("juiceKit.juiceKit", "testSuite"):run()`. 104 tests cover every public call. They pass on repeated runs, either way, with or without a player or a scene camera:
- **Playing:** every target form, and exactly what a play reports.
- **Listing:** every filter, the sort order, and the copies `list` and `get` hand out.
- **Defining your own presets:** broken specs are refused, defaults are worked out, redefining replaces, and presets build on presets.
- **Presets built on presets:** removing or redefining a base keeps its dependents playing the same thing, a chain survives losing its root and then its middle, and loops are refused with the old preset intact.
- **Looks:** each look's material and shape, and a look given to one play.
- **Intensity:** flash alpha, shake strength, particle count and hitstop length.
- **Reduce motion:** no shake, gentler flashes.
- **Stopping early:** `stopAll` mid-shake, mid-hitstop, mid-squash, mid-flash and mid-effect.
- **Stopping one play:** `Juice.stop` and `Juice.isPlaying` by result, id and name. Tested per piece (one shake, hitstop, flash, squash or effect stopped while another keeps going) and on whole combos, and a stop sent to and received from another peer reaches only that play.
- **Multiplayer routing:** another player's flash and hitstop never land on the caller, blasts are scaled by distance, and squash is left to the owner.
## Watch out for
- **Play from code every peer runs, or from one peer with the default scope.** By default (`scope = "auto"`) `play` sends the world parts to everyone. If the same call already runs on every peer (for example, a reaction to a synced health value), pass `scope = "local"`, or each peer receives it once per sender.
- **Shakes only move the camera in play mode.** In edit mode `Juice.play` still runs the effects, flashes and squashes, but it never touches the editor's fly-camera.
- **The flash is a post-process pass.** It tints the scene camera's picture, under your UI. If you want a flash *over* the UI (a menu fade), use a UI screen instead. The pass also tints offscreen captures and render-to-texture cameras taken while a flash plays, and clamps highlights brighter than white for that moment.
- **Heavy scenes slow down joiners.** Every entity a scene bakes in is replicated in play, and a joining peer receives them all as one snapshot before its gameplay starts. Keep static scenery light, or spawn it from code each peer runs, so new players get in quickly.
- **Reduce motion is per peer.** It is a comfort setting for the player at this screen. Set it from that player's own options, not from shared state.
- **Give every joiner a start view of your own.** A player prototype's camera rig is `OwnerOnly`, so a joiner whose player was spawned on another peer receives the body and no camera. The showroom's Gallery shows one way to handle it: it gives that player a local follow camera, and works out the starting heading from the spawn and the stage rather than from the body.
- **Testing with the standard runner.** `tests.run` runs a suite in edit mode with no avatar, an empty scene and fallback lighting, and in edit mode the camera on screen is the editor's. The kit's "where am I" (`U.herePos`) falls back from the avatar to the gameplay camera, then to the camera on screen, then to the world origin, so the suite passes either way. The runner takes the suite's asset name: `juiceKit`, not `juiceKit.testSuite`.
- **A second player needs a second client.** Two agent sessions in one engine (`play as <name>`) are one peer, so a real two-player check needs another desktop session or the web, ideally signed in to another account (see #14448).
- **Small API traps.** `entity.exists` raises when given a live entity's name, so pass an id. `_G` is sealed. A local named after an engine namespace (`live`, `av`) hides that namespace.
## Known issues
These are engine problems met while building the kit or the showroom, each with its engine issue number. The kit or the showroom works around each one where it can; they are listed so a creator who meets one knows what it is.
- **No character is drawn on a joining player's screen** (#16913, #14448). In a two-session test, the player who joined saw neither their own character nor the other player's. Another player's avatar arrives with no visible body (#16913), and two sessions signed in to the same account are mishandled when the second one joins (#14448). The showroom still gives the joiner a working view and HUD, and every effect still plays for them.
- **A joining player in a session with no window gets no view** (#19169). Run two-player checks with two windowed sessions.
- **A one-shot particle burst `.effect` fails to build** (#19206). The kit draws its effects with its own pooled particles instead, so nothing in the kit depends on it.
- **A test clean-up hook registered outside a group is dropped** (#12497). A top-level `Test.afterEach` never runs, so each `describe` in the kit's suite installs its own clean-up.
- **A follow camera binding to a body whose look has not loaded starts in front of it** (#19313). The orbital follow camera takes its heading from the body's facing when it binds. A body that has not loaded yet reports the default facing, so the camera lands in front of the player looking away. The showroom sets the heading itself for the first moments after a player appears.
- **Harmless `asset-unresolved` warnings** (#12937). The code checker reports them for `~.JuiceShakeApply`, `~.JuiceShakeRestore`, `~.juiceSolid`, `~.juiceGlow`, `~.juiceSmoke` and `~.juiceFlash`; they all resolve when running.
# SecondWind 1.2.0
Health, damage, death and respawn for any multiplayer game, in one component. Give players, enemies and breakable props health with one call, hurt them with another, and choose everything else with a setting: six health displays (a plain HP bar, a regenerating shield, armour, Zelda-style hearts, lives, or downed-then-revive), eight damage types with flames, sparks and numbers every player sees, eight death styles, teams and friendly fire, a kill feed in five looks, traps, and genre presets from shooter to party game, all labelled in a catalog you can filter by genre and visual style. Built on Lifeline (health, bars, numbers) and MatchFlow (countdowns, rounds), and multiplayer-safe.
## What's new in 1.2.0
The package is now called **SecondWind**, like this world, and it sits beside Lifeline and MatchFlow. It used to be called healthKit. It is still the same package with the same guid (`5da0a53c-5953-4adc-80fb-f5afb02f6568`), and every call, setting, event, preset and network channel works exactly as in 1.1.4.
- **New require line:** `local HK = require("SecondWind.api")`, in place of `require("healthKit.api")`.
- **New asset names:** the components are `SecondWind.Vitals`, `SecondWind.Rules`, `SecondWind.HUD` and `SecondWind.Trap`, the modules are `SecondWind.catalog`, `SecondWind.logic`, `SecondWind.feed` and `SecondWind.fx`, and the test suite is `secondwind`.
- **Upgrading from 1.1.x:** after you update, change `healthKit.` to `SecondWind.` in your `require` and `asset.resolve` lines. An installed copy stays at the version it was installed at, so a world on 1.1.4 keeps working until it updates.
- **Kept on purpose:** the network channels keep their names (`healthKit:evts`, `healthKit:state` and the rest), and the HUD's screen id stays `healthkit_hud`, so nothing a game sends, listens for or looks up changes.
- **A store listing:** the package description now says what the kit gives a creator, its six health displays and eight death styles, and how to install it with Lifeline and MatchFlow.
## What's new in 1.1.4
Every call, setting, event and preset works exactly as in 1.1.1. This version fixes the first example and the descriptions.
- **The first example works as pasted.** Its dummy stood outside the lava and above it, and nothing in the kit moves a dummy, so it couldn't be pushed in and never burned. It now stands in the edge of the lava, on the ground, and an orange slab shows where the lava is. Checked in a new world holding only the two-call install: the dummy went from 60 to 40 to 20 to dead in about 4 s, came back with full hearts where it stood 4 s later, and did it again; a player standing in the lava burned from 150 to 95 in 3 s.
- **Second Wind opens in the gallery again.** 1.1.3 saved a small test scene as the world's main scene by mistake, so the editor opened in it.
- **Known limits names the public engine issue #19188** for the install problem, instead of two internal ticket ids.
- **The package description says eight death styles**, as the catalog and this README do. It said six and left out `remove` and `stay`, and it called the component `Health` instead of `Vitals`.
## What's new in 1.1.3
Every call, setting, event and preset works exactly as in 1.1.1. This version corrects step 1 again.
- **Install in two calls.** The one-call install that 1.1.2 gave doesn't work. When SecondWind, Lifeline and MatchFlow go in one call, the last two only update the copies SecondWind brought under `/source/deps/` and nothing is installed in `/source`, so SecondWind still can't reach them. Step 1 now installs SecondWind first, then Lifeline and MatchFlow in a second call with `at=/source`. Checked with the published kit in a new world: after the one call, `module 'Lifeline.api' not found` and five publish errors remained. After the second call, the scene built, the player got 150 HP, the lava burned, the hearts dummy died and came back, and `zm push` published the world. If you followed 1.1.2's step 1, run the second call now; it fixes the world in place.
## What's new in 1.1.2
Every call, setting, event and preset works exactly as in 1.1.1. This version fixes the install instructions.
- **The first example works in a brand-new world.** Step 1 used to say Lifeline and MatchFlow come along with SecondWind. In a new world they do arrive, but SecondWind can't reach them: its components fail with `module 'Lifeline.api' not found` and the world can't be published (see Known limits). Step 1 now installs Lifeline and MatchFlow as well. Checked in a new world holding only those three installs: the scene builds, the player gets 150 HP from the `heroShooter` preset, the lava burns, the hearts dummy dies and comes back, the health bar shows, and `zm push` publishes the world. Earlier versions were only checked in the world that authors the kit, which has both installed.
## What's new in 1.1.1
Every 1.0.0 and 1.1.0 call, setting, event and preset works as before. New in this version:
- **Fewer messages per hit.** Events for other peers wait up to 0.05 s and leave together as one `healthKit:evts` message, in order, so a burst (a chain, a blast, a kill and its death) is one message instead of one per event. Listeners on the peer that raised them (`HK.on`) still hear each event at once, and a health snapshot never overtakes the events queued before it. 1.1.1 still receives the one-per-event `healthKit:evt` messages that 1.1.0 peers send, so mixed sessions work.
- **`HK.syncReport().totals`** also counts `evtSent` (event batches sent) and `evtBatched` (the events inside them).
- **A lighter showcase gallery.** Its dummies and damage stations only work while a player is within 18 m (`wakeRadius` on the Gallery component). The dummies drain just as fast with half as many hits, the kill-feed boards rebuild only when the feed changes, signs and pads refresh a few times a second instead of every frame, and name labels are looked up by name instead of by scanning every entity. With a player inside, the gallery's host now sends about 12 to 14 messages a second instead of about 46, a damage request through a browser host comes back in 0.2 to 0.4 s instead of 25 s or more, and on a peer that doesn't host it the Gallery script costs 0.07 ms a frame.
- **Gallery labels stay hidden after a scene rebuild.** Right after a rebuild the engine can briefly refuse to switch a `{PlayerName}` label off; the gallery now retries until it holds.
## What's new in 1.1.0
Every 1.0.0 call, setting, event and preset works as before. New in this version:
- **A labelled catalog.** Every health model, damage type, death style, kill feed look and preset now says its `kind`, its `family`, the `genres` it suits, whether it has a look of its own (`hasLook`) and, if so, its `style`. `HK.find({ kind = "preset", genre = "coop" })` picks by label, and `HK.vocabulary()` lists the words. See [Picking from the catalog](#picking-from-the-catalog).
- **Hit effects for everyone.** Flames, sparks, bubbles and frost now show on every player's screen, not just for the player who landed the hit. Each hit is also written into the target's synced `hitSeq`, `hitType` and `hitAmount`, so a peer that misses the hit message still draws the effect.
- **One health on every screen.** On every death, respawn, revive, life lost and knock-down, the owner sends a full state snapshot (`healthKit:state`) at once, again after 0.5 s and again after 2 s. It also sends one after any change and every 5 s regardless. A peer whose view stays off for more than half a second asks for a fresh one (`healthKit:resync`). `HK.state` gives the latest agreed snapshot, and `HK.syncReport()` shows how sync is doing.
- **Fall damage fixed around death and respawn.** Fall tracking now starts fresh on death, on respawn and on any teleport, with one second of grace after a respawn. A player who died from a fall no longer takes a second fall hit when they reappear, and their own screen and everyone else's agree.
- **A clean gallery and a new cover.** The gallery dummies no longer show a `{PlayerName}` placeholder, in play or in the editor.
## Install and use (five minutes)
1. **Install.** Install three packages from ZeroMind, in two calls:
- first **SecondWind**: `zeromind_install guid=5da0a53c-5953-4adc-80fb-f5afb02f6568`
- then the two it is built on, **Lifeline** (`7b85ce05-3903-4374-b654-2d4ffea295a5`) and **MatchFlow** (`31cf13b4-df7d-4888-85e2-7947a7301614`), into `/source`: `zeromind_install guid=7b85ce05-3903-4374-b654-2d4ffea295a5,31cf13b4-df7d-4888-85e2-7947a7301614 at=/source`
Don't skip the second call, don't merge the two, and keep `at=/source`. SecondWind brings its own copies of Lifeline and MatchFlow but the engine doesn't connect it to them yet, and in one call, or without `at`, the second install only updates those copies (see Known limits). While Lifeline installs, the log may say `'~Lifeline.api' is ambiguous` for a moment; that clears when the install finishes.
2. **Set it up in your scene's `build.luau`.** A new world's main scene (or one made with the `player` scene template) builds its player in `build.luau` with a line like `local body = entity.spawn("body", { parent = prototype })`. Add these lines at the end of `content()`, after that player setup:
```lua
local HK = require("SecondWind.api")
-- World rules + on-screen HUD (kill feed, death screen, bars). Rules must be synced.
local rules = entity.spawnSynced("HealthRules")
rules.component.add(HK.RULES, { preset = "heroShooter" }) -- or leave preset empty
entity.spawn("HealthHUD").component.add(asset.resolve("SecondWind.HUD", "component"), {})
-- Players: give the PlayerPrototype's body health. Every joining player gets it.
HK.attach(body, { team = "blue" }) -- `body` from the default player setup
-- A trap: lava that burns anyone standing in it (a 3 x 1 x 3 m box on the ground).
local lava = entity.spawnSynced("Lava", { position = { 0, 0.5, 4 } })
lava.component.add(asset.resolve("SecondWind.Trap", "component"),
{ damageType = "fire", label = "Lava", amount = 15, sizeX = 3, sizeZ = 3 })
-- An orange slab on the ground, so you can see where the lava is.
local glow = entity.spawn("LavaGlow", { position = { 0, 0.05, 4 } })
glow.component.add("Model", { model = "cube", material = asset.resolve("@builtin::materials.plastic_white", "material"),
tintR = 1, tintG = 0.35, tintB = 0.05, tintBlend = 1 })
glow.localScale = { 3, 0.1, 3 }
-- A dummy standing in the edge of the lava: it burns, dies, and comes back where it stood.
local dummy = entity.spawnSynced("Dummy", { position = { 1, 0.5, 4 } })
dummy.component.add("Model", { model = "cube" })
HK.attach(dummy, { model = "hearts", deathStyle = "timed", displayName = "Dummy" })
```
3. **Press Play.** The lava is the orange slab about 4 m in front of where you start, and the dummy stands in its edge. The dummy catches fire, loses its hearts, dies, and comes back where it stood 4 seconds later, then burns again. Walk onto the slab and you catch fire too: flames, damage numbers, BURNING on screen and your bar dropping from 150. Stay in and you die and come back at the spawn point after the death screen's countdown.
Hurting something from code is one call:
```lua
HK.damage(target, 20, { by = attacker, type = "fire", team = "red" })
```
Switching anything is one setting, even while the game runs:
```lua
HK.setModel(target, "shield") -- or hearts, lives, downed, armor, hitPoints
HK.setDeathStyle(target, "spectate") -- or instant, timed, wave, ragdoll, dissolve, remove, stay
HK.applyPreset("battleRoyale") -- the whole world at once
```
`HK.catalog()` lists everything below, with descriptions and labels.
## Picking from the catalog
Every entry (model, damage type, death style, feed look, preset) carries these labels:
| label | values |
|---|---|
| `kind` | `healthModel`, `damageType`, `deathStyle`, `feedLook`, `preset` |
| `family` | health models: `pool`, `counted`, `secondChance`. Damage types: `direct`, `elemental`, `world`. Death styles: `respawn`, `deathAnimation`, `outOfPlay`. Feed looks: `killFeed`. Presets: `competitive`, `adventure`, `cooperative`, `casual` |
| `genres` | a list from `shooter`, `heroShooter`, `battleRoyale`, `adventure`, `roguelike`, `rpg`, `coop`, `survival`, `platformer`, `party` |
| `hasLook` | `true` when the entry draws something of its own |
| `style` | for entries with a look: `minimal`, `scifi`, `fantasy`, `cartoon`, `pixel` (a preset takes the style of its kill feed look) |
```lua
local HK = require("SecondWind.api")
HK.find({ kind = "preset", genre = "coop" }) -- { coopRevive entry }
HK.find({ kind = "feedLook", style = { "pixel", "cartoon" } }) -- any of the listed values
HK.find({ kind = "healthModel", family = "counted" }) -- hearts, lives
HK.find({ genre = "roguelike" }) -- everything that suits a roguelike
for _, p in HK.find({ kind = "preset", genre = "battleRoyale" }) do
print(p.id, p.family, p.style, table.concat(p.genres, ", "))
end
HK.vocabulary() -- { kinds, families, genres, styles }, each word with a label and description
```
Every key is optional and takes one value or a list (any of them matches). An unknown key or kind is an error that names the valid ones, so a typo never just returns nothing. `SecondWind.catalog` also offers `C.filter(kind, query)` for one kind (a kind id like `"preset"` or a list name like `"presets"`).
## What it offers
### Health models (`model`)
| id | what it does |
|---|---|
| `hitPoints` | One health bar. |
| `shield` | A shield soaks damage first and recharges after `shieldRegenDelay` seconds without a hit. Electric damage does double to shields. |
| `armor` | An armour pool absorbs `armorAbsorb` of each physical, fire, ice, electric or explosion hit and wears down. Poison, fall and environment damage go around it. |
| `hearts` | Zelda-style hearts with half hearts (one half heart = 10 HP). Every hit costs at least half a heart and rounds up to whole half hearts. |
| `lives` | `lives` lives, each with a pool of `lifeHealth`. Emptying a pool spends a life and refills it. Losing the last life is death. |
| `downed` | A lethal hit knocks you down instead. You crawl at reduced speed and bleed out over `bleedOutTime`. A living teammate who stands within `reviveRadius` for `reviveTime` picks you up. |
### Damage types (`type`)
| id | behaviour | looks like |
|---|---|---|
| `physical` | A plain hit. | white sparks |
| `fire` | Keeps burning for a few seconds after the hit. Ice puts it out. | rising flames; burning targets trail embers |
| `poison` | Stacks up to 5. Each stack ticks every second, and a new hit refreshes them. Goes around armour. | poison bubbles; poisoned targets trail bubbles |
| `ice` | Slows the target to half speed for a few seconds. Puts out fire. | ice shards; chilled targets trail frost |
| `electric` | Chains to up to 3 nearby targets at 60% damage. Double damage to shields. | electric sparks and arcs to each chained target |
| `explosion` | Falls off with distance and knocks targets back (`HK.explode`). | an expanding explosion shell |
| `fall` | Taken automatically when landing faster than `Rules.fallSafeSpeed`. Goes around armour. | landing dust |
| `environment` | Lava, spikes, acid: the world itself (`SecondWind.Trap`). Goes around armour. | red spikes |
### Death styles (`deathStyle`)
| id | what happens |
|---|---|
| `instant` | Back at a spawn point straight away. |
| `timed` | Back at a spawn point after `respawnDelay` seconds, with a MatchFlow countdown on screen. |
| `wave` | Everyone who died comes back together on the next wave, every `Rules.waveInterval` seconds. |
| `ragdoll` | The body is flung and tumbles limp, then respawns after `respawnDelay`. |
| `dissolve` | The body sinks and dissolves into sparks, then respawns after `respawnDelay`. |
| `spectate` | Stays dead and watches a living player until the round ends (`HK.endRound()`, a MatchFlow round ending, or `Rules.autoEndRound`). |
| `remove` | Gone for good: for breakable props and one-shot enemies. |
| `stay` | Stays dead until a script calls `HK.revive`. |
### Presets (`preset`)
| id | model | death style | feed | friendly fire |
|---|---|---|---|---|
| `arcadeShooter` | hitPoints | instant | pixel | off |
| `heroShooter` | shield (150 HP + 75 shield) | timed (5 s) | scifi | off |
| `adventureHearts` | hearts (5) | dissolve | fantasy | off |
| `roguelikeLives` | lives (3 × 40 HP) | ragdoll | pixel | off |
| `coopRevive` | downed (20 s bleed-out) | wave | minimal | off |
| `battleRoyale` | armor | spectate | minimal | on |
| `partyBrawl` | hitPoints, huge knockback | ragdoll | cartoon | on |
### Kill feed looks (`feedLook`)
`minimal`, `scifi`, `fantasy`, `cartoon`, `pixel`. Rows read like "Nova burned Kai" or "Kai was killed by Lava".
## Reference
### How settings resolve
For each entity, each value is taken from the first of these that sets it:
1. The entity's own `Vitals` fields. A `model` or `deathStyle` of `auto` means "not set".
2. The entity's own `preset`.
3. For player bodies only: the world `Rules` (`playerModel`, `playerDeathStyle`, and the `preset`'s tuning).
4. For everything else: `hitPoints` and `Rules.otherDeathStyle`.
`HK.config(target)` returns the resolved result. A model switch keeps the current health fraction.
### `SecondWind.api` (`local HK = require("SecondWind.api")`)
Every target argument accepts an entity, a component or an entity id.
| call | what it does |
|---|---|
| `HK.damage(target, amount, opts?) -> ok, reason` | Hurt a target. opts: `by`, `type`, `team`, `origin {x,y,z}`, `knockback` (m/s), `crit`, `dot`, `label` ("Lava"), `ignoreInvulnerability`, `status` (false = no burn/poison/chill). |
| `HK.heal(target, amount, opts?) -> ok, reason` | Restore health. Hearts round up to half hearts. opts: `by`. |
| `HK.kill(target, opts?) -> ok, reason` | Kill outright, skipping lives and downed. opts: `by`, `type`. |
| `HK.revive(target, opts?) -> ok, reason` | Pick a downed target up, or respawn a dead one now. |
| `HK.explode(pos, opts?) -> count` | Explosion with falloff and knockback. opts: `radius` (5), `damage` (60), `by`, `team`, `knockback` (10), `minFalloff` (0.2). |
| `HK.attach(entity, settings?) -> Vitals` | Add Lifeline.Health and SecondWind.Vitals with these settings. Use it in a build or on a freshly spawned entity. |
| `HK.configure(target, settings) -> ok, reason` | Change any Vitals settings at runtime. |
| `HK.setModel(target, model)` / `HK.setDeathStyle(target, style)` | Switch one setting. Pass `"auto"` to follow the preset or the rules again. |
| `HK.applyPreset(name, target?)` | With no target, sets the world preset. With a target, sets that entity's own preset. |
| `HK.setRules(t) -> ok, reason` | Change world rules, e.g. `{ friendlyFire = true }`. |
| `HK.endRound()` | End the round: spectators come back. |
| `HK.state(target)` | Snapshot in the terms of its model: `health, maxHealth, fraction, shield, maxShield, armor, maxArmor, hearts, heartsLeft, lives, livesLeft, downed, bleedLeft, reviveProgress, dead, deaths, respawnIn, dyingStyle, burning, poisonStacks, chilled, model, deathStyle, preset, team, name`. |
| `HK.isAlive(target)` / `HK.isDowned(target)` | Booleans. A downed target counts as alive. |
| `HK.config(target)` | The resolved settings (model, deathStyle, preset, all tuning). |
| `HK.get(target)` / `HK.lifeline(target)` / `HK.all()` | The Vitals, the Lifeline.Health underneath, and every Vitals keyed by id. |
| `HK.rules()` / `HK.ruleValues()` | The Rules component, and its values as a table (defaults when there is no Rules entity). |
| `HK.nameOf(target)` / `HK.isPlayer(id)` / `HK.idOf(x)` | Display name, player-body test, and id normaliser. |
| `HK.on(event, fn) -> conn` | Listen to an event on this peer (`"*"` = all). `conn:Disconnect()` stops it. |
| `HK.killFeed()` | Recent kill feed entries on this peer. |
| `HK.setDamageFilter(fn?)` | `fn(vitals, amount, info) -> amount`, run on the owner after team checks. Return 0 to cancel. |
| `HK.catalog()` | Every model, damage type, death style, preset, feed look and event, plus `version` and `vocabulary`. Every entry carries its labels. |
| `HK.find(query?) -> { entry }` | Catalog entries matching labels: `kind`, `family`, `genre`, `style`, `hasLook`, `id`. Each takes one value or a list. |
| `HK.vocabulary()` | The words the labels use: kinds, families per kind, genres and styles. |
| `HK.syncReport()` | How health sync is doing on this peer: `totals` (`sent`, `asked`, `served`, `resyncs`, `snapshots`, `evtSent`, `evtBatched`), the entities this peer `owned` and the ones it `watched` (in sync, seconds off, latest snapshot). |
| `HK.VERSION`, `HK.HEALTH`, `HK.RULES`, `HK.logic` | The version string, the Vitals and Rules component assets, and the pure rules maths. |
### Events (`HK.on`)
`damaged`, `healed`, `blocked` (reason: immune, friendly, self, armor, dead), `lifeLost`, `downed`, `revived`, `died`, `killed` (a kill feed entry: `killer, killerName, victim, victimName, type, text`), `respawned`, `statusApplied`, `chained`, `exploded`, `wave`, `roundEnded`. They fire on every peer.
### `SecondWind.Vitals` component
Settings: `model`, `deathStyle`, `preset`, `team`, `displayName`, `barStyle`, `startFull`, `invulnerable`, `fallDamage`, `canRevive`.
Tuning: `maxHealth` 100, `regenRate` 0, `maxShield` 50, `shieldRegenRate` 20, `shieldRegenDelay` 3, `maxArmor` 50, `armorAbsorb` 0.66, `hearts` 3, `lives` 3, `lifeHealth` 30, `bleedOutTime` 15, `reviveTime` 3, `reviveRadius` 2, `reviveHealth` 0.3, `respawnDelay` 4, `invulnerability` 0.5 (i-frames after a hit), `knockbackScale` 1.
Read-only synced state: `armor`, `livesLeft`, `downed`, `bleedLeft`, `reviveProgress`, `reviver`, `respawnIn`, `dyingStyle`, `burning`, `poisonStacks`, `chilled`, `activeModel`, `activeStyle`, `activePreset`, `hitSeq`, `hitType`, `hitAmount` (the last hit, for its effect), `changeSeq` (bumped on every health change).
Methods: `damage(amount, opts)`, `heal(amount, opts)`, `kill(opts)`, `revive(opts)`.
It needs `Lifeline.Health` on the same entity; `awake` adds it when missing.
### `SecondWind.Rules` component (one per scene, on a synced entity)
`preset`, `friendlyFire` (off), `selfDamage` (on), `playerModel` (hitPoints), `playerDeathStyle` (timed), `otherDeathStyle` (timed), `feedLook` (minimal), `waveInterval` 10, `autoEndRound` (on), `matchEntity` ("Match", a MatchFlow entity whose round end ends the SecondWind round), `maxRemoteDamage` 1000, `fallSafeSpeed` 10, `fallDamagePerSpeed` 7, `allowClientConfig` (off). Read-only: `waveIn`, `waveSeq`, `roundSeq`.
### `SecondWind.HUD` component (per peer, local)
`feedLook` (`rules` follows Rules.feedLook), `showFeed`, `maxFeed` 5, `feedTime` 6, `showDeathScreen`, `showStatus`, `showDownedMarkers`, `countdownStyle` (any MatchFlow countdown style; default `bold`), `spectateSwitch` 8, `addLifelineHUD`, `autoBarStyle`. Method: `nextTarget()` switches the spectated player.
### `SecondWind.Trap` component
A box volume that hurts what enters it: `damageType`, `label`, `amount`, `mode` (`perSecond` | `onEnter`), `interval`, `sizeX/Y/Z`, `knockback`, `team`, `by`, `playersOnly`, `active`. It runs on the authority of each victim, so put it on any entity, synced or not.
### Other modules
- `SecondWind.catalog`: the data behind `HK.catalog()`.
- `SecondWind.logic`: pure maths (pools, armour and shield split, falloff, fall damage, team rule, feed text).
- `SecondWind.feed`: `render(look, entries, opts)` for your own UI.
- `SecondWind.fx`: `burst(type, pos, amount)` hit effects.
### Multiplayer
- **Authority:** each health is owned by the peer that owns its entity. A player's body belongs to that player, and synced world entities belong to the host. Only the owner changes health. All state is synced fields, so every peer sees the same health, deaths and respawns.
- **Remote calls:** `HK.damage` on a target you don't own sends a request to its owner. The owner checks that you own the attacker you named, applies team and friendly-fire rules, and caps the amount at `maxRemoteDamage`.
- **Admin calls:** `configure`, `kill`, `revive` and `endRound` from another peer are refused unless that peer owns the Rules entity or `allowClientConfig` is on.
- **Events:** effects and events run on every peer. Events for other peers leave in batches on `healthKit:evts`, at most one message every 0.05 s, in the order they were raised; `healthKit:evt` messages from 1.1.0 peers are still received. Hit effects are drawn on the next frame from a queue, and each hit is drawn once per peer however it arrives (event or synced `hitSeq`).
- **State snapshots:** the owner sends the full health state on the `healthKit:state` channel. It sends right away on every death, respawn, revive, life lost and knock-down, then again after 0.5 s and 2 s. It also sends after changes (at most once a second for players, every 3 s for others) and every 5 s regardless. Snapshots are batched, about 2 to 3 messages a second for a busy scene. A peer whose fields disagree with the last snapshot for over 0.5 s asks for a fresh one on `healthKit:resync`. All the kit's channels are registered when the kit loads; you do not call them yourself.
### Tests
The `SecondWind.secondwind` test suite has 87 tests covering every public call: catalog labels and filtering, hit effects reaching peers, fall damage around death and respawn, health sync, event batching, the README's install step and first example, the package description, and the 1.2.0 rename (new names, same guid, no old requires left, channels kept). Run it with `tools.use("tests", "run", { suite = "secondwind" })`.
## Known limits
- **#2612: a package cannot list Lifeline as a dependency.** `package.yaml` names no dependencies, so installing SecondWind doesn't install Lifeline and MatchFlow as packages of your world. Install them yourself, as step 1 says.
- **#19188: installing SecondWind doesn't connect it to Lifeline and MatchFlow.** The install logs errors saying a component type was not found and a version is still being fetched. It writes Lifeline and MatchFlow under `/source/deps/`, but SecondWind's `require("Lifeline.api")` doesn't find them there: its components fail with `module 'Lifeline.api' not found`, and `zm push` refuses with `could not resolve require("Lifeline.api")`. Installing Lifeline and MatchFlow in a call of their own with `at=/source`, as step 1 says, fixes both. In the same call as SecondWind, or without `at`, that install answers `updated` with a path under `/source/deps/` and puts nothing in `/source`. The copies under `/source/deps/` stay behind; the MatchFlow one logs `module ~.rules … has no root to resolve against` on each scene rebuild, which the first example plays through.
- **#19168: effects spawned from a message another player sent.** Worked around: the kit queues hit effects and draws them on the next frame instead of inside the message handler, and also draws them from the synced `hitSeq`/`hitType`/`hitAmount` fields when no message arrives.
- **#19201: the `{PlayerName}` label.** The engine's humanoid avatar shows a `{PlayerName}` placeholder on any body no player owns, and has no setting to turn it off. This only matters if you give dummies or enemies the humanoid avatar. The showcase gallery hides those labels in play and in the editor: it disables `PlayerNameLabel` on bodies with no `PlayerAvatar` above them.
- **Message rate.** Lifeline, which the kit builds on and does not change, sends one `lifeline:evt` message for every hit, so the owner of many bodies taking damage at once still sends at least one message per hit. A peer with a low frame rate, such as a browser tab at 10 to 15 fps, drains those messages slowly, and everything sent after them waits behind them. The kit's own events are batched (see 1.1.1). Keep the number of bodies taking damage at the same moment modest, or only run them near players, as the showcase gallery does.
- **Tower freeze, seen once in a browser.** During a two-player test, a player in a web browser walked off the gallery's JUMP OFF platform and stayed hanging in the air, unable to move, until knocked out. It was not reproduced on the native engine, where the same walk falls and takes fall damage, and it may have come from the browser's controls. Nothing in the kit was found to cause it: the kit only moves a player's body when it respawns.
- **Two-player checks.** Checked live with two players, one on the native engine and one in a web browser. In 1.1.0, hit effects, fall deaths and respawns matched on both screens. In 1.1.1, with the browser hosting the gallery, batched events arrived in order and damage requests came back in 0.2 to 0.4 s. The test suite also plays the remote peer's side (receiving, drifting, asking for a resync, catching up).
- **Web browser.** The kit uses only cross-platform engine APIs (no file, OS or native calls outside its tests). In 1.1.1 a browser tab hosted the showcase gallery for the checks above and ran at about 15 frames a second with the gallery awake around a player, against 80 to 200 ms a frame before. Big scenes run slower in a browser than on the native engine, which is why the message rate above matters most there. The first example works in a browser: in a brand-new world with SecondWind installed by the two-call step 1 (on 1.1.2's code, which 1.1.3 and 1.1.4 don't change), the player started with 150 health, the lava set them on fire with flames, damage numbers and BURNING on screen, the bar dropped to about 20 of 150, and the dummy took damage. It plays choppily in a browser tab, even in that small scene. On the native engine the same scene runs at about 19 ms a frame, and the kit's components take about 0.5 ms of it; most of the rest is UI drawing, the built-in character controller and rendering, so the kit is not the likely cause. The browser frame itself has not been profiled. 1.1.4's example only moves the dummy and adds a plain coloured cube, and 1.2.0 only renames the package; both were checked on the native engine.