navKit
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 sp…
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:
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):
-- 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:
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
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
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 reportsunreachableonce 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
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:
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
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 aNavDebugcomponent (keyKeyNby default;button = trueadds 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, andhintputs 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
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
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,setPausedand click-to-move work from any peer: a peer that is not in charge sends the command to the one that is (channelnavKit: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
NavNetcomponent on anavKit.crowdLineentity it owns) holding each agent's slot number, position and facing. An agent appears in it up tonetRatetimes 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 isnetErrormetres (20) from where it was last sent, and every 10 s while it moves. So on other peersentity.positioncan be a few metres behind; useagent:visualPosition()orNK.state(e).positionthere. 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.positionof an agent follows only the slow fallback (it can lag 10 s on every screen, the moving one included): read where an agent is withNK.agent(e):visualPosition()orNK.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().orphanscounts 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:
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.onevents still reach every peer. Open issue: OrigoZero/zero#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 callPathfinding.pathyourself on graphs of your own. Open issue: OrigoZero/zero#19531. - The tops of sign letters are clipped.
Text3Dlabels 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. - 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. - Players do not push physics bodies. The engine's character controller does not shove dynamic bodies, so a
NavObstaclecrate only moves if something moves it; the Pathwise gallery ships a smallgallery.Pushablecomponent for its crate. Open issue: OrigoZero/zero#19539. - The player's camera does not show the engine's debug drawing in play.
debugVizdraws only on thedebuglayer, which play cameras leave out, so navKit draws its debug lines itself (navKit.navDebugrender feature, the engine'sdebugGeometryshader); headless engines draw nothing. Open issue: OrigoZero/zero#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.bakeagain, orNavMap:rebake()); onlyNavObstacleboxes follow moving things live. ANavMapdoes 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
gridmap keeps the lowest walkable floor of each spot; usemultiLevelfor 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
avoidanceoff. - 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
unreachablewith reasonstuckand, withretry, 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
replannedis 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().orphanscounts 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
netRateor raisenetError.
Version history
- 1.2.1: Fixes from a hands-on review on desktop and in the browser. Browser:
NavMapnow 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; theNavDebugpanel 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 thanmaxStepapart; 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, afternetErrormetres (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; aNavMapwith late colliders. Second review: new per-agent settingsturnRate,avoidanceanddebugDraw; the multi-floor map kind ismultiLevel(layeredstill 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), andClickToMoveasks the UI's hit test whether a press landed on the game's own UI. Tests for each (72 tests). - 1.2.0:
NavDebuggains 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.ClickToMoveignores 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'sscalenow 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
NavNetcomponent;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,steerPointandrouteIndexstay 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.
Scoped to this part · feeds back into the world's score.