# 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.
# MatchFlow 1.1.0
**The match loop every multiplayer game needs, as one component.** One call puts a match in your scene, and the world runs:
```
waiting for players → lobby countdown → 3-2-1-GO → round with timer → results → break → next round
```
One machine is in charge, so every player sees the same countdown, clock, round end and results at the same moment, and when that machine's player quits, the match carries on for everyone else on the same clock and scores. Eight match formats sit behind one field. Ten countdown styles with their own 3-2-1-GO sounds, four results screens, seven in-round HUD layouts, a lobby panel and a king-of-the-hill zone with five looks are each one field away, and every style carries tags so a game can list and filter them.
**matchFlowKit is this package.** matchFlowKit is the name it was first asked for. It ships as **MatchFlow** and keeps that name because other kits already install it and reach it by that name: scoreKit, spawnerKit, SecondWind, pickupsKit and others load `~MatchFlow.*`, and renaming the package would break every one of them. One package, one name; searching ZeroMind for either "MatchFlow" or "matchFlowKit" finds it.
## First example
Install the package from ZeroMind (search "MatchFlow match flow kit"). It lands at `/source/MatchFlow.package`. A brand-new world already has a scene with a ground and a player: open `scenes/main.scene/build.luau` and add these two lines at the end of its `content()` function, just before its final `end`:
```lua
local MatchFlow = require("MatchFlow.api")
MatchFlow { format = "timed", seconds = 30, minPlayers = 1 }
```
Press **Play**. A five-second "Starting in" lobby counts down, then a bold 3-2-1-GO with sound, a 30-second round timer with the scoreboard, a podium, a five-second break, and the next round. Every score is 0 until your game reports some (see "Hooking your game in"). Leave out `minPlayers = 1` and the match waits for two players, which is the default.
`MatchFlow { ... }` spawns the synced `Match` entity with a MatchFlow component set from the table, and returns the component. Every field below can go in the table, and `name` names the entity (default `Match`). From inside another package, require it as `"~MatchFlow.api"`.
**Edit it freely.** Change `30` to `45` (or any field) and save: the scene rebuilds and the match updates, on every machine, and a machine that opens the world later loads the new numbers too. Inside a scene's `content()`, `MatchFlow { ... }` always makes the match itself, so the scene's build owns it and keeps it, under the same id, from one rebuild to the next. Called from an ordinary script instead (outside a build), it finds a match already in the scene by that name and updates its fields rather than adding a second one.
To switch the match type, change one field: `format = "firstToScore"`, `"bestOf"`, `"lastStanding"`, and so on. The screens are restyled the same way:
```lua
MatchFlow {
format = "bestOf", rounds = 3, targetScore = 5,
countdownStyle = "trafficLight", resultsScreen = "recap", hudLayout = "scoreBug",
resultsLook = "cartoon", hudLook = "cartoon", lobbyLook = "cartoon",
}
```
The component works without the front door too: `entity.spawnSynced("Match").component.add("MatchFlow", { format = "timed", roundSeconds = 30 })`.
## Hooking your game in
Get the component from any script, report what happens, and listen for what changes:
```lua
local m = require("MatchFlow.api").find() -- the MatchFlow on the entity named "Match"
-- report (call from any machine; non-authority calls are forwarded to the one in charge)
m:addScore(avatar, 1) -- who = peer id, an entity (its owner is the player) or nil for "me"
m:knockOut(victimAvatar) -- lastStanding, or a player going down in waves
m:finish(avatar) -- race: crossing the line
m:clearWave() -- waves: the team cleared a wave
m:endTurn() -- turnBased: the local player ends their turn early
m:setOnHill(avatar, true) -- kingOfTheHill: only needed without a Hill zone (see below)
-- listen
m.events.onStateChanged:connect(function(e) print(e.previous, "->", e.phase) end)
m.events.onRoundEnded:connect(function(e) print("round", e.round, "won by", e.winnerName, e.outcome) end)
m.events.onMatchEnded:connect(function(e) print("match won by", e.winnerName) end)
m.events.onCountdown:connect(function(e) if e.n == 0 then unfreezePlayers() end end)
```
A typical game spawns its pickups, enemies or checkpoints only while `m:getPhase() == "playing"`, and only on the machine where `m:isAuthority()` is true. The example `game/OrbRush.component` in this kit's world does exactly that in about 100 lines: synced orbs, and `m:addScore(body, 1)` on pickup.
A score reported outside `playing`, or for a player who is out or watching, is ignored. You don't need to guard your calls.
## Formats
| `format` | Round ends when | Winner | Fields it reads |
|---|---|---|---|
| `timed` | the clock runs out | highest score | `roundSeconds` |
| `firstToScore` | someone reaches the target (or time runs out) | first to `targetScore` | `targetScore`, `roundSeconds` |
| `bestOf` | as `firstToScore`, per round | most round wins over up to `rounds` rounds; a majority ends it early | `rounds`, `targetScore`, `roundSeconds` |
| `lastStanding` | one player (or none) is left | the survivor; earlier knockouts place lower | `roundSeconds` (0 = no limit) |
| `kingOfTheHill` | someone holds the hill alone for `targetScore` seconds | longest uncontested hold | `targetScore`, `roundSeconds` |
| `race` | everyone has finished (or time runs out) | finish order | `roundSeconds` |
| `waves` | `waves` waves cleared (victory), or everyone down or out of time (defeat) | the team; the top scorer is shown as MVP | `waves`, `roundSeconds` |
| `turnBased` | every player has had `turns` turns | highest score | `turnSeconds`, `turns` |
A round with no time limit (`roundSeconds = 0`) shows elapsed time. A turn-based round shows the current turn's clock.
### The names first asked for
Every one of these works and stays working beside the current name. MatchFlow reads them as the current one, and the machine in charge writes the current name back into `format` and the length into `roundSeconds`, so other kits reading those fields see the usual values.
| First name | Current name |
|---|---|
| `seconds` (field) | `roundSeconds`: above 0, `seconds` wins; 0 leaves `roundSeconds` in charge |
| `format = "scoreLimit"` / `"bestOfRounds"` / `"lastOneStanding"` / `"raceToFinish"` / `"surviveWaves"` | `firstToScore` / `bestOf` / `lastStanding` / `race` / `waves` |
| `countdownStyle = "bigDigits"` / `"scifiHologram"` / `"cartoonPop"` | `bold` / `hologram` / `cartoon` (also for the `Countdown` component's `style`) |
| `resultsScreen = "winnerBanner"` / `"roundRecap"` | `banner` / `recap` |
| event `onState` / `onRoundEnd` | `onStateChanged` / `onRoundEnded`: both fire, with the same fields |
## King of the hill: the Hill zone
Drop a `Hill` on any entity in a level. A player standing in it alone is holding it, two or more is contested, and the match counts the hold time with no code of yours: the machine in charge tells the match who is on the hill.
```lua
-- in a scene's build.luau, inside content()
local MatchFlow = require("MatchFlow.api")
MatchFlow { format = "kingOfTheHill", targetScore = 20, seconds = 90, hudLayout = "holdMeter" }
local hill = entity.spawn("Hill", { position = { 0, 0, 8 } })
hill.component.add(MatchFlow.component("Hill"), { look = "scifiPad", radius = 3 })
```
**A hill that moves:** give it `spots` and `moveEvery`. During a round it hops to the next spot every `moveEvery` seconds, flashing for `warnSeconds` before it goes. Every machine works out the spot from the match clock, so they all agree with nothing extra sent.
```lua
hill.component.add(MatchFlow.component("Hill"), { look = "glowRing", spots = "10,0; -10,0; 0,10", moveEvery = 20 })
```
| Look (`look`) | What it is |
|---|---|
| `glowRing` | a glowing ring on the floor with a soft light column |
| `flagPlatform` | a low platform with a flag that takes the hill's state colour |
| `scifiPad` | a tech pad with segmented light strips and a holo column |
| `runeCircle` | a fantasy stone circle of glowing runes |
| `outline` | a plain thin outline, nothing else |
Each look shows three states readable from across a level: idle, held, and contested (pulsing red). The fields, events and methods are in `Hill.component/README.md`. A hill also works with no match in the scene, lighting up from who stands in it.
## In-round HUD layouts
`hudLayout` picks how the round is shown. Every layout draws the same match, in the `minimal`, `scifi` and `cartoon` looks (`hudLook`), and has its own phone layout.
| `hudLayout` | What it is |
|---|---|
| `classic` (default) | timer at the top centre with the format line, live scoreboard at the side |
| `cornerTimer` | a compact timer in a corner with your place and score |
| `scoreBug` | a sports-TV score bug with a LIVE dot |
| `versusBar` | you against the best other player across the top, with a tug bar |
| `timerStrip` | a thin timer-only strip, nothing else |
| `rankedSidebar` | every player ranked down the side, with gaps to the leader |
| `holdMeter` | king of the hill: who holds it, each player's hold time against the target, CONTESTED |
Draw one yourself with `require("MatchFlow.hud").render(look, data, { w, h, layout, narrow, t })`; `data` comes from `MatchFlow.view.hud(...)`, and `Hud.sample(layout)` gives sample data.
## Every field and its default
| Field | Default | What it does |
|---|---|---|
| `format` | `"timed"` | one of the eight formats above (or a first name) |
| `roundSeconds` | `30` | round length (0 = no limit; `timed` treats 0 as 60) |
| `seconds` | `0` | the round length under its first name; above 0 it wins |
| `targetScore` | `10` | score to win `firstToScore`/`bestOf`, seconds to hold in `kingOfTheHill` |
| `rounds` | `3` | maximum rounds in `bestOf` |
| `waves` | `5` | waves to clear in `waves` |
| `turnSeconds` / `turns` | `10` / `3` | turn length and turns per player in `turnBased` |
| `minPlayers` | `2` | players needed before the lobby counts down |
| `lobbySeconds` | `5` | lobby countdown once enough players are in |
| `countdownSeconds` | `3` | the 3-2-1-GO before each round (0 skips it) |
| `resultsSeconds` | `7` | how long results stay up |
| `breakSeconds` | `5` | break before the next round |
| `autoStart` | `true` | start by itself at `minPlayers`; when false, call `m:start()` |
| `includePeers` | `true` | everyone in the world is a player; when false, call `m:addPlayer(id, name)` yourself |
| `countdownStyle` | `"bold"` | one of the ten countdown styles |
| `resultsScreen` | `"podium"` | `podium`, `table`, `banner` or `recap` |
| `hudLayout` | `"classic"` | one of the seven HUD layouts |
| `resultsLook` / `hudLook` / `lobbyLook` | `"scifi"` | `minimal`, `scifi` or `cartoon` |
| `showUi` | `true` | false draws nothing, so you can build your own UI from `getState()` |
| `sounds` / `volume` | `true` / `0.8` | the countdown's tick and GO sounds |
## Styles and tags
Every style in the kit is in one tagged catalog: the 8 formats, 10 countdowns, 4 results screens, 7 HUD layouts, 5 hill looks and 3 looks.
```lua
local Catalog = require("MatchFlow.api").catalog -- or require("MatchFlow.catalog")
Catalog.list("countdown") -- { { kind, id, name, description, tags, aliases, field } }
Catalog.filter({ tag = "retro" }) -- every retro style, of any kind
Catalog.filter({ kind = "hudLayout", anyTag = { "sports", "racing" } })
Catalog.filter({ kind = "countdown", tags = { "sports", "racing" } }) -- all of the tags
Catalog.get("results", "winnerBanner").id -- "banner": first names resolve
Catalog.tags() -- every tag in use, sorted
```
Kinds: `format`, `countdown`, `results`, `hudLayout`, `hillLook`, `look`. Tags come from a fixed vocabulary: retro, scifi, sports, fantasy, cartoon, minimal, party, arena, racing, arcade, broadcast, competitive, coop, casual, strategy, versus, team.
| Kind | id: tags |
|---|---|
| format | `timed`: arena, party, arcade, competitive · `firstToScore`: arena, sports, party, competitive · `bestOf`: sports, arena, competitive, versus · `lastStanding`: arena, party, competitive · `kingOfTheHill`: arena, party, competitive · `race`: racing, sports, competitive · `waves`: arena, coop, team, scifi · `turnBased`: party, strategy, casual |
| countdown | `bold`: arena, sports, party, arcade · `trafficLight`: racing, sports · `pixel`: retro, arcade · `hologram`: scifi, arena · `cartoon`: cartoon, party, casual · `minimal`: minimal, casual · `fantasy`: fantasy · `flipClock`: retro, minimal · `neon`: retro, arcade, scifi · `broadcast`: sports, broadcast, racing |
| results | `podium`: party, sports, arena · `table`: minimal, arena, racing, competitive · `banner`: party, sports, cartoon · `recap`: sports, arena, competitive |
| hudLayout | `classic`: arena, party · `cornerTimer`: minimal, racing, casual · `scoreBug`: sports, broadcast, racing · `versusBar`: versus, arena, sports, competitive · `timerStrip`: minimal, casual · `rankedSidebar`: arena, party, racing, competitive · `holdMeter`: arena, party, competitive |
| hillLook | `glowRing`: scifi, arena, arcade · `flagPlatform`: sports, party, cartoon · `scifiPad`: scifi, arena · `runeCircle`: fantasy · `outline`: minimal |
| look | `minimal`: minimal, casual · `scifi`: scifi, arena · `cartoon`: cartoon, party, casual |
## Countdown styles
Every style has its own tick (3, 2, 1) and GO sound. The ticks climb a little in pitch as the count nears GO, so the last second sounds urgent. In a match the sounds play on every machine, at the moment each number appears on its screen.
| `countdownStyle` | Look | Sound |
|---|---|---|
| `bold` | big bold slam-in numbers with a glow streak | punch |
| `trafficLight` | red → amber → green racing lights | race beeps |
| `pixel` | 8-bit block digits on a CRT panel | chiptune |
| `hologram` | cyan sci-fi ring, scanlines, "ENGAGE" | sci-fi sweep |
| `cartoon` | bouncy number on a starburst, "GO!" | toy boing |
| `minimal` | thin progress ring, clean type | soft tone |
| `fantasy` | golden runic circle, "BEGIN" | bell |
| `flipClock` | split-flap cards that flip each second | mechanical click |
| `neon` | buzzing neon tube sign | synth |
| `broadcast` | sports-TV lower-third with a LIVE tag | stadium beeps and an air horn |
### A countdown on its own
`Countdown` is the same visuals and sounds as a standalone component, for any other timer in your game: a respawn, a bomb or a door. It is local to each machine.
```lua
local MatchFlow = require("MatchFlow.api")
local cd = entity.spawn("RespawnTimer").component.add(MatchFlow.component("Countdown"), { style = "neon", seconds = 5, autoStart = false, anchor = "top" })
cd.events.onFinished:connect(respawn)
cd:start() -- or cd:start(10)
```
Fields: `style`, `seconds` (3), `autoStart` (true), `loop` / `loopDelay`, `goText` and `caption` (override the words), `sounds`, `volume`, `showUi`, `anchor` (`center`/`top`/`bottom`), `scale`, `layer`. Events: `onTick{n}`, `onFinished`. Methods: `start(seconds?)`, `stop()`, `isRunning()`, `timeLeft()`.
To draw one inside your own UI, call `require("MatchFlow.countdowns").render(style, secondsLeft, { w = 420, h = 320 })`, which returns a widget node.
## Screens
| Screen | Choices | Looks |
|---|---|---|
| Results (`resultsScreen`) | `podium` (top three on steps, then the rest), `table` (full standings with places, score and rounds won), `banner` (one big winner card), `recap` (round-by-round grid with each round's winner) | `minimal`, `scifi`, `cartoon` |
| In-round HUD (`hudLayout`) | the seven layouts above | `minimal`, `scifi`, `cartoon` |
| Lobby / break panel | "Waiting for players 1 / 2" with player chips, "Starting in 5", "Next round in 5" | `minimal`, `scifi`, `cartoon` |
All of them are plain functions over data (`results.render(kind, look, data)`, `hud.render`, `lobby.render`), so you can draw them yourself with `showUi = false`.
### Small screens
- **Every panel sizes itself from the screen.** MatchFlow reads the safe area (the part no notch or system bar covers) each frame through `require("MatchFlow.theme").viewport()`, which returns `{ w, h, narrow, short }` in UI points. `narrow` is a phone held upright (under 640 wide), `short` is one held sideways (under 480 tall).
- **Phones get compact layouts, not smaller text.** Each HUD layout has its own phone arrangement. The podium lists 4th place onwards under the steps, the standings table folds its detail column into each row, the banner stacks the runners-up, and the round-by-round recap turns on its side. Pass `narrow = true` or `false` in the opts of `results.render` and `hud.render` to choose for yourself.
- **Checked** in the engine at a 412 × 892 pt phone, a 248 pt sliver and a 1280 × 702 desktop; a test renders every screen and every HUD layout at phone and desktop sizes. It has not been run on a real phone, and this release has not been checked in a web browser.
- **Fonts** are engine-bundled families (Anton, Archivo Black, Arimo, Bricolage Grotesque, Comic Neue, EB Garamond, JetBrains Mono).
- **Sound** in a browser plays once the page has had a click or tap: browsers keep audio locked until then.
- **Drawing your own panels?** The UI reads a plain number strictly between 0 and 1 in `width`, `height`, `left`, `top` (and their min/max/right/bottom/flexBasis cousins) as a fraction of the parent. Wrap your tree in `require("MatchFlow.theme").solid(node)`, as every kit panel does, to snap those to whole points.
## Methods
| Method | |
|---|---|
| `addScore(who?, amount?)`, `setScore(who?, value)` | add to or set a player's score |
| `knockOut(who?)`, `finish(who?)`, `setOnHill(who?, on)` | knockouts, race finishes, hill presence |
| `clearWave()`, `endTurn(who?)` | waves and turns |
| `addPlayer(id, name)`, `removePlayer(id)` | add bots or hand-picked players (any id that is not a peer id) |
| `start()`, `reset()` | start now regardless of head-count; throw the match away and wait again |
| `getState()`, `getStandings()`, `getPhase()`, `timeLeft()` | read the match as it is on screen (see How multiplayer works) |
| `hillInfo()` | the hill as shown: phase, elapsed, holder, holderName, contested, count, target; cheap enough for every frame |
| `syncInfo()` | clock offset, round trip, display delay and grace on this machine, for checking a multiplayer setup |
| `isAuthority()`, `localPlayer()` | whether this machine is in charge; the local player's id |
Events: `onStateChanged{phase, previous}` (also as `onState`), `onCountdown{n}` (3, 2, 1, then 0 = GO), `onRoundEnded{round, winner, winnerName, outcome}` (also as `onRoundEnd`), `onMatchEnded{winner, winnerName, outcome}`, `onTurn{player, name}`, `onWave{wave}`. Events fire on every machine. `outcome` is one of `time`, `target`, `lastStanding`, `allFinished`, `victory`, `defeat`, `turns` or `empty`.
The front door, `require("MatchFlow.api")`: call it (`MatchFlow { ... }`) or `start(opts)` to spawn or update the match, `find(name?)` for the MatchFlow component on the entity named `Match`, `component("MatchFlow" | "Countdown" | "Hill")` for the component types wherever the package is installed, `catalog`, and `VERSION`.
## How multiplayer works
- **One machine is in charge:** the owner of the synced `Match` entity, normally whoever created it. Only it runs the rules. It keeps a match clock and publishes the whole match (phase, clock, players, scores, history) in a synced snapshot stamped with that clock. It publishes whenever something changes, and at least four times a second.
- **Every screen shows the same moment.** Each machine matches its clock to the match clock by pinging the machine in charge; the quickest of the last eight round trips sets it. It keeps the last few snapshots and shows the match as it stood a short **display delay** ago. The machine in charge shows it that way too. Every snapshot has landed before its moment comes up, so the countdown numbers, the timer, the round end and the results change on every screen at the same instant.
- **The display delay** is long enough for the slowest player's snapshots to have landed: the worst lateness other machines measured in the last 20 s plus 0.05 s, or half the slowest round trip plus 0.1 s if that is longer, kept between 0.15 and 0.6 s. On a live two-machine test with a 60–70 ms round trip it sat at 0.18–0.21 s. When it changes, the new value is published half a second ahead and slides in the same way on every machine.
- **Reads trail by the same delay.** `getState()`, `getStandings()`, `getPhase()`, `timeLeft()`, `hillInfo()` and every event describe the match as it is on screen.
- **The 0:00 hold.** A timed round stays open for a short grace after 0:00 (about 0.2–0.3 s) so reports made just before it still count. The timer sits at 0:00 on every screen for that moment, then the results appear everywhere at once.
- **Reports** (`addScore` and the rest) are sent to the machine in charge, stamped with the time on the reporter's screen. A score, knockout, finish or cleared wave counts if it was made on screen after GO and before 0:00. Calling a report on every machine for the same event would count it more than once, so report each event from one place, typically the authority as OrbRush does. A Hill zone reports from the machine in charge only.
- **Players** are the peers in the room the `Match` entity lives in. Someone who joins mid-round sees "Watching · you join next round" and plays from the next round. Someone who leaves mid-round is counted out of that round, and drops off the board when the match ends.
- **Checking a setup:** `m:syncInfo()` returns whether this machine is in charge, its clock offset and round trip, the display delay and grace, the newest snapshot's age, and the player count.
### If the host quits mid-round
The match carries on for everyone else, on the same clock and with the same scores. Nothing restarts. It is not instant: expect a pause of about 10 seconds first (see Known limits, #9546). And in some cases the match can disappear for a player who stays instead of carrying on (see Known limits, #19490).
1. **The wait.** When the host's game closes, the engine takes about 10 seconds to notice the player has left. Until it does, the relay still counts the host as the `Match` entity's owner, and no other machine may take the match over. For that time the match does not move on: the round does not end, phase changes wait and nothing new is counted. Every machine keeps the last snapshot the host published, at most a quarter of a second old.
2. **The takeover.** Once the engine reports the host gone, the relay hands the `Match` entity to a player who stays, and that machine takes charge at once. It continues from the host's last snapshot on the same match clock, with the same phase, round and scores. It does not wait on the quit host's last snapshot. If the relay does not hand the entity on, the remaining player with the lowest peer id claims it about half a second after the host drops off the room, and anyone else after 4 s.
3. **After it.** The other machines keep the clock they already matched and start pinging the new machine. The player who quit is counted out of the round. A phase change that fell due in the gap (the clock reaching zero, say) happens on every screen at once. A report made between the host quitting and the takeover is lost. A Hill zone re-reports who is on it from the new machine within a second, so hold time keeps counting.
Tested on two real machines with 1.1.0: the machine running the match was quit in the middle of a round. The other machine took over about 10 seconds later; the clock carried on, the scores were kept and the round ended normally. The tests also check the handover from the host's last snapshot: the new machine's clock matches what the other screens showed, scores carry over, a long gap is carried over whole, and the round ends on the same clock.
If everyone leaves, the round ends with outcome `empty`, and the match waits for players again.
## Using it offline or in single-player
With nobody else connected, the local machine is in charge. Set `minPlayers = 1` to play solo, or add bots with `m:addPlayer(1001, "Bot")` and drive their scores yourself.
## What's inside
| Asset | Role |
|---|---|
| `MatchFlow.component` | the match: the rules, sync, events, UI |
| `Hill.component` | the king-of-the-hill zone, five looks, optionally moving |
| `Countdown.component` | standalone countdown |
| `api.module` | the front door: `MatchFlow { ... }`, `find`, `component`, `catalog`, `VERSION` |
| `catalog.module` | every style with its tags |
| `rules.module` | pure state machine for all eight formats (no engine calls, unit tested) |
| `timeline.module` | the shared clock, display delay and host handover (pure, tested with simulated latency) |
| `hill.module` | the hill's pure maths: who is inside, which spot, which state |
| `countdowns.module` | the ten countdown styles as widget-tree renderers |
| `results.module`, `hud.module`, `lobby.module` | the screens and HUD layouts |
| `theme.module` | the minimal, sci-fi and cartoon design tokens and helpers, `viewport()` and `solid()` |
| `view.module` | turns match state into the data each screen draws |
| `audio.module` + `sounds/` | 20 short tick and GO clips, two for each of the ten sound sets |
| `matchflow.testSuite` | the tests (see below) |
Run the tests with `tools.use("tests", "run", { suite = "matchflow" })` in edit mode. They cover every format, the roster rules, every renderer and HUD layout at desktop and phone sizes, the sound sets, two simulated machines over a jittery network, the host handover, the hill's maths, every first name, the catalog and its tags, and this README's version.
## Known limits
Only engine issues with an open GitHub issue are listed here, each by its number. All five were open on 2026-10-18.
- **When the host stops mid-round, the match can disappear for a player who stays (#19490).** Sometimes, not every time (it happened in one of two live tries), the match a scene builds, as the first example builds it, vanishes on the remaining player's machine when the host stops in the middle of a round. That player then has no match: no timer, no scoreboard and no results, and the round does not carry on for them. MatchFlow cannot keep the match alive when the engine removes it.
- **When the host quits, the match waits about 10 seconds before another machine takes over (#9546).** The engine takes that long to notice that a player whose game closed has left, and until it does no other machine may take the match over. Nothing moves on during the wait; then the takeover carries on with the same clock and scores (see "If the host quits mid-round"). A host who only leaves play, without closing the game, was noticed within about a second in the 1.0.0 live test.
- **A kit that arrived as another kit's dependency is not found by its bare name (#19199).** When MatchFlow came with another kit it sits under `/source/deps/`, and `require("MatchFlow.api")` or `component.add("MatchFlow", ...)` answer "not found" from your own scripts. Use `require("~MatchFlow.api")` and `MatchFlow.component("MatchFlow")`.
- **Two players can't be tested inside one engine (#18688, #19114).** The suite tests multiplayer through simulated machines; two-machine behaviour was last checked by hand with two real editors for 1.0.0.
## Changelog
### 1.1.0
- **Hill zone** (`Hill` component): drop it in a level and king of the hill counts hold time on its own. Five looks (`glowRing`, `flagPlatform`, `scifiPad`, `runeCircle`, `outline`), idle / held / contested states, and an optional hill that moves between spots during a round.
- **Seven HUD layouts** behind `hudLayout`: `classic`, `cornerTimer`, `scoreBug`, `versusBar`, `timerStrip`, `rankedSidebar`, `holdMeter`, each in all three looks and on a phone. `classic` is unchanged and still the default.
- **Tags on every style** and `MatchFlow.catalog` to list and filter them.
- **The names first asked for work**: `seconds`, `scoreLimit`, `bestOfRounds`, `lastOneStanding`, `raceToFinish`, `surviveWaves`, `bigDigits`, `scifiHologram`, `cartoonPop`, `winnerBanner`, `roundRecap`, `onState`, `onRoundEnd`. Nothing was renamed.
- **The front door**: `MatchFlow { format = "timed", seconds = 30 }` from `require("MatchFlow.api")`. Inside a scene's `content()` it always makes the match itself, so editing the numbers and saving rebuilds the scene and updates the match on every machine and on a fresh load. (Before this fix, a second build found the match the first build had saved and tried to change it, which the engine refuses: the rebuild failed, and on a machine loading the world everything the build placed after the match, such as a Hill, was missing.)
- **Known limits** now names the ~10 second wait before another machine takes over when the host's game is closed (#9546), and that the match can disappear for a player who stays when the host stops mid-round (#19490).
- **Host quitting mid-round**: the machine the match is handed to no longer defers to the quit host's last snapshot, and a gap of any length is carried over (it was capped at 5 s, which could pull the clock back on every screen).
- **Gallery**: the world opens on it, one page with no tabs, readable from the spawn. Each of the ten countdowns stands beside a results screen, a live card shows the five hill looks with each one labelled under its hill, and the seven HUD layouts sit in one row. "Walk around" puts you next to a live hill to step into. Orb Rush is the second scene.
- **matchFlowKit** is named in this README as this package; the package keeps its name, MatchFlow.
### 1.0.0
- First release: one component for lobby, countdown, round, results and break; eight formats; ten countdowns with sounds; four results screens; HUD and lobby in three looks; shared match clock and host migration.