# catalog
Every style the kit ships, as rows with tags: `list(filter)`, `ids(family)`, `get(family, id)`, `check(family, id)` (errors naming the valid ids) and `markdown(family)` (the README tables). Families: display, popup, board, results, look. Reached through `Score.catalog`.
# nav
The one module a world talks to. It starts and stops the HUD, picks styles, marks points of interest and waypoints, adds map areas, runs objectives, sends toasts and opens the map.
```lua
local Nav = require("navUiKit.nav")
Nav.start()
Nav.mark(entity.find("Chest"), { icon = "chest", label = "Chest", waypoint = true })
Nav.objective("gems", "Collect gems", { total = 5 })
Nav.toast("Press E to open", { variant = "prompt", key = "KeyE", action = "Open" })
Nav.setTheme("fantasy")
```
`Nav.catalogue()` lists every style as data. The package README has the full reference.
# PickupHUD
A simple on-screen counter for the local player, in five styles that match the looks: cartoon, scifi, fantasy, pixel and realistic. It shows:
- your counters (`counters`, or coins plus anything above 0);
- the letters of a `word`, which light up as they are collected;
- running power-ups with their time left;
- with more than one player, everyone's coins.
Every peer draws its own, so put it on a plain local entity: `entity.spawn("HUD").component.add("pickupsKit.PickupHUD", { style = "pixel", word = "ZERO" })`. Changing `style` restyles it at once.
# core
The runtime behind `scoreKit.score`: who the keeper is, player keys, the keeper's checks on requests from other machines, prediction on the other machines, the synced snapshot, events, rounds (with or without MatchFlow), time runs and posting to the board. Game code uses `scoreKit.score` instead.
# displays
On-screen score readouts (HUD widgets) for scoreKit. Every display is a pure function of a `DisplayData` table (see `demo.module`) and returns one widget-tree node exactly `w x h`, scaled from its natural size and transparent outside its own panel. Motion comes only from `data.t`, `data.sinceAward` and `data.sinceBreak`. Each display shows the score, shows the multiplier when it is above 1, bumps or flashes on a fresh award, drains with the combo window and gives a short "combo lost" cue.
```lua
local D = require("scoreKit.displays")
local node = D.render("comboMeter", data, { w = 380, look = "scifi" })
D.ids() -- every style id
D.get("arcade") -- { id, name, description, natural = {w,h}, themed, tags }
```
Styles (`themed` ones draw in `opts.look`; the rest have a fixed look):
- `arcade`: zero-padded odometer digits that roll column by column in a black glass bezel, with a 1UP/SCORE header and a magenta MULT chip.
- `pixel`: a chunky 8-bit counter in the theme pixel font with a hard shadow, a pixel `x3` and a stepped 12-block combo bar.
- `comboMeter` (themed): the score, a meter that fills toward the next multiplier step with one segment per hit, a strip that drains with the window, and an "x4 at 10" hint.
- `multiplierRing` (themed): one ring segment per multiplier step, an inner ring that drains with the window, a big xN in the centre and the score in a pill.
- `stars` (themed): three stars that fill as the score passes its targets, with the next star partly filled and a "next ★ at 5,000" hint.
- `grade` (themed): a big S/A/B/C/D medallion, one colour per grade and a moving shine on S, plus a progress bar toward the next grade.
- `splits` (themed): time trial. A running timer, a green/red ahead/behind banner, split rows with deltas, the current split highlighted and the best time to beat. With no timer it shows the score only.
- `minimal`: thin type straight over the game with a soft shadow, a small label, a subtle ×N and a hairline combo line.
- `scifi`: a clipped-corner holo frame with scanlines, glowing mono digits, a MULT readout and the combo as skewed tick segments.
- `fantasy`: a gilded walnut plaque on a parchment ribbon with gold serif numerals, a wax-seal multiplier and rune gems that light up per step.
- `cartoon`: an inked number on a wobbling starburst that squashes and stretches on each award, with a spinning combo badge and an "OOPS!" on a break.
- `neon`: a neon-tube sign that flickers a little, with the multiplier in a cyan tube and an underline tube for the combo window.
- `broadcast`: a sports TV lower-third with a LIVE dot, place out of players, player name, score and a yellow multiplier tag.
# geom
The camera maths the popups and the sign need: `vec`, `rotate` (by a quaternion), `view` (a camera onto a canvas), `project` (world point to UI points, depth, on screen) and `cameraView` (the active camera's view). Used by the HUD's popups and ScoreSign; no dependencies.
# board
The pure leaderboard logic over plain tables: `submit` (keeps each player's best, reports personal best / world record / rank), `ranked`, `rankOf`, `around`, `top`, `merge` (best row per player across stores), `validRow` (corrupt rows are skipped) and `newSeason` (archives the top rows). Used by the store and the tests.
# core
The kit's shared state: settings, points of interest, map areas and shapes, objectives, per-viewer fog and waypoints, toast routing, and the baked map picture.
Everything that differs between players is keyed by a viewer key: the player's userId in play (`C.localKey()` for this peer), or any string for a stand-in such as the gallery walker or a test viewer. The renderers read this module; `navUiKit.nav` writes it. Useful calls: `C.localKey()`, `C.keyOf(who)`, `C.posOf(target)`, `C.reveal(key, x, z, r)`, `C.exploredShare(key)`, `C.objectivesFor(key)`, `C.deliverToast(to, toast)` and `C.bake()`. Set `C.relay = { send = fn(kind, payload) }` to carry shared objectives and toasts to other peers, and feed received messages to `Nav._receive(kind, payload)`.
# results
End-of-round screens for scoreKit: pure functions that turn a `ResultsData` (see `demo.module`) into a UI widget tree in any of the five theme looks (arcade, minimal, scifi, fantasy, cartoon). A game's round-over flow calls `Results.render(id, look, results, { w, h, t, narrow })` each frame with `t` = seconds since the screen appeared. Entrances cascade in over ~0.6 s, count-ups settle by ~2.5 s, and below 520 points wide the screens use a single-column phone layout.
| id | screen |
|---|---|
| `ratingCard` | Stars that pop in one by one, a big grade medallion, the score and this round's place, a breakdown table adding up to the total, best combo / max multiplier chips. |
| `celebration` | NEW PERSONAL BEST or NEW WORLD RECORD over rays and confetti, with the new score, the previous best, the improvement and the board rank. A calm "Good run" with what it takes to beat your best when neither. |
| `tally` | A tally that counts up: each breakdown line appears and counts from 0, a running total climbs, then the final total punches in with stars and grade. |
| `timeTrial` | The final time big, each split against its best with green (ahead) / red (behind) deltas and a diverging bar, a PB / record badge and the time to beat. A first run with no best says so. |
`Results.views` lists each screen with its natural size and tags; `Results.ids()` and `Results.get(id)` read it.
# hud
One HUD for one viewer: the minimap, compass, waypoint markers, objective tracker, toasts, the map screen and the in-world beams and rings. It lays them out inside the phone-safe area, reflows them for portrait and landscape, and redraws each widget only as often as it needs.
Every number on it is measured from its own viewer, so two players' HUDs share nothing but the world's points of interest. The NavHud component makes one for the local player. Use `Hud.new({ key, prefix, avatar, camera, box, isLocal, world3d, theme })` for a split view or a stand-in viewer: tick it with `hud:update(dt)` and remove it with `hud:destroy()`, which unregisters every screen and despawns every beam, ring and camera it made.
# theme
The five looks (arcade, minimal, scifi, fantasy, cartoon; three of them MatchFlow's own tokens), the widget-tree builders every renderer shares, number/time/delta formatting, star and arc paths, and a 5x7 pixel font. Used by every renderer; use it to match the kit in your own UI.
# popups
The "+10" popups that appear where points were scored. `Popups.render(id, popup, { k, look })` draws one `PopupData` (see `demo.module`) as a pure function of `popup.age / popup.life`. The popup pops in, rises (a penalty falls) and fades out. The call returns `node, w, h`; place the node at `left = x - w/2, top = y - h`. Once the age reaches `life` it returns `nil`. Kinds: `normal`, `combo` (shows the xN), `big` (larger, with a burst), `bonus` (the label over the number) and `penalty` (red, falling).
```lua
local Pop = require("scoreKit.popups")
local node, w, h = Pop.render("cartoon", popup, { k = 1 })
```
Styles:
- `pop` (themed): a clean bold outlined number with a combo chip, a burst ring on big and a green pill label on bonus.
- `arcade`: the pixel-font number hops up in hard steps and blinks out; big throws pixel sparks.
- `scifi`: a glowing mono number between brackets that spread open; big draws an expanding hex and a penalty glitches.
- `fantasy`: gold italic serif numerals drift up like embers among sparks; big radiates rays.
- `cartoon`: a chunky inked number with an elastic bounce and tilt; big lands on a starburst and bonus sits on a ribbon.
# rules
The pure maths behind every score: the combo and multiplier rules (`config`, `add`, `tick`, `bonus`, `breakCombo`, `multiplierFor`, `stepProgress`), star and grade ratings (`rate`), ranking with shared places (`rank`) and the token bucket that limits scores sent by other machines (`checkRemote`). No engine calls; the package README's Combo rules section is this file in words.
# scoreKit 1.3.1
Scores, combos, multipliers, star ratings, grade letters and leaderboards for any Zero game, in one package. A game writes one line, `Score.add(player, 10)`, and the player sees it straight away: the number rolls up, a "+10" pops where it was scored, quick points build a combo, the multiplier climbs to x5, and the end of the round brings a rating card and a place on a leaderboard that is still there tomorrow.
- **13 score displays, 5 popup styles, 6 leaderboard views, 4 end-of-round screens, 5 looks.** Every style works off the same data. Changing one setting swaps one for another, and nothing else in your code changes.
- **Multiplayer by design.** One machine (the *keeper*) runs the rules and writes the board. Every player sees the same scores and the same board, and each player gets their own display, popups and results.
- **Builds on MatchFlow, navUiKit and pickupsKit.** Rounds are MatchFlow matches: scores count in them and players are ranked when a round ends. Notifications ("x4 COMBO", "New personal best!") are navUiKit toasts. pickupsKit's coins, gems and hazards score through `ScorePickups`, once per grab.
## Install
One install: search ZeroMind for "scoreKit" and install it from the ZeroMind panel, or ask your agent to run `zeromind_install` with `guid = a1e6c665-2332-4886-954e-06c028e86b19`. Your world gets `/source/scoreKit.package`, and the kits it builds on come with it under `/source/deps/`: MatchFlow (rounds), navUiKit (toasts) and pickupsKit (pickups that score, with the SecondWind, Lifeline and juiceKit it uses). There is nothing else to set up. While the files land, the log can show passing "waiting for content it requires" lines.
## First example
Your world needs a scene with a player. A brand-new world has none yet: make one with `tools.use("scene", "create", "main", "player", { startup = true })` (ask the agent panel, or run it in an `execute` call). That writes `scenes/main.scene`, with a ground, lights and the default player. Then replace everything in `scenes/main.scene/entrypoint.luau` with this:
```lua
local Score = require("scoreKit.score")
local controls
function onLoad()
controls = asset.resolve("@builtin::inputMaps.default", "inputMap"):activate()
controls.jump:onPressed(function()
Score.add(nil, 10) -- nil = the player on this machine
end)
end
```
Press **Play** and jump (Space, or the jump button on a phone or a gamepad). The arcade counter appears top-left and rolls up by 10, and a "+10" pops under it. Jump three times quickly and the third jump is worth 20 (x2). Keep it going and you reach x3 at 6 jumps, x4 at 10 and x5 at 15, with an "x3 COMBO" toast along the way. Wait more than 2.5 s and the combo breaks.
That's everything you need on one machine. With two or more players, scoring is keeper-only by default, so a jump on another player's machine is not counted. Score from code on the keeper (a coin it sees taken), or set `acceptRemote = true` to let each machine report its own player's points (see "Who can write the board"). The first `Score.add` creates the synced `ScoreKit` entity that keeps the score. You can also place it yourself to set its options:
```lua
-- in a scene's build.luau, inside content()
local kit = entity.spawnSynced("ScoreKit")
kit.component.add(asset.resolve("scoreKit.ScoreKit", "component"), { display = "comboMeter", look = "fantasy", board = "level1" })
```
From a coin, a kill or a checkpoint, pass whatever touched it. scoreKit walks up from the touching entity to the player's body:
```lua
Score.add(touchingEntity, 10, { at = coin, reason = "coins" }) -- "+10" pops at the coin; "coins" is a results line
Score.add(attackerBody, 100, { reason = "kills" })
Score.penalty(playerBody, 25) -- a hit taken: -25, and the combo breaks
Score.bonus(playerBody, "No damage", 500) -- a named bonus line, never multiplied
Score.breakCombo(playerBody, "fell")
```
## Combo rules
These are the exact rules, with the defaults. They are written so that you can balance a game around them.
1. **A hit is any `Score.add` with a positive amount**, unless it is passed `combo = false`.
2. **The combo** counts hits in a row. A hit that comes at most **2.5 s** (`window`) after the previous hit adds 1 to the combo. A hit that comes later, or after the combo broke, starts it again at 1. A gap of exactly `window` still counts.
3. **The multiplier** is 1 plus the number of `steps` the combo has reached, and never above `maxMultiplier`. With the default `steps = { 3, 6, 10, 15 }` and `maxMultiplier = 5`:
| combo | 1–2 | 3–5 | 6–9 | 10–14 | 15 and up |
|---|---|---|---|---|---|
| multiplier | x1 | x2 | x3 | x4 | x5 |
`every = n` replaces the steps with a uniform step: x2 at n hits, x3 at 2n, and so on up to `maxMultiplier`.
4. **The hit that reaches a step is already multiplied.** The 3rd hit is the first worth double. Points awarded = amount × multiplier, rounded to the nearest whole number with halves rounding up (`round = false` keeps fractions).
5. **The combo breaks** when:
- **time runs out**: more than `window` seconds pass with no hit. The keeper checks this every frame, so a combo breaks on time even if no more points come.
- **the game says so**: `Score.breakCombo(player)`.
- **a penalty**: a negative amount always breaks the combo, unless `breakOnPenalty = false`.
With `decay = "reset"` (the default) a break sets the combo to 0 and the multiplier to x1. With `decay = "step"` a timeout drops one multiplier step instead: the combo goes to that step's start (x4 → combo 6, x3) and gets a fresh window. If no hit comes, it keeps stepping down. `breakCombo` and penalties always reset.
6. **Penalties** (negative amounts) are never multiplied.
7. **`combo = false`** points are not a hit: they don't grow the combo or refresh its window, and they are not multiplied (pass `multiply = true` as well to multiply them by the current multiplier). **`multiply = false`** keeps a hit's points at face value while it still counts toward the combo.
8. **Bonuses** (`Score.bonus`) are never multiplied, never touch the combo, and get their own line in the results breakdown. The same label adds up.
9. **Score = points before multipliers + multiplier extra + bonuses − penalties.** The results breakdown lists exactly these, so its lines always add up to the total.
Set the rules on the `ScoreKit` component's fields, or call this on every machine with the same values:
```lua
Score.configure({ window = 1.8, steps = { 5, 10, 20, 40 }, maxMultiplier = 8, decay = "step", breakOnPenalty = false })
```
## Ratings: stars and grades
A level sets targets. `Score.rate` (and the `stars`, `grade` displays and every results screen) turn a score or a time into 0–3 stars and a grade letter:
```lua
Score.setTargets({ score = { 1000, 2500, 5000 } }) -- points for 1, 2, 3 stars
Score.setTargets({ time = { 90, 60, 45 } }) -- seconds for 1, 2, 3 stars (lower is better)
Score.setTargets({ score = { 1000, 2500, 5000 }, grades = { S = 7000, A = 5000, B = 3000, C = 1500 } })
local r = Score.rate(3100) -- { stars = 2, grade = "B", frac = 0.24, next = 5000, kind = "score" }
```
- **Stars** = how many targets the value meets (score: at least the target; time: at most the target).
- **Grades** without explicit lines: **S** = 20% past the three-star target (score ≥ 1.2 × three, time ≤ three ÷ 1.2), **A** = three stars, **B** = two, **C** = one, **D** = below one. Explicit `grades` lines replace these.
- `frac` is the progress from the last star target to the next one. `next` is the next target (nil at three stars).
- With no targets set, the defaults are `{ score = { 1000, 2500, 5000 } }`, so the rating displays work out of the box.
## Rounds with MatchFlow
When the scene has a **MatchFlow** match (the Match Flow Kit), scoreKit binds to it on its own (`match = "auto"`):
- **Scores count in its matches.** The keeper reports every award, already multiplied, with `m:addScore(peer, points)`, so MatchFlow's HUD, standings and podium show the same numbers as scoreKit.
- **Points only count while the round is playing** (`matchOnly = true`). An add during the lobby, countdown or results is refused, and a predicted "+10" is taken back.
- **Every score goes back to 0** when a round's countdown starts.
- **When a round ends, players are ranked.** MatchFlow's places are used, so every format ranks the way MatchFlow does. Each player's score is posted to the board, and every player gets their own results: the `roundEnded` event (on every machine) carries `{ n, ranking = { { key, name, score, place, stars, grade } }, results = { [key] = ResultsData } }`. Each player sees their results screen after MatchFlow's podium, during the break, never on top of it: the card waits until MatchFlow has finished showing its own results on that player's machine. It then stands in for MatchFlow's "next round" panel and shows "Next round in N" itself. If the break is over before the card could show, it is skipped, and a card still up when the next round counts down closes, so there is only ever one end-of-round screen. A new personal best or record shows its celebration first.
Without MatchFlow, call `Score.endRound()` yourself to rank, post and show results, and `Score.resetRound()` to start over. `Score.submit(player)` posts a score to the board at any moment, for example at the end of a level.
```lua
-- a whole match in two components on synced entities
local match = entity.spawnSynced("Match")
match.component.add(asset.resolve("~MatchFlow.MatchFlow", "component"), { format = "timed", roundSeconds = 30 })
local kit = entity.spawnSynced("ScoreKit")
kit.component.add(asset.resolve("scoreKit.ScoreKit", "component"), { results = "tally" })
```
MatchFlow comes with scoreKit as one of its dependencies, so name it with a `~` (`~MatchFlow.MatchFlow`): a kit that arrived as another kit's dependency is not found by its bare name from your own scripts (#19199).
## Pickups that score (pickupsKit)
scoreKit comes with **pickupsKit**: coins, gems, hearts, keys and power-ups in 47 looks, where pickupsKit decides who took each one, even when two players grab at once. The `ScorePickups` component turns every grab into points for the player who took it:
```lua
-- in a scene's build.luau, inside content()
local Pickups = require("scoreKit.pickups").kit() -- pickupsKit's API, as scoreKit installed it
for i = 1, 5 do
Pickups.spawn({ (i - 3) * 1.5, 0, 4 }, { prefab = "goldCoin" })
end
Pickups.spawn({ 0, 0, 7 }, { prefab = "ruby" })
Pickups.spawn({ 3, 0, 6 }, { prefab = "damageOrb", effect = "none" }) -- a hazard: no power-up, just a penalty
entity.spawn("PickupScores").component.add(asset.resolve("scoreKit.ScorePickups", "component"),
{ points = "goldCoin=10, ruby=50, damageOrb=-25" })
```
- **`points`** gives each pickupsKit prefab (`goldCoin`, `ruby` …) or kind (`coin`, `gem` …) its points. A prefab's entry beats its kind's. A negative value is a penalty: never multiplied, and it breaks the combo. A pickup with no entry scores nothing (pickupsKit still counts it).
- **Each grab scores once.** pickupsKit tells every machine about a grab once. Only the machine keeping the score turns it into a `Score.add` for the player who took it, with the "+N" at the pickup and `coins`, `gems` … (or `hazards`) as the results line. Before the first point there is no `ScoreKit` yet: then the senior machine scores the grab, and its `Score.add` creates the kit, as the first example's jump does. So a coin is worth the same whichever player grabs it, on whichever machine.
- Grabs build the combo like any other hit, and count only while a MatchFlow round is playing.
- From code: `require("scoreKit.pickups").listen("goldCoin=10")` does the same and returns a stop function. `score(ev, points)` scores one pickupsKit `collect` event.
- **Where pickupsKit's counter goes.** pickupsKit's `PickupHUD` sits in a top corner, and MatchFlow's standings take the top right, so put the counter top-left: `entity.spawn("PickupCounter").component.add(Pickups.HUD, { corner = "topLeft" })`. With `corner = "auto"`, scoreKit's score display sees it there and moves to the bottom-left, so the three never cover each other.
- `require("scoreKit.pickups").kit()` is pickupsKit's own API (`spawn`, `burst`, `on`, `get` …), so a game that got pickupsKit with scoreKit reaches it through scoreKit. pickupsKit's README has every look and setting.
## Time trials
```lua
Score.startRun(player) -- start the clock
Score.split(player, "Bridge") -- a checkpoint (labels default to CP1, CP2 ...)
Score.finishRun(player) -- the time goes to the time board, fastest first, with its splits
```
The `splits` display shows each split against the player's own best: green when ahead, red when behind. The `timeTrial` results screen shows the finished run against the best. The keeper times every run on its own clock. It uses the runner's own reading (which has no network delay) only when that reading is within 0.75 s of the keeper's. A runner can't claim a time their run didn't take.
## Leaderboards
Each world has its own boards, kept in the world's `runtime_data`, so they survive the world being closed and opened again. A board keeps **one row per player: their best of the season** (highest score, or fastest time on a time board), and counts their plays.
```lua
local board = Score.board() -- the kit's board (field `board`, default "main")
local times = Score.timeBoard() -- "<board>_times": fastest first
board:top(10) -- { { k, n, v, rank, st, g, at, c } }
board:around(player, 5) -- you and the players just above and below
board:entry(player), board:rankOf(player)
board:season(), board:seasons() -- the current season, and past ones with their top 10
board:newSeason("Summer") -- archive this season's top 10 and start an empty one
board:remove(player) -- take a stale or test row off this season (the writer in charge only)
board:status() -- is it saved? (below)
board:data(player) -- exactly what the board views draw
board:onChange(fn)
Score.board("level2") -- any number of boards, by id
```
**Seasons.** `newSeason` archives the current season's top 10 (names, values, stars, grades), records how many players it had, and opens season N+1 empty. Old rows stay in storage under their season. `board:seasons()` and the `seasons` view show the champions of every past season. Only the board's writer can start a season, and only while it is the keeper.
## Who can write the board
**Only the keeper changes scores, and only the keeper writes the board.** The keeper is the machine that owns the synced `ScoreKit` entity: whoever loaded the scene first, or, after that machine leaves, the remaining player with the lowest peer id. Every other machine only *asks* it, and by default its asks are refused:
- **Scoring is keeper-only by default** (`acceptRemote = false`). Only code running on the keeper changes a score. The keeper sees every player's body, so a coin, a kill or a checkpoint is judged there, as the gallery's coins are. You can run the same code on every machine: the keeper's call counts, and on the other machines `Score.add` sends nothing, predicts nothing and fires `refused` locally. So each pickup counts once. `Score.isKeeper()` says where you are.
- **No machine can post its own value to the board.** A post asked for from another machine carries no number. The keeper posts the score it counted, and only to the kit's score board. Times reach the time board only from runs the keeper timed itself.
- **Only the keeper's messages are believed.** Other machines take standings, awards and round results only from the keeper's peer. A player who sends their own "snapshot" or "award" is ignored, and the ignore is logged.
- **Points formats are ranked on the keeper's counted scores**, not on MatchFlow's standings: MatchFlow takes reports from any machine. MatchFlow's places count only for `race`, `lastStanding` and `kingOfTheHill`, where order is not decided by points.
- **Only while playing**, when bound to a MatchFlow match.
**`acceptRemote = true`** lets each machine report points for its own player, for events only that machine can see, such as a button press. Its "+N" then shows instantly. That is a trust decision, because a modified client can then claim points for itself within these caps:
- **Only its own player** (`remoteFor = "self"`): a request to score for anyone else is refused and logged.
- **No absurd amounts**: a single remote add above `maxPerAdd` (1000) is refused.
- **No floods**: more than `maxPerSecond` (20) remote adds per player per second are refused (short bursts of up to twice that are allowed).
A refused request is answered with the reason: the sender's "+N" is taken back, a `refused` event fires there, and a warning is logged on both machines.
**Two modes, set by the `writers` field:**
| `writers` | Where the board lives | Who can write it |
|---|---|---|
| empty (default) | the world store (`runtime_data.world`) | the keeper, whoever it is |
| `"<userId>, <userId>"` | those accounts' **own** player stores (`runtime_data.player(id)`) | only those accounts, and only while one of them is the keeper. The Zero server enforces it: no other account can write a player store. |
The default mode protects the board from the kit's own network path. Nothing another player's game sends can change it. But `runtime_data.world` itself accepts writes from any client, so a player who runs modified engine code could still write it directly. If your board must hold against that too, set `writers` to your own account id(s) (`runtime_data.localId()` prints yours).
**What happens when a write can't be made.** Scores are never lost silently:
| The board's status | When | What happens |
|---|---|---|
| `saved` | every write landed | nothing to do |
| `saving` | writes in flight | they land within a moment |
| `retrying` | a write failed (network, store unavailable) | it stays queued and is retried with back-off (0.5 s, 1 s, 2 s … up to 30 s) until it lands. The queue is also kept in the keeper's own player store, so it is replayed if the keeper's tab closes first. |
| `held` | locked mode and the keeper is not a writer | the result is **held**: queued in the world store as a pending result, not put on the board. The player gets a toast saying so, and the results screen says "Held for the board's keeper". A writer later calls `board:held()`, `board:approveHeld("all")` or `board:rejectHeld(ids)`. |
| `offline` | `runtime_data` is not available | nothing is written; the status says so |
| `readOnly` | on a machine that is not the keeper | `board:status()` there reports the keeper's status from its snapshot |
Every board view shows this status as a chip, so players can see whether their score is saved.
## Multiplayer
- **The keeper** runs the rules (combos time out on its clock), applies every add, writes the board and publishes every player's score, combo, multiplier and combo timer in a synced snapshot on the `ScoreKit` entity, up to 12 times a second (and at least twice a second). **Every player sees the same scores and the same board.**
- **Each player's display and popups are their own.** Each machine draws its own player's corner display, "+N" popups, toasts and results screen. Nobody sees another player's popups.
- **Seen quickly.** A player sees their own points as soon as the keeper's next snapshot reaches them (it publishes up to 12 times a second). With `acceptRemote = true`, `Score.add` on the player's own machine is also *predicted*: the points, the combo and the "+N" show the moment it is called. The prediction is let go once the keeper's snapshot confirms it, and taken back if it is refused. One that is never confirmed is dropped after 3 s with a warning.
- **Players come from the scene's player setup** (`layers.active.players`). Every player in the match has their own score, results and board row:
- A signed-in player's key is `u:<userId>`, so a returning player keeps their personal bests.
- **The same account signed in on two machines is two players.** The session on the lowest peer keeps `u:<userId>`; the next is `u:<userId>#2` and shows as "Name (2)", and so on. Each has its own score, its own place in the round and its own board row.
- `p:<peer>` is used for an anonymous session, and `b:<name>` for bots (`Score.add("Robo", 10)`, `Score.setName`).
- **Only players who are here are ranked.** A round's ranking, its places ("1st of 2") and the board posts cover the players in the scene's player setup when the round ends, plus any bots the game scored by name. A player who left before the end is not ranked or posted, and a key nobody plays under can never take a place.
- **Each event counts once.** With the default keeper-only scoring, only the keeper's `Score.add` counts, so the same pickup code can run on every machine. With `acceptRemote = true`, report an event either from the keeper or from the player's own machine, not both.
- **If the keeper leaves**, the entity passes to another machine, which carries on from the last snapshot. Writes still queued on the leaver are replayed the next time that account keeps. Points scored in the instant of the handover can be lost.
- **Events** fire on every machine: `award`, `comboUp`, `comboBreak`, `record`, `held`, `submitted`, `run`, `roundStarted`, `roundEnded`, `refused`, `snapshot`. Use `Score.on(name, fn)`, which returns an unsubscribe function.
## Styles
Every style is listed by `Score.catalog()` as `{ id, family, name, description, natural = { w, h }, themed, tags = { look, looks, size, genres, shows } }`. Filter it with `Score.catalog("display")` or `Score.catalog({ family = "board", genre = "racing" })`. Themed styles draw in any of the five looks.
```lua
Score.setDisplay("multiplierRing") -- this machine's corner display
Score.setLook("fantasy") -- themed displays, boards, results
Score.setPopups("cartoon") -- "+N" popups ("off" to hide)
Score.setResults("tally") -- the end-of-round screen
Score.setHud({ corner = "topRight", scale = 0.8 }) -- corner: auto, topLeft, topRight, bottomLeft, bottomRight, top, bottom
```
Or set the world's defaults with the `ScoreKit` fields `display`, `look`, `popups`, `results` and `corner`.
### Score displays (`display`)
| id | name | look | size | suits | what it is |
|---|---|---|---|---|---|
| `arcade` | Arcade Odometer | arcade | medium | arcade, shmup, pinball, platformer | Zero-padded rolling digits in a black glass bezel. Every digit column rolls like an odometer, with a 1UP label and a multiplier chip. |
| `pixel` | Pixel Counter | arcade | medium | retro, arcade, platformer | Chunky 8-bit pixel digits with a hard shadow, a stepped pixel combo bar and a pixel x3. |
| `comboMeter` | Combo Meter | any (themed) | medium | action, rhythm, brawler, arcade | Score plus a wide meter that fills toward the next multiplier step, and a strip that drains with the combo window. |
| `multiplierRing` | Multiplier Ring | any (themed) | medium | action, shooter, rhythm | A circular ring that drains with the combo window, one segment per multiplier step, and a big xN in the centre. |
| `stars` | Star Rating | any (themed) | medium | puzzle, casual, mobile, platformer | Score with three stars that fill as the score passes its targets. The next star fills partially. |
| `grade` | Grade | any (themed) | medium | action, rhythm, racing, stylish | A big grade letter (S A B C D, each its own colour, S shines) with progress toward the next grade. |
| `splits` | Time Trial Splits | any (themed) | large | racing, speedrun, time trial, parkour | Running timer, finished splits with green/red deltas, the current split highlighted, an ahead/behind banner and the time to beat. |
| `minimal` | Minimal | minimal | small | any, casual, exploration | Quiet thin type straight over the game with a soft shadow, a small label and a subtle xN. |
| `scifi` | Holo Readout | scifi | medium | shooter, space, cyberpunk | Clipped-corner holographic frame with scanlines, glowing mono digits and the combo as tick segments. |
| `fantasy` | Gilded Plaque | fantasy | large | rpg, adventure, medieval | A walnut plaque on a parchment ribbon with gold serif numerals, a wax-seal multiplier and rune gems lighting up. |
| `cartoon` | Bouncy Cartoon | cartoon | medium | casual, kids, party, platformer | A chunky inked number on a yellow starburst that squashes and stretches on every award, with a combo starburst badge. |
| `neon` | Neon Sign | arcade | medium | arcade, synthwave, rhythm | A glowing neon-tube number with a subtle flicker, and the multiplier in a second tube colour. |
| `broadcast` | TV Score Bug | any | small | sports, racing, party, multiplayer | A sports-broadcast lower third: LIVE dot, place, player name, score and a multiplier tag. |
### Popups (`popups`)
"+N" where the points were scored: projected from the `at` position you pass, or under the display when there is none. Kinds: normal, combo (with its xN), big (at least `bigAt` = 100), bonus (with its label) and penalty (red, falling).
| id | name | look | size | suits | what it is |
|---|---|---|---|---|---|
| `pop` | Clean Pop | any (themed) | small | any | Bold outlined number that pops in, rises and fades. Draws in the current look. |
| `arcade` | Arcade Pixel | arcade | small | retro, arcade, platformer | Pixel-font number that hops up in hard steps and blinks out. Big throws pixel sparks. |
| `scifi` | Holo Bracket | scifi | small | shooter, space, cyberpunk | Glowing mono number between brackets that spread open. Big draws an expanding hex, penalty glitches. |
| `fantasy` | Gold Embers | fantasy | small | rpg, adventure, medieval | Gold serif numerals that drift up like embers with sparks. Big radiates rays. |
| `cartoon` | Inked Bounce | cartoon | small | casual, kids, party | Chunky inked number with an elastic bounce and a tilt. Big lands on a starburst, bonus on a ribbon. |
`popups = "auto"` (the `ScoreKit` default) picks the popup that matches the look.
### Leaderboards (board views)
| id | name | look | size | suits | what it is |
|---|---|---|---|---|---|
| `top10` | Top 10 | any (themed) | medium | arcade, platformer, puzzle, racing, party | The classic high-score table: rank with medals, avatar, name, stars and grade, value. Your row is highlighted, and pinned under a divider when you are outside the top 10. |
| `podium` | Podium | any (themed) | large | party, arcade, sports, racing | The top three on stepped podium blocks (1st centre, crowned), with 4th to 8th as a compact list. |
| `aroundMe` | Around Me | any (themed) | medium | arcade, platformer, puzzle, racing, endless | Your rank as a hero (#17 of 214, top 8%), the rows just above and below you, and how far it is to pass the next player. |
| `bestTimes` | Best Times | any (themed) | medium | racing, platformer, speedrun, time-trial | Fastest first, with a stopwatch: the track record on top, every other time with its gap to first and a gap bar. |
| `seasons` | Seasons | any (themed) | large | competitive, arcade, sports, racing | This season's standings under a season badge, beside a hall of champions: each past season with its top three. |
| `sign` | Scoreboard Sign | any (themed) | large | arcade, platformer, party, racing, hub | A sign that stands in the level (the `ScoreSign` component), framed per look: arcade marquee with bulbs, LED panel, holo-board, wooden notice board, chalkboard. Top 5 in big type. |
Draw any view anywhere with `require("scoreKit.boards").render(id, look, Score.board():data(), { w, h, t })`.
**A board in the level:** the `ScoreSign` component builds a sign (two posts and a backboard) and draws a board view on its face, live:
```lua
local sign = entity.spawn("HallOfFame", { position = { 0, 0, -10 } })
sign.component.add(asset.resolve("scoreKit.ScoreSign", "component"), { view = "sign", look = "fantasy", width = 5, height = 3 })
```
Fields: `board` (empty = the kit's), `view` (any board view), `look`, `title`, `width`, `height` (metres), `lift` (height above the ground), `frame` (false: no posts or backboard, for your own wall), `maxDistance`.
### End-of-round screens (`results`)
| id | name | look | size | suits | what it is |
|---|---|---|---|---|---|
| `ratingCard` | Rating Card | any (themed) | medium | arcade, platformer, puzzle, rhythm, party | Stars that pop in one by one, a big grade letter, the score and this round's place, a breakdown table that adds up to the total, and best-combo / max-multiplier chips. |
| `celebration` | Celebration | any (themed) | large | arcade, platformer, racing, endless, party | NEW PERSONAL BEST or NEW WORLD RECORD with rays and confetti, the new score, the previous best, the improvement and the board rank. A calm "Good run" when neither. |
| `tally` | Tally | any (themed) | medium | arcade, platformer, action, rhythm | A combo and bonus tally that counts up: each breakdown line appears and counts from 0, a running total climbs, then the final total punches in with the rating. |
| `timeTrial` | Time Trial | any (themed) | medium | racing, platformer, speedrun, time-trial | The final time big, every split against its best with green (ahead) / red (behind) deltas and a gap bar, a PB / record badge and the time to beat. Handles a first run with no best. |
A finished time run always shows `timeTrial`. A round that brings a personal best or record shows `celebration` before a `ratingCard`. The screen closes on its own after `resultsSeconds`, or on a click or tap.
### Looks
| id | name | what it is |
|---|---|---|
| `arcade` | Arcade | Black glass, neon yellow and magenta, chunky monospace digits. |
| `minimal` | Minimal | White cards, quiet blue accent, clean sans type (MatchFlow's minimal). |
| `scifi` | Sci-Fi | Dark navy glass, cyan and pink glow, clipped corners (MatchFlow's sci-fi). |
| `fantasy` | Fantasy | Parchment and walnut, gilded gold, serif type. |
| `cartoon` | Cartoon | Cream panels, thick ink outlines, hard drop shadows (MatchFlow's cartoon). |
`minimal`, `scifi` and `cartoon` use MatchFlow's own design tokens, so a world running both kits looks like one game.
## The ScoreKit component
| Field | Default | What it does |
|---|---|---|
| `display` | `arcade` | the corner score display |
| `look` | `arcade` | the look for themed displays, boards and results |
| `popups` | `auto` | "+N" popup style (`auto` follows the look, `off` hides them) |
| `corner` | `auto` | `auto` (top-left; bottom-left when pickupsKit's counter is in the top-left or a MatchFlow timer would overlap; the bottom on a phone), `topLeft`, `topRight`, `bottomLeft`, `bottomRight`, `top`, `bottom` |
| `showHud` | `true` | off: draw nothing, and build your own HUD from `Score.state()` |
| `results` / `resultsSeconds` | `ratingCard` / `7` | the end-of-round screen (`none` for none) and how long it stays |
| `toasts` / `toastComboFrom` | `true` / `3` | navUiKit toasts; a combo toast when the multiplier reaches this (0 = never) |
| `window`, `steps`, `maxMultiplier`, `decay`, `breakOnPenalty` | `2.5`, `"3,6,10,15"`, `5`, `reset`, `true` | the combo rules |
| `board` / `boardTitle` | `main` / "" | the board's id and title (time board: `<board>_times`) |
| `writers` | "" | user ids that may write the board (empty: the keeper) |
| `match` | `auto` | bind to the scene's MatchFlow (`off`, or the Match entity's name) |
| `acceptRemote`, `maxPerAdd`, `maxPerSecond` | `false`, `1000`, `20` | off: only the keeper scores. On: other machines may report their own player's points, capped by the other two |
`Score.configure` also takes `matchOnly`, `remoteFor`, `round`, `bigAt`, `every`, `targets`, `comboToast` and `recordToast` (navUiKit toast variants, default `minimal` and `achievement`), and `resultsAt` (`auto` waits for MatchFlow's results to finish).
## API
`local Score = require("scoreKit.score")`. From inside another package, use `require("~scoreKit.score")`.
| Call | Does |
|---|---|
| `Score.add(player, amount, { at, reason, combo, multiply })` | add points; returns the points awarded, as a number (see below) |
| `Score.penalty(player, amount, opts)` / `Score.bonus(player, label, amount)` / `Score.breakCombo(player, reason)` | take points away / a named bonus / break the combo. `penalty` and `bonus` return the points awarded too. |
| `Score.get(player)` / `Score.state(player)` / `Score.players()` | a score / everything live about a player / everyone this round, ranked |
| `Score.configure(opts)` / `Score.rules()` / `Score.setTargets(t)` / `Score.rate(v, t)` | rules and settings / the rules in force / a level's targets / a rating |
| `Score.setDisplay`, `setLook`, `setPopups`, `setResults`, `setHud`, `start`, `stop` | this machine's HUD |
| `Score.catalog(filter)` | every style with its tags |
| `Score.endRound(opts)`, `Score.resetRound()`, `Score.submit(player, value, { board })`, `Score.lastRound()`, `Score.match()` | rounds and posting |
| `Score.startRun`, `split`, `finishRun`, `cancelRun` | time trials |
| `Score.board(id)`, `Score.timeBoard()` | board handles (see Leaderboards) |
| `Score.on(event, fn)`, `Score.isKeeper()`, `Score.keyOf(player)`, `Score.setName(player, name)` | events and identity |
| `require("scoreKit.pickups")`: `listen(points)`, `score(ev, points)`, `scoresHere()`, `parse`, `pointsFor`, `reasonFor`, `kit()` | pickupsKit grabs as points (see Pickups that score) |
**What `Score.add` returns.** Always a number: the points that call put on the score.
- **On the keeper** (a solo game, or the host): the points awarded after the combo multiplier, at once. Three quick `Score.add(player, 10)` calls return 10, 10, then 20 (x2). `Score.penalty(player, 25)` returns -25, and `Score.bonus(player, "No damage", 500)` returns 500.
- **0 when nothing was awarded:** outside a playing MatchFlow round, or when the call was refused.
- **On another machine with keeper-only scoring** (`acceptRemote = false`, the default): 0. That machine's call awards nothing; the keeper's own call for the same event is the one that counts.
- **On another machine with `acceptRemote = true`:** the points it predicts, shown at once. The keeper confirms them within a round trip, or takes them back if it refuses.
The renderers are plain functions over data, for drawing a style inside your own UI. Each data shape is written down, with a moving sample generator, in `scoreKit.demo`:
- `scoreKit.displays.render(id, data, { w, h, look, t })`
- `scoreKit.popups.render(id, popup, { k, look })`
- `scoreKit.boards.render(id, look, data, { w, h, t, narrow })`
- `scoreKit.results.render(id, look, data, { w, h, t, narrow })`
## The gallery
`ScoreGallery` is the style browser the Hall of Fame world opens with. Its **Everything** tab puts every score display, every leaderboard and every end-of-round screen on one page, side by side and labelled with their tags. Further tabs show each family larger, plus the popups. Every card draws from **one feed** at the same moment: in a round it is your own live score, combo and multiplier, the world's real boards and your last round's results (the card is marked LIVE). Before there is anything live, all cards play the same demo run in lockstep (marked DEMO). A look switcher redraws every themed card, and a "Use" button on a card puts that style on your own HUD. The **Live round** tab shows the world's real round and board. Add it to any scene: `entity.spawn("Gallery").component.add(asset.resolve("scoreKit.ScoreGallery", "component"), {})`.
**The coin round is scoreKit with pickupsKit.** The arena's seven gold coins, the ruby and the damage orb are real pickupsKit pickups, placed in the scene's `build.luau` exactly as in "Pickups that score". pickupsKit decides who took each one, pops it, counts it on its own counter (top left, so MatchFlow's standings keep the top right) and brings it back. The score display then sits bottom-left on its own (`corner = "auto"`). A `ScorePickups` with `points = "goldCoin=10, ruby=50, damageOrb=-25"` scores each grab. The world has no coin code of its own.
## Phones and the browser
- Every screen is sized from the safe area (`ui.safeArea()`, through MatchFlow's `theme.viewport()`). On a phone held upright (under 640 points wide), the corner display takes at most 62% of the width. Results screens and the gallery switch to single-column layouts (boards and results take `narrow`) instead of shrinking their text.
- Everything is UI-tree drawing (boxes, SVG, engine-bundled fonts: Anton, Archivo Black, Arimo, Comic Neue, EB Garamond, PT Serif, JetBrains Mono), with no textures or shaders to load, so the web build draws the same thing on its first frame.
- Results screens close on a tap. The gallery's buttons are full-size touch targets.
## What it builds on
| Package | World | Used for |
|---|---|---|
| **MatchFlow** 1.0.0 | Match Flow Kit | rounds: `addScore`, `getPhase`, `getStandings`, `onStateChanged`, `onRoundEnded`. Its theme tokens are the base of three looks. |
| **navUiKit** 1.0.1 | Wayfinder Studio | toasts (`Nav.toast`, achievement / minimal / banner variants), and its camera projection (`navUiKit.geom`) for popups and signs |
| **pickupsKit** 2.3.0 | Treasure Trove | pickups that score: `ScorePickups` and `scoreKit.pickups` listen to its `collect` event (see Pickups that score). It brings SecondWind, Lifeline and juiceKit with it, and the same MatchFlow. |
They come with scoreKit: its code names them (`~MatchFlow.theme`, `~navUiKit.nav`, `~navUiKit.hud`, `~navUiKit.geom`, `~pickupsKit.api`), and an install brings what the published package references. scoreKit finds them by package name, so they work wherever the install puts them (`/source` or `/source/deps/<guid>/`). Keep one copy of each: a second MatchFlow, navUiKit or pickupsKit makes its name ambiguous. If the log shows `module '~navUiKit.nav' not found`, `'~MatchFlow.theme' not found` or `'~pickupsKit.api' not found`, install the missing kit from ZeroMind ("navUiKit", "MatchFlow", "pickupsKit"). Only the part that uses it stops working: without navUiKit scoreKit draws its own toasts, without MatchFlow it uses its embedded looks, and without pickupsKit only `ScorePickups` fails to load.
scoreKit carries its toasts on a navUiKit HUD it makes itself through navUiKit's `hud` module. It shows only the toasts (no minimap, compass or objectives, and the map key is unbound), and needs no navUiKit component in your scene. If your world runs navUiKit's own HUD (`Nav.start()` or a NavHud), scoreKit's toasts appear on that one instead.
## Tests
The `scoreKit` test suite runs in edit mode with `tools.use("tests", "run", { suite = "scoreKit" })`. It covers:
- **Rules:** the combo rules against the numbers in this README, ratings and ranking.
- **Board:** bests, records, time boards, corrupt rows, merging, around-me, seasons.
- **Persistence through `runtime_data`:** write and read back, a new session seeing it, a non-keeper refusing, held results in locked mode with their approval and rejection, retrying and the outbox replay.
- **Keeper:** its checks on remote scores, prediction and refusal, adopting the keeper's rules on another machine, MatchFlow binding with a stand-in match, round ends whose breakdown adds up, time runs and their validation.
- **API:** every public call (HUD settings, reading, configuring, scoring, rounds, runs, every board-handle method), the events they fire, and the `ScoreKit` and `ScoreGallery` components.
- **Pickups:** the points table, the keeper scoring each pickupsKit grab once (combo, penalty, unlisted pickups), a machine that is not the keeper scoring nothing, only the senior machine scoring before any ScoreKit exists, grabs announced through pickupsKit's own network handler, and the `ScorePickups` component.
- **Styles:** the catalog, and every renderer in every look at desktop and phone sizes. Also the HUD, every gallery tab (and that all its cards are handed the same data in the same frame) and the sign.
- **Install layout:**
- every require and asset name in the package resolves wherever an install puts scoreKit and its dependencies (package-relative `~.x`, packages by name `~Name.x`, or `@builtin::`)
- a package named `~Name` is found under `/source/deps/`
- toasts run on the HUD scoreKit makes itself
- **This README:** its version against `package.yaml`, `Score.version` and the changelog, its rules and style tables, and its first example run against a stand-in jump button (three quick jumps make 40).
- **Coverage:** a check that every public function of every module is called by the suite.
The suite removes everything it writes to the world's store.
## Known limits
Engine issues that still affect scoreKit, each with the GitHub issue that tracks it:
- **An install also writes a partial second copy of scoreKit's own modules (#10762).** A fresh install puts scoreKit in `/source/scoreKit.package`, and beside it seven of its modules again (`theme`, `basetheme`, `rules`, `displays`, `popups`, `boards`, `results`) under `/source/deps/877b8381-7a16-4946-9242-f1140f46092a/scoreKit.package/`. The engine pins each file to the versions of the modules it requires, and an install fetches every pinned version the package does not ship as a lone copy. The copy has no `package.yaml`, so it is not a second scoreKit: `scoreKit.*` and `~scoreKit.*` still resolve to the real one, and nothing in a fresh world loaded from it. It is safe to delete. Saving and committing scoreKit again does not refresh those pins, so the kit cannot avoid it: the install splits part of the package's own tree into `deps/`. MatchFlow and navUiKit leave the same kind of `versions/` folders under their own `deps/` folders, and those old copies can log `no root to resolve against` while the world opens.
- **A kit that came with scoreKit is not found by its bare name from your own scripts (#19199).** MatchFlow, navUiKit and pickupsKit arrive as scoreKit's dependencies. In your own code `asset.resolve("MatchFlow.MatchFlow", "component")` answers "not found", while `asset.resolve("~MatchFlow.MatchFlow", "component")` works. For pickupsKit, use `require("scoreKit.pickups").kit()`.
- **The world store is open to modified clients** in the default mode (see Who can write the board). The engine has no store that only the world's authority can write (#19283), so the kit's own network path cannot change the board, but a player running modified engine code can still write `runtime_data.world` directly and forge rows. Use `writers` for a board that must hold against that too.
- **One account on two machines presents one player in the engine (#14448).** scoreKit works around it by giving each session its own key (`u:<id>`, `u:<id>#2` …). "Nova (2)" is whichever of that account's machines joined the room second, so that board row belongs to the account's second seat rather than to one particular computer.
- **The test suite can't be two players in one engine (#18688, #19114).** The loopback test mode doesn't echo messages, and an agent in the engine can't bring in a second player. So the suite delivers the other machine's messages through the real network handlers: scoreKit's own (refusals, rate limits, predictions, forged snapshots) and pickupsKit's (`pk:got` grab announcements). Two real machines were played by hand for 1.3.0 instead. Two editors on the relay, signed in to one account, played the gallery's coin round together, with one machine hosting. Every coin, ruby and damage orb the other machine's player grabbed was announced once and scored once, during the round only, under that player's own key ("claude-code @ aura", with the host as "(2)"). Their own score display showed the keeper's total (95), and pickupsKit's counter agreed (8 coins, 1 gem).
- **Moving an installed MatchFlow, navUiKit or pickupsKit needs an editor restart (#19314).** After a package is moved or reinstalled somewhere else, the engine's name lookup keeps its old location: `~MatchFlow` then reads as "not found" (or "ambiguous", if a copy was left behind) until the editor restarts. Reproduced on 2026-10-18. A fresh install, or a world opened fresh, is not affected.
- **The sign's face is UI drawn where the sign is on screen (#19284).** Zero's 3D `UiPanel` turns widget trees into plain rich text, so the styled board is drawn as UI fitted to the backboard's projected size, squashed to the viewing angle. It is hidden when its back faces you or beyond `maxDistance`, but a wall between you and the sign does not hide it.
By design:
- **`acceptRemote = true` trusts each machine with its own player's points**, within `maxPerAdd` and `maxPerSecond`. A modified client can then award itself points up to those caps. Keep the default for anything competitive.
- **MatchFlow takes reports from any machine.** scoreKit ranks points rounds on its own counted scores. But in `race`, `lastStanding` and `kingOfTheHill` it uses MatchFlow's places, and those trust MatchFlow's own reports (`finish`, `knockOut`, `setOnHill`).
- **Points scored in the instant the keeper leaves can be lost.** The next keeper carries on from the last published snapshot.
- **A player who leaves before the round ends is not ranked or posted.** Their points that round don't reach the board.
## Changelog
### 1.3.1
- **One end-of-round screen at a time.** With MatchFlow, the results card waits until MatchFlow has finished its own results on that machine and moved on to the break. Before, it only waited while that machine already read "results": on a machine that wasn't hosting, the round's results arrive a moment before its MatchFlow switches over, so the card and MatchFlow's podium were drawn on top of each other. A card the break has passed by is skipped, and one still up when the next round counts down closes.
- **pickupsKit's counter and MatchFlow's standings no longer overlap.** With `corner = "auto"`, the score display moves to the bottom-left when pickupsKit's counter is in the top-left (`H.pickupHudCorners()`). The gallery puts the counter top-left, so MatchFlow's standings keep the top right.
- **Clean text in the pixel font.** The arcade look's pixel text read text byte by byte, so "·" and "…" (and any accented letter) drew as one "?" per byte, and "@" had no glyph. It now reads characters, draws "·", "…", "@", brackets, quotes and other punctuation, and draws accented letters as their plain letter (`T.pixelGlyphs`). A test renders every display, board and results screen in every look with awkward names and fails on any "?".
- **Gallery:** its board is now "Hall of Fame" (id `hallOfFame`), with both players' rows carried over, and the demo boards are titled "Gem Dash". A stray empty entity left in the gallery scene is gone.
### 1.3.0
- **`Score.add`, `Score.penalty` and `Score.bonus` return the points awarded.** On the keeper they returned nothing; they now return the amount after the combo multiplier (10, 10, 20 for three quick 10s), negative for a penalty, 0 when nothing was awarded. Off the keeper they return 0 with keeper-only scoring, or the predicted amount with `acceptRemote = true`. See "What `Score.add` returns".
- **The gallery's coin round is built on pickupsKit 2.3.0**, installed in the Hall of Fame world under `/source/deps/` like MatchFlow and navUiKit. Its coins, ruby and damage orb are pickupsKit pickups placed in the scene, scored by `ScorePickups`. The world's own one-off coin script is gone.
- **`ScorePickups` and `scoreKit.pickups`: pickups that score.** Every pickupsKit grab becomes one `Score.add` on the machine keeping the score, for the player who took it, from a points table such as `"goldCoin=10, ruby=50, damageOrb=-25"`. Before any `ScoreKit` exists, the senior machine scores the grab and its `Score.add` creates the kit. pickupsKit is now a dependency of scoreKit, next to MatchFlow and navUiKit.
- **README checked in a brand-new world with nothing but scoreKit installed.** The first example now says how to make the scene it goes in (a new world has none), and the MatchFlow example names MatchFlow as `~MatchFlow.MatchFlow`, the form that resolves when MatchFlow came with scoreKit.
- **Known limits name only open issues.** The notes on #19298, #19299 and #19268 are gone: the engine now carries a table with an `x` field and an entity inside a table intact, so scoreKit no longer works around them. Added #10762 (the partial copy an install writes), #19199 (dependencies by bare name), #19314 (moving an installed kit), #19284 (the sign's face) and #18688 / #19114 (two players in one engine).
- **Played on two real machines.** The coin round with two editors on the relay: every grab on the machine that wasn't hosting scored exactly once, for its own player.
- **Fixed:** navUiKit's HUD modules pin different copies of navUiKit's core, so toasts sent through `Nav.toast` reached a core the HUD never read. scoreKit hands toasts straight to its HUD (or to the world's own navUiKit HUD), and its HUD shows only the toast band.
### 1.2.1
- MatchFlow and navUiKit are installed as dependencies under `/source/deps/<origin world>/` in the Hall of Fame world, next to controlsKit, the layout an install of scoreKit gives you. No code change: scoreKit already reaches them by package name.
- Known limits now cite the engine issues behind them (#19283, #14448, #19298, #19299, and #19268, now fixed).
- **Fixed:** two writes to the same board row are sent in order. A `board:remove` made while that row's save was still on its way used to be folded into the save already sent and lost, so the row came back.
- **Fixed:** navUiKit is looked up when toasts first need it, and again (at most once a second) until it is found. Right after an install or an editor start the first lookup can fail, and that used to leave toasts off for the whole session. `H.navModules()` returns the navUiKit modules in use.
### 1.2.0
- **One account signed in on two machines is two players.** Each session gets its own key (`u:<id>`, `u:<id>#2` …) and name ("Nova", "Nova (2)"), so the round ranking, the results and the board show both with their own points. Before, both sessions shared one key: one machine's points were credited to the other, and the board listed one row.
- **Only players who are here are ranked, placed and posted.** Absent player keys are dropped at the next round's start and never count toward "1st of N".
- **`board:remove(player)`**: the writer in charge can take a stale or test row off the current season. A queued delete hides the row at once.
- Removed a leftover test player ("Player Two") from the gallery's board.
### 1.1.0
- **Keeper-only scoring by default** (`acceptRemote` now defaults to `false`). Another machine's `Score.add`, `bonus`, runs and board posts are refused at the keeper, and not sent or predicted at all once a keeper is known. The same code can run on every machine and count once. `acceptRemote = true` restores self-reported points within the caps.
- **A board post from another machine never carries its own value.** The keeper posts the score it counted, to the kit's board only. Time boards take only runs the keeper timed.
- **Only the keeper's standings, awards and round results are believed**: forged `snap`/`ev`/`nak` messages from other peers are ignored.
- **Points rounds are ranked on scoreKit's counted scores**, not on MatchFlow's standings. MatchFlow's places are used for `race`, `lastStanding` and `kingOfTheHill` only.
- **Fixed:** after leaving play and pressing Play again (or any rebuild that replaces the MatchFlow component on the same entity), scoreKit kept the old, frozen match. It refused every point as "not playing" and never saw the round end. It now follows the live component.
- Gallery: an **Everything** tab with every display, leaderboard and results screen on one page, all drawn from one shared feed (live scores when there are any).
### 1.0.0
- First release: `Score.add` with combos and multipliers, ratings (stars and S–D grades), rounds through MatchFlow, toasts through navUiKit, persistent boards (top scores, best times, personal bests, seasons, locked writers, held results), 13 displays, 5 popup styles, 6 board views with the `ScoreSign`, 4 results screens, 5 looks, the `ScoreGallery`, and the `scoreKit` test suite.
# 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:
```lua
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:
```lua
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 with `Nav.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 `userId` reaches every session of that account. `"all"` reaches everyone.
- **POIs** are marked on every peer by the same script (or by the PointOfInterest component). Pass `player` to `Nav.mark` or `Nav.setWaypoint` for 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 own `onLoad`: declaring an id that already exists keeps its progress (pass `count` to 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, and `to = player` sends 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 `navUiKit` multiplayer channel. To use your own transport, set `require("navUiKit.core").relay = { send = function(kind, payload) ... end }` and call `Nav._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:update` takes 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, or `hud: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.satellite` view, 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.simulateSafeArea` does nothing while playing, so the HUD won't move away from a simulated notch in play. Check notch layouts in edit mode with NavHud's `previewInEditor` on. 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's `NameLabel` child). 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:
```lua
{
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" })`.
# pickupsKit 2.3.0
Coins, gems, hearts, shields, keys, letters and power-ups for any game: one `Pickup` component, 47 looks in five styles, each tagged with its size, and multiplayer claims where a simultaneous grab has exactly one winner.
Built for platformers, arena shooters, kart racers and adventure games. Every pickup also has its own synthesised sound.
## New in 2.3.0
- **The names you type:** `heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate` are main `prefab` names now, beside `goldCoin`, `pixelGem` and the rest. Every older name still works: the 2.2 names (`redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard`, `ammoBox`) and the original ids (`cartoon_coin`, `real_gold_coin`…). `looks()` lists each look's other names as `otherNames`.
- **The gallery** labels each pickup with its main name, so you can read the label and type it. Its look switcher changes the coin's `prefab`, the way "Switching a look" below describes. The gallery scene saves with nothing pending, so it clears and reloads cleanly.
## Install
One install: `zeromind_install guid=0d70dd00-9297-45d5-a290-5a51b9f9e262`, or search ZeroMind for "pickupsKit" and install it from the ZeroMind panel.
That one install also brings the kits pickupsKit builds on, in the versions it was built and tested with, as its own dependencies under `/source/deps/`. Your world ends up with `/source/pickupsKit.package` and nothing else to set up. While the files land, the log can show passing lines such as `waiting for content it requires` or `which is being fetched`; they stop once the install is done.
## Builds on
- **SecondWind 1.2.0** (the "Second Wind" world): hearts, medkits and potions heal through it, shields top up its shield, and the damage and invincible power-ups go through its damage filter. SecondWind is itself built on **Lifeline 1.0.1** and **MatchFlow 1.0.0**, which come along too.
- **juiceKit 1.2.1** or newer (the "Juicebox" world): the sparkle, flash and pop when something is picked up, spawns or expires. An install brings juiceKit 1.2.2.
Your own scripts reach these kits through pickupsKit, because a kit that came in as a dependency belongs to the kit that brought it: `Pickups.health()` returns SecondWind's api, `Pickups.healthParts()` its `Rules`, `Health`, `HUD` and `Trap` components, and `Pickups.juice()` juiceKit's effects. If you install SecondWind or juiceKit yourself as well, `require("SecondWind.api")` works in your own code as usual.
`Pickups.dependencies()` lists each kit with the version it needs, the version installed, whether it has loaded, and `ok`, which is true when the installed one is that version or newer.
**Pickups never wait for these.** The kit loads SecondWind and juiceKit the first time it needs them, not when it starts. If one is missing or still downloading (a web build fetches packages while the world opens), pickups still appear, spin, collect and count, and the counter and chests still work; the kit logs one warning and tries again a little later. A heart taken meanwhile is still collected, with `note = "no health kit"` on its `collect` event.
## Quick start
Add these lines inside `content()` in your scene's `build.luau`, just before its closing `end`. A brand-new world may have no `build.luau` yet: make a scene with a ground and a player first, with `tools.use("scene", "create", "level", "player", { startup = true })`, and add the lines to `scenes/level.scene/build.luau` the same way.
```lua
local Pickups = require("pickupsKit.api")
-- the coin counter in the corner of the screen
entity.spawn("PickupHUD").component.add(Pickups.HUD, { style = "pixel" })
-- five pixel gems in a line in front of where the player starts
for i = 1, 5 do
Pickups.spawn({ (i - 3) * 1.5, 0, 4 }, { prefab = "pixelGem" })
end
```
Press **Play** and walk forward. Each gem you touch pops with a sparkle and a coin sound, and the counter adds 1. Each gem comes back 5 seconds later. The host makes the synced ledger that holds the counts by itself, so there is nothing else to place.
## More examples
All of these go in `content()` too, after the quick start.
```lua
-- Health for players, so hearts and shields have something to heal.
local HK = Pickups.health()
entity.spawnSynced("HealthRules").component.add(HK.RULES, {})
HK.attach(body, { model = "shield" }) -- `body` is your PlayerPrototype's body entity
-- A heart that heals 25, a speed power-up and a key that never comes back.
Pickups.spawn({ -2, 0, 6 }, { prefab = "heart" })
Pickups.spawn({ 0, 0, 6 }, { prefab = "speedOrb", duration = 10 })
Pickups.spawn({ 2, 0, 6 }, { prefab = "silverKey", respawn = false })
-- Letter tokens that spell ZERO, one copy per player.
for i, l in { "Z", "E", "R", "O" } do
Pickups.spawn({ -3 + i * 1.5, 0, 8 }, { prefab = "letterToken", letter = l, mode = "perPlayer" })
end
-- A chest that spills 12 rubies when a player walks up to it.
local chest = entity.spawnSynced("Chest", { position = { 0, 0, 11 } })
chest.component.add(Pickups.CHEST, { prefab = "ruby", count = 12 })
```
Hearts and shields heal only bodies that have SecondWind health, as above. Without it a heart is still collected, but it has nothing to heal. `Pickups.health()` is nil while SecondWind is still loading; in `content()` it has loaded.
To show a word's letters lighting up, give the HUD a word: `{ style = "cartoon", word = "ZERO" }`.
**Reading and spending from your own code** (in a component or anything else that runs in play):
```lua
Pickups.on("collect", function(ev) print(ev.key, "took", ev.prefab, ev.amount) end)
if Pickups.spend(player, "coins", 10) then openDoor() end
print(Pickups.get(player, "keys"), Pickups.letters(player), Pickups.spelled(player, "ZERO"))
```
**Switching a look is one setting**, and it applies right away, for every player, also while the game runs:
```lua
pickupEntity.component.get(Pickups.PICKUP).prefab = "pixelGem" -- on the engine that owns the pickup
Pickups.setPrefab(pickupEntity, "pixelGem") -- from any engine
```
The model is rebuilt on every engine as the change arrives. The pickup keeps its other settings. With `effect = "auto"` (the default) it now does what the new look does.
**Who can write the setting.** Like every synced setting, `prefab` can only be written by the engine that owns the pickup: the host for scene pickups, or whoever spawned it. Any other engine's write is refused by the engine on the spot and changes nothing, and the kit can't catch it. `Pickups.setPrefab` works from every engine: on the owner it writes the setting, and elsewhere it asks the owner to. Both were played with two players: a coin one player owned changed to `pixelGem` for both through the plain setting, and a gallery pickup the other player's engine owned changed through `setPrefab` called by the player who wasn't hosting. Old names worked the same way, through `look = "cartoon_coin"` and `setPrefab(box, "cartoon_mystery")`.
**Coin bursts** from a defeated enemy or a broken pot:
```lua
Pickups.burst(enemy.position, { prefab = "goldCoin", count = 10, lifetime = 12 })
```
The coins fly out and bounce. Each one can only be taken once it has settled, and each blinks for its last 3 seconds before it disappears.
## The Pickup component
`pickupsKit.Pickup`. Every setting is synced: a change made on the engine that owns the pickup reaches every engine, also while the game runs.
| Setting | Default | What it does |
|---|---|---|
| `prefab` | `goldCoin` | Which of the 47 looks it wears, by its short name (the Looks table). This also sets what it does when `effect` is `auto`. |
| `look` | `goldCoin` | The older name of `prefab`, kept so existing scenes and code keep working. It takes the new names and the old ids. Whichever of the two you write, the other follows. |
| `effect` | `auto` | What touching it does: `auto`, `counter`, `heal`, `shield`, `item`, `letter`, `powerup`, `mystery` or `none`. |
| `amount` | 0 | How much it gives: coins, health, shield and so on. 0 uses the look's amount. |
| `counter` | "" | Counter for `counter`: coins, gems, stars, rings, xp, ammo, or any name of your own. |
| `item` | "" | Item for `item`: keys, keycards, or your own. It is counted like a counter. |
| `letter` | "A" | The letter shown on letter tokens. |
| `powerup`, `duration` | "", 0 | The power-up and its length in seconds: `speed`, `jump`, `damage`, `magnet` or `invincible`. |
| `radius` | 1.1 | How close a player must come to take it, in metres. |
| `magnetRange` | 0 | Within this range it flies to the player. 0 means no magnet. A `magnet` power-up triples it. |
| `respawn`, `respawnTime` | true, 5 | Whether it comes back after being taken, and after how many seconds. |
| `lifetime`, `blinkTime` | 0, 3 | It disappears after `lifetime` seconds untouched (0 = never), blinking for the last `blinkTime` of them. With `respawn` on, it then comes back after `respawnTime`. |
| `mode` | `shared` | `shared`: the first player to touch it gets it. `perPlayer`: every player can take their own copy, and it comes back for each of them separately. |
| `bob`, `spin`, `hover`, `scale` | 0.12, 1.8, 0.6, 1 | Bob height in metres, spin in turns per second, hover height and size. |
| `visible` | true | Hide it without removing it. |
| `castShadows` | false | Cast a sun shadow. Off by default, because many small spinning casters redraw the shadow map every frame, which costs most in a web browser. Turn it on for a few hero pickups. |
`available`, `taken`, `armed` and `launch` are state fields that the kit writes itself.
**Old names keep working.** Every look had a longer id before 2.2.0 (`cartoon_coin`, `real_gold_coin`, `pixel_gem_coin`...), and six looks had other short names in 2.2: `redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard` and `ammoBox` are now `heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate`. Every older name still works everywhere a look goes: the `look` setting, `spawn`, `setPrefab`, `setLook`, `burst`, chests and `look()`, and it is stored as the main name. The `prefab` field's own list holds only the main names, so writing an older name straight into `prefab` is refused by the engine; use `look`, or `setPrefab`. The Looks table shows every other name a look answers to, and `looks()` returns them as `otherNames`.
**Other components:**
- `PickupChest` opens when a player comes within `radius` and spills `count` coins of any `prefab` (`look` is its older name there too). It closes again after `cooldown` seconds, or stays open if `cooldown` is 0. It draws its own chest model; set `showModel = false` to use yours.
- `PickupHUD` is the on-screen counter, with `style` set to cartoon, scifi, fantasy, pixel or realistic. Its other settings:
- `counters`: which counters it shows, in order;
- `word`: a word whose letters light up as they are collected;
- `showPowerups`: running power-ups with their time left;
- `showPlayers`: every player's coins;
- `corner`: which top corner it sits in.
- `PickupLedger` is the synced record of every player's counters and letters. The host spawns it by itself.
## Looks
There are 47 looks in five styles that a world can mix and match: cartoon 9, sci-fi 10, fantasy 10, pixel 9 and realistic 9. To list them, call `Pickups.looks()`, or filter with `Pickups.looks({ style = "pixel" })`, `Pickups.looks({ kind = "coin" })` or `Pickups.looks({ size = "small" })`; filters combine. Each entry carries its `id` (the `prefab` name), `oldId`, `name`, `style`, `kind`, `effect`, `size`, `sizeM`, `dims` and what it gives.
| Style | What it looks like |
|---|---|
| cartoon | Chunky glossy shapes with dark ink outlines. |
| scifi | Dark metal shells around glowing energy cores. |
| fantasy | Gems, potions in glass, gold and runes. |
| pixel | Voxel sprites: 8-bit pixels with real depth. |
| realistic | Brushed metal, brass, painted boxes and real-world props. |
**Sizes.** Every look carries a size tag measured from its model at `scale = 1`. `dims` is its width, height and depth in metres, and `sizeM` is the largest of the three. `Pickups.sizes()` lists the tags:
| Size | Largest side | Looks |
|---|---|---|
| `small` | up to 0.62 m | 9: gems, wisps, the realistic coins and kits |
| `medium` | 0.63 to 0.75 m | 28: most coins, hearts, keys, badges and pixel sprites |
| `large` | over 0.75 m | 10: power-up cores, potions, shields and the winged orb |
Use the tag to pick looks that sit well together, or to choose a `radius`: about 1.1 suits small and medium looks, and about 1.3 suits large ones. `pickupsKit.visual.extent(prefab)` measures a model's box directly.
| `prefab` | Also answers to | Name | Style | Kind | Effect | Size | Gives |
|---|---|---|---|---|---|---|---|
| `goldCoin` | `cartoon_coin` | Gold coin | cartoon | coin | counter | medium (0.72 m) | coins +1 |
| `goldStar` | `cartoon_star` | Star | cartoon | star | counter | large (0.78 m) | stars +1 |
| `goldRing` | `cartoon_ring` | Gold ring | cartoon | ring | counter | medium (0.72 m) | rings +1 |
| `heart` | `cartoon_heart`, `redHeart` | Heart | cartoon | heart | heal | medium (0.68 m) | heal 25 |
| `mysteryBox` | `cartoon_mystery` | Mystery box | cartoon | mystery | mystery | medium (0.65 m) | random roll |
| `letterToken` | `cartoon_letter` | Letter token | cartoon | letter | letter | medium (0.70 m) | its `letter` |
| `speedBadge` | `cartoon_speed` | Speed badge | cartoon | powerup | powerup | medium (0.70 m) | speed 8s |
| `damageBadge` | `cartoon_damage` | Damage badge | cartoon | powerup | powerup | medium (0.70 m) | damage 8s |
| `jumpBadge` | `cartoon_jump` | Jump badge | cartoon | powerup | powerup | medium (0.70 m) | jump 8s |
| `energyCredit` | `scifi_chip` | Energy credit | scifi | coin | counter | medium (0.70 m) | coins +1 |
| `shieldCell` | `scifi_shield` | Shield cell | scifi | shield | shield | large (0.77 m) | shield 30 |
| `accessCard` | `scifi_keycard` | Keycard | scifi | keycard | item | medium (0.64 m) | keycards +1 |
| `ammoCell` | `scifi_ammo` | Ammo cell | scifi | ammo | counter | medium (0.72 m) | ammo +30 |
| `speedOrb` | `scifi_speed`, `speedCore` | Speed orb | scifi | powerup | powerup | large (0.78 m) | speed 8s |
| `damageOrb` | `damageCore`, `scifi_damage` | Damage orb | scifi | powerup | powerup | large (0.78 m) | damage 8s |
| `jumpOrb` | `jumpCore`, `scifi_jump` | Jump orb | scifi | powerup | powerup | large (0.78 m) | jump 8s |
| `magnetCore` | `scifi_magnet` | Magnet core | scifi | powerup | powerup | large (0.78 m) | magnet 10s |
| `xpOrb` | `scifi_xp`, `xpShard` | XP orb | scifi | xp | counter | medium (0.66 m) | xp +10 |
| `nanoMedkit` | `scifi_medkit` | Nano medkit | scifi | medkit | heal | medium (0.64 m) | heal 50 |
| `ruby` | `fantasy_ruby` | Ruby | fantasy | gem | counter | small (0.60 m) | gems +1 |
| `emerald` | `fantasy_emerald` | Emerald | fantasy | gem | counter | small (0.54 m) | gems +3 |
| `healingPotion` | `fantasy_potion_red` | Healing potion | fantasy | potion | heal | large (0.76 m) | heal 35 |
| `wardPotion` | `fantasy_potion_blue` | Ward potion | fantasy | potion | shield | large (0.76 m) | shield 35 |
| `goldenKey` | `fantasy_key` | Golden key | fantasy | key | item | medium (0.69 m) | keys +1 |
| `silverKey` | | Silver key | fantasy | key | item | medium (0.69 m) | keys +1 |
| `crestShield` | | Crest shield | fantasy | shield | shield | large (0.81 m) | shield 40 |
| `wingedOrb` | `fantasy_jump` | Winged orb | fantasy | powerup | powerup | large (0.94 m) | jump 8s |
| `runeStone` | `fantasy_rune` | Rune stone | fantasy | letter | letter | medium (0.67 m) | its `letter` |
| `xpWisp` | `fantasy_wisp` | XP wisp | fantasy | xp | counter | small (0.47 m) | xp +25 |
| `pixelGem` | `pixel_gem_coin` | Pixel gem | pixel | coin | counter | medium (0.72 m) | coins +1 |
| `pixelCoin` | `pixel_coin` | Pixel coin | pixel | coin | counter | medium (0.68 m) | coins +1 |
| `pixelHeart` | `pixel_heart` | Pixel heart | pixel | heart | heal | medium (0.68 m) | heal 25 |
| `pixelKey` | `pixel_key` | Pixel key | pixel | key | item | medium (0.70 m) | keys +1 |
| `powerStar` | `pixel_star` | Power star | pixel | powerup | powerup | medium (0.72 m) | invincible 8s |
| `jumpMushroom` | `pixel_mushroom` | Jump mushroom | pixel | powerup | powerup | medium (0.68 m) | jump 10s |
| `pixelSword` | `pixel_sword` | Pixel sword | pixel | powerup | powerup | medium (0.72 m) | damage 8s |
| `pixelLetter` | `pixel_letter` | Pixel letter | pixel | letter | letter | medium (0.72 m) | its `letter` |
| `questionBlock` | `pixel_mystery` | Pixel ? block | pixel | mystery | mystery | medium (0.63 m) | random roll |
| `silverCoin` | `real_silver_coin` | Silver coin | realistic | coin | counter | small (0.60 m) | coins +1 |
| `goldDoubloon` | `real_gold_coin` | Gold doubloon | realistic | coin | counter | medium (0.64 m) | coins +5 |
| `goldBar` | `real_gold_bar` | Gold bar | realistic | coin | counter | small (0.62 m) | coins +25 |
| `cutDiamond` | `real_diamond` | Diamond | realistic | gem | counter | small (0.60 m) | gems +5 |
| `firstAidKit` | `real_medkit` | Medkit | realistic | medkit | heal | small (0.62 m) | heal 50 |
| `ammoCrate` | `ammoBox`, `real_ammo` | Ammo crate | realistic | ammo | counter | small (0.62 m) | ammo +30 |
| `brassKey` | `real_key` | Brass key | realistic | key | item | medium (0.69 m) | keys +1 |
| `armorPlate` | `real_armor` | Armor plate | realistic | shield | shield | medium (0.71 m) | shield 50 |
| `energyDrink` | `real_energy` | Energy drink | realistic | powerup | powerup | small (0.60 m) | speed 8s |
## Effects
| Effect | What it does |
|---|---|
| `counter` | Adds `amount` to the player's `counter`. |
| `heal` | Heals `amount` through SecondWind (`HK.heal`), on the engine of the player who took it. A player at full health still takes it: the heart is collected and counted, and heals 0. |
| `shield` | Tops up the SecondWind shield by `amount`, up to the maximum, on the engine that holds that player's health (the kit sends it there). |
| `item` | Adds `amount` of `item` (keys, keycards...). Spend it with `Pickups.spend(player, "keys", 1)`. |
| `letter` | Adds the token's letter. Check a word with `Pickups.spelled(player, "ZERO")`. |
| `powerup` | Gives a timed power-up. Taking the same one again adds to its time. |
| `mystery` | Rolls one weighted reward: coins, XP, heal, shield or any power-up. |
| `none` | Nothing but the `collect` event, so you can handle it yourself. |
**Power-ups:**
- `speed`: moveSpeed ×1.6.
- `jump`: jumpForce ×1.55.
- `damage`: deals ×2 damage through SecondWind.
- `magnet`: pickups pull from 3× further away.
- `invincible`: takes no SecondWind damage.
Each family has a speed, damage and jump look: cartoon badges (`speedBadge`, `damageBadge`, `jumpBadge`), sci-fi orbs (`speedOrb`, `damageOrb`, `jumpOrb`), and the pixel `jumpMushroom`, `pixelSword` and `powerStar`.
Speed and jump are held on the player's CharacterController, on that player's own engine, for as long as the power-up runs. If something else, such as SecondWind's chill, changes the speed meanwhile, that change is respected.
Damage and invincible work through SecondWind's damage filter. Every engine knows every player's power-ups, so the filter is right wherever the damage is decided. The kit installs the filter the first time one of those power-ups is given, or straight away through `Pickups.setup()`. SecondWind keeps a single filter, so if your world has its own, call `Pickups.setup({ damageFilter = false })` and multiply by `Pickups.damageMultiplier(attacker, victim)` inside yours.
## API
`local Pickups = require("pickupsKit.api")`. A "player" argument can be an avatar entity, any collector entity, a peer number or a ledger key (`"p3"`).
**Pickups**
- `spawn(pos, settings?, { synced = true, name }?)` returns the entity. It errors on an unknown prefab or mode. `settings.prefab` names the look (`settings.look` is its older name).
- `burst(pos, { prefab, count = 8, amount, spread = 2.4, lifetime = 12, blinkTime = 3, magnetRange, mode, synced })` returns the coins.
- `setPrefab(pickup, prefab)` (also `setLook`), `collect(pickup, collector)`, `available(pickup, collector?)`.
- `all()` returns `{ [id] = Pickup }`. `openChest(chest)`.
**Counters**
- `get(player, counter)`, `counts(player)`, `allCounts()`.
- `add(player, counter, n)`, `set(player, counter, v)`, `spend(player, counter, n)`, `reset(player?)`.
- `ledger()` returns the PickupLedger component in use.
**Letters**: `letters(player)`, `spelled(player, word)`, `giveLetter(player, letter)`, `clearLetters(player)`.
**Power-ups**: `givePowerup(player, name, secs)`, `powerup(player, name)`, `powerupLeft(player, name)`, `activePowerups(player)`, `damageMultiplier(attacker, victim)`.
**Catalog**: `looks(filter?)` (filter by `style`, `kind`, `effect` and `size`), `look(id)` (a new name or an old id), `styles()`, `kinds()`, `effects()`, `powerups()`, `counters()`, `sizes()`. Everything they return is a copy.
**The kits it builds on**: `dependencies()` returns `{ name, needs, installed, loaded, problem, ok, why }` for SecondWind and juiceKit. `health()` and `juice()` return each module, or nil and why not, loading it if it can. `healthParts()` returns SecondWind's `{ Rules, Health, HUD, Trap }` components.
**Identity**: `keyOf(x)`, `nameOf(x)`, `localKey()`, `localAvatar()`, `owns(x)`.
**Checking every engine**: `peerReport(seconds?)` asks every engine in the play session for its `selfReport()` and returns `{ [peer] = report }`, this engine's own included. Each report says whether SecondWind and juiceKit loaded on that engine (and why not), which platform it runs on (`native` or `browser`), whether it holds the ledger, its own player's health as SecondWind sees it, and the outcome of the last heal or shield it applied. It is how a problem on another player's engine, such as a browser, can be read from yours.
**Collectors**: `addCollector(entity)` lets a bot or a kart collect, with its counts kept under `"e<id>"`. `removeCollector(entity)` undoes it.
**Setup**: `setup({ hud = true, hudStyle = "cartoon", word = "", damageFilter = true })` is optional. It makes the ledger now rather than after the host's first second, adds the HUD and installs the damage filter. `installDamageFilter()` installs just the filter.
**Events**, through `on(event, fn)`, which returns an unsubscribe function:
| Event | Arguments |
|---|---|
| `collect` | `ev` = `{ id (event id), p (pickup id), prefab, look (the same name), style, kind, effect, amount, counter, item, letter, powerup, duration, mystery, key, cid (collector id), pos, mode }` |
| `counter` | `key, counter, value, delta` |
| `letter` | `key, letter, letters` |
| `powerup` | `key, name, seconds` |
| `powerupEnd` | `key, name` |
| `respawn` | `pickupId` |
| `expire` | `pickupId, pos` |
| `burst` | `pos, count` |
| `chest` | `chestId` |
`pickupsKit.visual` draws a look on this peer, for your own props or menus:
- `build(prefab, { letter, name, shadows }?)` returns a handle;
- `place(h, pos, yaw, scale)`, `setVisible(h, on)`, `setShadows(h, on)`, `alive(h)`, `destroy(h)`;
- `extent(prefab)` returns the model's `{ x, y, z }` box at scale 1, or nil for an unknown look;
- `release(h)` parks a visual hidden for reuse, and `acquire(prefab, opts?)` takes a parked one of that look back, or builds a new one. `poolSize()` and `clearPool()` look after the pool, which holds up to `POOL_PER_LOOK` (16) per look and `POOL_MAX` (96) in all.
`pickupsKit.logic` holds the rules as pure functions you can reuse or test: `canClaim`, `applyClaim`, `settle`, `addCount`, `spelled`, `rollMystery`, `burstVelocity`, `stepCoin`, `magnetStep`, `blinkVisible`, `versionAtLeast` and more. `pickupsKit.catalog` is the raw data: `LOOKS`, `BY_ID`, `IDS`, `SIZES`, `REQUIRES`, `ALIASES` (old id to new name), `OLD_ID` (new name to old id) and `canonical(id)`, which turns any old id into the current name.
## How multiplayer works
- **The owner decides.** Every pickup is a synced entity, and the engine that owns it (the host, for scene pickups) is the only one that decides who gets it. Each engine runs the touch test for the players and collectors it moves, then asks the owner. The owner checks that the pickup is still available to that player and that the player is really within reach, then marks it taken in the same step, so when two players touch the same coin the first claim to arrive wins and it is counted once.
- **Entities travel as entities.** The kit never relies on an entity's id string matching on another engine: pickups, chests and players go over the network as entity references, which each engine receives as its own, and become local ids on arrival. As a safeguard the kit also sends each entity's sync id, which every engine shares, and can resolve an entity from it if a reference ever arrives as raw data. (Before 2.2.0 the kit sent id strings, so a claim from a player who wasn't hosting named a pickup the host didn't know, and none of their pickups did anything.)
- **One announcement per collect.** The owner announces the result as one event with an id. Every engine records the power-ups and plays the pop, and the engine of the player who took it applies their heal, shield, speed and jump, exactly once per event.
- **Every message counts once.** Every kit message carries an id of its own, and a repeat is dropped, so a message that reaches an engine twice is handled once. After a live edit of the kit, the old copy's handlers stay registered (#19348), so they check that their copy is still the current one and stand down otherwise; an edit never leaves two copies answering.
- **The counts live in one place.** Counters and letters live in the synced `PickupLedger`, and only its owner writes it. The host makes it. When the player who started play leaves, nobody counts as host any more (#19347), so then the lowest-numbered player in the play room makes it after `Pickups.LEDGER_FALLBACK_S` (2.5) seconds. As a safeguard, a moment after a new player appears in the play room, the ledger's owner makes it again with the same counts (`Pickups.LEDGER_REPUBLISH_S`, 1 second), so a late joiner always receives a fresh one. If two ledgers ever meet, every engine keeps the one with the lower sync id, and the other's counts are folded into it.
- **Shared or per-player.** A `shared` pickup has one `available` flag. A `perPlayer` pickup keeps a synced `taken` map of who has had it, so each player has their own copy.
- **Settings are synced.** `prefab` and the other settings are written by the pickup's owner and sync to every engine. From any other engine, `setPrefab` asks the owner to make the change.
**Seen with two players.** 2.2.0 and 2.2.1 were played with two engines in one play session, the host and a player who wasn't hosting:
- Each kind of pickup was dropped at the other player's feet, and their own engine picked it up, sending the pickup and their player as entity references.
- Coins, keys, a letter and XP were counted once under their name in the host's ledger.
- Heals and shields reach the player who isn't hosting: a heart healed them 20 → 45, and a shield cell took their shield 0 → 30.
- Speed and jump took their controller from 2.5 to 4 and from 5 to 7.75.
- With damage, their hit on the host counted double. With invincible, 30 damage to them changed nothing.
- With the magnet, a pickup out of reach flew to them.
- One coin under both players was taken exactly once.
- When the host left play, the other engine took over the pickups and kept deciding them.
- `prefab = "pixelGem"` written by a coin's owner reached the other engine, and `setPrefab` called by the player who wasn't hosting changed a pickup the host owned. Old names worked the same way.
- With the other engine hosting, a player joining late read everyone's counts as they were: the host's player had 2 coins, 1 key, 10 XP and the letter Z, exactly as before the join.
- A coin taken from the host's gallery disappeared on the player's own engine, came back when the host respawned it, and played its pop and sound there.
- On a per-player gem, the player took their own copy while the host's player kept theirs, and their copy came back on its own timer.
- A blinking coin's expiry, decided by the host, reached the other engine.
- Walking up to a chest the host owned opened it, and all 12 burst coins appeared on both engines.
- The counter on the screen of the player who wasn't hosting showed their own counts from the host's ledger (7 coins, 3 gems, 1 key, 10 XP, Z), with both players listed.
- **In a web browser.** The same check, with the person's browser hosting (`platform = "browser"`) and an editor as the other player, passed 12 of 12 for the browser player: coins, keys, a letter and XP counted once under their name; a heart healed them 60 → 85; a shield cell took their shield 0 → 30; speed 2.5 → 4 and jump 5 → 7.75; their hit counted double with damage; invincible stopped 30 damage; the magnet pulled a pickup to them; one coin under both players was taken once. SecondWind and juiceKit both loaded in the browser from the copies that come with pickupsKit.
**How it is tested.** One engine can't be two real players (#18688, #19114), so the tests play both ends of the network through the same handlers the relay runs (`_deliver`). As the host: the other player's claims for every kind of pickup, naming the pickup and their avatar by entity, are each decided once, counted under their key, refused the second time, and announced with entity references. As the other player's engine: the host's announcements heal and shield their own player on the next frame, boost speed and jump on their CharacterController, double their damage, make them invincible and give them a magnet. The "two players" test runs 20 coins and a heart through two players whose claims arrive early, late and twice; every coin is taken once.
**For testing your own game's netcode** (internal, not a stable API): `_deliver(channel, fromPeer, ...)` runs one of the kit's network handlers (`pk:claim`, `pk:got`, `pk:ledger`, …) as if a message had arrived from `fromPeer`. `_tap(fn)` calls `fn(channel, ...)` for everything this engine broadcasts and returns an unsubscribe function.
## Performance in the browser
The kit is built to keep a busy level smooth in a web browser:
- **No pickup shadows by default.** Turning them off cut the gallery's sun shadow drawing from about 4.9 ms to 2.2 ms a frame and its frame spikes from 29 to 3. Use `castShadows` only where it matters.
- **Visuals are reused.** A pickup that is removed or changes look parks its model, and the next pickup of that look takes it back instead of building a new one. Repeat bursts and look switches cost a fraction of a fresh build.
- **First builds are spread out.** In play, pickups appearing for the first time build at most `Pickups.BUILD_BUDGET_MS` (3 ms) of models a frame, and always at least one. A wave of spawns therefore takes a few frames instead of stalling one. A prefab or letter change never waits.
- **Labels: show and hide, don't rewrite.** Changing a `Text3D` label's text redraws it, about 5 ms each time. For text that cycles through known values, make one label per value and show the one you need. Showing or hiding a label costs about 0.01 ms. The gallery's look-switch label works this way.
## Known limits
Engine problems that still affect the kit, each with its GitHub issue:
- **#19199: your own scripts can't reach a kit that came in as a dependency.** SecondWind and juiceKit arrive with pickupsKit, but `require("SecondWind.api")` in your own code answers `could not resolve require`. Go through the kit: `Pickups.health()`, `Pickups.healthParts()` and `Pickups.juice()`. Installing SecondWind or juiceKit yourself as well also works.
- **#19339: the editor's checker flags the kit's SecondWind lookups.** Opening `pickupsKit.api` shows `asset reference "SecondWind.Rules" could not be resolved` (and `Vitals`, `HUD`, `Trap`). They are warnings only: the lookups resolve when the game runs, and nothing blocks publishing.
- **#19340: a setting with a fixed list of choices can't take old names.** The `prefab` list holds only the new names, so writing an old id such as `cartoon_coin` straight into `prefab` is refused. Old ids go through `look`, `spawn`, `setPrefab`, `setLook` and `burst`. The editor's list for `look` shows the old ids after the new names.
- **#19260: the bundled Lifeline and MatchFlow copies resolve some of their own parts at older versions, which land as lone files that don't compile.** Lifeline's own health part was one of them: in a fresh install, `HK.attach` gave a player no health, so hearts couldn't heal anyone. pickupsKit fixes this for you: when it loads SecondWind it checks Lifeline's health, projectile and spawn-point parts and points any lone old copy at the complete one that ships with the install (the log says `pointed Lifeline at its complete …`). Always go through `Pickups.health()` before `HK.attach`, as the examples do. Lines such as `component source failed to load … versions.…` or `needs version … skipped as stale` can still appear in the log for Lifeline's zones and projectile and MatchFlow's match component, which pickups don't use.
- **#18688 and #19114: one engine can't be two players.** The loopback test mode doesn't echo messages (#18688), and an agent working inside the engine has no way to bring in a second player (#19114). The kit's own tests therefore drive both ends of the network through `_deliver`, and a real two-player check needs a second engine, as in "Seen with two players" above.
- **#19347: nobody is host once the player who started play leaves.** The host role stays with whoever started the play session, even after they leave, so code waiting for the host never runs. The kit works around it for its counts: when nobody is host, the lowest-numbered player in the play room makes the ledger. Your own host-only code (spawning enemies, starting a round) needs the same care.
- **#19348: a message handler can't be removed, and old ones keep running after a live edit.** Each time the kit's code is edited while the game runs, the previous copy's handlers stay registered. The kit's old handlers check that they are still current and do nothing otherwise, so live edits are safe. A full reload of play clears them.
## Showcase and tests
The world this package lives in, "Treasure Trove", opens on a gallery. It is set up exactly as a one-install world: pickupsKit in `/source`, and the kits it builds on only as its dependencies. It has every look, labelled and grouped by style, and a station for each behaviour:
- a respawning coin;
- blink and vanish;
- a coin burst from a chest;
- a magnet line;
- a shared pickup and a per-player one side by side;
- hearts and shields beside a lava trap;
- power-ups;
- spelling a word;
- a coin that cycles through prefabs, the pixel gem among them, by setting its `prefab` exactly as "Switching a look" above describes.
Every look's label shows its main `prefab` name, the one you type to use it.
`pickupsKit.pickupskit` is the test suite. It covers every public call of the api, logic, catalog, sounds, meshes, visual and fonts, the new names and every old id, `prefab` and `look`, the player who isn't hosting, and checks this README against the code: the version heading, the kit versions above, a row with style, kind, effect and size for every look, the quick start (short, compiles, real names) and this Known limits list. Run it with `tools.use("tests", "run", { suite = "pickupskit" })`.
## Version
2.3.0, which matches `package.yaml` (`pickupsKit.catalog.VERSION`, checked by the tests).
- 2.3.0:
- **`heart`, `speedOrb`, `damageOrb`, `jumpOrb`, `xpOrb` and `ammoCrate` are main names** (they were `redHeart`, `speedCore`, `damageCore`, `jumpCore`, `xpShard` and `ammoBox`, which still work, as do the pre-2.2 ids). `looks()` and `look()` list every other name a look answers to as `otherNames`; `catalog.OTHER_NAMES` holds them.
- **Gallery:** each label shows the look's main name; the look switcher sets the coin's `prefab`, as the README says to; the gallery scene saves with nothing pending, so clearing and reloading it gives every pickup back.
- 2.2.1:
- **Played with two players, start to finish.** Every behaviour in "Seen with two players" was watched across two engines, with each engine taking a turn as host. That covers every pickup kind for the player who isn't hosting, per-player copies, late joiners, respawns, expiries, bursts and chests, the pop and sound, the counter, and `prefab` switches.
- **`collect` events carry `prefab`** as well as `look` (the README's own example read `ev.prefab`, which was missing).
- **Who can write `prefab`:** the README says it plainly. The pickup's owner writes the setting, and `setPrefab` works from any engine.
- **Known limits** now name only problems that still exist, each with its GitHub issue.
- **Heals and shields reach a player in a web browser** (played 12 of 12, see "Seen with two players"). Messages also carry each entity's sync id as a safeguard.
- **Hearts heal in a fresh install.** Lifeline's own health part resolved to a lone old-version copy that doesn't compile (#19260), so `HK.attach` gave no health. pickupsKit now points Lifeline at the complete copy when it loads SecondWind. Lifeline is now a direct dependency of pickupsKit, at the same 1.0.1.
- **Every message counts once.** A live edit of the kit left the old copy's handlers answering too (#19348). Messages now carry ids, repeats are dropped, old handlers stand down, and a heal or shield is applied exactly once per event.
- **The counts ledger always exists, for everyone.** It was made only by the host, and nobody counts as host once the player who started play leaves (#19347), so a session could count nothing. The lowest-numbered player in the play room now makes it when nobody is host, its owner makes it again when a new player joins, and two ledgers fold into one.
- **`peerReport()` and `selfReport()`** read every engine's view of the kit from yours.
- **Tests:** 85, including entities that arrive as raw data, a heal heard twice, repeated messages, the ledger election, folding two ledgers and making the ledger again for a joining player.
- **Played in a web browser:** every pickup kind, 12 of 12, for a browser player (see "Seen with two players").
- 2.2.0:
- **Pickups work for every player, not just the host.** The kit sent pickup and player ids as text, but every engine has its own ids for the same entity, so a claim from a player who wasn't hosting named nothing the host knew. Now pickups, chests and players cross the network as entity references. Coins, keys, letters, XP, hearts, shields and every power-up reach whoever touched them; a coin is still only ever taken once.
- **`prefab` is the setting**, with a short name for every look (`goldCoin`, `pixelGem`, `speedCore`, `healingPotion`, `brassKey`...). `look` and every old id (`cartoon_coin`, `real_gold_coin`...) keep working. Added `setPrefab`, `oldId` on `looks()`, `catalog.OLD_ID` and `catalog.PREFAB_IDS`; the chest takes `prefab` too.
- **One install.** Installing pickupsKit now brings SecondWind 1.2.0, juiceKit 1.2.2, Lifeline 1.0.1 and MatchFlow 1.0.0 with it, and the kit uses them. Added `healthParts()` for SecondWind's components; your own code reaches SecondWind through `Pickups.health()`.
- **Tests:** 77, including the new names, `prefab` and `look`, the player who isn't hosting, and walking up to a chest.
- **Chests open when you walk up to them anywhere.** Since 1.1.0 the walk-up check measured from the middle of the map, so a chest away from it only opened from code.
- **Showcase:** set up as a one-install world, every pickup by its new name.
- 2.1.0: pickups load without waiting for SecondWind or juiceKit; SecondWind 1.2.0; heals and shields heard in a message apply on the next frame.
- 2.0.0: renamed to pickupsKit (from pickupKit, same guid); `pixelGem`; five new looks; size tags; dependency versions; remote pops on the next frame.
- 1.1.1: works with SecondWind 1.1.1; passing a Pickup component no longer logs.
- 1.1.0: performance in the browser: pickup shadows off by default, pooled visuals, a per-frame build budget.
- 1.0.1: a two-player test of 20 coins and a heart.
- 1.0.0: first release, as pickupKit.