# Trapline.Checkpoint
Touch it and it becomes where you come back. One setting, `look`, picks it: flag, campfire, beacon, crystal, bell, teleporter, or auto (Course theme). Each looks clearly different before and after you reach it, drawn per machine for that machine's own player. Fields: order (forward-only progress), label, radius, height, restoreLives, preview (editor state). Put it on any plain entity standing on the ground; players come back in front of it (+Z). Method: respawnPoint().
# Trapline.Course
The world rules for a Trapline course: lives per player, respawn delay, fall limit, forward-only checkpoints, where Try Again restarts, spawn protection, automatic player setup, juiceKit feedback, and the world `theme` that every `auto` part follows. Optional, one per scene, on a synced entity. Without it every part uses the same defaults (3 lives, 1.5 s, -25 m, forward only, restart from the start). See the package README, Settings.
# Trapline.Hazard
One trap, picked with one setting, `look`: spikes, lava, saw, axe, crusher, laser, electric, poison, rocks, flame, pit, water. Touching its deadly part costs a life through SecondWind (or `damage` per hit). Moving and timed looks run on Trapline's shared clock so every player sees the same pose; each machine only tests its own player. Fields: sizeX/Y/Z, cycle, offset, damage, interval, grace, label, active, playersOnly. Put it on any plain entity standing on the ground. Method: isDeadly(body, t?).
# Trapline.KillZone
An invisible box (sizeX × sizeY × sizeZ around its entity) that takes a life from anyone who enters it: put it under a level or across a gap. Shows a see-through red box while editing (showInEditor). Fields: label (default Fell), active, playersOnly. Works on any plain entity.
# Trapline.LivesDisplay
Your lives on your screen, picked with one setting, `look`: hearts (barsKit hearts), counter (head and number), tokens (pixel 1-UP), pips (barsKit segmented sci-fi bar), or auto (Course theme). Also draws -1 LIFE with the respawn countdown, CHECKPOINT!, breath bubbles under deep water, and the GAME OVER screen with Try Again (button, R or Enter). One per scene on a plain entity; each machine draws its own player. Fields: anchor, size, offsetY, showGameOver, showMessages, showBreath, visible.
# Trapline.Runner
One player's progress, on their own body: lives left, the checkpoint they come back at, game over, deaths, respawn countdown and breath. Added by `TL.attach(body)` (or at runtime when the world didn't). Needs SecondWind.Vitals and Lifeline.Health on the same body (TL.attach adds them). Only the owner's machine changes it; every field is synced so others can read it. On any SecondWind death it takes a life, waits the Course's respawnDelay and revives the body through SecondWind; Lifeline places it at this player's checkpoint. Out of lives it stays down until TL.restart.
# Trapline.api
The kit's public interface: TL.attach for players, per-player state (lives, checkpoint, game over), checkpoints and respawn (through Lifeline's spawn picker and SecondWind's revive), hazard hits through SecondWind, events (TL.on), the shared clock hazards run on (TL.now), switching looks and themes, and the labelled catalog (TL.catalog, TL.find). Required by every Trapline component and by game code. Full reference in the package README.
# Trapline.catalog
Everything Trapline offers as labelled plain data: 6 checkpoint looks, 12 hazard looks, the kill zone, 4 lives displays and 5 themes, each with kind, family, style and description (plus before/after for checkpoints, damageType/size/cycle for hazards). Read by the components for their enum fields and by TL.catalog / TL.find.
# checkpoints
The six checkpoint looks of Trapline, keyed by id: `flag` (cartoon), `campfire` (fantasy), `beacon` (sci-fi), `crystal` (fantasy), `bell` (pixel) and `teleporter` (sci-fi). Each entry has `build(root, cfg)`, which makes the look out of `Trapline.parts` primitives, and `animate(h, cfg, k, t, dt, current)`, which poses and colours it for activation `k` (0 = not reached, 1 = reached, in between = the activation moment), idle time `t` and the player's-current-checkpoint highlight.
Called by the `Trapline.Checkpoint` component; pick a look with its `look` setting. Every look faces +Z, stands on y = 0 inside a 1.5 m radius, and is clearly different before and after it is reached (colour, light and shape).
# deps
The five kits Trapline is built on (SecondWind, Lifeline, MatchFlow, juiceKit, barsKit), each with its ZeroMind asset id, its version and its exact build id. Each kit package is named by a literal reference, so the published Trapline records that exact version and installing Trapline brings it. `D.check()` says what the current world runs of each kit and whether it is the exact version Trapline was built against.
# Trapline.displays
The four lives displays as UI trees: D.render(look, { lives, maxLives, scale, t, lostAge, gainAge }). hearts and pips are drawn by barsKit (hearts and segmented looks) when barsKit is installed, with Trapline's own fallback otherwise; counter and tokens are Trapline's own. Used by Trapline.LivesDisplay; usable in your own UI.
# Trapline.hazards
Every hazard look by id, merged from Trapline.hazardsGround and Trapline.hazardsMotion. Its header documents the look contract (build, animate, danger on the shared clock, the local frame and the body capsule) for anyone adding a look. Used by Trapline.Hazard and the tests.
# hazardsGround
The ground hazard looks of the Trapline kit: `spikes` (cartoon, optionally pop-up), `lava` and `poison` (fantasy pools in a rock rim, bubbling, with a light), `rocks` (cartoon falling rocks under a ledge, with growing landing shadows), `pit` (pixel bottomless pit in a voxel grass rim) and `water` (cartoon deep-water tank you drown in).
Each entry implements the look contract documented in `Trapline.hazards` (`build`, `animate`, `danger`, all pure functions of the shared clock). `Trapline.hazards` merges this table with `hazardsMotion`; the `Hazard` component calls it to build the parts, animate them and test the player's capsule. Nothing else should require this module directly.
# hazardsMotion
The Trapline hazard looks that move or switch on and off: `saw` (cartoon blade running along a floor slot), `axe` (fantasy pendulum axe on a gantry), `crusher` (scifi press that lifts, warns and slams), `laser` (scifi blinking beam fence), `electric` (scifi floor plates that crackle with arcs) and `flame` (pixel voxel flamethrower jet). Each entry is `{ build, animate, danger }` per the look contract in `Trapline.hazards`, which merges this table with `hazardsGround`; the `Trapline.Hazard` component builds, animates and tests them. Poses and danger are pure functions of the shared clock, and every timed look spends a good share of its loop off with a visible warning before it turns on.
# Trapline.killZones
The kill zone looks, keyed by id: `invisible` (minimal: drawn only while editing, as a see-through red box), `abyss` (fantasy: a visible black void at the top of the box with swirling violet mist rings, rising motes and a glowing brink, for a gap in the floor) and `grid` (sci-fi: a glowing red holographic out-of-bounds wall with a sweeping scan line). Each entry is `{ build, animate, showInPlay }`; the deadly volume is always the whole box. Used by Trapline.KillZone, which picks one with its `look` setting.
# Trapline.parts
Small helpers the looks are built from: P.part (a primitive mesh child with colour, material and glow), groups, lights, Text3D labels, colour maths, Euler and axis-angle quaternions, easing, deterministic hashing, and loop helpers (P.cycle, P.loop, P.ramp, P.onOff) for timed hazards. Every part is a temporary local child, so looks are rebuilt by each machine and never saved into a scene.
# trapline tests
Covers every public call of Trapline.api and every part: catalog labels and filtering, every hazard look (builds, animates, deadly where it looks deadly, safe elsewhere, timed ones switch, pose depends only on the shared time), every checkpoint look (clearly different before and after), every lives display, the shared clock maths, checkpoints and forward-only, per-player progress, life lost, respawn, game over and Try Again, switching looks and themes, and the README's first example and version. Drives temporary dummies by hand far from the scene and restores the Course rules it changes. Two-player networking is play-tested instead.
# Lifeline.api
The one module scripts talk to: `damage`, `heal`, `kill`, `revive`, `fire` (projectiles), `on` (events on every peer), `get`/`all`/`isAlive`, `setDamageFilter` (owner-side validation), `setSpawnPicker`, `setStyle`. It routes requests to the owning peer over `multiplayer.send`.
# 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.
# fx
The hit effects, a different one per damage type: white sparks for physical, rising flames for fire, poison bubbles, ice shards, electric arcs to each chained target, an expanding explosion shell, landing dust for falls, and spikes for environment damage. Burning, poisoned and chilled entities also trail embers, bubbles or frost. Built from short-lived local entities, so every peer draws its own copy. `Fx.burst(type, pos, amount)` plays one. `Fx.enabled(false)` turns them off.
# 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.
# 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.
# 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.
# Lifeline — health, damage & respawn kit
Drop-in health for anything that can get hurt (players, enemies, breakable props), with damage zones, heal zones, projectiles, respawning, events for your own scripts, and a large wardrobe of looks: **13 health bar styles, 7 damage-number styles (4 presets) and 5 mix-and-match hit effects**, all switched by changing one field. Multiplayer-safe: only the peer that owns an entity decides what happens to its health.

