# Trap
A box volume that hurts whatever stands in it: lava, spikes, a fire vent, a poison pool, an electric floor or a frost field. Set `damageType`, `amount`, `mode` (`perSecond` or `onEnter`), `interval`, the box size, and the `label` the kill feed uses ("Kai was killed by Lava"). It can belong to a team, credit an attacker (`by`), push targets away, or hurt players only. Damage is applied by each victim's owner (on Lifeline's zone), so a Trap hits each victim exactly once whichever peers run it. It needs no other component on its entity.
# Vitals
Health for any entity: a player body, an enemy, a dummy or a crate. The `model` field picks hit points, regenerating shield, armour, hearts, lives or downed-and-revive. The `deathStyle` field picks how it dies and comes back: instant, timed, wave, ragdoll, dissolve, spectate, remove or stay. The `preset` field sets both at once. Each can be changed while the game runs.
It sits on top of a sibling `Lifeline.Health`, which holds the actual health, shield, bars and damage numbers, and adds that component in `awake` when it is missing. The entity's owner runs the rules; every peer draws the effects. Its state is synced fields (`armor`, `livesLeft`, `downed`, `bleedLeft`, `respawnIn`, `burning`, `poisonStacks`, `chilled` …), so every player sees the same thing.
Add it with `require("SecondWind.api").attach(entity, settings)` or `component.add`. Hurt it with `HK.damage`. See the package README for every field.
# 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`.
# Health
Gives any entity health, an optional shield, regeneration, invulnerability frames, armor, death and a respawn / reset / remove rule. Attach it to a player body, enemy or prop; no sibling components are required. On a CharacterController body it disables movement while dead. Only the owning peer changes it; use `Lifeline.damage/heal/kill/revive` or the `damage`/`heal` methods. Fires typed events `damaged`, `healed`, `blocked`, `died`, `respawned`. See the package README for fields.
# PickupChest
A treasure chest. When a player comes within `radius`, it flips its lid and spills `count` coins of any `look`. The coins fly out, bounce and settle before they can be grabbed, then blink out after `coinLifetime` seconds.
It closes again after `cooldown` seconds (0 = it opens once). The chest's owner opens it, so it opens once for everyone. Put it on a synced entity. `showModel = false` hides the built-in chest so you can use your own model. `Pickups.openChest(chest)` opens it from code.
# PickupHUD
A simple on-screen counter for the local player, in five styles that match the looks: cartoon, scifi, fantasy, pixel and realistic. It shows:
- your counters (`counters`, or coins plus anything above 0);
- the letters of a `word`, which light up as they are collected;
- running power-ups with their time left;
- with more than one player, everyone's coins.
Every peer draws its own, so put it on a plain local entity: `entity.spawn("HUD").component.add("pickupsKit.PickupHUD", { style = "pixel", word = "ZERO" })`. Changing `style` restyles it at once.
# SpawnPoint
Marks a place where dead players can respawn; the entity's rotation is the respawn facing. With none in the scene, Lifeline falls back to PlayerSpawn entities.
# Projectile
A bolt that moves by its velocity and damages the first Health body it touches (never its `source`), stopping at solid geometry. Spawn one with `Lifeline.fire { ... }`, which also adds a glowing sphere Model. The firing peer owns and simulates it.
# juice
The juiceKit front door: `require("juiceKit.juice")`. `play(name, target, opts)` resolves a preset into its pieces, applies the global intensity, the look and reduce motion, routes each piece by scope (player pieces stay with their player, world pieces go to every peer through `net`), and returns what ran. It also holds the preset registry (`list`, `names`, `get`, `has`, `define`, `remove`), the settings (look, intensity, reduce motion), `stopAll`, `stats` and `_receive`, the handler other peers' messages arrive at. Gameplay code and the gallery call it; the package README documents every call.
# catalog
pickupsKit's data: the 47 looks (style, kind, effect, amount, sound, juice preset, size tag and dimensions, and the parts the model is built from), the size tags (`SIZES`, `sizeTag`), renamed ids (`ALIASES`, `canonical`), the kit versions it needs (`REQUIRES`), the five styles, kinds, effects, power-ups and their multipliers, known counters, the mystery-box table, HUD styles and `VERSION`. Read it through `Pickups.looks()` and friends rather than editing it; to add a look of your own, copy an entry into `LOOKS`.
# visual
Builds and drives a look's model: `build(lookId, { letter })` spawns its parts (meshes, materials, tints, glow and labels) as local entities. `place(handle, pos, yaw, scale)` moves it, `setVisible` blinks it and `destroy` removes it. `material(key)` resolves the kit's materials and `quat` turns Euler degrees into a quaternion. Each peer draws its own model, so switching a look is just a rebuild.
# PickupLedger
The synced record of every player's counters, items and letters, kept under each player's key (`p<peer>`, or `e<id>` for other collectors). Only its owner writes it; everyone reads it, late joiners included, so every player sees the same counts.
The host spawns one by itself, or `Pickups.setup()` does it, so you never have to place it. Read and change it through `Pickups.get/add/set/spend/reset/letters`.
# Pickup
One pickup you can put anywhere: `entity.spawnSynced("Coin").component.add("pickupsKit.Pickup", { look = "pixelGem" })`, or `Pickups.spawn(pos, settings)`.
- **Look:** `look` picks one of 47 looks in five styles (the old id `pixel_gem_coin` still works and means `pixelGem`). Changing it rebuilds the model at once on every peer, also while playing.
- **Effect:** by default it does what its look does (coins count, hearts heal, keys unlock). `effect`, `amount`, `counter`, `item`, `letter`, `powerup` and `duration` override that.
- **Behaviour:** `radius`, `magnetRange`, `respawn`/`respawnTime`, `lifetime`/`blinkTime`, `mode` (shared or perPlayer), `bob`, `spin`, `hover`, `scale`, `visible`.
The entity's owner decides who gets it, so two players touching it in the same frame give exactly one winner. The package README has the full table of settings.
# sounds
A sound for every pickup with no audio files to license. Each sound is synthesised from a short recipe (a coin chime, a gem sparkle, an 8-bit blip, a heal swell, a power-up rise and so on) and baked into the package's `sfx_*` sound clips.
- `play(id, { at, volume, pitch })` plays one, softer with distance.
- `ids()` lists them, and `synth(id)` returns the samples.
- `bake()` rewrites the clips from the recipes.
- `volume` sets the kit's overall loudness.
# 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.
# HUD
Each player's on-screen layer. It draws:
- the kill feed (top right) in the minimal, scifi, fantasy, cartoon or pixel look;
- the death screen: a MatchFlow countdown for timed respawns, the wave timer, or "spectating" with the name of the player being watched;
- your lives, armour and status effects;
- DOWNED markers with revive progress over downed entities.
While you spectate, the camera follows a living player and switches to the next one every `spectateSwitch` seconds. It adds Lifeline's HUD (health bars and damage numbers) if the scene has none. Put one on any local entity; it needs no sync.
# logic
The kit's rules as pure functions, with no entities and no networking, so every peer agrees and the rules can be tested on their own:
- **Claims:** `keyOfPeer`, `keyOfEntity`, `resolveEffect`, `rollMystery`, `canClaim`, `applyClaim`, and `settle` (who wins when several claim at once).
- **Records:** `addCount`, `addLetter`, `spelled`, `missing`.
- **Motion:** `blinkVisible`, `bobOffset`, `popScale`, `magnetStep`, `magnetSpeed`, `burstVelocity`, `stepCoin` (the coin physics).
- **Time:** `powerupLeft`, `extendPowerup`, `formatTime`.
The api and components call these; your own code can too.
# Rules
The world-wide health rules, one per scene, on a synced entity. It holds the genre `preset`, the friendly-fire and self-damage switches, the model and death style for player bodies set to `auto`, the death style for everything else, the kill feed look, the respawn wave timer, round ending (by itself, or when a MatchFlow `Match` entity ends a round), fall-damage tuning, the cap on damage another peer may request, and `allowClientConfig`. Its owner runs the wave and round timers. Change it with `HK.setRules` or `HK.applyPreset`.
# 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.
# pickupsKit 2.3.0
Coins, gems, hearts, shields, keys, letters and power-ups for any game: one `Pickup` component, 47 looks in five styles, each tagged with its size, and multiplayer claims where a simultaneous grab has exactly one winner.
Built for platformers, arena shooters, kart racers and adventure games. Every pickup also has its own synthesised sound.
## New in 2.3.0
- **The names you type:** `heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate` are main `prefab` names now, beside `goldCoin`, `pixelGem` and the rest. Every older name still works: the 2.2 names (`redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard`, `ammoBox`) and the original ids (`cartoon_coin`, `real_gold_coin`…). `looks()` lists each look's other names as `otherNames`.
- **The gallery** labels each pickup with its main name, so you can read the label and type it. Its look switcher changes the coin's `prefab`, the way "Switching a look" below describes. The gallery scene saves with nothing pending, so it clears and reloads cleanly.
## Install
One install: `zeromind_install guid=0d70dd00-9297-45d5-a290-5a51b9f9e262`, or search ZeroMind for "pickupsKit" and install it from the ZeroMind panel.
That one install also brings the kits pickupsKit builds on, in the versions it was built and tested with, as its own dependencies under `/source/deps/`. Your world ends up with `/source/pickupsKit.package` and nothing else to set up. While the files land, the log can show passing lines such as `waiting for content it requires` or `which is being fetched`; they stop once the install is done.
## Builds on
- **SecondWind 1.2.0** (the "Second Wind" world): hearts, medkits and potions heal through it, shields top up its shield, and the damage and invincible power-ups go through its damage filter. SecondWind is itself built on **Lifeline 1.0.1** and **MatchFlow 1.0.0**, which come along too.
- **juiceKit 1.2.1** or newer (the "Juicebox" world): the sparkle, flash and pop when something is picked up, spawns or expires. An install brings juiceKit 1.2.2.
Your own scripts reach these kits through pickupsKit, because a kit that came in as a dependency belongs to the kit that brought it: `Pickups.health()` returns SecondWind's api, `Pickups.healthParts()` its `Rules`, `Health`, `HUD` and `Trap` components, and `Pickups.juice()` juiceKit's effects. If you install SecondWind or juiceKit yourself as well, `require("SecondWind.api")` works in your own code as usual.
`Pickups.dependencies()` lists each kit with the version it needs, the version installed, whether it has loaded, and `ok`, which is true when the installed one is that version or newer.
**Pickups never wait for these.** The kit loads SecondWind and juiceKit the first time it needs them, not when it starts. If one is missing or still downloading (a web build fetches packages while the world opens), pickups still appear, spin, collect and count, and the counter and chests still work; the kit logs one warning and tries again a little later. A heart taken meanwhile is still collected, with `note = "no health kit"` on its `collect` event.
## Quick start
Add these lines inside `content()` in your scene's `build.luau`, just before its closing `end`. A brand-new world may have no `build.luau` yet: make a scene with a ground and a player first, with `tools.use("scene", "create", "level", "player", { startup = true })`, and add the lines to `scenes/level.scene/build.luau` the same way.
```lua
local Pickups = require("pickupsKit.api")
-- the coin counter in the corner of the screen
entity.spawn("PickupHUD").component.add(Pickups.HUD, { style = "pixel" })
-- five pixel gems in a line in front of where the player starts
for i = 1, 5 do
Pickups.spawn({ (i - 3) * 1.5, 0, 4 }, { prefab = "pixelGem" })
end
```
Press **Play** and walk forward. Each gem you touch pops with a sparkle and a coin sound, and the counter adds 1. Each gem comes back 5 seconds later. The host makes the synced ledger that holds the counts by itself, so there is nothing else to place.
## More examples
All of these go in `content()` too, after the quick start.
```lua
-- Health for players, so hearts and shields have something to heal.
local HK = Pickups.health()
entity.spawnSynced("HealthRules").component.add(HK.RULES, {})
HK.attach(body, { model = "shield" }) -- `body` is your PlayerPrototype's body entity
-- A heart that heals 25, a speed power-up and a key that never comes back.
Pickups.spawn({ -2, 0, 6 }, { prefab = "heart" })
Pickups.spawn({ 0, 0, 6 }, { prefab = "speedOrb", duration = 10 })
Pickups.spawn({ 2, 0, 6 }, { prefab = "silverKey", respawn = false })
-- Letter tokens that spell ZERO, one copy per player.
for i, l in { "Z", "E", "R", "O" } do
Pickups.spawn({ -3 + i * 1.5, 0, 8 }, { prefab = "letterToken", letter = l, mode = "perPlayer" })
end
-- A chest that spills 12 rubies when a player walks up to it.
local chest = entity.spawnSynced("Chest", { position = { 0, 0, 11 } })
chest.component.add(Pickups.CHEST, { prefab = "ruby", count = 12 })
```
Hearts and shields heal only bodies that have SecondWind health, as above. Without it a heart is still collected, but it has nothing to heal. `Pickups.health()` is nil while SecondWind is still loading; in `content()` it has loaded.
To show a word's letters lighting up, give the HUD a word: `{ style = "cartoon", word = "ZERO" }`.
**Reading and spending from your own code** (in a component or anything else that runs in play):
```lua
Pickups.on("collect", function(ev) print(ev.key, "took", ev.prefab, ev.amount) end)
if Pickups.spend(player, "coins", 10) then openDoor() end
print(Pickups.get(player, "keys"), Pickups.letters(player), Pickups.spelled(player, "ZERO"))
```
**Switching a look is one setting**, and it applies right away, for every player, also while the game runs:
```lua
pickupEntity.component.get(Pickups.PICKUP).prefab = "pixelGem" -- on the engine that owns the pickup
Pickups.setPrefab(pickupEntity, "pixelGem") -- from any engine
```
The model is rebuilt on every engine as the change arrives. The pickup keeps its other settings. With `effect = "auto"` (the default) it now does what the new look does.
**Who can write the setting.** Like every synced setting, `prefab` can only be written by the engine that owns the pickup: the host for scene pickups, or whoever spawned it. Any other engine's write is refused by the engine on the spot and changes nothing, and the kit can't catch it. `Pickups.setPrefab` works from every engine: on the owner it writes the setting, and elsewhere it asks the owner to. Both were played with two players: a coin one player owned changed to `pixelGem` for both through the plain setting, and a gallery pickup the other player's engine owned changed through `setPrefab` called by the player who wasn't hosting. Old names worked the same way, through `look = "cartoon_coin"` and `setPrefab(box, "cartoon_mystery")`.
**Coin bursts** from a defeated enemy or a broken pot:
```lua
Pickups.burst(enemy.position, { prefab = "goldCoin", count = 10, lifetime = 12 })
```
The coins fly out and bounce. Each one can only be taken once it has settled, and each blinks for its last 3 seconds before it disappears.
## The Pickup component
`pickupsKit.Pickup`. Every setting is synced: a change made on the engine that owns the pickup reaches every engine, also while the game runs.
| Setting | Default | What it does |
|---|---|---|
| `prefab` | `goldCoin` | Which of the 47 looks it wears, by its short name (the Looks table). This also sets what it does when `effect` is `auto`. |
| `look` | `goldCoin` | The older name of `prefab`, kept so existing scenes and code keep working. It takes the new names and the old ids. Whichever of the two you write, the other follows. |
| `effect` | `auto` | What touching it does: `auto`, `counter`, `heal`, `shield`, `item`, `letter`, `powerup`, `mystery` or `none`. |
| `amount` | 0 | How much it gives: coins, health, shield and so on. 0 uses the look's amount. |
| `counter` | "" | Counter for `counter`: coins, gems, stars, rings, xp, ammo, or any name of your own. |
| `item` | "" | Item for `item`: keys, keycards, or your own. It is counted like a counter. |
| `letter` | "A" | The letter shown on letter tokens. |
| `powerup`, `duration` | "", 0 | The power-up and its length in seconds: `speed`, `jump`, `damage`, `magnet` or `invincible`. |
| `radius` | 1.1 | How close a player must come to take it, in metres. |
| `magnetRange` | 0 | Within this range it flies to the player. 0 means no magnet. A `magnet` power-up triples it. |
| `respawn`, `respawnTime` | true, 5 | Whether it comes back after being taken, and after how many seconds. |
| `lifetime`, `blinkTime` | 0, 3 | It disappears after `lifetime` seconds untouched (0 = never), blinking for the last `blinkTime` of them. With `respawn` on, it then comes back after `respawnTime`. |
| `mode` | `shared` | `shared`: the first player to touch it gets it. `perPlayer`: every player can take their own copy, and it comes back for each of them separately. |
| `bob`, `spin`, `hover`, `scale` | 0.12, 1.8, 0.6, 1 | Bob height in metres, spin in turns per second, hover height and size. |
| `visible` | true | Hide it without removing it. |
| `castShadows` | false | Cast a sun shadow. Off by default, because many small spinning casters redraw the shadow map every frame, which costs most in a web browser. Turn it on for a few hero pickups. |
`available`, `taken`, `armed` and `launch` are state fields that the kit writes itself.
**Old names keep working.** Every look had a longer id before 2.2.0 (`cartoon_coin`, `real_gold_coin`, `pixel_gem_coin`...), and six looks had other short names in 2.2: `redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard` and `ammoBox` are now `heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate`. Every older name still works everywhere a look goes: the `look` setting, `spawn`, `setPrefab`, `setLook`, `burst`, chests and `look()`, and it is stored as the main name. The `prefab` field's own list holds only the main names, so writing an older name straight into `prefab` is refused by the engine; use `look`, or `setPrefab`. The Looks table shows every other name a look answers to, and `looks()` returns them as `otherNames`.
**Other components:**
- `PickupChest` opens when a player comes within `radius` and spills `count` coins of any `prefab` (`look` is its older name there too). It closes again after `cooldown` seconds, or stays open if `cooldown` is 0. It draws its own chest model; set `showModel = false` to use yours.
- `PickupHUD` is the on-screen counter, with `style` set to cartoon, scifi, fantasy, pixel or realistic. Its other settings:
- `counters`: which counters it shows, in order;
- `word`: a word whose letters light up as they are collected;
- `showPowerups`: running power-ups with their time left;
- `showPlayers`: every player's coins;
- `corner`: which top corner it sits in.
- `PickupLedger` is the synced record of every player's counters and letters. The host spawns it by itself.
## Looks
There are 47 looks in five styles that a world can mix and match: cartoon 9, sci-fi 10, fantasy 10, pixel 9 and realistic 9. To list them, call `Pickups.looks()`, or filter with `Pickups.looks({ style = "pixel" })`, `Pickups.looks({ kind = "coin" })` or `Pickups.looks({ size = "small" })`; filters combine. Each entry carries its `id` (the `prefab` name), `oldId`, `name`, `style`, `kind`, `effect`, `size`, `sizeM`, `dims` and what it gives.
| Style | What it looks like |
|---|---|
| cartoon | Chunky glossy shapes with dark ink outlines. |
| scifi | Dark metal shells around glowing energy cores. |
| fantasy | Gems, potions in glass, gold and runes. |
| pixel | Voxel sprites: 8-bit pixels with real depth. |
| realistic | Brushed metal, brass, painted boxes and real-world props. |
**Sizes.** Every look carries a size tag measured from its model at `scale = 1`. `dims` is its width, height and depth in metres, and `sizeM` is the largest of the three. `Pickups.sizes()` lists the tags:
| Size | Largest side | Looks |
|---|---|---|
| `small` | up to 0.62 m | 9: gems, wisps, the realistic coins and kits |
| `medium` | 0.63 to 0.75 m | 28: most coins, hearts, keys, badges and pixel sprites |
| `large` | over 0.75 m | 10: power-up cores, potions, shields and the winged orb |
Use the tag to pick looks that sit well together, or to choose a `radius`: about 1.1 suits small and medium looks, and about 1.3 suits large ones. `pickupsKit.visual.extent(prefab)` measures a model's box directly.
| `prefab` | Also answers to | Name | Style | Kind | Effect | Size | Gives |
|---|---|---|---|---|---|---|---|
| `goldCoin` | `cartoon_coin` | Gold coin | cartoon | coin | counter | medium (0.72 m) | coins +1 |
| `goldStar` | `cartoon_star` | Star | cartoon | star | counter | large (0.78 m) | stars +1 |
| `goldRing` | `cartoon_ring` | Gold ring | cartoon | ring | counter | medium (0.72 m) | rings +1 |
| `heart` | `cartoon_heart`, `redHeart` | Heart | cartoon | heart | heal | medium (0.68 m) | heal 25 |
| `mysteryBox` | `cartoon_mystery` | Mystery box | cartoon | mystery | mystery | medium (0.65 m) | random roll |
| `letterToken` | `cartoon_letter` | Letter token | cartoon | letter | letter | medium (0.70 m) | its `letter` |
| `speedBadge` | `cartoon_speed` | Speed badge | cartoon | powerup | powerup | medium (0.70 m) | speed 8s |
| `damageBadge` | `cartoon_damage` | Damage badge | cartoon | powerup | powerup | medium (0.70 m) | damage 8s |
| `jumpBadge` | `cartoon_jump` | Jump badge | cartoon | powerup | powerup | medium (0.70 m) | jump 8s |
| `energyCredit` | `scifi_chip` | Energy credit | scifi | coin | counter | medium (0.70 m) | coins +1 |
| `shieldCell` | `scifi_shield` | Shield cell | scifi | shield | shield | large (0.77 m) | shield 30 |
| `accessCard` | `scifi_keycard` | Keycard | scifi | keycard | item | medium (0.64 m) | keycards +1 |
| `ammoCell` | `scifi_ammo` | Ammo cell | scifi | ammo | counter | medium (0.72 m) | ammo +30 |
| `speedOrb` | `scifi_speed`, `speedCore` | Speed orb | scifi | powerup | powerup | large (0.78 m) | speed 8s |
| `damageOrb` | `damageCore`, `scifi_damage` | Damage orb | scifi | powerup | powerup | large (0.78 m) | damage 8s |
| `jumpOrb` | `jumpCore`, `scifi_jump` | Jump orb | scifi | powerup | powerup | large (0.78 m) | jump 8s |
| `magnetCore` | `scifi_magnet` | Magnet core | scifi | powerup | powerup | large (0.78 m) | magnet 10s |
| `xpOrb` | `scifi_xp`, `xpShard` | XP orb | scifi | xp | counter | medium (0.66 m) | xp +10 |
| `nanoMedkit` | `scifi_medkit` | Nano medkit | scifi | medkit | heal | medium (0.64 m) | heal 50 |
| `ruby` | `fantasy_ruby` | Ruby | fantasy | gem | counter | small (0.60 m) | gems +1 |
| `emerald` | `fantasy_emerald` | Emerald | fantasy | gem | counter | small (0.54 m) | gems +3 |
| `healingPotion` | `fantasy_potion_red` | Healing potion | fantasy | potion | heal | large (0.76 m) | heal 35 |
| `wardPotion` | `fantasy_potion_blue` | Ward potion | fantasy | potion | shield | large (0.76 m) | shield 35 |
| `goldenKey` | `fantasy_key` | Golden key | fantasy | key | item | medium (0.69 m) | keys +1 |
| `silverKey` | | Silver key | fantasy | key | item | medium (0.69 m) | keys +1 |
| `crestShield` | | Crest shield | fantasy | shield | shield | large (0.81 m) | shield 40 |
| `wingedOrb` | `fantasy_jump` | Winged orb | fantasy | powerup | powerup | large (0.94 m) | jump 8s |
| `runeStone` | `fantasy_rune` | Rune stone | fantasy | letter | letter | medium (0.67 m) | its `letter` |
| `xpWisp` | `fantasy_wisp` | XP wisp | fantasy | xp | counter | small (0.47 m) | xp +25 |
| `pixelGem` | `pixel_gem_coin` | Pixel gem | pixel | coin | counter | medium (0.72 m) | coins +1 |
| `pixelCoin` | `pixel_coin` | Pixel coin | pixel | coin | counter | medium (0.68 m) | coins +1 |
| `pixelHeart` | `pixel_heart` | Pixel heart | pixel | heart | heal | medium (0.68 m) | heal 25 |
| `pixelKey` | `pixel_key` | Pixel key | pixel | key | item | medium (0.70 m) | keys +1 |
| `powerStar` | `pixel_star` | Power star | pixel | powerup | powerup | medium (0.72 m) | invincible 8s |
| `jumpMushroom` | `pixel_mushroom` | Jump mushroom | pixel | powerup | powerup | medium (0.68 m) | jump 10s |
| `pixelSword` | `pixel_sword` | Pixel sword | pixel | powerup | powerup | medium (0.72 m) | damage 8s |
| `pixelLetter` | `pixel_letter` | Pixel letter | pixel | letter | letter | medium (0.72 m) | its `letter` |
| `questionBlock` | `pixel_mystery` | Pixel ? block | pixel | mystery | mystery | medium (0.63 m) | random roll |
| `silverCoin` | `real_silver_coin` | Silver coin | realistic | coin | counter | small (0.60 m) | coins +1 |
| `goldDoubloon` | `real_gold_coin` | Gold doubloon | realistic | coin | counter | medium (0.64 m) | coins +5 |
| `goldBar` | `real_gold_bar` | Gold bar | realistic | coin | counter | small (0.62 m) | coins +25 |
| `cutDiamond` | `real_diamond` | Diamond | realistic | gem | counter | small (0.60 m) | gems +5 |
| `firstAidKit` | `real_medkit` | Medkit | realistic | medkit | heal | small (0.62 m) | heal 50 |
| `ammoCrate` | `ammoBox`, `real_ammo` | Ammo crate | realistic | ammo | counter | small (0.62 m) | ammo +30 |
| `brassKey` | `real_key` | Brass key | realistic | key | item | medium (0.69 m) | keys +1 |
| `armorPlate` | `real_armor` | Armor plate | realistic | shield | shield | medium (0.71 m) | shield 50 |
| `energyDrink` | `real_energy` | Energy drink | realistic | powerup | powerup | small (0.60 m) | speed 8s |
## Effects
| Effect | What it does |
|---|---|
| `counter` | Adds `amount` to the player's `counter`. |
| `heal` | Heals `amount` through SecondWind (`HK.heal`), on the engine of the player who took it. A player at full health still takes it: the heart is collected and counted, and heals 0. |
| `shield` | Tops up the SecondWind shield by `amount`, up to the maximum, on the engine that holds that player's health (the kit sends it there). |
| `item` | Adds `amount` of `item` (keys, keycards...). Spend it with `Pickups.spend(player, "keys", 1)`. |
| `letter` | Adds the token's letter. Check a word with `Pickups.spelled(player, "ZERO")`. |
| `powerup` | Gives a timed power-up. Taking the same one again adds to its time. |
| `mystery` | Rolls one weighted reward: coins, XP, heal, shield or any power-up. |
| `none` | Nothing but the `collect` event, so you can handle it yourself. |
**Power-ups:**
- `speed`: moveSpeed ×1.6.
- `jump`: jumpForce ×1.55.
- `damage`: deals ×2 damage through SecondWind.
- `magnet`: pickups pull from 3× further away.
- `invincible`: takes no SecondWind damage.
Each family has a speed, damage and jump look: cartoon badges (`speedBadge`, `damageBadge`, `jumpBadge`), sci-fi orbs (`speedOrb`, `damageOrb`, `jumpOrb`), and the pixel `jumpMushroom`, `pixelSword` and `powerStar`.
Speed and jump are held on the player's CharacterController, on that player's own engine, for as long as the power-up runs. If something else, such as SecondWind's chill, changes the speed meanwhile, that change is respected.
Damage and invincible work through SecondWind's damage filter. Every engine knows every player's power-ups, so the filter is right wherever the damage is decided. The kit installs the filter the first time one of those power-ups is given, or straight away through `Pickups.setup()`. SecondWind keeps a single filter, so if your world has its own, call `Pickups.setup({ damageFilter = false })` and multiply by `Pickups.damageMultiplier(attacker, victim)` inside yours.
## API
`local Pickups = require("pickupsKit.api")`. A "player" argument can be an avatar entity, any collector entity, a peer number or a ledger key (`"p3"`).
**Pickups**
- `spawn(pos, settings?, { synced = true, name }?)` returns the entity. It errors on an unknown prefab or mode. `settings.prefab` names the look (`settings.look` is its older name).
- `burst(pos, { prefab, count = 8, amount, spread = 2.4, lifetime = 12, blinkTime = 3, magnetRange, mode, synced })` returns the coins.
- `setPrefab(pickup, prefab)` (also `setLook`), `collect(pickup, collector)`, `available(pickup, collector?)`.
- `all()` returns `{ [id] = Pickup }`. `openChest(chest)`.
**Counters**
- `get(player, counter)`, `counts(player)`, `allCounts()`.
- `add(player, counter, n)`, `set(player, counter, v)`, `spend(player, counter, n)`, `reset(player?)`.
- `ledger()` returns the PickupLedger component in use.
**Letters**: `letters(player)`, `spelled(player, word)`, `giveLetter(player, letter)`, `clearLetters(player)`.
**Power-ups**: `givePowerup(player, name, secs)`, `powerup(player, name)`, `powerupLeft(player, name)`, `activePowerups(player)`, `damageMultiplier(attacker, victim)`.
**Catalog**: `looks(filter?)` (filter by `style`, `kind`, `effect` and `size`), `look(id)` (a new name or an old id), `styles()`, `kinds()`, `effects()`, `powerups()`, `counters()`, `sizes()`. Everything they return is a copy.
**The kits it builds on**: `dependencies()` returns `{ name, needs, installed, loaded, problem, ok, why }` for SecondWind and juiceKit. `health()` and `juice()` return each module, or nil and why not, loading it if it can. `healthParts()` returns SecondWind's `{ Rules, Health, HUD, Trap }` components.
**Identity**: `keyOf(x)`, `nameOf(x)`, `localKey()`, `localAvatar()`, `owns(x)`.
**Checking every engine**: `peerReport(seconds?)` asks every engine in the play session for its `selfReport()` and returns `{ [peer] = report }`, this engine's own included. Each report says whether SecondWind and juiceKit loaded on that engine (and why not), which platform it runs on (`native` or `browser`), whether it holds the ledger, its own player's health as SecondWind sees it, and the outcome of the last heal or shield it applied. It is how a problem on another player's engine, such as a browser, can be read from yours.
**Collectors**: `addCollector(entity)` lets a bot or a kart collect, with its counts kept under `"e<id>"`. `removeCollector(entity)` undoes it.
**Setup**: `setup({ hud = true, hudStyle = "cartoon", word = "", damageFilter = true })` is optional. It makes the ledger now rather than after the host's first second, adds the HUD and installs the damage filter. `installDamageFilter()` installs just the filter.
**Events**, through `on(event, fn)`, which returns an unsubscribe function:
| Event | Arguments |
|---|---|
| `collect` | `ev` = `{ id (event id), p (pickup id), prefab, look (the same name), style, kind, effect, amount, counter, item, letter, powerup, duration, mystery, key, cid (collector id), pos, mode }` |
| `counter` | `key, counter, value, delta` |
| `letter` | `key, letter, letters` |
| `powerup` | `key, name, seconds` |
| `powerupEnd` | `key, name` |
| `respawn` | `pickupId` |
| `expire` | `pickupId, pos` |
| `burst` | `pos, count` |
| `chest` | `chestId` |
`pickupsKit.visual` draws a look on this peer, for your own props or menus:
- `build(prefab, { letter, name, shadows }?)` returns a handle;
- `place(h, pos, yaw, scale)`, `setVisible(h, on)`, `setShadows(h, on)`, `alive(h)`, `destroy(h)`;
- `extent(prefab)` returns the model's `{ x, y, z }` box at scale 1, or nil for an unknown look;
- `release(h)` parks a visual hidden for reuse, and `acquire(prefab, opts?)` takes a parked one of that look back, or builds a new one. `poolSize()` and `clearPool()` look after the pool, which holds up to `POOL_PER_LOOK` (16) per look and `POOL_MAX` (96) in all.
`pickupsKit.logic` holds the rules as pure functions you can reuse or test: `canClaim`, `applyClaim`, `settle`, `addCount`, `spelled`, `rollMystery`, `burstVelocity`, `stepCoin`, `magnetStep`, `blinkVisible`, `versionAtLeast` and more. `pickupsKit.catalog` is the raw data: `LOOKS`, `BY_ID`, `IDS`, `SIZES`, `REQUIRES`, `ALIASES` (old id to new name), `OLD_ID` (new name to old id) and `canonical(id)`, which turns any old id into the current name.
## How multiplayer works
- **The owner decides.** Every pickup is a synced entity, and the engine that owns it (the host, for scene pickups) is the only one that decides who gets it. Each engine runs the touch test for the players and collectors it moves, then asks the owner. The owner checks that the pickup is still available to that player and that the player is really within reach, then marks it taken in the same step, so when two players touch the same coin the first claim to arrive wins and it is counted once.
- **Entities travel as entities.** The kit never relies on an entity's id string matching on another engine: pickups, chests and players go over the network as entity references, which each engine receives as its own, and become local ids on arrival. As a safeguard the kit also sends each entity's sync id, which every engine shares, and can resolve an entity from it if a reference ever arrives as raw data. (Before 2.2.0 the kit sent id strings, so a claim from a player who wasn't hosting named a pickup the host didn't know, and none of their pickups did anything.)
- **One announcement per collect.** The owner announces the result as one event with an id. Every engine records the power-ups and plays the pop, and the engine of the player who took it applies their heal, shield, speed and jump, exactly once per event.
- **Every message counts once.** Every kit message carries an id of its own, and a repeat is dropped, so a message that reaches an engine twice is handled once. After a live edit of the kit, the old copy's handlers stay registered (#19348), so they check that their copy is still the current one and stand down otherwise; an edit never leaves two copies answering.
- **The counts live in one place.** Counters and letters live in the synced `PickupLedger`, and only its owner writes it. The host makes it. When the player who started play leaves, nobody counts as host any more (#19347), so then the lowest-numbered player in the play room makes it after `Pickups.LEDGER_FALLBACK_S` (2.5) seconds. As a safeguard, a moment after a new player appears in the play room, the ledger's owner makes it again with the same counts (`Pickups.LEDGER_REPUBLISH_S`, 1 second), so a late joiner always receives a fresh one. If two ledgers ever meet, every engine keeps the one with the lower sync id, and the other's counts are folded into it.
- **Shared or per-player.** A `shared` pickup has one `available` flag. A `perPlayer` pickup keeps a synced `taken` map of who has had it, so each player has their own copy.
- **Settings are synced.** `prefab` and the other settings are written by the pickup's owner and sync to every engine. From any other engine, `setPrefab` asks the owner to make the change.
**Seen with two players.** 2.2.0 and 2.2.1 were played with two engines in one play session, the host and a player who wasn't hosting:
- Each kind of pickup was dropped at the other player's feet, and their own engine picked it up, sending the pickup and their player as entity references.
- Coins, keys, a letter and XP were counted once under their name in the host's ledger.
- Heals and shields reach the player who isn't hosting: a heart healed them 20 → 45, and a shield cell took their shield 0 → 30.
- Speed and jump took their controller from 2.5 to 4 and from 5 to 7.75.
- With damage, their hit on the host counted double. With invincible, 30 damage to them changed nothing.
- With the magnet, a pickup out of reach flew to them.
- One coin under both players was taken exactly once.
- When the host left play, the other engine took over the pickups and kept deciding them.
- `prefab = "pixelGem"` written by a coin's owner reached the other engine, and `setPrefab` called by the player who wasn't hosting changed a pickup the host owned. Old names worked the same way.
- With the other engine hosting, a player joining late read everyone's counts as they were: the host's player had 2 coins, 1 key, 10 XP and the letter Z, exactly as before the join.
- A coin taken from the host's gallery disappeared on the player's own engine, came back when the host respawned it, and played its pop and sound there.
- On a per-player gem, the player took their own copy while the host's player kept theirs, and their copy came back on its own timer.
- A blinking coin's expiry, decided by the host, reached the other engine.
- Walking up to a chest the host owned opened it, and all 12 burst coins appeared on both engines.
- The counter on the screen of the player who wasn't hosting showed their own counts from the host's ledger (7 coins, 3 gems, 1 key, 10 XP, Z), with both players listed.
- **In a web browser.** The same check, with the person's browser hosting (`platform = "browser"`) and an editor as the other player, passed 12 of 12 for the browser player: coins, keys, a letter and XP counted once under their name; a heart healed them 60 → 85; a shield cell took their shield 0 → 30; speed 2.5 → 4 and jump 5 → 7.75; their hit counted double with damage; invincible stopped 30 damage; the magnet pulled a pickup to them; one coin under both players was taken once. SecondWind and juiceKit both loaded in the browser from the copies that come with pickupsKit.
**How it is tested.** One engine can't be two real players (#18688, #19114), so the tests play both ends of the network through the same handlers the relay runs (`_deliver`). As the host: the other player's claims for every kind of pickup, naming the pickup and their avatar by entity, are each decided once, counted under their key, refused the second time, and announced with entity references. As the other player's engine: the host's announcements heal and shield their own player on the next frame, boost speed and jump on their CharacterController, double their damage, make them invincible and give them a magnet. The "two players" test runs 20 coins and a heart through two players whose claims arrive early, late and twice; every coin is taken once.
**For testing your own game's netcode** (internal, not a stable API): `_deliver(channel, fromPeer, ...)` runs one of the kit's network handlers (`pk:claim`, `pk:got`, `pk:ledger`, …) as if a message had arrived from `fromPeer`. `_tap(fn)` calls `fn(channel, ...)` for everything this engine broadcasts and returns an unsubscribe function.
## Performance in the browser
The kit is built to keep a busy level smooth in a web browser:
- **No pickup shadows by default.** Turning them off cut the gallery's sun shadow drawing from about 4.9 ms to 2.2 ms a frame and its frame spikes from 29 to 3. Use `castShadows` only where it matters.
- **Visuals are reused.** A pickup that is removed or changes look parks its model, and the next pickup of that look takes it back instead of building a new one. Repeat bursts and look switches cost a fraction of a fresh build.
- **First builds are spread out.** In play, pickups appearing for the first time build at most `Pickups.BUILD_BUDGET_MS` (3 ms) of models a frame, and always at least one. A wave of spawns therefore takes a few frames instead of stalling one. A prefab or letter change never waits.
- **Labels: show and hide, don't rewrite.** Changing a `Text3D` label's text redraws it, about 5 ms each time. For text that cycles through known values, make one label per value and show the one you need. Showing or hiding a label costs about 0.01 ms. The gallery's look-switch label works this way.
## Known limits
Engine problems that still affect the kit, each with its GitHub issue:
- **#19199: your own scripts can't reach a kit that came in as a dependency.** SecondWind and juiceKit arrive with pickupsKit, but `require("SecondWind.api")` in your own code answers `could not resolve require`. Go through the kit: `Pickups.health()`, `Pickups.healthParts()` and `Pickups.juice()`. Installing SecondWind or juiceKit yourself as well also works.
- **#19339: the editor's checker flags the kit's SecondWind lookups.** Opening `pickupsKit.api` shows `asset reference "SecondWind.Rules" could not be resolved` (and `Vitals`, `HUD`, `Trap`). They are warnings only: the lookups resolve when the game runs, and nothing blocks publishing.
- **#19340: a setting with a fixed list of choices can't take old names.** The `prefab` list holds only the new names, so writing an old id such as `cartoon_coin` straight into `prefab` is refused. Old ids go through `look`, `spawn`, `setPrefab`, `setLook` and `burst`. The editor's list for `look` shows the old ids after the new names.
- **#19260: the bundled Lifeline and MatchFlow copies resolve some of their own parts at older versions, which land as lone files that don't compile.** Lifeline's own health part was one of them: in a fresh install, `HK.attach` gave a player no health, so hearts couldn't heal anyone. pickupsKit fixes this for you: when it loads SecondWind it checks Lifeline's health, projectile and spawn-point parts and points any lone old copy at the complete one that ships with the install (the log says `pointed Lifeline at its complete …`). Always go through `Pickups.health()` before `HK.attach`, as the examples do. Lines such as `component source failed to load … versions.…` or `needs version … skipped as stale` can still appear in the log for Lifeline's zones and projectile and MatchFlow's match component, which pickups don't use.
- **#18688 and #19114: one engine can't be two players.** The loopback test mode doesn't echo messages (#18688), and an agent working inside the engine has no way to bring in a second player (#19114). The kit's own tests therefore drive both ends of the network through `_deliver`, and a real two-player check needs a second engine, as in "Seen with two players" above.
- **#19347: nobody is host once the player who started play leaves.** The host role stays with whoever started the play session, even after they leave, so code waiting for the host never runs. The kit works around it for its counts: when nobody is host, the lowest-numbered player in the play room makes the ledger. Your own host-only code (spawning enemies, starting a round) needs the same care.
- **#19348: a message handler can't be removed, and old ones keep running after a live edit.** Each time the kit's code is edited while the game runs, the previous copy's handlers stay registered. The kit's old handlers check that they are still current and do nothing otherwise, so live edits are safe. A full reload of play clears them.
## Showcase and tests
The world this package lives in, "Treasure Trove", opens on a gallery. It is set up exactly as a one-install world: pickupsKit in `/source`, and the kits it builds on only as its dependencies. It has every look, labelled and grouped by style, and a station for each behaviour:
- a respawning coin;
- blink and vanish;
- a coin burst from a chest;
- a magnet line;
- a shared pickup and a per-player one side by side;
- hearts and shields beside a lava trap;
- power-ups;
- spelling a word;
- a coin that cycles through prefabs, the pixel gem among them, by setting its `prefab` exactly as "Switching a look" above describes.
Every look's label shows its main `prefab` name, the one you type to use it.
`pickupsKit.pickupskit` is the test suite. It covers every public call of the api, logic, catalog, sounds, meshes, visual and fonts, the new names and every old id, `prefab` and `look`, the player who isn't hosting, and checks this README against the code: the version heading, the kit versions above, a row with style, kind, effect and size for every look, the quick start (short, compiles, real names) and this Known limits list. Run it with `tools.use("tests", "run", { suite = "pickupskit" })`.
## Version
2.3.0, which matches `package.yaml` (`pickupsKit.catalog.VERSION`, checked by the tests).
- 2.3.0:
- **`heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate` are main names** (they were `redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard` and `ammoBox`, which still work, as do the pre-2.2 ids). `looks()` and `look()` list every other name a look answers to as `otherNames`; `catalog.OTHER_NAMES` holds them.
- **Gallery:** each label shows the look's main name; the look switcher sets the coin's `prefab`, as the README says to; the gallery scene saves with nothing pending, so clearing and reloading it gives every pickup back.
- 2.2.1:
- **Played with two players, start to finish.** Every behaviour in "Seen with two players" was watched across two engines, with each engine taking a turn as host. That covers every pickup kind for the player who isn't hosting, per-player copies, late joiners, respawns, expiries, bursts and chests, the pop and sound, the counter, and `prefab` switches.
- **`collect` events carry `prefab`** as well as `look` (the README's own example read `ev.prefab`, which was missing).
- **Who can write `prefab`:** the README says it plainly. The pickup's owner writes the setting, and `setPrefab` works from any engine.
- **Known limits** now name only problems that still exist, each with its GitHub issue.
- **Heals and shields reach a player in a web browser** (played 12 of 12, see "Seen with two players"). Messages also carry each entity's sync id as a safeguard.
- **Hearts heal in a fresh install.** Lifeline's own health part resolved to a lone old-version copy that doesn't compile (#19260), so `HK.attach` gave no health. pickupsKit now points Lifeline at the complete copy when it loads SecondWind. Lifeline is now a direct dependency of pickupsKit, at the same 1.0.1.
- **Every message counts once.** A live edit of the kit left the old copy's handlers answering too (#19348). Messages now carry ids, repeats are dropped, old handlers stand down, and a heal or shield is applied exactly once per event.
- **The counts ledger always exists, for everyone.** It was made only by the host, and nobody counts as host once the player who started play leaves (#19347), so a session could count nothing. The lowest-numbered player in the play room now makes it when nobody is host, its owner makes it again when a new player joins, and two ledgers fold into one.
- **`peerReport()` and `selfReport()`** read every engine's view of the kit from yours.
- **Tests:** 85, including entities that arrive as raw data, a heal heard twice, repeated messages, the ledger election, folding two ledgers and making the ledger again for a joining player.
- **Played in a web browser:** every pickup kind, 12 of 12, for a browser player (see "Seen with two players").
- 2.2.0:
- **Pickups work for every player, not just the host.** The kit sent pickup and player ids as text, but every engine has its own ids for the same entity, so a claim from a player who wasn't hosting named nothing the host knew. Now pickups, chests and players cross the network as entity references. Coins, keys, letters, XP, hearts, shields and every power-up reach whoever touched them; a coin is still only ever taken once.
- **`prefab` is the setting**, with a short name for every look (`goldCoin`, `pixelGem`, `speedCore`, `healingPotion`, `brassKey`...). `look` and every old id (`cartoon_coin`, `real_gold_coin`...) keep working. Added `setPrefab`, `oldId` on `looks()`, `catalog.OLD_ID` and `catalog.PREFAB_IDS`; the chest takes `prefab` too.
- **One install.** Installing pickupsKit now brings SecondWind 1.2.0, juiceKit 1.2.2, Lifeline 1.0.1 and MatchFlow 1.0.0 with it, and the kit uses them. Added `healthParts()` for SecondWind's components; your own code reaches SecondWind through `Pickups.health()`.
- **Tests:** 77, including the new names, `prefab` and `look`, the player who isn't hosting, and walking up to a chest.
- **Chests open when you walk up to them anywhere.** Since 1.1.0 the walk-up check measured from the middle of the map, so a chest away from it only opened from code.
- **Showcase:** set up as a one-install world, every pickup by its new name.
- 2.1.0: pickups load without waiting for SecondWind or juiceKit; SecondWind 1.2.0; heals and shields heard in a message apply on the next frame.
- 2.0.0: renamed to pickupsKit (from pickupKit, same guid); `pixelGem`; five new looks; size tags; dependency versions; remote pops on the next frame.
- 1.1.1: works with SecondWind 1.1.1; passing a Pickup component no longer logs.
- 1.1.0: performance in the browser: pickup shadows off by default, pooled visuals, a per-frame build budget.
- 1.0.1: a two-player test of 20 coins and a heart.
- 1.0.0: first release, as pickupKit.
# 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.