pickupsKit
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.
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,xpOrbandammoCrateare mainprefabnames now, besidegoldCoin,pixelGemand 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 asotherNames. - 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.
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.
-- 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):
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:
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:
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:
PickupChestopens when a player comes withinradiusand spillscountcoins of anyprefab(lookis its older name there too). It closes again aftercooldownseconds, or stays open ifcooldownis 0. It draws its own chest model; setshowModel = falseto use yours.PickupHUDis the on-screen counter, withstyleset 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.
PickupLedgeris 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.prefabnames the look (settings.lookis 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)(alsosetLook),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, andacquire(prefab, opts?)takes a parked one of that look back, or builds a new one.poolSize()andclearPool()look after the pool, which holds up toPOOL_PER_LOOK(16) per look andPOOL_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 afterPickups.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
sharedpickup has oneavailableflag. AperPlayerpickup keeps a syncedtakenmap of who has had it, so each player has their own copy. - Settings are synced.
prefaband the other settings are written by the pickup's owner and sync to every engine. From any other engine,setPrefabasks 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, andsetPrefabcalled 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
castShadowsonly 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
Text3Dlabel'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 answerscould not resolve require. Go through the kit:Pickups.health(),Pickups.healthParts()andPickups.juice(). Installing SecondWind or juiceKit yourself as well also works. - #19339: the editor's checker flags the kit's SecondWind lookups. Opening
pickupsKit.apishowsasset reference "SecondWind.Rules" could not be resolved(andVitals,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
prefablist holds only the new names, so writing an old id such ascartoon_coinstraight intoprefabis refused. Old ids go throughlook,spawn,setPrefab,setLookandburst. The editor's list forlookshows 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.attachgave 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 sayspointed Lifeline at its complete …). Always go throughPickups.health()beforeHK.attach, as the examples do. Lines such ascomponent source failed to load … versions.…orneeds version … skipped as stalecan 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
prefabexactly 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,xpOrbandammoCrateare main names (they wereredHeart,speedCore,damageCore,jumpCore,xpShardandammoBox, which still work, as do the pre-2.2 ids).looks()andlook()list every other name a look answers to asotherNames;catalog.OTHER_NAMESholds 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
prefabswitches. collectevents carryprefabas well aslook(the README's own example readev.prefab, which was missing).- Who can write
prefab: the README says it plainly. The pickup's owner writes the setting, andsetPrefabworks 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.attachgave 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()andselfReport()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").
- 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
- 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.
prefabis the setting, with a short name for every look (goldCoin,pixelGem,speedCore,healingPotion,brassKey...).lookand every old id (cartoon_coin,real_gold_coin...) keep working. AddedsetPrefab,oldIdonlooks(),catalog.OLD_IDandcatalog.PREFAB_IDS; the chest takesprefabtoo.- 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 throughPickups.health(). - Tests: 77, including the new names,
prefabandlook, 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.
Scoped to this part · feeds back into the world's score.