## Installing it into your world
The package is called **Lifeline** — asset `Lifeline.package`, guid `7b85ce05-3903-4374-b654-2d4ffea295a5`, MIT licensed. (Its showcase world is *Lifeline — Health, Damage & Respawn Kit*.)
One install, any of these ways:
- **From the ZeroMind library in the editor** — search for `health` or `Lifeline` and install the **Lifeline** package.
- **By guid, from the engine shell:**
```
zero zeromind installAsset 7b85ce05-3903-4374-b654-2d4ffea295a5
```
- **From Luau:**
```lua
tools.use("zeromind", "preview", "7b85ce05-3903-4374-b654-2d4ffea295a5") -- what it would write
tools.use("zeromind", "installAsset", "7b85ce05-3903-4374-b654-2d4ffea295a5") -- do it
```
- **From an agent:** `zeromind.install { guid = "7b85ce05-3903-4374-b654-2d4ffea295a5" }`.
It lands in your `/source` as `Lifeline.package/`, so after installing, every piece resolves by that name: `Lifeline.Health`, `Lifeline.LifelineHUD`, `Lifeline.DamageZone`, `Lifeline.HealZone`, `Lifeline.Projectile`, `Lifeline.ProjectileLauncher`, `Lifeline.SpawnPoint`, and `require("Lifeline.api")` / `require("Lifeline.bars")`. Nothing in it is world-specific — you bring the entities. Run `zeromind.update` later to pull new versions.
## Five-minute start
**1. Give things health.** Add `Lifeline.Health` to the player body on your scene's PlayerPrototype, and to any enemy or prop. Put enemies and props on **synced** entities (`entity.spawnSynced`) so every player sees the same health.
```lua
body.component.add("Lifeline.Health", { maxHealth = 100, regenRate = 5, maxShield = 25 })
crate.component.add("Lifeline.Health", { maxHealth = 40, onDeath = "remove" })
```
**2. Show it.** Add `Lifeline.LifelineHUD` to one plain (non-synced) entity in the scene. Every peer runs its own copy.
```lua
entity.spawn("HUD").component.add("Lifeline.LifelineHUD", { playerBar = "hearts", worldBar = "overhead" })
```
**3. Hurt and heal.** Add a zone to any entity. It's a box of `sizeX × sizeY × sizeZ` around the entity and moves with it.
```lua
lava.component.add("Lifeline.DamageZone", { sizeX = 4, sizeZ = 4, kind = "fire", amount = 5, mode = "perSecond" })
spikes.component.add("Lifeline.DamageZone", { kind = "spikes", amount = 25, mode = "onEnter" })
pad.component.add("Lifeline.HealZone", { amount = 10, interval = 0.5 })
```
**4. From code.**
```lua
local Lifeline = require("Lifeline.api")
Lifeline.damage(target, 20, { kind = "fire", source = attacker, crit = false })
Lifeline.heal(target, 15)
Lifeline.fire { origin = muzzlePos, direction = aim, damage = 15, source = shooter } -- projectile
Lifeline.on("died", function(e) print(e.target, "died to", e.kind) end)
```
**5. Respawn.** Dead players come back after `respawnDelay` seconds at a random free `Lifeline.SpawnPoint` (or at the scene's `PlayerSpawn` if you haven't placed any), with full health and a moment of spawn protection.
That's it. The `scenes.main` showcase in this world uses every piece.
## What happens on death (`onDeath`, per entity)
| value | effect |
|---|---|
| `auto` (default) | `respawn` for players, `reset` for everything else |
| `respawn` | back at a spawn point after `respawnDelay`, full health |
| `reset` | back where it started after `respawnDelay`, full health |
| `remove` | disappears after `removeDelay` |
| `stay` | stays dead until `Lifeline.revive(target)` |
## Health fields (most useful)
`maxHealth`, `health`, `maxShield`/`shield` (soaks damage first), `regenRate` + `regenDelay`, `shieldRegenRate` + `shieldRegenDelay`, `invulnerabilityTime` (i-frames after a hit; damage-over-time ticks ignore it), `invulnerable` (god mode → "IMMUNE"), `armor` (flat reduction → "BLOCKED"), `respawnDelay`, `spawnProtection`, `displayName`, `team`, `barStyle` (bar drawn over this entity, `none` hides it), `bossBar` (show the big boss bar when near), `bodyHeight`/`bodyRadius` (bar placement and hit volume), `killBelowY`. Read-only: `isDead`, `deaths`.
## Events
`Lifeline.on(name, fn)` fires on **every** peer for `damaged`, `healed`, `blocked`, `died`, `respawned`, or `"*"` for all. The payload is `{ event, target, amount, shieldAmount, kind, source, crit, health, shield, maxHealth, reason, origin, pos, isPlayer, name }`. It returns a connection with `:Disconnect()`.
Each Health component also exposes typed events: `entity.component.get("Lifeline.Health").events.died:connect(fn)`.
## Styles: change one field
On `LifelineHUD` (or at runtime per player with `Lifeline.setStyle { bar = …, worldBar = …, number = …, feedback = … }`):
- **Bars** (`playerBar`, `worldBar`, Health's `barStyle`): `classic` (lost chunk drains away), `segmented`, `ring`, `hearts` (half hearts, shield hearts, heartbeat at low HP), `boss`, `overhead`, `minimal`, `stacked` (shield on health), `scifi`, `fantasy`, `cartoon`, `pixel`, `orb`.
- **Numbers** (`numberStyle`): `classic` (pop / shaky crit / green heal / IMMUNE-BLOCKED), `combo` (hits on one target add up into a running total), `comic`, `minimal`.
- **Feedback** (`feedback`, any mix): `flash shake vignette heartbeat direction`, or `all` / `none`.
## Multiplayer & anti-cheat
Health fields are `Sync`. Only the entity's owner mutates them, and the relay refuses writes from anyone else — so one player cannot set another player's health from their own machine.
- **Hit something you own** (your avatar, or a prop your peer took) — it changes on your machine and the new value goes on the wire. Every other peer draws exactly that number: health bars, numbers and death screens all read the same synced field.
- **Hit something another player owns** — `Lifeline.damage` doesn't decide anything. It sends a request to that entity's owner over `multiplayer.send`, and the owner validates it before applying. `Lifeline.setDamageFilter(function(comp, amount, info, fromPeer) ... end)` is where that validation goes: return the amount to allow (clamped if you like), or 0 to refuse. `fromPeer` is set only when the hit arrived from another peer's request.
- **Zones** run on every peer but act only on entities that peer owns, so a player in a fire zone is burned exactly once, by their own machine. A player's damage events are broadcast by the owner, so remote players see the same flash, numbers and score hooks.
- **Events** (`Lifeline.on`) fire on every peer — same channel the kit uses for its own visuals.
**The honest limit:** the engine's model is that the owning peer is the authority for its own entities. That is what stops *other* players from touching your health, and it is what this kit is built on. It does not, by itself, stop a modified client from lying about *its own* health (a hacked client could ignore a fire zone). If your game needs damage that a cheating client can't dodge, run the damage on a peer you control and funnel it through `setDamageFilter` — the filter runs on the owner, so it's the place to require proof, rate-limit, or refuse anything that doesn't match your own simulation.
## Other hooks
`Lifeline.kill`, `Lifeline.revive`, `Lifeline.get(target)`, `Lifeline.all()`, `Lifeline.isAlive`, `Lifeline.setSpawnPicker(fn(comp, points) -> entity)`, `Lifeline.kinds` (damage kinds → colour/label, add your own).
## Contents
`Health`, `DamageZone`, `HealZone`, `Projectile`, `ProjectileLauncher` (player blaster on a key, or an auto turret), `SpawnPoint`, `LifelineHUD` components, plus the `api`, `bars`, `numbers`, `feedback` and `zone` modules.
# barsKit 1.3.0
Health and resource bars for any world: **one kit, lots of looks.**
You pick a look and say what the bar shows. To change the look later you change one word, live, and nothing else gets rewritten.
- **20 looks of its own**, plus Lifeline's 13 as `lifeline:<style>`, in **8 theme families**: sci-fi, fantasy, cartoon, pixel, minimal, realistic, horror and cozy. Each look says its **shape**, whether it is a **screen or world** bar, and its **size**, so you can pick one without trying them all.
- **Works with the SecondWind health kit (formerly healthKit) out of the box**: your health, shield, armour, hearts and lives, and the health of any enemy, NPC or prop.
- **Not tied to health**: it can show stamina, mana, energy, XP, a boss, or any number your game keeps. The `Resource` component gives you a pool that drains and refills if you don't have one yet.
- **Feels good**: the value eases to its new level, the lost chunk lingers as a ghost before draining, the bar pulses when low, numbers are optional, and each kind of resource has sensible colours.
- **Fits any screen**: the HUD scales with the screen and respects safe areas, so it stays readable on a phone as well as a desktop. Bars never leave the screen, and the gallery or a panel of yours and the HUD never cover each other.
- **Leaves nothing behind**: `BK.remove` takes a bar down along with the entity it was made on, and closing or removing the gallery takes its screens with it.
- **Multiplayer**: your HUD bars always show *your* body. Bars over other players, enemies and props read synced SecondWind health, so everyone sees the same value.
It depends on **SecondWind 1.2.0** (the health kit formerly called healthKit) and, through it, **Lifeline 1.0.1**. Both install with it.
## Install and use
Install the package into your world with `zeromind_install` (search ZeroMind for "barsKit"). SecondWind and Lifeline come with it.
### 1. Give your player health first
A health bar shows the health the health kit keeps, so **the player's body needs health before any health, shield, armour, hearts or lives bar can show anything.** Without it, the bar finds no health to read and is **hidden entirely**. You get no error, just no bar. The same goes for an enemy or prop you put a bar on.
The health kit installs with barsKit. It is **SecondWind** (it used to be called healthKit: same package, same guid), and it lands in `deps/7a497fbf-1b4e-4655-8abf-e588fa7caa5c/SecondWind.package`. A short name such as `SecondWind.api` or `healthKit.api` does not reach into `deps/`, so load it by its full name, as below.
Add these lines to your scene's `build.luau`, inside `content()`, after the default player setup (`body` is the player body it makes):
```lua
-- the health kit (SecondWind, formerly healthKit), by the name its install gives it
local VITALS = asset.resolve("deps.7a497fbf-1b4e-4655-8abf-e588fa7caa5c.SecondWind.Vitals", "component")
body.component.add(VITALS, { maxHealth = 100 }) -- every joining player gets health
-- or with a regenerating shield, the way this world's own scene does it:
-- body.component.add(VITALS, { model = "shield", maxHealth = 100, maxShield = 40 })
-- anything else that should have a health bar: here, an enemy to hit
local enemy = entity.spawnSynced("Grunt")
enemy.component.add("Asset", { source = asset.resolve("@builtin::avatars.humanoid", "avatar") })
enemy.localPosition = { 0, 0, 6 }
enemy.component.add(VITALS, { maxHealth = 250, displayName = "Grunt" })
```
Adding the `Vitals` component gives the entity SecondWind's health and Lifeline's `Health` with it. In `build.luau`, add the component as above rather than calling `HK.attach`: the scene rebuilds every time you edit the file, and a second `HK.attach` on the same body stops the rebuild with "already has a Vitals component". From game code that runs while you play, use the API by the same full name: `local HK = require("deps.7a497fbf-1b4e-4655-8abf-e588fa7caa5c.SecondWind.api")`, then `HK.attach(entity, { ... })`, `HK.damage(...)` and `HK.heal(...)`. See the SecondWind README for health models (`hitPoints`, `shield`, `armor`, `hearts`, `lives`), death styles and teams.
To check it worked: `BK.read("health", body).ok` is `true` once the body has health, and `false` before.
If your game keeps its own health instead of using SecondWind, skip this step and point the bar at your number (`show = "value"` with `bar:set`, or `BK.bind`; see *Pointing a bar at something*). Stamina and mana bars need a `Resource` instead (see *No stamina or mana yet?*).
### 2. Put bars on screen
```lua
local BK = require("barsKit.api")
-- your health in the top-left corner
local hp = BK.bar({ look = "ghost", show = "health" })
-- a little bar over an enemy's head (the enemy needs health too, step 1)
BK.bar(enemy, { look = "overhead" })
-- a boss plate across the top
BK.bar({ look = "boss", target = "The Iron Warden", anchor = "topCenter", phases = "0.66 0.33" })
-- switch the look: one word, live
BK.setLook(hp, "pixel")
BK.setLookAll("hologram") -- every bar at once
BK.setLookAll("ornate", { placement = "screen" }) -- just the HUD
```
In a scene, you can add the component directly: `e.component.add(asset.resolve("barsKit.Bar", "component"), { look = "orb", show = "mana", anchor = "bottomRight" })`. Every field below is also settable in the editor's inspector.
## Pointing a bar at something
`show` says what the bar shows, and `target` says whose it is. With no target, a screen bar reads the local player and a world bar reads the entity it sits on.
| `show` | reads |
|---|---|
| `health` (default) | SecondWind health (works on players, enemies and props) |
| `shield`, `armor`, `hearts`, `lives` | the matching SecondWind value |
| `stamina`, `mana`, `xp`, anything else | the `Resource` of that name on the target |
| `value` | a number you set with `bar:set(value, max)` |
You can also bind any function, or point at any component field:
```lua
BK.bind(bar, function() return myGame.energy, myGame.maxEnergy end)
BK.bind(bar, { component = "Turret", field = "heat", max = 100 }) -- or maxField = "maxHeat"
BK.bar({ look = "segmented", show = "value", kind = "ammo" }):set(12, 30)
```
### No stamina or mana yet? Make a resource
```lua
BK.resource(player, "stamina", { max = 100, regenRate = 25, regenDelay = 0.8, drainRate = 35, drainKey = "ShiftLeft" })
BK.resource(player, "mana", { max = 120, regenRate = 8 })
if BK.spend(player, "mana", 30) then castFireball() end -- false (and nothing spent) when there isn't enough
BK.gain(player, "mana", 10)
BK.bar({ look = "orb", show = "mana", anchor = "bottomRight" })
```
A `Resource` regenerates after `regenDelay` seconds without spending, and drains while `drainKey` is held or `draining` is true. `demo = true` makes it drain and refill on its own, for showcases. The owner of the entity is the authority; other peers' `spend`/`gain` calls are sent to it, and the value syncs to everyone.
Note: a *second* Resource on one entity is a named component instance. On player avatars, add it at runtime with `BK.resource(...)` (for example in `localPlayerReady`) rather than on the PlayerPrototype, because named second instances don't carry over when the prototype is cloned.
## The looks
**Where** is `screen` (HUD only), `world` (over an entity only) or `both`. **Size** is the HUD size in points at scale 1, and the size class is in brackets: `small`, `medium`, `wide` or `tall`.
| id | name | shape | where | size | families | suits |
|---|---|---|---|---|---|---|
| `line` | Thin line | line | both | 240×22 (medium) | minimal | health, stamina, xp, energy |
| `segmented` | Segmented | segmented | both | 264×36 (medium) | realistic, sci-fi | health, armor, energy, ammo |
| `ring` | Radial ring | ring | both | 88×88 (small) | sci-fi, minimal | health, shield, energy, cooldown |
| `hearts` | Heart containers (full, half and empty; blue shield hearts) | hearts | both | 180×52 (medium) | fantasy, cozy, pixel | health, lives, hearts |
| `boss` | Boss bar (name, phase marks, phase count) | bar | screen | 560×62 (wide) | fantasy, horror, realistic | boss |
| `overhead` | Over-head bar | bar | world | 180×34 (medium) | minimal, realistic | overhead, enemy, npc |
| `ghost` | Ghost bar (the lost chunk lingers, then drains) | bar | both | 280×30 (medium) | realistic, minimal | health, boss, shield |
| `stacked` | Shield on health | stacked | both | 272×44 (medium) | sci-fi, realistic | health, shield, armor |
| `arc` | Crosshair arc | arc | screen | 76×76 (small) | minimal, sci-fi | stamina, energy, ammo, crosshair |
| `orb` | Glass orb | orb | screen | 108×126 (small) | fantasy, realistic | mana, health, energy |
| `gauge` | Vertical gauge | vertical | screen | 58×176 (tall) | realistic, sci-fi | fuel, heat, oxygen, energy |
| `pips` | Pips / dots | pips | both | 194×32 (medium) | minimal, cozy, cartoon | lives, charges, ammo, combo |
| `pixel` | Pixel art | bar | both | 248×34 (medium) | pixel, cartoon | health, mana, stamina, xp |
| `hologram` | Sci-fi hologram | bar | both | 300×52 (medium) | sci-fi | shield, energy, oxygen |
| `ornate` | Ornate fantasy frame | bar | both | 312×50 (medium) | fantasy | health, mana, boss |
| `bubbly` | Bubbly cartoon | bar | both | 272×46 (medium) | cartoon, cozy | health, xp, stamina |
| `blood` | Blood vial (drips, flickers near death) | bar | both | 270×48 (medium) | horror | health, sanity, boss |
| `monitor` | Heart monitor (ECG that flatlines at zero) | trace | screen | 214×62 (medium) | realistic, horror, sci-fi | health, stress |
| `stitched` | Stitched felt | bar | both | 262×44 (medium) | cozy, cartoon | health, hunger, warmth |
| `megabar` | Tick column (retro platformer meter) | vertical | screen | 30×150 (tall) | pixel, cartoon | health, energy, weapon, boss |
The eleven shapes are `line`, `bar`, `segmented`, `stacked`, `ring`, `arc`, `hearts`, `pips`, `orb`, `vertical` and `trace`.
Lifeline's own styles are there too, each with the same labels: `lifeline:classic`, `segmented`, `ring`, `hearts`, `boss`, `overhead`, `minimal`, `stacked`, `scifi`, `fantasy`, `cartoon`, `pixel`, `orb`.
### Asking the kit
```lua
BK.looks() -- every look: { id, name, families, suits, description,
-- shape, placement, size = { w, h }, worldSize = { w, h }, sizeClass }
BK.looks({ family = "horror" }) -- blood, monitor, boss
BK.looks({ suits = "mana" }) -- orb, pixel, ornate
BK.looks({ family = "cozy", suits = "health" })
BK.looks({ placement = "world" }) -- looks that can float over an entity ("both" counts)
BK.looks({ size = "small" }) -- ring, arc, orb (or "medium", "wide", "tall")
BK.looks({ shape = { "ring", "arc" } }) -- one shape or several
BK.looks({ family = "pixel", shape = "bar" }) -- filters combine
BK.looks({ includeLifeline = true })
BK.families() -- { { id, name, looks } } for the 8 families
BK.shapes() -- { { id, name, looks } } for the 11 shapes
BK.suits() -- everything looks say they suit
BK.render("orb", { frac = 0.4, kind = "mana" }) -- draw a look inside your own UI tree
```
## Bar fields
| field | default | what it does |
|---|---|---|
| `look` | `ghost` | Which look. Change it any time. |
| `show` | `health` | What it shows (see above). |
| `target` | — | Whose value: an entity name or id. Empty means the local player (screen) or this entity (world). |
| `placement` | `screen` | `screen` = HUD; `world` = floats over the target. |
| `anchor` | `topLeft` | Screen corner or edge: `topLeft`, `topCenter`, `topRight`, `left`, `center`, `right`, `bottomLeft`, `bottomCenter`, `bottomRight`, `crosshair`. Bars on one anchor stack by `order`. |
| `label` | — | Caption. Defaults to the resource's name (or the boss's name). |
| `kind` / `color` | auto | Colour by resource kind (health, shield, armor, stamina, mana, energy, xp, boss, rage, oxygen, heat, ammo, lives, hunger, generic), or a hex override. |
| `numbers` | `auto` | `value` (75 / 100), `percent`, `none` or `auto`. |
| `lowWarning` | `0.25` | Below this fraction the bar pulses. 0 turns it off. |
| `size` | `1` | Scale on top of the screen-size scale. |
| `phases` | — | Boss phase marks as fractions, e.g. `"0.66 0.33"`. |
| `segments` | auto | Cell, pip or heart count, where the look uses one. |
| `worldHeight`, `range` | 2.2, 30 | World bars: height above the target, and how far from the camera they still show. |
| `hideWhenFull`, `hideOwn` | false, true | Hide while full; don't draw a world bar over your own body. |
| `offsetX`, `offsetY`, `visible` | | Fine placement and on/off. |
Methods on a Bar (call them with a colon): `bar:setLook(id)`, `bar:cycleLook(dir)`, `bar:bind(fn | spec)`, `bar:set(value, max)`, `bar:sample()`. On a Resource: `res:spend(n)`, `res:gain(n)`, `res:set(v)`, `res:fraction()`. The API mirrors them: `BK.setLook`, `BK.bind`, `BK.sample`, `BK.read(show, target)`, `BK.bars()`, `BK.barOf(entity)`, `BK.localPlayer()`.
## Multiplayer
- Bar settings are local (not synced). Each peer runs its own HUD, so your screen bars always read **your** avatar.
- World bars and boss bars read SecondWind's synced state, so every player sees the same value over the same enemy.
- `Resource` values are owned by the entity's owner and synced. `BK.spend`/`BK.gain` from another peer are sent to the owner.
- Damage and healing go through SecondWind (`HK.damage`, `HK.heal`), which already routes them to the authority.
## The gallery (`barsKit.Showcase`)
Add the `Showcase` component to any entity to get the gallery:
- Every look side by side, all following one value that drains and refills. Each card is labelled with the look's name, its id and families, and a gold line giving its shape, where it goes and its size.
- Filter chips for families, recolour chips for resource kinds, a numbers toggle, and Lifeline's looks.
- A switcher that flips your own health bar through every look. Use `[` / `]` or the ◀ ▶ buttons; `G` hides or shows the gallery.
The gallery and the HUD never cover each other, at any screen size:
- The panel takes the largest part of the screen the HUD leaves free (`BK.freeRect`), clear of every screen bar and the touch buttons.
- While the gallery is open the switcher sits inside it. When it is closed, the switcher stands alone and screen bars step clear of it.
- Bars that would land under the panel (the crosshair arc, world bars) are hidden until it closes.
- Cards are dealt into even rows. Rows differ by at most one card, and every row is stretched to fill the width, so there are no empty spots.
**On a desktop-sized window, every look and its label is on screen at once, with no scrolling.** The gallery tries every column count from 3 to 8, splits the panel's height evenly between the rows, and picks the grid that draws the looks largest without shrinking any one of them too far (`BK.layout.fitGrid`). A 1080p window comes out as 6 columns by 4 rows, and a shorter 1280×682 window as 7 by 3. The cards get compact there: the family line shortens or drops families ("+2") instead of being cut off. A panel too small to fit every card at a readable size scrolls instead, with the larger cards. That covers a phone in either orientation, where the touch buttons and the boss plate leave the gallery a small panel. On a phone the cards reflow to two columns, and a card's labels shorten rather than get cut off.
```lua
local g = entity.find("BarsGallery").component.get(asset.resolve("barsKit.Showcase", "component"))
g:gridInfo() -- the grid it drew last: { fits, scrolls, cols, rows, cellH, gridH, minScale, cards, panel }
g:layoutFor(1920, 1080) -- lay it out for another screen size without drawing: info, widget tree
```
`G` toggles the gallery, as does setting the component's `open` field. Removing the component or despawning its entity takes down both of its screens.
## Phones and touch screens
- Bars scale with the screen but never drop below 0.85 on a phone, so text stays legible.
- Corner bars are laid out first. A centred bar (a boss bar) that meets one first slides sideways, and only then steps a row down (or up, for a bottom bar), so nothing overlaps on a narrow screen.
- A bar never leaves the screen. On a tiny screen with no clear row left, it shrinks into the widest gap of a row it tried.
- While the player is on touch, screen bars step clear of the on-screen buttons (Jump, Sprint, …). Top stacks move down past them and bottom stacks move up.
- World bars (over heads) draw under screen bars, with the nearest on top.
## Your own panels and the HUD
All of these use UI points, the same space as `ui.screenSize()`. Pass `nil` as the rect to clear a key.
```lua
-- keep screen bars out of a rect (a minimap, a chat box): the bars move
BK.keepOut("minimap", { x = 1100, y = 10, w = 170, h = 170 })
-- a panel of yours covers a rect (an inventory): bars that would land under it are hidden until you clear it
BK.cover("inventory", rect)
-- or let the HUD tell you where there is room: the largest rect clear of every bar, touch button and keepOut
local sz = ui.screenSize()
local rect = BK.freeRect(sz.width, sz.height, { pad = 8, margin = 6, minW = 300, minH = 200 })
BK.screenRects() -- where the HUD drew its screen bars last frame: { { x, y, w, h, anchor, look, hidden } }
BK.layout -- the pure helpers the gallery uses: largestFree, evenRows, rowWidths, packLines, fitGrid, overlaps
```
## Removing bars
```lua
local hp = BK.bar({ look = "ghost", show = "health" }) -- makes an entity for the bar
BK.remove(hp) -- removes the bar and despawns that entity; true when it took a bar down
BK.remove(hp) -- false: the bar is already gone (calling it again is harmless)
local tag = BK.bar(enemy, { look = "overhead" })
BK.remove(tag) -- removes only the Bar component; the enemy stays
BK.unbind(bar) -- undo BK.bind: the bar goes back to its `show` field
BK.isBound(bar) -- is a function or field spec bound right now?
```
Removing a Bar component by hand, despawning its entity or removing a `Resource` cleans up the same way. No HUD entries, bindings, covers, keepOuts or screens stay behind; the tests check this.
## Known limits
Checked on 2026-10-18 in the desktop app: a fresh install into a brand-new, empty world, and two people in Gaugeworks at once. barsKit loaded in full and every bar in this README worked there. Each limit below happened.
- **#19201: a player's name tag reads "{PlayerName}".** Some avatars show the literal text `{PlayerName}` over their heads instead of a name. This is the engine's own name tag, not a barsKit bar. barsKit's gallery panel is opaque, so the tag never shows through a gallery label.
- **#19260: older copies of dependencies sit in `versions/` folders.** A fresh install leaves single-module copies of two of the kit's dependencies beside the full packages. The health kit's own folder (`deps/7a497fbf-1b4e-4655-8abf-e588fa7caa5c/SecondWind.package`) holds no old copies.
- `deps/45f92606-2e79-4b2b-8e03-b1fa2649e5c2/versions/<hash>/Lifeline.package/` holds older copies of the Lifeline health package's `api` module and its `Health` component.
- `deps/40bc9b7f-d32c-427f-b0ac-22d25071e2fa/versions/<hash>/MatchFlow.package/` holds an older copy of MatchFlow's `rules` module.
- The warning in the log comes from the copied Lifeline `Health` component, which can't find its `api` module: "component source failed to load … `deps.45f92606-…versions.<hash>.Lifeline.Health` … module `~.api` in the calling script has no root to resolve against".
- barsKit uses the full `Lifeline.package` and `MatchFlow.package` and never these copies. Bars, the gallery and `Bar` and `Showcase` components saved in a scene all load and survive a world reload. Nothing needs deleting.
- **#19449: a second `Resource` on one entity reaches other players mixed into the first.** The engine syncs one instance of a component per entity. If a body has both a stamina and a mana `Resource`, the other players' copy of that body has one `Resource`: it keeps the first pool's name and max, but its value follows the second pool. In the Gaugeworks test, another player read your "stamina" as 120 out of 100 while your mana was full. **Your own bars are right**, because they read your own copy. Bars that show *another* player's second pool are not. Until #19449 is fixed, keep pools that other players need to see on separate entities (for example a child entity per pool), or show only one synced pool per body.
- **#19169: a second session that joins a game someone is already playing doesn't fully get its player.** The first player in a game is not affected. Seen twice in Gaugeworks on 2026-10-18:
- **Web browser version:** the joining player got no player view (the camera stayed on the spawn and they couldn't move) and none of the world's screens, so no barsKit bars, gallery or switcher. barsKit's code did run in that session: its health took the demo's hits and its bars reached the other player. The engine just never finished handing that player their body. The browser console showed only the engine refusing to spawn the world's shared entities again (the dummy, the pot, the boss and the crate), nothing from barsKit.
- **Desktop app:** the joining player got their HUD and saw the right health, boss and dummy values. But bars over the bodies (barsKit's and the health kit's own) were drawn at ankle height instead of over the heads.
- **Web browser version, one player:** a fresh install of 1.3.0 works. All of these work: the gallery with every card, your health bar with numbers, a boss bar, an over-head bar, `G` and `[` / `]`. #19201 shows there too. Two people in the web version can't be checked until #19169 is fixed.
## Version
1.3.0. `require("barsKit.api").VERSION`. Tests: `barsKit.testSuite`, 68 tests (run with the `tests` toolbox).
- 1.3.0: the release where barsKit behaves differently: it runs on **SecondWind 1.2.0** (formerly healthKit) and draws a backing plate behind numbers over bars. Both came in 1.2.1; this version number says so. No call or setting changed. Known limits now name the platform they were checked on (the desktop app; the 1.2.1 notes wrongly said the web browser version) and add **#19449**: a second `Resource` on one entity reaches other players mixed into the first. A new test checks that the version agrees in `package.yaml`, `BK.VERSION`, the README's title and its Version line. Heart containers now draw real heart shapes at 180×52, with all five hearts inside the card, even in a session where Lifeline's heart renderer has not loaded yet. It used to fall back to 100×46 solid red squares with the fifth one cut off, seen in the phone gallery. barsKit now keeps trying to load Lifeline's renderer and switches to it once it loads. `BK.remove` now returns false for a bar that is already gone, as documented; it used to return true again. Known limits describe what #19260 really leaves on a fresh install: old Lifeline `api` and `Health` copies and an old MatchFlow `rules` copy.
- 1.2.1: tidy-up release; no call or setting changed. barsKit now uses the health kit by the name a fresh install gives it: **SecondWind 1.2.0** (formerly healthKit, same package and guid), loaded by its full name. Before this, barsKit and its README's first example failed to load in a new world. The first example now works pasted as-is into a new world with only barsKit installed, and keeps working when you edit the scene again. The gallery's sample value and screens run only while the gallery is placed on an entity and the game is playing, not when its script loads. The gallery panel and switcher are opaque, so nothing in the world shows through their labels. A number drawn on top of a bar (over-head, ghost, pixel art, ornate, bubbly) sits on a backing plate, so the pale ghost trail can't hide it. The heart monitor's readout lines no longer touch. Gallery components get `sample()` and `isRunning()`. Known limits now list only what still happens on a fresh install.
- 1.2.0: every look is on screen at once on desktop-sized windows, labelled and without scrolling: the gallery picks its column count and card size to fit the panel (`BK.layout.fitGrid`, `BK.layout.packLines`), and adds `gridInfo()` and `layoutFor()`. Phones scroll as before. The shared value, boss bar, overhead dummy, ghost bar and look switcher are unchanged. The README now says to give your player health first, and how, and lists the known limits.
- 1.1.0: every look says its shape, where it goes (screen, world or both) and its size, and `BK.looks` filters on all three (`BK.shapes()` lists the shapes). The gallery places itself in the room the HUD leaves free and hides bars under it, keeps the switcher inside it while open, and deals cards into even rows. A centred bar slides sideways before stepping down and never leaves the screen, and world bars draw under screen bars. New: `BK.remove`, `BK.unbind`, `BK.isBound`, `BK.cover`, `BK.screenRects`, `BK.freeRect` and `BK.layout`. Removing a bar, unbinding or closing the gallery leaves nothing behind.
- 1.0.2: lighter gallery: virtual scrolling (only the rows in view are laid out and painted), the look switcher is redrawn only when it changes, and the gallery rebuilds 20 times a second on phones (30 on desktop). On a phone the open gallery now costs about 3ms a frame instead of about 9.5ms.
- 1.0.1: phone layout: a legible minimum scale, no overlap between corner and centre bars, bars and the gallery keep clear of touch buttons, and `BK.keepOut`.
- 1.0.0: first release.
# 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.
# api
The public face of barsKit: `local BK = require("barsKit.api")`.
- Put a bar up with `BK.bar(opts)` or `BK.bar(entity, opts)`. Take it down with `BK.remove`, which also despawns the entity `BK.bar(opts)` made for it.
- Switch looks live with `BK.setLook` and `BK.setLookAll`.
- Ask the catalog with `BK.looks({ family, suits, shape, placement, size })`, `BK.families()`, `BK.shapes()` and `BK.suits()`. Each look reports its shape, its placement (screen, world or both) and its size.
- Bind any number with `BK.bind`. `BK.unbind` and `BK.isBound` undo and check it.
- Make pools that drain and refill with `BK.resource`, `BK.spend` and `BK.gain`.
- Lay your own UI out beside the HUD with `BK.keepOut`, `BK.cover`, `BK.screenRects`, `BK.freeRect` and `BK.layout`.
`BK.VERSION` is the kit version. Game code calls the API; see the package README for examples.