interactKit
Version 1.3.2** — everything a player can walk up to and use: doors, switches, locks, containers, a barricade and invisible trigger zones, with "Press E to open" prompts that work on keyboard, gamepad and a phone's touch screen. For adventure, horror, puzzle and shooter games, an…
interactKit
Version 1.3.2 — everything a player can walk up to and use: doors, switches, locks, containers, a barricade and invisible trigger zones, with "Press E to open" prompts that work on keyboard, gamepad and a phone's touch screen. For adventure, horror, puzzle and shooter games, and multiplayer from the ground up.
- Interactable — put it on anything. A prompt appears when the player is close enough (
trigger = "proximity") or looking at it (trigger = "look") withinrange.holdTime = 0is a quick press; above 0 the player holds while a fill-up runs. One interact button everywhere: E, the gamepad's west face button (X on Xbox, square on PlayStation, Y on Switch), or the on-screen Interact button on a phone. - TriggerVolume — an invisible box or sphere that reports
enter,exitandstayfor every player, and can open, close or toggle what it is wired to. - 7 doors, 7 switches, 3 locks, 4 containers, a barricade, 5 prompt styles, 3 looks — listed below.
Everything works the same way: a kind field picks the variant, a look field picks the style, targets wires it to other things by name, and one field picks the prompt style. Swapping a sliding door for a portcullis is changing kind = "sliding" to kind = "portcullis".
Install
Search ZeroMind for interactKit and install interactKit.package (or zero zeromind installAsset <guid> in the engine shell). It lands at /source/interactKit.package and brings its two dependencies with it, controlsKit and juiceKit (under /source/deps/). Keys, keycards, coins and gems are interactKit's own, so nothing else is needed.
Reach interactKit by its package name, require("~interactKit.api"), and its components with Interact.component("Door"): both work wherever the package is installed, at the top of /source or under deps/ when another kit depends on it.
Quick start
A new world's scenes/main.scene/build.luau already has a ground and a player. Add these lines at the end of its content() function:
-- ── interactKit quick start ─────────────────────────────────────────
local Interact = require("~interactKit.api")
-- a door in a short wall, and a lever beside it that opens it
Interact.spawn("door.hinged", { 0, 0, 6 }, { name = "FrontDoor", synced = true, yaw = 180,
look = "wood", fields = { wall = 1.5 } })
Interact.spawn("switch.lever", { 2.6, 0, 5 }, { synced = true, yaw = 180, look = "wood",
fields = { targets = { "FrontDoor" } } })
-- a locked chest, and the key that opens it (each player picks up their own)
Interact.spawn("container.chest", { -3, 0, 6 }, { synced = true, yaw = 180, look = "cartoon",
fields = { loot = "coin", lootCount = 8 }, lock = { kind = "key" } })
Interact.spawnKey({ -3, 0, 3.5 })
Press Play. Walk to the lever on the right and press E: the door swings open and you can walk through. The chest on the left says Locked — Needs a key; walk over the key, come back, and it says Unlock. Press E and coins spill out.
synced = true makes each thing one shared object, so every player sees the same door. Without it every player gets their own copy.
No code at all: add interactKit.Door (or Switch, Container, Barricade, TriggerVolume, Interactable, Lock) to an entity in the editor and set kind, look and targets in the inspector. A device works on its own; add an Interactable beside it only to change its prompt (range, look-at, hold time, style).
The gallery scene (interactKit.gallery) has every kind, look and prompt style laid out, labelled and usable.
Styles
Doors — interactKit.Door, kind =
| Kind | What it is |
|---|---|
hinged | A single door swinging on side hinges. |
double | Two leaves swinging apart from the middle. |
sliding | A barn or shop door rolling sideways on a rail. |
garage | Slats rolling up into a drum over a wide opening. |
portcullis | An iron grid rising into a stone gatehouse. |
bookcase | A full bookcase that swings away to reveal a passage. |
airlock | Split halves sliding into the walls, with a red / green status light. |
Moving parts carry their colliders: an open door is a doorway you walk through, a closed one is a wall.
Switches — interactKit.Switch, kind =
| Kind | How it works | Tells its targets |
|---|---|---|
lever | Press to throw on / off. | follow (open while on) |
button | Press; it pops back up. | toggle on every press |
plate | On while any player stands on it. No button. | follow |
keypad | Press, type code on the pad. | follow |
valve | Hold to spin the wheel a full turn; hold again to close. | follow |
crank | Hold to wind; let go early and it unwinds. | follow |
chain | Press to pull. | toggle on every pull |
Locks — interactKit.Lock, kind =
| Kind | Opens for |
|---|---|
key | The player holding a key (Interact.spawnKey(pos)). Drawn as a padlock. |
keycard | The player holding a keycard of the lock's color — red, blue, green, yellow, purple or white (Interact.spawnKey(pos, { kind = "keycard", color = "red" })). A red card opens red locks and not blue ones. Drawn as a card reader in that colour. |
combination | Whoever dials code (digits). Drawn as a dial. |
A Lock sits beside any Door, Container or Switch on the same entity.
Containers — interactKit.Container, kind =
chest (lid swings up), crate (lid pops off), locker (door swings open), barrel (lid flips). The first open spills lootCount items of loot on the ground in front: coin, gem, key or keycard, with a colour as gem:red or keycard:blue (older pickupsKit look names such as fantasy_ruby are read as the nearest kind). Each piece goes to the first player to walk over it.
Items — interactKit.Item, kind =
| Kind | Gives | |
|---|---|---|
key | keys | Opens key locks for the player holding it. |
keycard | keycard_<color> | Red, blue, green, yellow, purple or white; opens keycard locks of that colour. |
coin | coins | What containers spill by default. |
gem | gems | A cut gem in any key colour. |
Walk over one to pick it up. perPlayer (the default for keys) means every player takes their own copy, and each sees only their own disappear; shared items go to whoever is first. item gives a different item name, amount more than one, respawn brings it back. Every player's counts are kept in interactKit's own inventory, shared by the host, and shown in a small strip in the top-left corner of each player's screen once they carry something.
Barricade — interactKit.Barricade
Hold to nail boards across an opening one at a time; once full, hold to tear them down. It blocks the way while any board is up.
Prompt styles — promptStyle =
| Style | What it looks like |
|---|---|
glyph (default) | The interact button as it looks on the device in hand — E keycap, Xbox X, PlayStation square, Switch Y, generic pad, or a TAP / HOLD badge on a phone — with the action beside it. |
ring | A radial ring around the button that fills while holding. |
tooltip | A small card floating over the object: its name, the button and the action. |
dot | A small dot on every usable thing nearby that grows into the button when focused. |
pixel | A chunky pixel-art box with a pixel glyph and a segmented hold bar. |
The whole world, one field: put interactKit.InteractSettings on any one entity and set its promptStyle (from code: Interact.setPromptStyle("ring")). One object, one field: promptStyle on its Interactable; default follows the world setting. Both apply at once, also while playing.
Looks — look =
| Look | What it looks like |
|---|---|
wood | Old wood and iron: dark planks, black straps, brass fittings. |
scifi | Sci-fi metal: brushed steel panels with glowing cyan trim. |
cartoon | Chunky bright plastic in candy colours. |
Every door, switch, lock badge, container and the barricade comes in all three.
Wiring without code
Every Switch, Door, Container, Barricade, TriggerVolume and plain Interactable has targets: a list of entity names (or ids). What they are told:
| Source | Sends |
|---|---|
| Lever, plate, keypad, valve, crank | follow (open while on, close while off). Set action to invert, toggle, open, close, unlock or lock to change it. |
| Button, chain | toggle on every press / pull |
| TriggerVolume | enterAction when the first player enters, exitAction when the last leaves (once = only the first time) |
| Door, Container | follow: opens its targets with it |
| Barricade | close when the first board goes up, open when the last comes down |
| Interactable | its action when used |
Doors and containers take open, close, toggle; switches take on / open, off / close, toggle; anything with a Lock takes unlock and lock. playerOperable = false on a door means only wiring moves it (a portcullis behind a crank).
API
local Interact = require("~interactKit.api") — the stable public surface, together with the components below. The other modules in the package are internal.
Constants
| Name | Value |
|---|---|
Interact.VERSION | "1.3.2" (matches package.yaml and this README) |
Interact.PROMPT_STYLES | { "glyph", "ring", "tooltip", "dot", "pixel" } |
Interact.LOOKS | { "wood", "scifi", "cartoon" } |
Interact.ACTIONS | { "toggle", "open", "close", "follow", "invert", "unlock", "lock" } |
Building
| Call | Does |
|---|---|
spawn(id, pos, opts?) -> entity | Spawns any catalog kind ("door.airlock", "switch.valve", "container.chest", "barricade.boards", "core.trigger"...). opts: name, synced (true for shared state), yaw (degrees), look, fields (the component's fields), lock (Lock fields, adds a Lock), interact (Interactable fields, or false for none). Errors on an unknown id. |
spawnKey(pos, opts?) -> entity | A key or keycard players pick up by walking over it (an interactKit.Item), counted per player. opts: kind ("key" / "keycard"), color (keycards), look (wood, scifi, cartoon), perPlayer (default true: every player picks up their own), respawn, respawnTime, name, synced (default true). Same as spawn("item.key", pos, ...). |
catalog(filter?) -> { entry } | Every kind: { id, family, kind, name, component, description, tags, looks, hold, wireable }. Filters combine: { family = "door", tag = "scifi", look = "cartoon", hold = true, component = "Switch" }. Copies. |
kinds(family) -> { string } | The kinds of "door", "switch", "lock", "container", "barricade", "prompt" or "core". |
tags() -> { string } | Every tag in use, sorted. |
Using and wiring
| Call | Does |
|---|---|
use(target, args?, cb?) | Uses target (entity, id or name) as the local player would. args: code (keypads, combinations), expect (the state you saw), scripted = true (skip the reach check, for cutscenes and scripts). cb(ok, reason) gets the owner's answer. Errors on an unknown target. |
signal(target, action, cb?) | Sends a wiring action (open, close, toggle, on, off, unlock, lock) as a switch would. |
fire(sourceId, targets, action, on, peer?) | Sends action to every target in the list; follow / invert become open or close from on. Never sends to sourceId itself. |
idOf(target) -> id? / resolve(target) -> id? | An entity, id or name to an entity id, or nil. |
component(name) -> type | interactKit's component type by short name — Interactable, TriggerVolume, Door, Switch, Lock, Container, Barricade, InteractSettings, InteractHUD — for e.component.add(Interact.component("Door"), { kind = "airlock" }), get and has. Errors on an unknown name. |
Events
| Call | Does |
|---|---|
on(target, name, fn) -> unsubscribe | fn(ev, entityId) on every peer. target is an entity, id, name, or "*" for all. Events: use, open, close, on, off, unlock, lock, denied (ev.reason), loot, board, unboard, press, pull, enter, exit, stay (ev.dt), holder. ev.peer is the player's peer id where there is one. |
emit(entityId, name, data?) | Fires an event on this peer only (for your own devices). |
Reading state
| Call | Does |
|---|---|
statusOf(id, peer) -> table | What the prompt says for that player: label, hint, available, state, locked, busy, needsCode, codeLength, progress. |
settingsOf(id) -> table? | The merged prompt settings: label, trigger, range, holdTime, promptStyle, promptOffset, active, exclusive. |
usable() -> { id } | Every usable thing this peer knows. |
device(target) -> record? / lock(target) -> record? | The registered device or lock behind an entity. |
Settings
| Call | Does |
|---|---|
setPromptStyle(style) / promptStyle() | The world's prompt style (what InteractSettings.promptStyle sets). Errors on an unknown style. |
setShowLabels(on) / showLabels() | Action text beside the button. |
setEffects(on) | juiceKit feedback on open / close. |
Keys, keycards and effects
| Call | Does |
|---|---|
count(peer, item) -> number / give(peer, item, n) -> boolean | A player's items: keys, keycard_red, coins, ... in interactKit's inventory (n may be negative; never below 0). A give on a peer that does not own the inventory is sent to its owner. |
items(peer) -> table | Everything that player carries: { keys = 1, coins = 8 } (a copy). |
keyOf(peer) -> string | The key a player's counts are kept under ("p<peer>"). |
setInventory({ count = fn(peer, item), give = fn(peer, item, n), counts = fn(peer)? } or nil) | Use your own inventory for locks instead of interactKit's; nil goes back. |
juice() | juiceKit's Juice module, or nil and why not. |
fx(preset, at, opts?) | Plays a juiceKit preset on this peer only. |
Already counting keys somewhere else (your own inventory, or pickupsKit installed at the top of your world)? Point the locks at it:
local Pickups = require("~pickupsKit.api") -- your world's own copy
Interact.setInventory({
count = function(peer, item) return Pickups.get("p" .. peer, item) end,
give = function(peer, item, n) Pickups.add("p" .. peer, item, n) end,
})
Your own devices
Make anything a usable device with the same prompts, networking and locks:
Interact.registerDevice(entity.id, {
family = "custom", kind = "bell", comp = myComponentPublic,
defaults = { label = "Ring", range = 3 },
status = function(peer) return { label = "Ring the bell" } end,
handle = function(peer, verb, args) -- runs on the owner
if verb == "use" then myComponentPublic.rung += 1; return true end
return false, "unknown verb"
end,
signal = function(action, args) return true end, -- optional: wiring
})
unregisterDevice(id, record), registerInteractable(id, comp) / unregisterInteractable(id, comp), registerLock(id, { isLocked, setLocked, describe(peer), tryUnlock(peer, args) }) / unregisterLock(id, record). ensureHud() makes sure this peer's prompt HUD exists in play (devices call it for you).
Components
| Component | Fields (state fields are written by the owner) |
|---|---|
Interactable | label, title, trigger (proximity / look), range, holdTime, promptStyle, promptOffset, active, exclusive, targets, action; event used |
TriggerVolume | size, shape (box / sphere), targets, enterAction, exitAction, once, showInEditor; state count, fired; events entered, exited |
Door | kind, look, width, height, wall, speed, autoClose, playerOperable, active, targets; state open |
Switch | kind, look, targets, action, code, once, holdTime, active; state on, progress |
Lock | kind, color, item, code, consume, relock, openOnUnlock, badge; state locked |
Container | kind, look, loot, lootCount, lootItem, refill, autoClose, active, targets; state open, looted |
Barricade | look, width, height, wall, maxBoards, secondsPerBoard, active, targets; state boards, tearing, work |
Item | kind (key / keycard / coin / gem), color, look, item, amount, perPlayer, respawn, respawnTime, radius, removeWhenTaken, active; state available, taken; event collect (ev.peer, ev.item, ev.amount) |
InteractSettings | promptStyle (the whole world's), showLabels, effects |
InteractInventory | Spawned for you in play by the host: every player's counts (counts), written by its owner. |
InteractHUD | Spawned for you on each player's machine: prompts, hold fill-ups, messages, code pad. |
Set a state field in the editor for the starting state (a door that starts open, a barricade that starts boarded).
Multiplayer
- The owner decides. Any player may ask; the peer that owns the entity (the host, for scene objects) checks reach, the lock, and that the thing is still in the state the player saw, then writes the shared state. Every player animates from it, so all see the same door, lever, chest or barricade, late joiners included.
- Two players, one handle. Requests carry the state the player saw, so two players pulling the same lever at once flip it once. Holds (valve, crank, barricade,
exclusivehold-to-use) are claimed by one player at a time; the other sees "In use by another player", and a claim lapses when its player goes quiet. - Keys belong to the player who picked them up. The owner checks the count of the player who asked, so a locked door opens for the player holding the key and refuses the one who isn't.
- Effects play on every machine from the shared state change; nothing extra is sent. Channels:
ik:req,ik:res,ik:evt.
Tests
tools.use("tests", "run", "interactKitTests") in edit mode — 85 tests. They cover every public call of interactKit.api (one test fails if a public call is added without a test), every kind in every look, kind swaps, wiring and every switch, per-player keys and keycards, items (per player, shared, out of reach, respawning) and the shared inventory, combination locks, loot, the barricade, trigger volumes, hold claims, simultaneous requests and remote players (stand-in bodies whose requests go through the real network handler), prompt styles and the code pad. Guard tests fail if any code reaches a dependency by folder path, adds a pickupsKit component, or times anything with CPU time, and if a taken item rebuilds or respawns its parts, reappears for the player who took it, or shows again after that player's own repeated grab.
The README quick start was played as written in a world scene holding only it: the lever opens the door, walking over the key counts it, the chest says Locked, then Unlock, and opens, and the spilled coins are collected. The gallery has been booted in a browser, where it comes up on the gallery.
Known limits
None known. A limit is listed here only when there is an open public issue a reader can look up.
How it behaves
Design choices rather than limits:
- The prompt focuses one thing at a time: looking at something in range beats being nearest to something else.
- Codes are digits only, up to 8.
- Doors swing one way, away from the side they face, and do not push players aside while closing.
- Doors, switches and the rest are built from primitive shapes. Their parts are rebuilt on every machine from
kindandlookand never saved into a scene. - In edit mode things snap to their state; they animate in play.
Changes
- 1.3.2 — Faster with many usable things. Each frame the prompt HUD merged the settings and asked the status of every usable thing in the world, however far away; with a few hundred (a farm with a prompt per plant) that cost about 5 ms a frame. It now remembers how far each thing can be in focus and skips the far ones with one distance check, so the cost follows what is near the player. Prompts, focus and ranges are unchanged.
- 1.3.1 — Fixed: picking anything up made the game heavier and heavier. A taken item rebuilt all its parts every frame on every player's machine; because they hang under a shared object, that also flooded the network, so a second player fell further behind and saw doors open late or never, and could be told "already taken" for their own key. Items now build their shape once and hide with a single change; a taken item costs a clock check per frame. The player who walks over an item sees it go at once, and their own repeated grab never brings it back. The carried-items strip re-reads the inventory a few times a second instead of every frame. Two-player checks through the kit's network handler: each player keeps their own key, the key door opens for the holder and refuses the one without, a chest two players open in the same frame opens once, and every coin goes to exactly one player.
- 1.3.0 — Fixed: in a world with only interactKit installed, walking over the quick start's key did nothing, because the key was a pickupsKit pickup and pickupsKit's components cannot load from
/source/deps/. Keys, keycards, coins and gems are now interactKit's ownItemcomponent, counted per player in interactKit's own shared inventory; containers spill Items; each player sees what they carry in a strip at the top left once they carry something. interactKit no longer depends on or names pickupsKit, so installing it brings only controlsKit and juiceKit (Interact.pickupsis gone;setInventoryconnects any other inventory). NewInteract.items, catalog familyitem. Gallery relaid out so every label reads from the spawn point, in the editor (which now opens on that view) and in play, with no label under a station's own prompt; its key and keycards are Items and the pickupsKit counter panel is gone. - 1.2.2 — Web build hardening. Every timer (hold claims, auto-close, button pop-up, messages) now runs on
getTime(), the wall clock, instead ofos.clock(), which is CPU time and drifts in the browser — and would disagree between a desktop host and a web player. controlsKit's glyphs load on first use, so a dependency still downloading in a web build cannot take the kit down; prompts draw a plain lettered keycap until it arrives. - 1.2.1 — Fixed: with only interactKit installed, its dependencies land under
/source/deps/, where the folder paths it used (@root::controlsKit.prompts,"interactKit.Door") did not reach them. Dependencies are now loaded by package name (~controlsKit.prompts) and components by resolved type (Interact.component("Door"), new). The quick start usesrequire("~interactKit.api"). - 1.2.0 —
Interact.spawnKey(keys and coloured keycards without knowing pickupsKit item names), so the quick start needs only interactKit. Full API reference. Tests cover every public call (68). - 1.1.0 —
InteractSettings: the world's prompt style in one field. Fixed a turned airlock's halves blocking its doorway; the bookcase opens to 90°. Keyboard, gamepad and touch verified for press and hold. - 1.0.0 — first release.
Dependencies
Installed with interactKit:
- controlsKit 1.0.1 — button-prompt glyphs.
- juiceKit 1.2.2 — open / close feedback.
Not a dependency any more: pickupsKit. Up to 1.2.2 interactKit used its pickups for keys and loot, but an install puts pickupsKit under /source/deps/, where its Pickup components cannot load their own code, so keys never counted in a fresh world. interactKit's own Item replaces them, and interactKit no longer names pickupsKit anywhere, so an install does not bring it along. To have pickupsKit's counters open interactKit locks in a world that has pickupsKit installed at the top, see setInventory under API.
Scoped to this part · feeds back into the world's score.