# 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.