# navKit.crowd
Runs every NavAgent once per frame on the peer in charge of it: a time-budgeted planning queue, path following with corner smoothing and arrival slowdown, sampled velocity avoidance weighted by mass, overlap pushing, vehicle steering with a turning circle, flock forces and the boids module's leader-follow for swarms, flight with the boids ray probe, link traversal (walk, climb, leap, warp, wait for doors), re-planning when obstacles move or block the way, and stuck detection. Fires navKit's events.
# navKit.api
The one interface to navKit: `local NK = require("navKit.api")`. Bakes maps (`NK.bake`), adds agents, obstacles, links and click-to-move, sends agents to targets from any peer (`NK.setTarget`, `NK.follow`, `NK.stop`, `NK.setStyle`), finds paths without an agent (`NK.findPath`), lists the catalog of movement styles, map types and link kinds (`NK.catalog`, `NK.find`), switches debug drawing (`NK.debug`) and reports events (`NK.on`). The package README documents every call.
# 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.
# aiKit.compat
Health for characters nobody owns. SecondWind changes health only on an entity's owner; in sessions where the relay leaves scene entities unowned nobody would. `damage` and `heal` apply aiKit's hits and heals directly through SecondWind's own pipeline when the target is unowned and this peer is the room host, and otherwise call SecondWind as usual; `respawn` brings a dead unowned character back. Used by `aiKit.actions`, the Brain and `NoiseMaker`.
# navKit 1.2.1
Navigation for characters, creatures, drones and vehicles in any multiplayer Zero world. Bake a walkable map from the level you already have with one call, connect places with ramps, stairs, ladders, jumps, teleporters and doors, and put a `NavAgent` on anything: it walks to a spot or follows something, smooths its path, slows down to arrive, steers around other agents, re-plans when something moves into its way, and tells you when it arrives or cannot get there. Eight movement styles, each with its own look, swap with one setting while running.
Version **1.2.1** (`NK.VERSION`). The version in this README, `package.yaml` and `NK.VERSION` move together.
navKit is built on the engine's own `@builtin::systems.pathfinding` package (A*, its grid and waypoint graphs, cost zones and off-mesh links) and `@builtin::modules.boids` (flock leader-following and the flier's obstacle probe). Both ship with every engine, so navKit needs nothing else installed.
## Quick start
This works in a brand-new world with nothing installed but navKit.
**1. Install** the `navKit.package` asset into `/source` (search ZeroMind for "navKit", or `zeromind_install guid=286dd3bb-0744-4157-b9f0-c98cc03ee326 at=/source`). navKit needs nothing else: it builds on engine packages every world already has.
**2. Add to `scenes/main.scene/build.luau`.** At the very top of the file:
```lua
local NK = require("navKit.api")
```
and at the end of `function content()`, just before its `end` (the new-world scene already has the ground, the light and the player):
```lua
-- a wall to walk around
local wall = entity.spawn("Wall", { position = { 0, 1, 6 }, scale = { 10, 2, 0.5 } })
wall.component.add("Model", { model = "cube" })
wall.component.add("BoxCollider", {})
-- bake a walkable grid of the ground when play starts
entity.spawn("NavMap").component.add(NK.MAP, { kind = "grid", size = { x = 40, y = 6, z = 40 } })
-- a runner that goes round the wall to the far side
local bot = entity.spawnSynced("Bot", { position = { 4, 0, 2 } })
NK.addAgent(bot, { style = "runner", target = { 0, 0, 12 } })
-- click the ground and it goes there instead; N shows the map and its path
NK.addClickToMove(bot)
entity.spawn("NavDebug").component.add(NK.DEBUG, { button = true })
```
**3. Press play.** The orange runner leans into a sprint, rounds the end of the wall and brakes to a stop behind it. Click anywhere on the ground and it runs there. Press N to see the baked grid (green), its path and the spot it steers for. Change `style = "runner"` to `"wheeled"` and it becomes a cart with a turning circle; to `"flier"` and it flies over the wall.
To react to what agents do, listen in code that runs in play, such as the scene's `entrypoint.luau`:
```lua
local NK = require("navKit.api")
local conn
function onLoad()
conn = NK.on("arrived", function(e) print(e.name .. " arrived at " .. math.floor(e.position.z)) end)
end
function onUnload()
if conn then conn:Disconnect() end
end
```
## What you get
| piece | what it is |
|---|---|
| `navKit.api` | the one interface: `local NK = require("navKit.api")` |
| `NavAgent` component | finds its way; `style` picks how it moves |
| `NavMap` component | bakes a map as soon as the game runs, once the level's colliders are in (the scene-file way to call `NK.bake`) |
| `NavLink` component | a ramp, stairs, ladder, jump, teleporter or door between two spots |
| `NavObstacle` component | its entity blocks the map where it stands; agents re-route when it moves |
| `ClickToMove` component | click or tap the ground and the agent goes there |
| `NavDebug` component | a key (N), and an optional on-screen button, that show or hide the debug drawing |
| `NavNet` component | the crowd line navKit spawns to send every agent's position in one update (internal) |
| `navDebug` render feature | draws the debug lines in play |
| `navKit` test suite | a test for every public call |
## Maps
```lua
local map = NK.bake({ kind = "grid" }) -- around every static collider
NK.bake({ kind = "multiLevel", name = "tower", min = { -20, -1, -20 }, max = { 20, 12, 20 } })
NK.bake({ kind = "waypoint", name = "fields", center = { 0, 0, 0 }, size = { 200, 20, 200 }, spacing = 6 })
```
A bake casts one ray down through each column of cells and keeps every floor it meets whose slope and headroom fit an agent; neighbouring floors join when the step between them is low enough. It reads **colliders** (a mesh with no collider is not in the physics world, so it is not ground), and **terrain**: a `Terrain` with collision on is a collider; one with collision off is read by its drawn surface. Everything that moves is left out: dynamic and kinematic bodies (pushable crates, characters), sensors and every NavAgent. Use `NavObstacle` for things that move and should block.
### Map types
| kind | label | what it is | best for | tags |
|---|---|---|---|---|
| `grid` | Walkable grid | A fine grid of cells over one floor (the lowest walkable floor of each spot), on the engine pathfinding package's grid graph. | rooms, streets, arenas up to ~100 m | precise, single-floor, indoor, arena, default |
| `waypoint` | Waypoint network | Points `spacing` metres apart, each on the roomiest spot near it, joined where a straight walk is clear, on the engine's waypoint graph. Cheap to search. | big open places | light, open-world, large, fast-search, outdoor |
| `multiLevel` (also `layered`) | Multi-level map | Every walkable floor over each spot (ground, balconies, upper storeys), joined by ramps and stairs in the level and by navKit links. | buildings, vertical levels | multi-floor, buildings, vertical, balconies, precise |
### Bake options
| option | default | meaning |
|---|---|---|
| `kind` | `grid` | grid, waypoint or multiLevel (`layered`, its older name, still works) |
| `name` | `"default"` | registry name; agents use the map under them, or the one named in their `map` field |
| `min`, `max` | around every static collider | the box to bake (or `center` + `size`) |
| `cellSize` | 0.5 (waypoint 1.0) | metres per cell |
| `agentRadius` | 0.4 | radius the waypoint network and debug view assume (paths use each agent's own radius) |
| `agentHeight` | 1.8 | headroom a floor needs |
| `maxStep` | 0.45 | tallest step between neighbouring cells |
| `maxSlope` | 42 | steepest walkable slope, degrees |
| `spacing` | 3.5 | waypoint maps: metres between points |
| `exclude` | `{}` | more entities the bake ignores |
| `terrain` | `true` | read terrain by its drawn surface where no collider answers |
| `spread` | `false` | yield between rows (call from a coroutine, e.g. a component hook) |
Every map keeps each cell's **clearance** (metres to the nearest wall or drop), so one bake serves agents of every width: a heavy (radius 0.8) is kept out of gaps a walker fits through.
The `NavMap` component bakes on every peer, desktop and browser alike, from the box `size` centred on its entity (`size.x = 0` bakes around everything). It starts on the first frame the game runs and waits until the static colliders in its box stop changing (a browser streams a level's assets in over its first seconds), then bakes. If that bake finds no walkable floor it tries again every second for 20 s, and if colliders arrive in its box within 30 s it bakes again (at most three times). Agents that need a map still being baked wait for it. `bakeInEdit` also bakes it in edit mode, for the debug view while authoring.
## Agents
```lua
local bot = entity.spawnSynced("Bot", { position = { 0, 0, 0 } })
NK.addAgent(bot, { style = "walker", target = { 10, 0, 4 } })
NK.setTarget(bot, { -6, 0, 9 }) -- from any peer
NK.follow(bot, "Player1") -- an entity, its name or its id
NK.setStyle(bot, "heavy") -- while it runs
NK.stop(bot)
print(NK.state(bot).status) -- idle, planning, moving, link, arrived, unreachable
```
What an agent does each frame, on the peer in charge of it:
- **plans** on the map under it (a few plans per frame, on a 5 ms budget), with its style's radius, link costs and refusals;
- **smooths** the path, dropping every corner a straight clear walk can skip, and rounds the corners it keeps;
- **slows down to arrive** inside its style's `slowRadius`, and before a link;
- **avoids other agents**: it picks, from a fan of candidate velocities, the one closest to where it wants to go that does not run into anyone within its style's look-ahead time; how much it cares is its `avoidance`, scaled by how heavy the other one is (everyone steps aside for a heavy); bodies that still overlap push apart, the lighter one moving more;
- **re-plans** when an obstacle appears in, or leaves, its map, when the next stretch of its path is blocked, when the followed entity moves away, when its style changes, and when it stops making progress; a target that cannot be reached is retried every 3 s (`retry`); when the goal is out of reach (walled in, too narrow for it, only over a link its style refuses) it reports `unreachable` once and goes to the closest spot it can reach;
- **crosses links**: walks ramps, stairs and doors (waiting for a door to open), climbs ladders on the ladder's line, leaps jumps on an arc, and shrinks into a teleporter and grows out of the other pad.
### NavAgent fields
| field | default | meaning |
|---|---|---|
| `style` | `walker` | movement style; change it any time |
| `target`, `hasTarget` | `{0,0,0}`, `false` | walk to `target` while `hasTarget` is on |
| `follow` | none | an entity to follow instead (re-plans as it moves) |
| `followDistance` | 2 | how close to stop behind the followed entity |
| `map` | `""` | map name; empty = the map under the agent |
| `speed`, `radius` | 0 | 0 = the style's own |
| `group` | `""` | swarm flock group (empty = "swarm") |
| `look` | `auto` | `auto` builds the style's body on every peer; `none` keeps the entity's own visuals |
| `arriveDistance` | 0.3 | how close counts as arrived |
| `turnRate` | 0 | how fast it turns to face where it goes, radians a second; 0 = the style's own (wheeled agents steer by their turning circle) |
| `avoidance` | true | steer around other agents; off = keep to its path and let others make way (bodies still never overlap) |
| `debugDraw` | `auto` | this agent's path and steering lines: `auto` = while the debug drawing is on, `on` = always on this screen, `off` = never |
| `retry` | `true` | try an unreachable target again every 3 s |
| `paused` | `false` | hold still without forgetting the target |
| `netRate` | 4 | most position samples a second this agent puts in its crowd line for other players (see Multiplayer) |
| `netError` | 20 | with other players present, the synced transform (the fallback, and what `entity.position` reads on other peers) is re-sent once the agent is this many metres from where it was last sent; it also goes out on links, teleports and changes of floor, and every 10 s while the agent moves |
| `smooth` | `true` | on this screen, smooth the body between network updates |
State fields, written by the peer in charge and read anywhere: `status`, `routeData` (the path as compact text, which is what crosses the network), `currentLink`, `eventData` (the latest event other peers fire too, `seq|name|reason|kind`). Rebuilt from those on the peers that do not move the agent, and read there through `NK.state(e)` / `agent:getState()` (an engine refuses writes to a synced entity's fields from anyone but its owner, so those peers keep them on navKit's own record): the route (the smoothed path, `{ {x, y, z, via} }`, `via` = `walk` or a link kind), the latest event and its reason. On the peer in charge they are also the fields `route`, `eventSeq`, `lastEvent`, `lastReason`. Local to the peer in charge: `steerPoint`, `routeIndex`.
Methods (the same as the API calls): `agent:setTarget(pos)`, `agent:followEntity(e)`, `agent:stop()`, `agent:setStyle(id)`, `agent:getState()`, and `agent:visualPosition()` (where its body is drawn on this screen). Component events: `arrived`, `unreachable`, `linkStarted`, `linkFinished`, `styleChanged` (synced to every peer) and `replanned` (on the peer in charge only), each with `{ reason, kind, x, y, z }`.
## Movement styles
Every style is one setting on the agent and swaps while it runs (it re-plans with the new costs). Each has its own look, built from primitive shapes on every peer.
| id | label | family | what it does | speed | radius | refuses | look | tags |
|---|---|---|---|---|---|---|---|---|
| `walker` | Walker | ground | Steady pace, takes every kind of link, steps politely around others. | 3.0 | 0.4 | — | tall blue body, white visor, swinging arms | default, humanoid, npc, villager, balanced |
| `runner` | Runner | ground | Twice a walker's speed with quick acceleration; brakes late, takes jumps gladly. | 6.2 | 0.35 | — | short orange body leaning hard forward, swept fins, speed streaks | fast, athlete, courier, chaser, agile |
| `heavy` | Heavy | ground | Slow and wide: needs a broad path, barely yields and shoulders lighter agents aside. | 1.5 | 0.8 | ladder, jump | broad dark-red block, grey shoulders | tank, brute, big, slow, pusher, golem |
| `flier` | Flier | air | Ignores the map: flies straight over walls, crates and gaps at a hover height above whatever is below. | 4.5 | 0.5 | (no links needed) | cyan quad-rotor drone, spinning rotors | drone, bird, air, hover, flying, scout |
| `swarm` | Swarm | group | Small and many: moves as a flock with its group (separation, alignment, cohesion, following the group's leader). | 3.6 | 0.25 | ladder | three bobbing yellow bugs | flock, insects, herd, group, rats, boids |
| `wheeled` | Wheeled | vehicle | A cart: turning circle (2.2 m), cannot strafe, slows into tight turns. Ramps are fine. | 4.5 | 0.6 | ladder, jump, stairs | green cart, rolling black wheels | vehicle, cart, car, rover, robot, no-strafe |
| `sneaker` | Sneaker | ground | Cautious and slow; keeps to walls and cover instead of crossing open ground, avoids zones labelled `Exposed`. | 1.9 | 0.35 | — | low purple cloak, pointed hood, glowing eyes | stealth, thief, cautious, cover, spy, assassin |
| `climber` | Climber | ground | Loves ladders and jumps: takes them over a longer walk, climbs and leaps fast. | 3.3 | 0.35 | — | pink body, arms raised to yellow hands, rope coil | parkour, monkey, spider, agile, acrobat, vertical |
Path costs per style (multipliers on the engine's cost labels): runner `Jump 0.7`; wheeled `Ramp 0.8` (and refuses stairs, ladders and jumps, including cost zones labelled `Stairs`); sneaker `Exposed 8, Cover 0.4, Door 2`, plus 3.5x extra cost on open ground (cells far from any wall); climber `Ladder 0.12, Jump 0.15, Ramp 1.8, Stairs 1.8`.
Make your own: `NK.defineStyle("guard", { base = "walker", speed = 2.2, costs = { Door = 0.5 }, tags = { "guard" } })`. Unset values come from `base`. Call it on every peer (a module your components require, or the scene's entrypoint) before agents use it.
## Links
```lua
NK.addLink({ kind = "ladder", from = { 4, 0, 2 }, to = { 4, 3.2, 2.8 } })
NK.addLink({ kind = "jump", from = { 0, 3.2, 0 }, to = { 3.5, 3.2, 0 }, bidirectional = false })
NK.addLink({ kind = "teleporter", from = { -10, 0, 5 }, to = { 20, 6, 30 } })
local door = NK.addLink({ kind = "door", from = { 0, 0, 9 }, to = { 0, 0, 11 } })
NK.updateLink(door, { locked = true }) -- no path uses it until unlocked
```
Or as a component, on the entity marking one end:
```lua
entity.spawn("LadderFoot", { position = { 4, 0, 2 } })
.component.add(NK.LINK, { kind = "ladder", toOffset = { x = 0, y = 3.2, z = 0.8 } })
```
| kind | label | how agents cross it | cost label | refused by | tags |
|---|---|---|---|---|---|
| `ramp` | Ramp | walk the slope | `Ramp` | — | slope, walk, vehicle-friendly |
| `stairs` | Stairs | walk it at 70% speed | `Stairs` | wheeled | steps, walk, building |
| `ladder` | Ladder | climb on the lower end's line, then step off | `Ladder` | heavy, wheeled, swarm | vertical, climb, building |
| `jump` | Jump | leap on an arc | `Jump` | heavy, wheeled | gap, parkour, leap |
| `teleporter` | Teleporter | shrink on one pad, grow on the other | `Teleport` | — | warp, portal, sci-fi, instant |
| `door` | Door | wait for it to open, walk through | `Door` | locked doors only | building, gate, lockable |
A ramp or stairs that exists as geometry needs no link: the bake joins it. Links are for what the bake cannot see (a ladder, a gap to jump, a teleporter) or for marking a walkway with a cost label. A link is used only by a map that holds both of its ends (or the one named in its `map`). Link ends snap to the nearest walkable spot. `NavLink`'s `door` field names a panel that slides up while any agent is near or crossing it, on every peer.
**Cost zones:** the engine's own `PathfindingModifier` component (or `Pathfinding.addModifier`) marks a box with a label; every agent pays its style's cost for that label inside it (`Exposed`, `Cover`, `Stairs`, or your own). The gallery's sneaker lane uses one.
## Obstacles
```lua
local crate = entity.spawnSynced("Crate", { position = { 0, 0.6, 4 } })
crate.component.add("Model", { model = "cube" })
crate.component.add("BoxCollider", {})
crate.component.add("Physics", { kind = "dynamic", mass = 3 })
NK.addObstacle(crate, { padding = 0.3 })
NK.setBlocked("roadworks", { 0, 0, 0 }, { 4, 2, 4 }) -- a box with no entity; NK.setBlocked("roadworks") clears it
```
A `NavObstacle` blocks its bounds (plus `padding`) on every map, as an engine cost zone with an infinite cost. When it moves more than `moveThreshold` (0.2 m), the agents whose map it touches re-plan, so they route around where it is now and take the way it left open. It runs on every peer from the entity's own transform. `obstacle:box()` says where it blocks.
## Click to move and debug drawing
- `NK.addClickToMove(entity)`: clicking or tapping the ground sends that agent there, from every player's screen; the target goes to the peer in charge of the agent. A click is a press and release within 8 px and 0.35 s, so camera drags are ignored, and a press on the game's own on-screen UI (a button, a panel) is not a click on the ground. `clickAt(x, y)` does it from code.
- `NK.debug(true)` / `NK.debug({ map = true, paths = false, steer = true })`, or a `NavDebug` component (key `KeyN` by default; `button = true` adds an on-screen panel in the bottom-right corner (its button answers a mouse click or a tap; in play the game holds the pointer, so the panel reads the press from the game's input and the UI's hit test as well as from the UI) whose button toggles it, by mouse or by touch, and `hint` puts a line of your own help text above that button): green lines are walkable ground, blue upper floors, amber narrow spots, cyan the waypoint networks, coloured arcs the links (yellow ladders, orange jumps, purple teleporters, white doors, grey when locked), red boxes the obstacles; each agent's path is drawn in its style's colour with a cross on every corner, and a white line with a pink diamond shows the spot it steers for. Drawing is per screen.
## Catalog
```lua
NK.find({ kind = "style", tag = "vehicle" }) -- wheeled
NK.find({ kind = { "style", "map" }, family = "ground" })
NK.find({ kind = "link", tag = "building" }) -- stairs, ladder, door
NK.catalog() -- { styles, maps, links, entries, events, version }
```
Query keys: `kind` (style, map, link), `family`, `tag`, `id`; each takes a value or a list (any matches); every key given must match. Unknown keys or kinds are errors.
## Events
```lua
local conn = NK.on("unreachable", function(e) print(e.name, e.reason) end)
NK.on("*", function(name, e) end)
conn:Disconnect()
```
| event | when | payload |
|---|---|---|
| `arrived` | an agent reached its target (or stopped `followDistance` short of what it follows) | `agent, name, style, status, position, reason, kind` |
| `unreachable` | no path, no map, or stuck too long; also fired once per goal when the goal is out of reach and the agent heads for the closest spot it can reach instead (`reason` says which; the agent then stops there with status `unreachable`) | same |
| `replanned` | a new path; `reason`: new target, target moved, blocked, obstacle moved, style changed, stuck, map baked, retry | same |
| `linkStarted`, `linkFinished` | crossing a link; `kind` is its kind | same |
| `styleChanged` | the style was swapped; `kind` is the new style | same |
| `baked` | a map was baked | `name, kind, info` |
| `obstacleMoved` | an obstacle appeared, moved or left | `key, entity, active` |
The peer in charge of an agent fires its events as they happen; every other peer fires them when the agent's synced `eventData` line changes, so every screen can react. `replanned` is the exception: it fires a dozen times a second in a busy crowd, so it stays on the peer in charge (the new route still reaches everyone). Two events within one network update can reach other peers as one.
## Multiplayer
- **The host moves the agents.** The peer that owns an agent's synced entity (normally its creator: the host, for a scene's entities) plans and moves it, and writes its transform and state fields, which reach every peer. While nobody owns it, the room host does. `NK.isAuthority(e)` says whether this peer is in charge.
- **Commands from anyone.** `NK.setTarget`, `follow`, `stop`, `setStyle`, `setPaused` and click-to-move work from any peer: a peer that is not in charge sends the command to the one that is (channel `navKit:cmd`).
- **Positions travel as one crowd line, not one update per agent.** Writing every agent's transform every frame floods a relay link: the 58-agent gallery sent about 96 KB/s, and a second player's view fell minutes behind. With other players in the room, the peer that moves agents writes one synced line (a `NavNet` component on a `navKit.crowdLine` entity it owns) holding each agent's slot number, position and facing. An agent appears in it up to `netRate` times a second while it moves, and every 2 s while it stands. Every other screen draws each body on a local pivot: the latest sample, carried on at its velocity for up to 0.6 s, eased into each correction, so agents glide instead of stepping. The agent's own synced transform becomes a slow fallback (each send costs a few hundred bytes): sent on links, teleports and changes of floor, once the agent is `netError` metres (20) from where it was last sent, and every 10 s while it moves. So on other peers `entity.position` can be a few metres behind; use `agent:visualPosition()` or `NK.state(e).position` there. Alone in a room, the transform is written every frame as before. Routes cross as compact text (`routeData`), events as one line (`eventData`), and the steering point and route index stay local.
- **Measured in the browser and with two players (1.2.1)** (the Pathwise gallery: a fresh Chrome tab in charge as host, a Zero desktop window joined beside it, 45-58 agents):
- **Browser in charge:** all five maps baked (about 1 s for the largest, 9,000 cells). 34-55 frames a second at 18-29 ms a frame, with navKit's whole tick at 3.5-4.6 ms a frame and occasional spikes to 16-72 ms when several long plans land together; the slowest single plan took 137 ms. An agent sent across the courtyard was moving at once and arrived in 6 s. All 29 pedestrians of the crowd reached the far side within 38 s and were all back within 59 s, watched from the desktop. No script errors.
- **Same places:** 57 of 57 agents and their styles on both screens. Bodies drawn on the desktop were 0.09-0.18 m from where the browser drew them on average (worst 0.72 m), with a 0.5-1.7 s lag and a 0.8-1.9 s round trip.
- **A crate in the way:** the crate, moved by the browser, reached the desktop in 0.4 s. Pushed out of the corridor, both screens routed the walkers straight through; pushed back in, the desktop showed all three going around within 4.8 s, and both screens agreed once settled.
- **A style change:** a swap made on the desktop showed there in 0.1-0.2 s, with the new look's parts built, and in the browser within 0.3 s.
- **Desktop in charge** (the browser handed its agents over, and the desktop owned all 57 within 3 s): all 29 pedestrians crossed and came back within 68 s, at 2.4 ms of navKit time a frame.
- With others present, `entity.position` of an agent follows only the slow fallback (it can lag 10 s on every screen, the moving one included): read where an agent is with `NK.agent(e):visualPosition()` or `NK.state(e).position`.
- **Measured with two players** (the Pathwise gallery, two Zero desktop windows, 88 agents, both freshly loaded): the same 88 agents and styles on both screens; bodies on the viewing screen 0.06-0.35 m from the moving screen's on average (worst 1.3 m); a ~1.5-3 s round trip that held steady; a style swap visible on the other screen within ~3 s; a moved crate and the box it blocks within 1.6 s, and the walkers' new detours on both screens within ~5 s; about 10.6 KB/s sent (96 KB/s before 1.1.0).
- **Everything else is local and identical.** Every peer bakes the same maps from the same scene, registers the same links and obstacles from the scene (obstacles follow their synced entity's transform), builds each agent's look from its synced `style`, and draws its own debug view.
- **When the player moving them leaves.** The agents a departed player was moving are claimed by the lowest-numbered player still in the room after 3 s, and walk on from where they stood; `NK.stats().orphans` counts any still waiting for the relay to grant the claim. Crowd lines a departed player left behind are removed by whoever the room hands them to.
- Put agents on synced entities (`entity.spawnSynced`). An agent on a plain local entity is moved by every peer separately.
## API reference (`local NK = require("navKit.api")`)
| call | what it does |
|---|---|
| `NK.bake(opts?) -> map` | Bake a map from the level (see Maps). |
| `NK.map(name?) -> map?` | A baked map by name (no name: the only one, or `default`). |
| `NK.maps() -> { map }` | Every baked map, by name. |
| `NK.mapAt(pos) -> map?` | The (smallest) map whose box holds a point. |
| `NK.removeMap(name) -> boolean` | Forget a map. |
| `NK.findPath(from, to, opts?) -> path` | A path without an agent: `{ status = ok/partial/unreachable, points, raw, cost, expanded, map }`. opts: `map`, `style`, `radius`, `costs`, `refuse`, `exposure`, `smooth`. |
| `NK.isWalkable(pos, radius?, map?) -> boolean` | Is there floor here with room for that radius? |
| `NK.nearestPoint(pos, radius?, map?) -> pos?` | The nearest walkable spot. |
| `NK.addLink(opts) -> id` | Add a link (see Links). |
| `NK.updateLink(id, opts) -> boolean` | Change a link's ends, cost, `locked`, `enabled`, `bidirectional`, `map`. |
| `NK.removeLink(id) -> boolean` | Remove a link. |
| `NK.links() -> { link }` | Every link. |
| `NK.addAgent(e, opts?) -> NavAgent` | Add (or update) a NavAgent; opts are its fields, `target` sets `hasTarget`. Unknown styles are errors. |
| `NK.agent(e) -> NavAgent?` | The NavAgent of an entity, name, id or component. |
| `NK.agents() -> { NavAgent }` | Every agent on this peer. |
| `NK.setTarget(e, pos)` | Send an agent to a spot (from any peer). |
| `NK.follow(e, target)` | Follow an entity. |
| `NK.stop(e)` | Stand still and forget the target. |
| `NK.setPaused(e, paused)` | Hold still without forgetting. |
| `NK.setStyle(e, styleId)` | Swap the movement style while running. |
| `NK.state(e) -> table` | `status, style, target, follow, route, routeIndex, steer, link, position, lastEvent, reason, authority`. |
| `NK.isAuthority(e) -> boolean` | Whether this peer moves that agent. |
| `NK.addObstacle(e, opts?) -> NavObstacle` | Make an entity block the map (opts: `padding`, `moveThreshold`, `active`). |
| `NK.setBlocked(key, min?, max?)` | Block a box by key; without `min` it unblocks. |
| `NK.addClickToMove(e, opts?) -> ClickToMove` | Click or tap to move that agent. |
| `NK.debug(opts)` | Debug drawing on this screen: `true`, `false` or `{ map, paths, steer }`. |
| `NK.debugState() -> table` | `{ map, paths, steer, mapVerts, pathVerts }`. |
| `NK.styles()`, `NK.style(id)` | The movement styles, or one. |
| `NK.mapKinds()`, `NK.linkKinds()` | The map types and link kinds. |
| `NK.catalog()`, `NK.find(query?)` | Everything labelled; filter it. |
| `NK.defineStyle(id, def) -> style` | Add a movement style of your own. |
| `NK.on(name, fn) -> conn` | Listen to an event on this peer. |
| `NK.eventCounts() -> table` | How often each event fired here, and the last 40. |
| `NK.stats() -> table` | `agents, authority, maps, links, obstacles, plans, planMsLast, planMsMax, tickMs, queued`. |
| `NK.vec(v) -> {x, y, z}` | Read any point form (`{x=}`, `{1,2,3}`, a vector, an entity position). |
Constants: `NK.VERSION` (`"1.2.1"`), `NK.STYLES`, `NK.MAP_KINDS`, `NK.LINK_KINDS`, `NK.EVENTS`, and the component names `NK.AGENT`, `NK.OBSTACLE`, `NK.LINK`, `NK.MAP`, `NK.CLICK`, `NK.DEBUG`.
A baked map (from `NK.bake` or `NK.map`) also answers `map:findPath(from, to, profile)`, `map:heightAt(pos)`, `map:clearanceAt(pos)`, `map:isWalkable(pos, radius)`, `map:nearestPoint(pos, radius)`, `map:clearLine(a, b, radius)` and `map:info()`.
## Tests
The suite `navKit` has a test for every public call, and tests in which agents really move: one walks to its target and arrives, one goes around a box that appears in its way, one climbs a ladder on a multi-level map and another on a grid map (starting at the ladder's foot), a flier switched to walker in mid-air lands and arrives, an agent left inside a box steps out and arrives, nine agents spawned on one spot with three to each goal all arrive without overlapping, an agent's own `turnRate`, `avoidance` and `debugDraw` do what they say, and a `NavMap` whose colliders arrive late bakes again. Run it in edit mode with:
```lua
tools.use("tests", "run", { suite = "navKit" })
```
## Performance
On a desktop, the gallery's 58 agents cost about 2-5 ms of script time a frame in total (planning included), and baking its five maps (about 18,000 cells) takes about a second spread over the first frames of play. In a Chrome tab the same gallery costs 3.5-4.6 ms a frame (spikes up to about 70 ms when several long plans land in one frame) and runs at 34-55 frames a second; keep crowds there in the tens rather than the hundreds. A plan on a 9,000-cell grid takes 1-20 ms on a desktop (up to about 140 ms in a browser); at most 8 plans (or 5 ms of planning) run per frame, the rest wait their turn. The map debug drawing is uploaded once per change.
## Known limits
Each of these is a problem in the Zero engine, not in navKit, and each points at the issue it is tracked in on the engine's tracker (`OrigoZero/zero` on GitHub). navKit works around each one as described.
- **Nobody-owns-it sessions.** In a long editor session the relay can leave a scene's synced entities with no owner (every `owner()` reads 0 and claims do not stick). navKit then lets the room host move those agents, so they still move the same for everyone, but the components' own synced events (`agent.events.arrived`) stay on the host; `NK.on` events still reach every peer. Open issue: [OrigoZero/zero#19323](https://github.com/OrigoZero/zero/issues/19323).
- **The engine's A\* puts link labels on the wrong step.** `Pathfinding`'s path rebuild drops edges without metadata, so on its own graphs a link's action lands several waypoints early. navKit's maps give every edge metadata, so navKit paths are right; it only matters if you call `Pathfinding.path` yourself on graphs of your own. Open issue: [OrigoZero/zero#19531](https://github.com/OrigoZero/zero/issues/19531).
- **The tops of sign letters are clipped.** `Text3D` labels lose the top edge of tall letters (the Pathwise gallery's signs show it). Nothing in navKit draws text; it affects worlds that label their navigation, like the gallery. Open issue: [OrigoZero/zero#19525](https://github.com/OrigoZero/zero/issues/19525).
- **In the browser, kits register late.** At startup a browser tab reports a missing engine module (`@builtin::modules.frameDriver`) for packages such as spawnerKit and juiceKit and registers their components only later, so things they spawn early can arrive before your own hooks are listening. The Pathwise gallery's crowd keeper adopts any pedestrian it missed once a second for this reason. Open issue: [OrigoZero/zero#19309](https://github.com/OrigoZero/zero/issues/19309).
- **Players do not push physics bodies.** The engine's character controller does not shove dynamic bodies, so a `NavObstacle` crate only moves if something moves it; the Pathwise gallery ships a small `gallery.Pushable` component for its crate. Open issue: [OrigoZero/zero#19539](https://github.com/OrigoZero/zero/issues/19539).
- **The player's camera does not show the engine's debug drawing in play.** `debugViz` draws only on the `debug` layer, which play cameras leave out, so navKit draws its debug lines itself (`navKit.navDebug` render feature, the engine's `debugGeometry` shader); headless engines draw nothing. Open issue: [OrigoZero/zero#19540](https://github.com/OrigoZero/zero/issues/19540).
## How navKit behaves (design notes)
These are navKit's own choices, not bugs, so no issue tracks them.
- **Maps are baked, not rebuilt.** A level that changes shape (a wall built or destroyed) needs a new bake (`NK.bake` again, or `NavMap:rebake()`); only `NavObstacle` boxes follow moving things live. A `NavMap` does bake again by itself if colliders arrive in its box within 30 s of its first bake (a level still streaming in).
- **Grid maps are one floor.** A `grid` map keeps the lowest walkable floor of each spot; use `multiLevel` for floors above one another.
- **Agents do not collide with physics.** They are moved along the map, not pushed by the physics engine; they do not avoid players' characters (only other agents and obstacles), and a player walks through them. Agents never stand inside each other: overlapping bodies push apart (the lighter one more), even with `avoidance` off.
- **Fliers fly straight.** A flier heads straight for its target over whatever is below; it steers around tall things with the boids ray probe but does not plan a 3D route, so it can be held up behind a wall too tall to clear.
- **Ground height follows the map.** Agents stand on the baked floor height; a floor that moves after the bake (a lift) is not followed.
- **Avoidance is local and late.** Agents pick, each frame, the velocity nearest their path that does not run into anyone within about a second; two walkers meeting exactly head-on start stepping aside only about a metre apart. In a very dense crowd they can slow to a shuffle and re-plan ("stuck"); after seven checks without progress (about 11 s) an agent reports `unreachable` with reason `stuck` and, with `retry`, tries again.
- **Agents sent to one spot queue up.** Only one body fits on a spot: an agent whose goal is taken arrives when it touches an agent that has arrived nearer that goal.
- **Events can merge across the network.** Other peers fire agent events from a synced line, so two events within one network update arrive as the second, and `replanned` is not sent at all.
- **One navKit version per session.** Peers match an agent's synced fields by the NavAgent definition each of them loaded, so everyone in a session must run the same navKit. A fresh visit always does; an editor that hot-reloaded navKit while playing beside others may not (reload the page or restart play).
- **Agents of a player who left wait for the room.** When the peer moving some agents leaves, the lowest-numbered peer still in the room claims them after 3 s (`NK.stats().orphans` counts the ones still waiting); until the relay grants the claim they stand where they were.
- **A saturated link falls behind.** If a world sends more synced state than the relay link carries, discrete changes (a style swap, an event, a route) queue up and reach other players late. navKit's own traffic is one crowd line (about 1 KB, 4 times a second for 60 agents) plus a slow per-agent fallback; for very large crowds lower `netRate` or raise `netError`.
## Version history
- **1.2.1**: Fixes from a hands-on review on desktop and in the browser. **Browser**: `NavMap` now bakes from the first frame the game runs (it used to bake only if play mode was already on when it started, which in a browser left every map unbaked and every agent still), waits until its box's colliders stop arriving, retries a bake that found no floor, and bakes again when colliders arrive late; the `NavDebug` panel moves onto the window when the viewport camera does not draw the game UI. **Links**: a ladder (or any link) on a grid or waypoint map is walked to, climbed and stepped off on top, instead of being "climbed" a few metres early on flat ground (an engine A\* bug put link labels on the wrong step; every navKit edge now carries metadata); grid maps no longer join cells more than `maxStep` apart; an agent plans on the map holding both its start and its goal. **Styles**: a flier switched to a ground style in the air lands on the floor below and then plans, instead of hanging at its flight height. **Movement**: an agent the look-past shortcut carries beyond a corner counts the corner as passed (it used to dither there for seconds); an agent left where its map has no floor for it (inside a planter, after another player handed it over) steps out to the nearest walkable spot at its own height. **Multiplayer**: the agents of a player who left are claimed by the lowest-numbered player still in the room (`NK.stats().orphans`); crowd lines left behind are removed; each agent's synced transform (the slow fallback behind the crowd line) goes out only on links, teleports and floor changes, after `netError` metres (now 20) or every 10 s, where it used to go out ~55 times a second for 58 agents and saturate a browser's link. **Tests**: agents that walk, detour around a box that appears in their way, climb ladders on both map kinds, and land after a style swap, and step out of a box they were left inside; a `NavMap` with late colliders. **Second review**: new per-agent settings `turnRate`, `avoidance` and `debugDraw`; the multi-floor map kind is `multiLevel` (`layered` still works); bodies spawned on the very same spot part (they used to stay inside each other for good, the browser crowd's knot), and agents sent to one spot queue up beside whoever stands on it instead of shoving round them for ever; `NavDebug`'s button answers real mouse clicks and taps in play (the game holds the pointer there, so the UI never saw them), and `ClickToMove` asks the UI's hit test whether a press landed on the game's own UI. Tests for each (72 tests).
- **1.2.0**: `NavDebug` gains an on-screen panel (`button`, `hint`): a button in the bottom-right corner that toggles the debug drawing by mouse or touch, showing the key and a one-line legend while drawing. `ClickToMove` ignores presses on the game's own on-screen UI. The walker, runner, sneaker and climber looks are rebuilt to the engine's unit-sized meshes (they had been floating, with loose parts) and given clearer silhouettes and motion: the walker swings its arms, the runner leans into speed streaks, the sneaker is a low hooded cloak, the climber (now pink, apart from the green cart) reaches up hand over hand. A look's `scale` now scales the whole body about its feet. Tests for the new parts.
- **1.1.0**: Multiplayer bandwidth, found by a two-player test. Positions of all agents travel together in one crowd line per moving peer (new `NavNet` component; `netRate`, `netSlot`, `netWriter`), with the agent transform as a slow fallback (`netError`); other screens draw each body on a smoothed, predicted local pivot (`smooth`, `visualPosition()`); routes cross as compact text (`routeData`), events as one line (`eventData`); `replanned`, `steerPoint` and `routeIndex` stay on the peer in charge; a moment's status change is written once a frame; unchanged targets are not re-sent; teleport shrinking is drawn locally. Agents re-register if a hot reload empties the crowd. Tests for the new parts (56 in all).
- **1.0.0**: First release. Grid, waypoint and multi-level maps baked from colliders and terrain; ramp, stairs, ladder, jump, teleporter and door links with per-style refusals; NavAgent with smoothing, arrival, avoidance, re-planning and events; eight movement styles with their own looks; NavObstacle; ClickToMove; debug drawing; host-authoritative multiplayer; a test for every public call.
# 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.
# aiKit 1.1.0
Brains and senses for the non-player characters of any multiplayer Zero world. Put a `Brain` on a character, pick a preset, and it walks, looks, listens, remembers, fights and gives up like the character it is meant to be: a guard on its rounds, a sniper on a perch, a swarm, a boss that changes tactics. Change the preset while the game runs and it behaves differently at once. Presets are plain data, so a designer can make "a guard that hears better but gives up later" without touching code.
Version **1.1.0** (`AI.VERSION`). The version in this README, `package.yaml` and `AI.VERSION` move together, and a test checks that they do.
aiKit moves characters with **navKit 1.2.1** (the Pathwise world) and gives them health, damage, death and healing with **SecondWind 1.2.0** (the Second Wind world), which itself runs on **Lifeline 1.0.1** and **MatchFlow 1.1.0**. aiKit 1.1.0 is built and tested against exactly those versions. It adds the parts neither has: senses, memory, decisions and the debug view.
## Quick start
**1. Install aiKit** into `/source`. One call brings everything it builds on with it: navKit 1.2.1 (movement), SecondWind 1.2.0 (health and damage), and the Lifeline 1.0.1 and MatchFlow 1.1.0 packages SecondWind uses. They land under `/source/deps/`, and `require("navKit.api")` and `require("SecondWind.api")` reach them from your own scripts too.
```
zeromind_install guid=549ff927-79bc-4db2-b5b2-35d5f3fd59cb at=/source
```
(or search ZeroMind for "aiKit" and install `aiKit.package`). If your world already has navKit or SecondWind installed, aiKit uses those; keep them at the versions above.
**2. Add to `scenes/main.scene/build.luau`.** At the very top of the file:
```lua
local AI = require("aiKit.api")
local NK = require("navKit.api")
```
and at the end of `function content()`, just before its `end` (a new world's scene already has the ground, the light and the player):
```lua
-- the ground the characters can walk on (baked when play starts)
entity.spawn("NavMap").component.add(NK.MAP, { kind = "grid", size = { x = 40, y = 6, z = 40 } })
-- a wall to hide behind (wide enough to hide the spawn point from the guard)
local wall = entity.spawn("Wall", { position = { 0, 1, 9 }, scale = { 16, 2, 0.5 } })
wall.component.add("Model", { model = "cube" })
wall.component.add("BoxCollider", {})
-- a guard walking a square behind it
local guard = entity.spawnSynced("Guard", { position = { -6, 0, 12 } })
guard.component.add("aiKit.Brain", {
preset = "guard",
route = { { -6, 0, 12 }, { 6, 0, 12 }, { 6, 0, 18 }, { -6, 0, 18 } },
})
-- V (or the button in the bottom-right corner) shows what it sees, hears and remembers
AI.addDebugToggle()
```
**3. Press play.** The blue guard walks its square behind the wall. Walk around the wall into its view: it stops, stares ("Suspicious?"), then runs at you ("Chase!"). Duck back behind the wall and it runs to where it **last saw** you, searches around there, gives up and goes back to its route. Press V to see its vision cone, the ring it would hear your footsteps in, and a red marker where it remembers you; press B to switch between the full view, state labels only and cones only. Walk slowly and it does not hear you at all.
Change `preset = "guard"` to `"nightwatch"` and it only sees you where it is lit; to `"turret"` and it stands and sweeps; to `"chaser"` and it hunts anything it sees or hears. From code, while playing: `AI.setPreset(guard, "listener")`.
## What you get
| piece | what it is |
|---|---|
| `aiKit.api` | the one interface: `local AI = require("aiKit.api")` |
| `Brain` component | senses, memory and a brain on one character; `preset` picks which |
| `AIDebug` component | the debug view's switches: on/off (V and a button) and its style (B and a button) |
| `NoiseMaker` component | bottles thrown on a timer, and keys that let a player throw a bottle (G) or fire a shot (F) |
| `aiKit.presets` | every character type as plain data, and the perception styles |
| `aiKit.fsm` | a small state machine |
| `aiKit.bt` | a small behaviour tree |
| `aiKit.brains` | the built-in brains, written with those two |
| `aiKit.senses`, `aiKit.memory` | sight, hearing, light and touch; the fading memory of where others were |
| `aiKit.actions` | the verbs brains are written in: move, face, attack, shoot, heal, slam, shout |
| `aiKit.debug` + `aiDebug` render feature | the debug view, drawn in the game view |
| `aiKit.compat` | health for characters nobody owns (see Multiplayer) |
| `aiKit` test suite | a test for everything a game can call |
## Presets
| preset | brain | kind | team | what it does |
|---|---|---|---|---|
| `guard` | guard | state machine | guards | Walks its patrol route. Spots you only when it can really see you, chases, loses you when you break line of sight, searches where it last saw you, gives up and goes back. Shouts so other guards come. |
| `wanderer` | wanderer | state machine | neutral | Strolls about where it was placed; stops to watch anyone who comes close. Harmless. |
| `chaser` | chaser | state machine | monsters | Roams; runs at anything it sees or hears and hits it up close; gives up a few seconds after losing it. |
| `ranged` | ranged | behaviour tree | bandits | Shoots and keeps its distance: when you come closer than 6 m it backs away to the middle of its 6–11 m band (round to the side when a wall is behind it), keeping its eyes on you, and fights from there; steps forward when you are farther than 11 m; strafes between shots. When it loses you it goes only as far as the edge of that band and watches the spot. |
| `coward` | coward | state machine | bandits | Fights while healthy, flees and hides when hurt, heals, comes back. |
| `ambusher` | ambusher | state machine | monsters | Crouches out of sight at its post, springs when prey comes close, returns to hiding afterwards. |
| `sniper` | sniper | behaviour tree | bandits | Holds a perch, watches a long narrow cone, aims with a visible laser and fires one heavy shot. Break line of sight and the aim is lost. |
| `healer` | healer | behaviour tree | monsters | Goes to whichever ally is most hurt and heals it with a beam; backs away from enemies. |
| `swarm` | swarm | behaviour tree | swarm | Small and many: moves with its group, shares what any of them sees, mobs a target together. |
| `turret` | turret | state machine | machines | Fixed gun: sweeps its arc, locks on, fires bursts, keeps aiming at the last spot for a moment. |
| `companion` | companion | behaviour tree | players | Waits at its post until a player comes close, then follows that player, fights what threatens them and patches them up when they are badly hurt. |
| `boss` | boss | behaviour tree | monsters | Brawler at full health, gunner that keeps its distance below 60%, berserker that charges and slams the ground below 30%. |
| `sentry` | sentry | state machine | guards | **Sight cone.** Stands its post sweeping its gaze; sees only inside the cone and only what walls do not hide; tracks and shouts for help. |
| `listener` | listener | state machine | monsters | **Hearing.** Blind, hears all round; noises are fainter with distance and through walls; it goes to where the noise was and swings at anything it bumps into. |
| `nightwatch` | guard | state machine | guards | **Light-sensitive sight.** A guard whose eyes need light: stand in a lamp's pool and it spots you at once; keep to the dark and you slip past. |
| `dummy` | wanderer | state machine | players | A harmless stand-in for a player that walks a route and makes footstep noises, so demos run with nobody playing. |
`AI.find({ family = "perception" })`, `AI.find({ tag = "stealth" })`, `AI.find({ brainKind = "bt" })` filter them.
### Perception styles
The three ways a character can take the world in have a list of their own, asked for the way presets are:
```lua
AI.perceptionStyles() -- { "sight", "hearing", "light" }
AI.perceptionStyle("light") -- { id, label, description, preset = "nightwatch", senses = { ... } }
```
| style | label | shown by preset | what it means |
|---|---|---|---|
| `sight` | Sight cone | `sentry` | Sees only inside a cone in front of it, up to its range, and only what walls do not hide. |
| `hearing` | All-around hearing | `listener` | Blind; hears noises all round, fainter with distance and through walls, and goes to where the noise was. |
| `light` | Light-sensitive sight | `nightwatch` | Sees only where there is light: spots you at once in a lamp's pool, misses you in the dark. |
`senses` in the answer is that preset's own sense settings (a copy), so a style can be put on any preset: `AI.definePreset("darkGuard", { base = "guard", senses = AI.perceptionStyle("light").senses })`.
#### Picking a style by its tags
`AI.styles()` lists the three styles with tags, so you can pick one by what it senses with, what it suits, or how a player beats it. Each entry is `AI.perceptionStyle(name)`'s answer plus `perceives`, `suits`, `beatenBy` and `tags` (all of them in one list). Pass a tag to keep only the styles that carry it, or a list of tags to keep those that carry every one:
```lua
AI.styles() -- all three, in order, each with its tags
AI.styles("stealth") -- sight, hearing and light
AI.styles("night-levels") -- light
AI.styles("stay-quiet") -- hearing
AI.styles({ "sight", "stealth" }) -- sight and light (both see; light needs a lamp)
local pick = AI.styles("guard-posts")[1]
AI.definePreset("postGuard", { base = "guard", senses = pick.senses })
```
| style | perceives (senses with) | suits | beaten by |
|---|---|---|---|
| `sight` | `sight` | `stealth`, `guard-posts` | `hide-behind-walls` |
| `hearing` | `hearing` | `horror`, `stealth` | `stay-quiet` |
| `light` | `sight`, `light` | `stealth`, `night-levels` | `stay-in-the-dark` |
Tags are lower case with hyphens; `"Guard posts"` and `"night_levels"` find the same tags. A tag no style carries answers an empty list.
## Making your own presets
Presets are tables. `AI.definePreset` adds one, starting from a `base`; `AI.tunePreset` changes one for the whole game. Tables merge key by key, so you only write what differs. Call either on every peer (the scene's `entrypoint.luau`, or a module your components require) before the characters that use it start; a running character picks up the change at once.
```lua
local AI = require("aiKit.api")
-- a guard that hears better but is slower to give up
AI.definePreset("eagleGuard", {
base = "guard",
label = "Eagle-eared guard",
senses = { hearing = { sensitivity = 2 } }, -- hears every noise twice as far
search = { time = 25, spots = 8 }, -- searches longer before it goes back
chase = { giveUp = 5 },
})
AI.tunePreset("guard", { move = { walk = 1.6 } }) -- every guard in the game strolls slower
```
Then `Brain { preset = "eagleGuard" }`. `false` switches a sense off (`senses = { sight = false }` is a blind character).
### Preset reference
| key | default | meaning |
|---|---|---|
| `base` | none | preset this one starts from |
| `label`, `description`, `family`, `tags` | | names for menus, the debug label and `AI.find` |
| `brain` | `chaser` | the state machine or behaviour tree it runs (built-in or `AI.defineBrain`) |
| `team` | `monsters` | its team; `players` is the players' team |
| `hostile` | `others` | who it fights: `others` (every team but its own and `neutral`), `none`, or a list of teams |
| `mobile` | `true` | `false` = never moves (turrets, perched snipers); no NavAgent is added |
| `move` | walker, 2.0 / 4.0 | `style` (any navKit movement style), `walk` and `run` speeds, `turnRate` |
| `look` | humanoid | `shape` (humanoid, brute, bug, turret, none), `color`, `accent`, `size`, `head` (cap, helmet, hood, horns, hat, blind, target, none), `gear` (baton, rifle, longRifle, sword, staff, claws, spear, lantern, cannon, none) |
| `health` | 100 HP, timed | SecondWind settings it is given when it has no health: `maxHealth`, `deathStyle`, `respawnDelay`, `regenRate`; `false` = none |
| `senses.sight` | 15 m, 110° | `range`, `fov` (whole cone, degrees), `eyeHeight`, `spotTime` (seconds of clear close view to be sure), `closeRange` (felt whatever the cone), `needsLight`, `lightThreshold` (0 dark .. 1 daylight), `darkRange` |
| `senses.hearing` | ×1 | `sensitivity` (multiplies every noise's reach), `threshold` (weakest noise it reacts to, 0..1), `wallDamp` (share of a noise that gets through a wall) |
| `memory` | 10 s / 7 s | `forget` (a seen spot fades over this), `forgetSound`, `share` (radius it tells allies what it saw), `shareWith` (`team` or `group`) |
| `combat` | melee 1.7 m | `kind` (melee, ranged, sniper, none), `range`, `damage`, `cooldown`, `type` (SecondWind damage type), `accuracy`, `noise` (how far its attacks are heard), `windup` (sniper), `volley` |
| `patrol` | wait 1.5 s | `wait` at each route point (the route itself is on the Brain: a list of points or a spline) |
| `wander` | 7 m | `radius`, `waitMin`, `waitMax` |
| `search` | 10 s, 5 m | `time`, `radius`, `spots`, `lookTime` |
| `chase` | 2 s | `giveUp`: seconds without sight before it goes to look |
| `suspicion` | 2 s | how long it stares at a glimpse or a noise before going to look |
| `flee` | 35% → 80% | `below`, `recoverAbove`, `distance` (coward) |
| `keepDistance` | 6–11 m | `min`, `max` (ranged, healer) |
| `ambush` | 6 m | `springRange`, `chaseTime` |
| `heal` | 12 HP | `amount`, `range`, `cooldown`, `below` (healer) |
| `follow` | 2.5 m | `distance`, `recruitRadius`, `guardRadius`, `healBelow`, `healAmount`, `healCooldown` (companion) |
| `turret` | | `turnSpeed`, `sweep`, `sweepSpeed` (turret, sentry, sniper) |
| `phases` | none | boss phases: `{ name, above, tactic = melee/ranged/berserk, run, combat, keepDistance, tint }` |
| `footsteps` | 0 | how far its own footsteps carry, metres (dummies) |
| `shout` | false | shouts when it starts a chase (guard) |
| `leash` | 0 | territory radius; 0 = no limit |
## Senses
Each sense writes a **spot** into the character's memory: where someone was, when, and how it knows (`sight`, `glimpse`, `told` by an ally, or `sound`). Brains only ever act on those spots, never on where a player really is: lose them and they go to where you **were**.
- **Sight.** Inside `range`, inside the cone around where the body faces, and with a clear ray from its eyes to your chest or head (any collider in between hides you). Awareness fills while it can see you, faster up close and in good light, and drains while it cannot: a glimpse makes it suspicious, a full bar is a sighting.
- **Light-sensitive sight** (`needsLight`). It reads the light level where you stand from the scene's own lights (sun unless shadowed, ambient, point and spot lights unless blocked) and cannot see you below `lightThreshold`, except within `darkRange`. `AI.lightAt(pos)` tells you what it sees.
- **Hearing.** Every noise reaches `radius × sensitivity` metres and fades linearly with distance; a wall in between lets `wallDamp` of it through. Noises from its own team are ignored unless they are alarming (shots, shouts, explosions). It hears where the noise **was**: a thrown bottle draws it to the bottle.
- **Touch.** Anything within about a metre is noticed, eyes or not.
- **Memory.** Spots fade over `forget` / `forgetSound` seconds. With `memory.share`, a character tells nearby allies what it saw.
- **Territory.** With `leash` (on the preset or the Brain), it ignores everything further than that from its territory centre.
### Noises
Players' footsteps are heard without any setup: walking is heard within 5 m, running within 11 m, moving slowly is silent (`AI.config.quietSpeed`, `walkNoise`, `runNoise`, `runSpeed`). Make any other noise from any peer with `AI.noise(position, loudness, madeBy, kind)`:
- `position`: where it happened;
- `loudness`: how far it carries in metres, or the name of one of the noises below for its loudness;
- `madeBy`: who made it (an entity, id or name). Leave it out when the noise gives nobody away: a thrown bottle draws listeners to the bottle, not to the thrower;
- `kind` (optional): what it was, shown in the debug view and in the `heard` event. It defaults to the noise's name, or `"noise"`.
```lua
AI.noise(position, "gunshot", shooter) -- the shooter gives itself away
AI.noise(bottlePos, "bottle") -- listeners go to the bottle
AI.noise(position, 6, nil, "creak") -- your own noise, heard within 6 m
```
| kind | reach | alarms its own team |
|---|---|---|
| `footstep` | 5 m | no |
| `run` | 11 m | no |
| `impact` | 10 m | yes |
| `bottle` | 16 m | yes |
| `shout` | 22 m | yes |
| `gunshot` | 35 m | yes |
| `alarm` | 40 m | yes |
| `explosion` | 50 m | yes |
The `NoiseMaker` component throws bottles on a timer (`interval`, `scatter`), and with `playerKeys = true` lets the local player throw a bottle where they look (G) or fire a shot (F) that hurts the character it hits.
## State machines and behaviour trees
Half the built-in brains are state machines, half behaviour trees, so both styles have worked examples in `aiKit.brains`. Write your own with the same pieces and give it to a preset:
```lua
local AI = require("aiKit.api")
local A, H = AI.actions, AI.helpers
-- a state machine: a lookout that runs to the bell when it sees someone
AI.defineBrain("bellRinger", {
initial = "watch",
states = {
watch = {
label = "Watch",
update = function(b, dt)
if H.enemy(b) then return "ring", "saw someone" end
A.faceYaw(b, b.homeYaw + math.sin(b.machine.time) * 0.8)
end,
},
ring = {
label = "Ring the bell!",
enter = function(b) A.shout(b, 40) end,
update = function(b, dt) if b.machine.time > 3 then return "watch" end end,
},
},
})
-- a behaviour tree: attack what you see, else wander
local BT = AI.bt
AI.defineBrain("simpleHunter", BT.selector("hunter", {
BT.sequence("fight", {
BT.condition("sees an enemy", function(b) b.target = H.enemy(b); return b.target ~= nil end),
BT.action("Attack", function(b, dt)
if A.inReach(b, b.target) then A.attack(b, b.target) else A.moveTo(b, b.target.pos, "run") end
return "running"
end),
}),
BT.action("Wander", function(b, dt)
if A.arrived(b) then A.moveTo(b, A.randomPoint(b, b.home, 6)) end
return "running"
end),
}))
AI.definePreset("bellRinger", { base = "sentry", brain = "bellRinger" })
```
Every state and node receives the brain record `b`: `b.pos`, `b.facing`, `b.home`, `b.homeYaw`, `b.route`, `b.mem` (its memory), `b.preset`, `b.combat`, `b.machine` / `b.tree`, `b.st` (scratch), `b.focus`. Helpers: `H.enemy(b)` (nearest enemy it sees now), `H.lead(b)` (best lead it has not dealt with), `H.handle(b, spot)`, `H.allies(b, radius)`. Actions: `A.moveTo(b, pos, "walk"|"run")`, `A.follow(b, id, distance)`, `A.stop(b)`, `A.arrived(b)`, `A.faceTo(b, pos)`, `A.faceYaw(b, yaw)`, `A.attack(b, spot)`, `A.inReach(b, spot)`, `A.heal(b, allyId, amount)`, `A.slam(b, radius, damage)`, `A.shout(b, radius)`, `A.randomPoint(b, center, radius)`, `A.fleePoint(b, from, distance)`.
`aiKit.fsm`: `FSM.define(name, { initial, states = { name = { enter, update, exit, label } }, transitions = { { from, to, when, reason } } })`, `FSM.new(def, ctx)`, `machine:update(dt)`, `machine:go(state, reason)`, `machine.state`, `machine.time`, `machine:label()`. A state's `update` returns the next state's name to switch.
`aiKit.bt`: `sequence`, `selector` (both with `{ memory = true }` to resume a running child), `parallel`, `condition`, `action` (returns `"success"`, `"failure"` or `"running"`), `wait`, `invert`, `succeed`, `cooldown`, `timeout`, `repeatUntilFail`; `BT.new(root)`, `tree:tick(ctx, dt)`, `tree.active` (the leaf that ran). Node definitions are shared; what a node remembers lives on each tree instance.
## The debug view
`AI.addDebugToggle()` (or an `AIDebug` component) adds two switches in the bottom-right corner of the game view: **on/off** (key V, or the "AI debug view" button) and **style** (key B, or the "Style" button). From code: `AI.debug(true)` / `AI.debug(false)` switches it, `AI.debugStyle(name)` picks a style.
It has three styles:
| style | draws |
|---|---|
| `full` (default) | everything below |
| `minimal` | only the state labels over each character's head |
| `cones` | only the vision cones, filled on the ground |
```lua
AI.debugStyle("cones") -- pick a style (the view stays on or off as it was)
AI.debug("minimal") -- switch the view on in a style
AI.debugStyles() -- { "full", "minimal", "cones" }, the order the B key cycles
```
What the full view shows, for every character within 70 m of your camera (so a whole level is readable from where you stand):
- its **state** over its head (and its preset), coloured by how alert it is, on a dark plate; labels grow with distance so a character 40-odd metres away is still readable;
- its **vision cone**, filled on the ground: green calm, yellow suspicious, red sure, violet when its eyes need light;
- a blue ring: how far it would hear a walking footstep;
- blue lines to the **noises it heard**, fading;
- the **spots it remembers**: red seen, yellow glimpsed, orange told by an ally, blue heard, fading as it forgets, with a line from its head;
- its patrol route, and every noise on the bus as a ring as far as it carries.
Each player switches their own view and style. Other players' characters are drawn too: the peer running them sends what they remember only while somebody has the view on.
## Events
Each Brain fires these on the peer that runs it. Subscribe with `brain.events.<name>:connect(fn)`:
| event | when | payload |
|---|---|---|
| `spotted` | it starts seeing someone it treats as an enemy | `who` (entity id), `x, y, z` (where they are) |
| `lost` | it stops seeing them | `who`, `x, y, z` (where it last saw them) |
| `heard` | a noise reaches it | `kind`, `who` (who made it, or `""`), `x, y, z` (where the noise was), `strength` (how loud it was there, 0..1) |
| `died` | its health runs out | `by` (who dealt the last hit, or `""`) |
| `stateChanged` | its state changes | `state`, `from`, `reason` |
| `acted` | it does something every screen draws | `kind` (melee, shot, snipe, heal, slam, shout), `fx, fy, fz` from, `tx, ty, tz` to |
```lua
local guard = AI.brain(entity.find("Guard"))
guard.events.spotted:connect(function(e) print("guard spotted", e.who) end)
guard.events.lost:connect(function(e) print("lost them at", e.x, e.z) end)
guard.events.heard:connect(function(e) print("heard a", e.kind) end)
guard.events.died:connect(function(e) print("guard down, by", e.by) end)
```
## Patrol routes and splines
A Brain's `route` is a list of points, or a spline from the engine's spline library (`@builtin::modules.spline`). A spline becomes evenly spaced patrol points, 1.5 m apart by default, that the character walks through without stopping; it pauses (the preset's `patrol.wait`) only at the points you placed on the spline, or nowhere with `pause = false`. A closed spline loops like any route. A list of points pauses at every point, as before.
```lua
local spline = require("@builtin::modules.spline")
local path = spline.new({ { x = -6, y = 0, z = 12 }, { x = 6, y = 0, z = 12 }, { x = 6, y = 0, z = 18 }, { x = -6, y = 0, z = 18 } }, { mode = "catmullRom", closed = true })
guard.component.add("aiKit.Brain", { preset = "guard", route = path }) -- on the Brain: pauses at the 4 placed points
AI.setRoute(guard, path) -- or while it runs
AI.setRoute(guard, { spline = path, pause = false }) -- one smooth loop, never stopping
AI.setRoute(guard, { spline = path, spacing = 3 }) -- points 3 m apart
```
## Health and damage
Characters get SecondWind health from their preset when they have none (`addHealth = false` on the Brain to give them your own with `HK.attach`). Every hit goes through SecondWind, so damage types, teams, friendly fire, death styles, respawns, the kill feed and hit effects follow the game's SecondWind rules. Players need SecondWind health of their own to be hurt (`HK.attach(body, { team = "players" })` in the player setup).
## Multiplayer
- **The host thinks.** The peer that owns a character's synced entity (or the room host while nobody owns it, the same rule navKit uses) senses, remembers and decides for it, and moves it through navKit. Put characters on synced entities (`entity.spawnSynced`).
- **Home is where it stands when a peer starts running it.** A character's home (where it returns, wanders round and respawns) is taken from where it really stands the first time a peer runs it, after that peer has the room's positions. A player who joins and takes over characters keeps them where they are; they do not walk off to the middle of the world.
- **Every screen sees the same thing.** The preset, state, alertness, boss phase, pose, facing of characters that do not walk, and a sniper's laser are synced fields; every peer builds the same body from the preset. Shots, hits, heals and slams reach other screens as one small `aiKit:fx` message each (sent only when someone else is in the room). Positions travel in navKit's crowd line.
- **Any player can be the target.** Players come from the scene's normal player setup (`layers.active.players`); every player's body is a candidate for every character's senses, and each player's own footsteps are reported by that player's peer.
- **Commands from anyone.** `AI.setPreset`, `setRoute`, `setTeam`, `setActive`, `setLeader`, `forget` and `alert` work from any peer: they are sent to the peer running the character (`aiKit:cmd`). `AI.noise` from any peer reaches the brains.
- **Debug data only on demand.** What a character remembers is sent (`debugData`, a few hundred bytes, three times a second) only while some player has the debug view on.
- **Hits and heals reach the right peer.** A hit or heal aiKit makes (a character's attack, a player's `NoiseMaker` shot, a healer's beam) on something this peer may not change goes to the peer that may (its owner, or the room host while nobody owns it) on `aiKit:hit`, and is applied there through SecondWind's own damage pipeline, capped by SecondWind's `maxRemoteDamage` rule. aiKit uses its own channel for this because it re-subscribes every play session (below).
- **Characters nobody owns.** SecondWind (through Lifeline) only lets an entity's owner change its health. The relay can leave synced entities with no owner; in a brand-new editor world that is every character. Then `aiKit.compat` applies aiKit's hits and heals on the room host through SecondWind's own pipeline, and brings its characters back after death. For the length of each such call, Lifeline counts that one entity as the host's (`Compat.asOwner`); nothing else about Lifeline's rule changes. An owned entity is only ever changed by its owner. A SecondWind `Trap`, a Lifeline damage zone or a game's own `HK.damage` call goes through SecondWind's ownership check and does not reach an unowned character; from a game's own code, use `require("aiKit.compat").damage(target, amount, opts)` and `.heal(target, amount, opts)`, which take SecondWind's arguments.
- **One peer per character.** Every peer that runs characters says which ones every 2 s (`aiKit:runs`, only while someone else is in the room). A peer that is only standing in for a character nobody owns steps back when another peer runs it as its owner, or stands in for it with a lower peer id. So a peer whose view of the room is wrong does not end up driving characters another peer also drives.
- **Messages survive play sessions.** aiKit subscribes its channels (`aiKit:cmd`, `aiKit:noise`, `aiKit:fx`, `aiKit:hit`, `aiKit:debug`, `aiKit:runs`, `aiKit:throw`) again whenever a peer's mode or rooms change, and a reload of the module silences the older subscriptions, so no message is lost or handled twice.
## API reference (`local AI = require("aiKit.api")`)
| call | what it does |
|---|---|
| `AI.addBrain(e, preset?, opts?) -> Brain` | Add (or update) a Brain; `opts` sets any Brain field. |
| `AI.brain(e) -> Brain?` | The Brain of an entity, name, id or component. |
| `AI.brains() -> { {id, name, preset, state, team, authority, alive} }` | Every Brain on this peer. |
| `AI.info(e) -> table` | `preset, state, alert, phase, team, authority, alive, memory, heard, position, facing, home, brain, brainKind`. |
| `AI.isAuthority(e) -> boolean` | Whether this peer runs that character. |
| `AI.setPreset(e, preset)` | Switch preset while it runs (any peer). |
| `AI.setRoute(e, route)` | New patrol route: a list of points, a spline, or `{ spline = path, spacing = m, pause = false }`. |
| `AI.setTeam(e, team)`, `AI.teamOf(e)` | Put a character or a player on a team. |
| `AI.setActive(e, on)` | Switch a brain off and on. |
| `AI.setLeader(e, player)` | Companions: follow this player. |
| `AI.forget(e)` | Forget everything it saw and heard. |
| `AI.alert(e, pos)` | Tell it someone was seen at a spot. |
| `AI.presets()`, `AI.preset(name)` | Preset names; one preset's full settings (a copy). |
| `AI.perceptionStyles()`, `AI.perceptionStyle(name)` | Perception style names (`sight`, `hearing`, `light`); one style's `label`, `description`, `preset` and `senses` (a copy). |
| `AI.styles(tag?)` | The perception styles with their tags (`perceives`, `suits`, `beatenBy`, `tags`); a tag or a list of tags keeps only the styles carrying them. |
| `AI.definePreset(name, def)`, `AI.tunePreset(name, changes)` | Add a preset; change one. |
| `AI.find(query?)`, `AI.catalog()` | Presets with labels, filtered by `family`, `tag`, `brainKind`, `brain`, `id`; everything labelled. |
| `AI.defineBrain(name, def)` | Register a state machine table or behaviour tree root as a brain. |
| `AI.noise(pos, loudness, madeBy?, kind?)`, `AI.recentNoises()` | Make a noise (loudness in metres or a noise name; `madeBy` = who gives themselves away); the noises still on the bus. |
| `AI.lightAt(pos, exclude?) -> number` | Light level at a spot, 0 dark .. ~1 daylight. |
| `AI.canSee(e, target) -> boolean` | Could it see that entity right now (cone, range, walls, light)? |
| `AI.debug(on)`, `AI.debugOn()` | The debug view on this screen: `true`, `false`, or a style name to switch it on in that style. |
| `AI.debugStyle(name?) -> string`, `AI.debugStyles()` | Pick the view's style (`full`, `minimal`, `cones`) and answer the current one; the style names. |
| `AI.addDebugToggle(opts?)` | Add the on-screen switches and keys (an `AIDebug` component). |
| `AI.stats(keep?) -> table` | Brains, sense ticks, raycasts and script milliseconds by section since the last call. |
Also: `AI.VERSION`, `AI.BRAIN` / `AI.DEBUG` (component names), `AI.NOISES`, `AI.config` (footstep speeds and reaches, light scales, `senseRate`, `thinkRate`, `noiseLife`), and the modules `AI.fsm`, `AI.bt`, `AI.memory`, `AI.senses`, `AI.actions`, `AI.helpers`, `AI.brainLib`.
### `Brain` component
Settings: `preset`, `team` (empty = the preset's), `group` (swarms that move and share together), `route` (a list of points or a spline), `leader` (companions), `active`, `addHealth`, `leash` and `leashCenter` (territory). Read-only, synced: `state`, `alert` (0 calm .. 1 sure), `phase`, `facing`, `pose`, `aiming`, `aimPoint`, `debugData`. Methods: `brain:info()`, `brain:record()`. Events (on the peer that runs it; see Events): `spotted`, `lost`, `heard`, `died`, `stateChanged`, `acted`.
### `AIDebug` component
`key` (`KeyV`, on/off), `styleKey` (`KeyB`, next style), `button` (true: the two on-screen buttons), `style` (`full`, `minimal` or `cones`; set it to pick one, read it for the current one), `startOn`, `hint` (a line of help text above the buttons, wrapped to fit), `on` (read-only); `:toggle()`, `:setStyle(name)`.
### `NoiseMaker` component
`interval` (0 = off), `scatter`, `kind`, `radius`, `playerKeys`, `throwKey` (`KeyG`), `shootKey` (`KeyF`), `shotDamage`; `:throwAt(pos)`.
## Performance
Measured on the native desktop engine in this world's showcase, with 30 characters running at once (every preset, the showcase minus its six swarm members). Per frame:
| | per frame, 30 characters |
|---|---|
| aiKit, debug view off | about 2.0 ms of its own script time (`AI.stats()`); about 3.1 ms counting every engine call its Brains make (the profiler) |
| aiKit, debug view on (`full`) | about 2.9 ms (`AI.stats()`); `minimal` and `cones` cost about the same |
| navKit, moving the 27 that walk | about 3.8 ms |
| SecondWind + Lifeline, health for all 30 | about 3.6 ms |
So 30 characters with their movement and health come to roughly 10–11 ms a frame on that machine, about 0.35 ms per character all in.
Each character senses 10 times a second (`AI.config.senseRate`) and decides 15 times a second (`thinkRate`), staggered so they do not all think on the same frame; navKit keeps the bodies moving smoothly in between. Lower `senseRate` and `thinkRate` to trade reaction time for frame time. The debug view rebuilds its lines 8 times a second for characters within 70 m of your camera. Its state labels are 3D text on a dark plate; aiKit rewrites a label at most twice a second and only when its text changes. The costliest single things are SecondWind's hit pipeline (about 1 ms per hit) and navKit planning; see their READMEs.
## Tests
The `aiKit` test suite (72 tests) covers every public call:
- constants and versions, and this README's one Known limits and one Version history section;
- presets: the built-ins, `definePreset`, `tunePreset`, `find`, `catalog`;
- the perception styles: `perceptionStyles`, `perceptionStyle`, and `styles` with its tags and tag filter;
- the state machine, the behaviour tree and memory;
- sight through and around walls, cones, hearing and wall damping, light-sensitive sight;
- every Brain command; patrol routes from point lists and splines, and a smooth spline walked without stopping;
- the noise call;
- the debug view, its styles and its on-screen switch;
- the `spotted`, `lost`, `heard` and `died` events;
- the guard's whole loop, and the ranged attacker keeping its distance;
- a character's home taken where it stands when a peer starts running it;
- `NoiseMaker`, custom brains, damage and healing through SecondWind;
- the multiplayer wiring;
- this README's first example, run in a new world's scene.
Run it with:
```lua
tools.use("tests", "run", { suite = "aiKit" })
```
It runs the same way in any world that installed aiKit.
## Known limits
Open engine issues aiKit is affected by:
- **No engine debug drawing in play cameras** ([OrigoZero/zero#19540](https://github.com/OrigoZero/zero/issues/19540)). aiKit draws its debug view with its own render feature (`aiKit.aiDebug`), as navKit does; a headless engine draws nothing.
- **Tops of 3D text clipped** ([OrigoZero/zero#19525](https://github.com/OrigoZero/zero/issues/19525)). The debug view's state labels and the showcase's signs lose the top edge of tall letters.
- **Late registration in a browser tab** ([OrigoZero/zero#19309](https://github.com/OrigoZero/zero/issues/19309)). Components of installed kits can register after a scene's first frame in a browser; a character spawned before its Brain registers starts thinking a moment later.
Limits of aiKit's own design:
- **Characters have no colliders.** navKit moves them along its map, so walls and other characters block them but physics does not; a shot hits a character when its line passes close enough to the body. Players walk through characters.
- **Light reading is an estimate.** It sums the scene's lights with shadow rays; it does not read emissive surfaces, baked lighting or light colour.
- **Territory is a circle.** `leash` is a radius around a centre, not an area shape.
- **Hearing does not path around corners.** A wall between halves a noise; the noise does not travel around the wall.
- **Unowned characters and SecondWind's own calls.** aiKit's hits and heals reach characters nobody owns (see Multiplayer); SecondWind traps, Lifeline zones and direct `HK.damage` calls do not.
## Version history
- **1.1.0**: First published release. Adds `AI.styles(tag?)`: the three perception styles with tags for what each senses with (sight, hearing, light), what it suits (stealth, horror, guard posts, night levels) and how a player beats it (hide behind walls, stay quiet, stay in the dark), filtered by one tag or a list. `AI.perceptionStyles()` and `AI.perceptionStyle(name)` answer exactly as in 1.0.0. Looking a character up by its name no longer writes an engine error. The test suite (72 tests) now runs in any world that installs aiKit. Built against navKit 1.2.1 and SecondWind 1.2.0, as 1.0.0 was.
- **1.0.0**: Pushed, never released. 16 presets (12 behaviours, 3 perception styles, a training dummy), with the perception styles also listed on their own (`AI.perceptionStyles`); state machine and behaviour tree brains; sight, hearing, light and touch senses; fading memory shared between allies; territory; patrol routes from points or splines, smooth splines walked without stopping; a debug view in the game view with three styles (`full`, `minimal`, `cones`) and on-screen switches, readable across a whole level; `spotted`, `lost`, `heard`, `died`, `stateChanged` and `acted` events; `AI.noise(position, loudness, madeBy, kind)`; NoiseMaker; health through SecondWind 1.2.0 and movement through navKit 1.2.1; multiplayer (hits over `aiKit:hit`, one peer per character via `aiKit:runs`, subscriptions renewed every session, home taken where a character stands when a peer starts running it); a 68-test suite.