navUiKit
Wayfinding and notification HUD for any Zero world: minimaps, a full map screen, compasses, waypoint markers, objective trackers and toasts. There are 28 styles in five looks, every player gets their own view, and one setting changes the style.
navUiKit 1.1.0
Wayfinding and notification HUD for any Zero world: minimaps, a full map screen, compasses, waypoint markers, objective trackers and toasts. There are 28 styles in five looks, every player gets their own view, and one setting changes the style.
Install and use
Install the package from ZeroMind (search "navUiKit" or use the Wayfinder Studio world). It brings the two controlsKit modules it draws button glyphs with. Then paste this into your scene's entrypoint.luau (in a new world, scenes/main.scene/entrypoint.luau) and press Play:
local Nav = require("navUiKit.nav")
function onLoad()
Nav.start() -- round minimap, compass bar, objectives, toasts
-- mark anything: an entity, an entity id, or a point { x, y, z }
local chest = entity.find("Chest") or { 8, 0, -12 }
Nav.mark(chest, { icon = "chest", color = "#FFB300", label = "Treasure", waypoint = true })
Nav.objective("find", "Find the treasure")
Nav.toast("Follow the beam", { title = "Quest started", icon = "quest" })
end
function onUnload()
Nav.stop()
end
That gives a round minimap at the top right, a compass bar, an objective list and a waypoint on the treasure (an entity called "Chest" if your scene has one, otherwise a spot 14 m from where you spawn): an icon over it, an edge arrow when it is off-screen, the distance in metres, a light beam and ground rings. Toasts slide in and fade. Press M (or Select, or tap the minimap) to open the map. onLoad runs when play starts, so nothing appears while you author.
Every play starts clean. Each time you press Play (and each time you stop), the kit forgets what the last session declared: markers, waypoints, objectives and their progress, map areas and shapes, explored fog, queued toasts and Nav.on listeners. Your onLoad then declares them again. So the example gives the same result on every play: one Treasure, one fresh objective, one toast. Settings (Nav.setTheme, Nav.setVariant, Nav.configure) are configuration and stay. Declare your game's markers and objectives in onLoad (or with PointOfInterest components), not once from the console.
Prefer no code? Add the NavHud component to any entity, and the PointOfInterest component to anything you want on the map (tick waypoint to guide players to it). Doing both is fine: a scene's NavHud, Nav.start() (once or many times) and extra NavHud copies all share one HUD per player.
Change the whole look with one line. Nothing else in your code changes:
Nav.setTheme("fantasy") -- scifi, fantasy, cartoon, pixel, minimal
Nav.setVariant("compass", "arrow") -- or Nav.use("compass.arrow")
Styles
Every style works in every look. Nav.catalogue() returns these rows as data, { id, family, variant, description, tags, themes, apply }, for a world or an agent to pick from, and Nav.use(id) applies a row. Filter by tag or family with Nav.catalogue({ tags = { "round", "phone-friendly" } }) or Nav.catalogue({ family = "compass" }); a row matches when it carries every tag you ask for.
| id | what it is | tags |
|---|---|---|
minimap.square | Square north-up minimap with the area plan, POIs and the player arrow. | square, north-up, map-plan, classic, phone-friendly |
minimap.round | Round north-up minimap; the default. | round, north-up, map-plan, classic, phone-friendly, default |
minimap.headingUp | Round minimap that turns with the camera so forward is always up; the N badge orbits the rim. | round, heading-up, map-plan, open-world, driving |
minimap.fog | Round minimap whose plan is hidden until the player has been near it (per-player fog of war). | round, north-up, fog-of-war, exploration, per-player |
minimap.radar | Icon-only radar: no map picture, range rings and a rotating sweep that lights blips as it passes. | round, heading-up, radar, sci-fi, icons-only, animated, combat |
minimap.pixel | Low-resolution pixel minimap sampled from a 1 m grid of the world, chunky icons. | square, north-up, retro, low-res, map-plan |
map.rooms | Full map screen with every area outlined and named, a legend, scale bar and north arrow. | full-screen, north-up, areas, labelled, indoor, default |
map.fog | Full map screen hidden under fog that clears where this player has explored; shows % explored. | full-screen, fog-of-war, exploration, per-player |
map.satellite | Full map screen showing the real world from straight above (an orthographic camera) with markers on top. | full-screen, realistic, top-down, open-world |
compass.bar | Compass strip across the top: N/E/S/W, degree ticks, tracked POIs with distances. Its frame follows the theme (sci-fi tape, ornate fantasy scroll, minimal line, cartoon pill, pixel tape). | strip, heading, bearings, distance, open-world, phone-friendly, default |
compass.dial | Round compass dial at the top centre, card turning with the camera, POI pips on the rim. | round, heading, bearings, classic, nautical |
compass.ring | Compass bezel round the minimap's own edge (round or square): N/E/S/W and degree ticks, a notch that swings to your heading (or a ring that turns with a heading-up map), tracked places on the rim. Frees the top of the screen. | ring, round, square, heading, bearings, minimap, compact, phone-friendly |
compass.arrow | No cardinal points at all: one arrow at the top that turns to point straight at the nearest waypoint, with its name, distance and an up/down cue when it is above or below you. | arrow, single-target, waypoint, distance, height, minimal, compact, phone-friendly, kid-friendly |
objectives.list | Objective list with progress bars, shared and personal sections. | list, progress, shared, personal, default |
objectives.line | One-line tracker under the compass showing the current objective and its count. | strip, compact, minimal, phone-friendly |
objectives.checklist | Checklist whose boxes tick and strike through as objectives complete. | list, checkbox, progress, cozy |
objectives.questCard | Quest card: title, the steps, a big 3/5 progress counter and bar. | card, quest, progress, rpg |
toasts.slide | Cards sliding in from the right edge, stacking upward, fading out on their own. | card, stack, notification, animated, default |
toasts.banner | Wide banner across the top centre, one at a time, the rest wait in a queue. | banner, wide, announcement, one-at-a-time |
toasts.pixel | Pixel-font toast box with a stepped slide, in any theme. | card, retro, typewriter, notification |
toasts.achievement | Achievement pop: large icon medallion, title and text, a shine sweep, one at a time. | medal, reward, animated, one-at-a-time |
toasts.minimal | Small text-only pill near the bottom centre. | pill, minimal, subtle, phone-friendly |
toasts.prompt | Button prompt ("[E] Open") using controlsKit glyphs for the player's device; stays until dismissed or it times out. | prompt, button-glyph, keyboard, gamepad, touch, phone-friendly |
waypoint.icon | Icon floating over the target on screen. | icon, on-screen, waypoint |
waypoint.edgeArrow | Arrow pinned to the screen edge that glides round it when the target is off-screen or behind. | arrow, on-screen, off-screen, waypoint |
waypoint.distance | Distance label in metres beside the icon or arrow. | label, distance, waypoint |
waypoint.beam | A beam of light rising from the target in the world. | in-world, 3d, light, waypoint, visible-from-afar |
waypoint.ring | Pulsing rings on the ground around the target. | in-world, 3d, ground, waypoint, animated |
The looks are rows too (family theme, applied with Nav.use("theme.pixel") or Nav.setTheme("pixel")):
| id | look | tags |
|---|---|---|
theme.scifi | Sci-fi: dark glass panels, cyan neon edges, condensed display type. | look, sci-fi, neon, dark, futuristic, default |
theme.fantasy | Fantasy: leather and brass, serif type, diamond ornaments. | look, fantasy, medieval, ornate, rpg |
theme.cartoon | Cartoon: white cards, thick black outlines, drop shadows, bold rounded type. | look, cartoon, bright, playful, kid-friendly, chunky |
theme.pixel | Pixel: chunky pixel boxes and a built-in 3x5 bitmap font, stepped motion. | look, retro, pixel-art, 8-bit, chunky |
theme.minimal | Minimal: soft translucent panels, hairlines, plain sans-serif type. | look, minimal, clean, subtle, realistic |
The waypoint parts are switches that combine (Nav.configure{ waypoint = { beam = false } }); the other families pick one variant each.
Tag vocabulary. Shape: round, square, strip, ring, arrow, card, list, pill, banner, medal, icon, label, full-screen, in-world. Behaviour: north-up, heading-up, fog-of-war, radar, exploration, single-target, waypoint, notification, prompt, progress, one-at-a-time. Look: sci-fi, fantasy, cartoon, retro, minimal, realistic, animated. Fit: phone-friendly, compact, kid-friendly, default.
The four compasses. bar is a heading strip across the top. dial is a compass rose whose card turns with the camera. ring puts the compass on the minimap's own edge: round or square to match it, N/E/S/W and degree ticks on the band, a notch that swings round to your heading (on a heading-up or radar minimap the ring turns instead and the notch stays on top), tracked places as pips on the rim and the nearest waypoint's distance just inside. It frees the top of the screen. arrow has no cardinal points at all: one arrow that turns to point straight at the nearest waypoint, with its name, distance, how many others there are, an up or down cue when it is more than 3 m above or below you, and a tick when you arrive. With no waypoint it points north and says so.
Icons for POIs and toasts (Nav.icons()): pin, star, flag, skull, chest, door, gem, home, quest, exclaim, coin, key, sword, heart, player, ally, enemy, beacon, tent, eye, trophy, check, shop, portal.
Multiplayer
The kit runs on every peer. Each peer draws one HUD for its own player, centred on their character and measured from where they stand. Nobody sees another player's minimap, fog, map, arrows, waypoints or personal objectives. Shared objectives stay shared.
- One HUD per player. However the HUD is started (a NavHud in the scene, on the player prototype,
Nav.start(), any mix, any number of times), each peer has exactly one HUD for its player. The first holder draws it and the others stand by. It goes when the last holder goes, or withNav.stop(). - Players are sessions. A player key is
"<userId>#<peerId>"while connected ("<userId>"offline). The relay gives every connection its own peer id, so two machines signed in to the same account are two players to the kit.require("navUiKit.core").localKey()is this peer's key. A player object or avatar entity is accepted wherever a key is, and names that one session. - Addressing. A full key, a player object or a body reaches one session. A bare
userIdreaches every session of that account."all"reaches everyone. - POIs are marked on every peer by the same script (or by the PointOfInterest component). Pass
playertoNav.markorNav.setWaypointfor one player only. - Objectives are shared by default: progress and completion are relayed to every peer. Pass
player = ...for a personal one. Every peer can declare the same objectives in its ownonLoad: declaring an id that already exists keeps its progress (passcountto set it on purpose), so a second player joining never resets the group's progress. - Late joiners catch up. When a peer's HUD starts it calls
Nav.sync(), and the peers already playing answer with the shared objectives as they stand. Ones finished before the joiner arrived don't toast again. - Toasts go to the local player by default.
to = "all"sends one to everyone, andto = playersends one to a single player. A toast for someone on another peer is relayed to their peer and shown only there. When every peer runs the same script (a trigger that fires everywhere), the target would get the toast twice, from its own call and from the relay. The same toast arriving both ways within 2.5 s shows once. - Per-player state for someone else is relayed too:
Nav.setWaypoint(id, on, player),Nav.clearWaypoints(player)and a personal objective's progress reach that player's peer, and each peer applies only what is addressed to its own player. - Waypoint beams and rings are local, temporary parts each peer draws for its own player. They never replicate and never land in a saved scene. Neither does the
Nav.start()host entity. - Messages travel on the
navUiKitmultiplayer channel. To use your own transport, setrequire("navUiKit.core").relay = { send = function(kind, payload) ... end }and callNav._receive(kind, payload)where it arrives.
Phones and performance
- Widgets lay out inside the safe area (notches, rounded corners) and reflow in portrait and landscape. Under 560 pt of width the minimap shrinks, the compass narrows, the objective list drops rows it has no room for, and toasts stack from the top under the compass.
- The minimap and map pictures are baked once from the world's colliders (
Nav.rebake()after moving level geometry). Per frame, only the small icon layers and the compass are redrawn, and static canvases are only rebuilt when they change. - What one HUD costs. Measured in the playground scene on the native desktop editor (one player, five POIs, four objectives, toasts, round minimap, compass bar):
hud:updatetakes a median 0.83 ms per frame (p90 1.5 ms, p99 4.0 ms, over 300 frames), and the profiler's average for NavHud was 0.78 to 1.2 ms. Drawing the HUD's screens adds about 2.4 ms to the engine's UI render pass (2.44 ms with the HUD, 0.04 ms with it stopped, averaged over 15 frames). Together, about 3.5 ms of CPU a frame. Starting the HUD several times costs no more: four holders measured 0.88 ms of script. - The satellite map's camera exists only while that map is open.
Nav.stop(), removing the last NavHud, orhud:destroy()removes every screen, beam, ring and camera the kit made.
Known limits
These come from open issues on the engine's GitHub tracker. Each one says what you would see.
- Two machines on one account (#14448). Two sessions of one account in the same play room collide, and the engine can refuse one player's spawn. The kit tells the two sessions apart (each connection has its own peer id), so a toast, waypoint or personal objective meant for one machine reaches only that one. But on the machine whose spawn was refused there is no body to centre on: its minimap, compass and distances are measured from its camera instead, and other players see no avatar for it. What was checked: on one machine, connected, the kit gave this session its own key (
userId#9), and a toast for another session of the same account was sent and not shown here. Nobody has run it on two real machines yet. - No picture-based round or heading-up minimap (#19181, #19182). A viewport widget draws nothing inside a fully rounded panel, and rotating one is ignored. So the minimaps are vector drawings baked from the world's colliders rather than a live camera picture, and the
map.satelliteview, the one real picture of the world, is a north-up rectangle on the full map screen only. If you put the satellite view from the lower-level builders into your own rounded panel, it comes out blank. - Simulated notches only work in edit mode (#19179). In the editor,
ui.simulateSafeAreadoes nothing while playing, so the HUD won't move away from a simulated notch in play. Check notch layouts in edit mode with NavHud'spreviewInEditoron. Real phones report their safe area normally. {PlayerName}over the gallery walker (#19201). The NavGallery component's walker is an avatar nobody owns, and such an avatar shows a literal{PlayerName}label. This world's gallery scene removes it in its entrypoint (it despawns the walker'sNameLabelchild). If you use NavGallery in your own scene with an avatar as the walker, do the same.
Reference
All calls are on local Nav = require("navUiKit.nav"). player arguments are optional: leave them out for the local player.
Life cycle
| call | does |
|---|---|
Nav.version | "1.1.0" |
Nav.start(opts?) | Puts the HUD on screen for the local player. Makes (once) a runtime-only entity navUiKit_hud carrying NavHud; it is never saved or replicated. If a NavHud is already in the scene they share the one HUD. opts is applied as Nav.configure(opts) first. Returns the entity. |
Nav.stop() | Removes every HUD on this peer (screens, beams, rings, satellite camera) and the host entity. NavHud components already in the scene stand down until Nav.start() or a new NavHud. POIs and objectives stay until the session ends (the next press of Play or Stop), so you can hide the HUD and bring it back mid-game. |
Nav.reset() | Clears POIs, areas, objectives, fog and settings back to defaults, now. (Pressing Play or Stop does the same for everything except settings by itself.) |
Nav.hud(player?) | The HUD object for a viewer, for split views and tests. |
Nav.on(event, fn) | Listens for poiAdded, poiRemoved, waypoint, objective, toast, mapOpened, mapClosed, styleChanged, hudStarted, hudStopped. Listeners last for one session: register them in onLoad. |
Nav.sync() | Asks the other peers for the shared objectives as they stand. NavHud calls it once when this peer's HUD starts in play; call it yourself after swapping in your own relay. |
Style and settings
| call | does |
|---|---|
Nav.setTheme(name) | scifi, fantasy, cartoon, pixel, minimal, or a theme added with require("navUiKit.theme").register(name, def, base). |
Nav.setVariant(family, variant) | family is minimap, map, compass, objectives or toasts. Unknown names throw. |
Nav.use(id) | Applies a catalogue id ("compass.ring", "toasts.achievement", "waypoint.beam", "theme.pixel") or a theme name. |
Nav.configure(opts) | Merges settings (nested tables merge). |
Nav.settings() | The live settings table (read only; change it with configure). |
Nav.styles() | { themes, minimap, map, compass, objectives, toasts, waypoint } with the variant names. |
Nav.catalogue(filter?) | Every style and look as a row: { id, family, variant, description, tags, themes, apply }. filter: { tags = { ... } } (rows with all of them), { tag = "..." }, { family = "..." }. |
Nav.icons() | Icon names. |
Settings and their defaults:
{
theme = "scifi",
variants = { minimap = "round", map = "rooms", compass = "bar", objectives = "list", toasts = "slide" }, -- compass: bar, dial, ring, arrow
minimap = { show = true, size = 180, zoom = 1, range = 45, corner = "topRight" }, -- corner: topRight, topLeft, bottomRight, bottomLeft
map = { key = "KeyM", pad = "select", tapMinimap = true },
compass = { show = true, span = 180 },
objectives = { show = true, max = 5 },
toasts = { show = true, duration = 4, max = 4 },
waypoint = { icon = true, edgeArrow = true, distance = true, beam = true, ring = true },
fog = { cell = 4, radius = 14 },
autoToasts = true, -- a toast when an objective completes
units = "m",
}
With compass = "ring" and minimap.show = false, the ring draws on its own in the minimap's corner, round a dark face.
Points of interest and waypoints
| call | does |
|---|---|
Nav.mark(target, opts?) | target is an entity, an entity id or a {x, y, z} point. opts: icon, color (#RRGGBB), label, waypoint, height (marker height above the target, m), player (only that player sees it), compass / minimap / map (false hides it there), id. Returns the POI id. |
Nav.unmark(id) | Removes it. Returns whether it existed. |
Nav.update(id, changes) | Changes any of the mark options, or hidden = true to hide it everywhere. |
Nav.get(id) | A copy of the record, or nil. |
Nav.list(player?) | POI ids; with player, only those that player can see. |
Nav.setWaypoint(id, on, player?) | Makes it a waypoint, or not, for everyone or one player. |
Nav.isWaypoint(id, player?) | Whether it is one. |
Nav.clearWaypoints(player?) | Clears all waypoints, for everyone or one player. |
Nav.distance(id, player?) | Metres from that player to the POI, or nil. |
PointOfInterest component fields: icon, color, label (empty uses the entity name), waypoint, height, onCompass, onMinimap, onMap. Its POI id is "poi_" .. entityId. Removing the component or the entity unmarks it.
Map content
| call | does |
|---|---|
Nav.addArea(name, {minX, minZ, maxX, maxZ}, opts?) | Names an area: the rooms map outlines and labels it. opts.id, opts.color. Returns its id. |
Nav.addShape({ {x, z}, ... }, opts?) | Draws an extra polygon (a road, a wall) on the minimap and map. Returns its id. |
Nav.removeShape(id) | Removes an area or shape. |
Nav.setBounds({minX, minZ, maxX, maxZ}) | Fixes the map extent instead of fitting the world. |
Nav.rebake() | Re-reads the world's colliders into the map picture. |
Nav.explored(player?) | Share of the map that player has uncovered, 0..1. |
Nav.reveal(x, z, radius?, player?) | Clears fog around a point (normally automatic). |
Objectives
| call | does |
|---|---|
Nav.objective(id, text, opts?) | Adds one, or updates it. opts: total (above 1 shows 3/5), count, shared (default true unless player), player, quest (questCard title), icon. Re-declaring an existing id keeps its progress, total, quest and icon unless you pass them. Returns the id. |
Nav.progress(id, n, add?) | Sets the count, or adds to it with add = true. It completes at the total. Returns the new count. |
Nav.complete(id) | Ticks it off; it fades from the tracker. |
Nav.removeObjective(id) | Removes it. |
Nav.objectives(player?) | What that player sees: shared plus their own. |
Toasts
| call | does |
|---|---|
Nav.toast(text, opts?) | opts: variant (default the toasts setting), title, icon, color, duration (seconds), to (nil = local player, "all", or a player / key), key + pad + action for prompts (key = "KeyE", pad = "south", action = "Open"), id (reusing an id updates that toast in place). Returns the id. Toasts queue, stack, slide and fade on their own. |
Nav.dismiss(id, player?) | Fades one out early (e.g. a prompt whose door went away). |
Map screen
| call | does |
|---|---|
Nav.openMap(player?) / Nav.closeMap(player?) / Nav.toggleMap(player?) | Opens, closes or toggles the full map. Players also use M, Select, a tap on the minimap, or the close button. |
Nav.isMapOpen(player?) | Whether it is open. |
Components
- NavHud: puts the HUD on screen for the local player. Its fields override the world settings:
theme,minimap,map,compass,objectives,toasts,corner,previewInEditor. On a player prototype it runs only for the copy this peer controls. Any number of NavHuds share one HUD. - PointOfInterest: see above.
- NavGallery: the style gallery used by this world's startup scene. Fields:
walker,camera,path,page,theme,speed,showInEditor.
Lower level
The widget builders take a command list and a box, so a world can draw them into its own screens: navUiKit.minimap, navUiKit.compass, navUiKit.markers, navUiKit.tracker, navUiKit.mapScreen and navUiKit.toasts. Each module's README lists its calls. navUiKit.hud's Hud.new{ key, avatar, camera, box, viewBox, isLocal, world3d, theme, variants } makes an extra HUD into any rectangle, such as a split-screen view or a picture-in-picture. A HUD with isLocal = false is not ticked for you: call hud:update(dt) each frame and hud:destroy() when done. Hud.acquire(owner, opts) / Hud.release(hud, owner) share one HUD between several holders, which is how NavHud does it.
Tests
navUiKitTests (47 tests) covers every call above. That includes every play starting clean (the README example run over three plays), one HUD however it is started, session keys and toasts for one machine on a shared account, echo suppression, the ring and arrow compasses and their layout, the catalogue tags and filters, the cross-peer messages, the HUD life cycle (nothing left behind after destroy or stop), per-viewer isolation, the toast queue and band, the waypoint 3D parts, every builder in every theme, opaque 8-digit colours, and one version number everywhere. Run it in edit mode with tools.use("tests", "run", { suite = "navUiKitTests" }).
Scoped to this part · feeds back into the world's score.