Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Locks and keys

A Lock keeps an entity shut until the player uses an item on it whose tags match the lock. Put <Lock accepts="key gold" /> inside an <Entity>: an item tagged both key and gold, used on that entity, opens it, and LockTrait’s open turns true in that step. The Lock changes nothing else. What an open lock does to the world, such as letting characters through a doorway, is a system of the game’s own, and how it looks is a view. The door, built puts all three together and is tested alone and in a room.

  • The inventory() plugin in the game’s plugins list in src/game.ts, which needs stats(), cooldowns() and behaviours() listed too. A scene that places a Lock in a game without it throws as it loads: inventory() is off in this world: add inventory() to plugins in src/game.ts.
  • An item registered with tags that the lock’s accepts names, as Inventory registers every item.
  • A call that uses the item on the locked entity: activateItem(character, { slot, target }), usually from an Interact press.

accepts is a list of tags separated by spaces. An item matches when its tags hold every one, so extra tags do no harm. A tag matches only the same string, letter for letter. An empty accepts takes every item.

The lock accepts The item’s tags Opens it
key gold ["key", "gold"] Yes
key gold ["key", "iron"] No
key gold ["key", "gold", "old"] Yes
key ["key", "iron"] Yes

An item needs no use to open a lock: its tags are enough. use: { consume: true } is what spends one copy as the lock opens. Without it, the key stays in the bag after it opens the lock.

activateItem(character, { slot, target }) asks the next step to use the stack in bag slot slot on target. The step then does one of the following:

  • The item matches a lock that is shut. The lock opens, the step emits the ItemAcceptedEvent event on the locked entity with the item’s name as item, and a consume: true item spends one copy.
  • The item matches no lock, and has no effects. Nothing happens, and the item stays in the bag. A wrong key is kept.
  • The lock is open already. An open lock refuses every item, so a second key does nothing and stays in the bag.
  • The item has effects, such as a potion. Its effects run on the character whether or not the lock takes it, and a consume: true item is spent. Use only keys on a lock.

A use also does nothing where the slot is empty, where the slot holds another item than it held at the call, and where the item is still cooling down from its cooldownSeconds.

The engine has no verb that finds the slot an item is in. A game searches the bag itself: character.get(InventoryTrait) is a list with one entry a slot, an ItemStack, { item, count }, or null for an empty slot. The door below tries every slot that holds one of the game’s keys.

There is one event and no trait for a match: ItemAcceptedEvent. A system reads it with readEvents(step, ItemAcceptedEvent), and a view hears it with useEvent(ItemAcceptedEvent, handler, { entity: door }). A game’s own kind of target, such as soil that takes a seed, is a trait with an accepts field registered with registerItemTarget, as Targets shows; it emits the same event and has no open.

A Lock runs in the room, or in the game itself when it plays alone, because it decides an outcome and spends an item. A client, the game running in one player’s browser, never opens a lock itself:

  • activateItem called on the player’s own character on a client sends the room the use, naming the target by the id the room streams it under. The client’s copy of the door from useEntity() carries that id, so it works as target in a room as it does alone.
  • The room uses the item on the target only where the target’s position stands within 1.5 m of the character’s position, its feet, measured in 3D. Past that, the room uses the item with no target, so a key does nothing and stays in the bag. 1.5 m is Interact’s default radius, so a door whose prompt shows at the default is within reach. A game played alone checks no distance.
  • The client predicts nothing. The lock shows open, and the key leaves the bag, when the room’s update arrives.

LockTrait and the door’s collider reach every client, so every player sees the same door open.

Interact’s own radius, 1.5 m by default, is measured the same way: from the entity’s position to the character’s feet, in 3D. Stand the door’s origin on the ground, as the door below does, so a character standing at the door is within reach.

A view reads the lock with useTrait(door, LockTrait)?.open, where door is useEntity() under the door’s <Entity>. Use the engine’s useTrait, from @spawnite/engine: it reads every trait, the engine’s predicted and core traits among them, where koota’s own, from koota/react, refuses those by type. For a one-off effect as the lock opens, such as a sound, hear ItemAcceptedEvent on the door with useEvent.

The player’s character comes from useCharacter(), which returns the entity the player controls, or undefined before it spawns. usePlayer().entity is the same entity, or null, beside the player’s health, id and name; a door needs only the entity.

The following file is the whole door: two keys, a key lying on the ground, a door with a lock, the press that tries the keys, and the system that clears the doorway once the lock opens.

packages/room/test/outside/Door.tsx
import { createQuery, type Entity as KootaEntity, type World } from "koota";
import {
activateItem,
box,
ColliderTrait,
definePlugin,
Entity,
Interact,
inventory,
InventoryTrait,
Lock,
LockTrait,
Loot,
readEach,
readTrait,
registerItem,
setCollider,
useCharacter,
useEntity,
useTrait,
type AuthoritativeStep,
type Position,
} from "@spawnite/engine";
/** The game's keys, by name: what a press at a door tries. */
export const keyItems = ["gold-key", "iron-key"];
/** Registers the keys, as a game's src/items.ts does once. The return
* forgets them. */
export function registerKeys() {
const forget = [
registerItem("gold-key", {
displayName: "Gold key",
tags: ["key", "gold"],
use: { consume: true },
}),
registerItem("iron-key", {
displayName: "Iron key",
tags: ["key", "iron"],
use: { consume: true },
}),
];
return () => forget.forEach((forgetItem) => forgetItem());
}
/** Uses every key the character carries on the door. The lock takes the
* first whose tags it accepts and spends it; the rest stay in the bag. */
export function tryKeys(character: KootaEntity, door: KootaEntity) {
const bag = character.get(InventoryTrait) ?? [];
bag.forEach((stack, slot) => {
if (stack && keyItems.includes(stack.item))
activateItem(character, { slot, target: door });
});
}
const locks = createQuery(LockTrait);
/** Clears the way through each door whose lock has opened: its collider
* stops meeting every layer, so nothing collides with it. */
function openDoors(world: World, step: AuthoritativeStep) {
readEach(world, locks, ([lock], door) => {
if (!lock.open) return;
const [panel] = readTrait(door, ColliderTrait)?.colliders ?? [];
const isCleared =
Array.isArray(panel?.collisionMask) &&
panel.collisionMask.length === 0;
if (panel && !isCleared) setCollider(step, door, { collisionMask: [] });
});
}
/** The doors: listed in the game's plugins beside `inventory()`. */
export const doors = definePlugin({
name: "doors",
requires: [inventory],
systems: { rules: { openDoors } },
});
/** A gold key lying on the ground, which a character takes as it walks
* over it. */
export function GoldKey({ position }: { position: Position }) {
return (
<Entity position={position}>
<Loot item="gold-key" />
</Entity>
);
}
export interface DoorProps {
position: Position;
/** The tags a key must carry, space-separated: "key gold". */
accepts: string;
}
/** A door 3 m wide that stops characters until a key it accepts opens
* its lock. Its origin stands on the ground, in the doorway's middle. */
export function Door({ position, accepts }: DoorProps) {
return (
<Entity
position={position}
collider={{
shape: box({ size: [3, 2.5, 0.3] }),
offset: [0, 1.25, 0],
}}
>
<Lock accepts={accepts} />
<DoorPanel />
</Entity>
);
}
/** The prompt and the panel the player sees, both gone once it opens. */
function DoorPanel() {
const door = useEntity();
const character = useCharacter();
const isOpen = useTrait(door, LockTrait)?.open ?? false;
if (isOpen) return null;
return (
<>
<Interact
prompt="Unlock"
onInteract={() => {
if (character) tryKeys(character, door);
}}
/>
<mesh position={[0, 1.25, 0]}>
<boxGeometry args={[3, 2.5, 0.3]} />
<meshStandardMaterial color="saddlebrown" />
</mesh>
</>
);
}

Each part does one job:

  • registerKeys registers two keys. Both carry the tag key, and the lock tells them apart by gold and iron. consume: true spends the key that opens the lock. A game registers its items once, at module scope, in src/items.ts.
  • tryKeys uses every key the character carries on the door, and the lock takes the one that fits. It tries only the names in keyItems, so a potion in the bag is never drunk on a door. It calls activateItem once for each slot, and a second key that matches finds the lock already open and stays in the bag.
  • openDoors is a system with no runsOn, so it runs in the room, and in the game itself when it plays alone. Each step it finds every lock that is open, and empties the mask of the lock’s first collider with setCollider(step, door, { collisionMask: [] }), so the collider meets no layer and nothing collides with it. The change streams to every client. The doors plugin lists it, and the game lists doors in its plugins, beside inventory().
  • GoldKey is a Loot: a character that walks within 1 m of it takes the key into its bag.
  • Door is an <Entity> with a fixed box collider 3 m wide, its origin on the ground in the middle of the doorway, and a <Lock>.
  • DoorPanel is the view. It shows the Interact prompt and the panel’s mesh while the lock is shut, and nothing once it opens. The press calls tryKeys on the client, alone and in a room alike.

A character walks only on a map’s ground, so the scene that places the door is a <World map="...">, as every scene with a character is.

Other ways to clear a doorway, and why this door does not use them:

  • A trigger collider. setCollider(step, door, { trigger: true }) turns the collider into a trigger, but a character’s walk still stops against it, so the door stays shut.
  • A teleport. teleport(step, door, { position }) moves the door’s body away at once, and its view with it. It works, but it moves the whole entity, its prompt too, to a place the scene never meant.
  • A swing with setNextPose. A kinematic body that a system turns a little each step opens over time, and pushes what stands in its way. It needs a body of kind Kinematic, a system that remembers how far the door has turned, and a call every step until it is open.
  • A collider prop from a view. A view that drops the collider when the lock opens runs on every client too, and decides an outcome from a view, which the room should decide.

The engine’s test of the door runs in Node, with the fixtures from @spawnite/engine/testing: it mounts a scene with the door headless, holds the character’s walk with holdInput, and steps the world. The following case picks up the key, opens the door, spends the key once, and walks through:

packages/room/test/outsideDoor.test.ts (part)
it("opens the door with the key the character picked up, spends that key once, and lets the character through", async ({
scene,
world,
step,
}) => {
onTestFinished(registerKeys());
const game = await scene(WithKey, world);
const character = need(world.queryFirst(ControlledTrait), "character");
const door = need(world.queryFirst(LockTrait), "door");
holdInput(game, characters.inputs.move, { x: 0, y: 1 });
step(180, game.input);
expect(countItem(character, "gold-key")).toBe(1);
expect(readZ(character)).toBeGreaterThan(doorZ);
tryKeys(character, door);
step(1, game.input);
expect(door.get(LockTrait)?.open).toBe(true);
expect(countItem(character, "gold-key")).toBe(0);
// A second key on the open door stays in the bag.
addItem(character, { item: "gold-key", count: 1 });
tryKeys(character, door);
step(1, game.input);
expect(countItem(character, "gold-key")).toBe(1);
step(120, game.input);
expect(readTrait(door, ColliderTrait)?.colliders[0]?.collisionMask).toEqual(
[],
);
expect(readZ(character)).toBeLessThan(doorZ - 1);
});

A press of Interact needs a key on a page, which a headless run has none of, so the test calls tryKeys, the function the press calls. The file’s other cases check that a character with no key stays on its side and that a wrong key stays in the bag. A second file plays the door in a real room: the client’s press opens the room’s door, spends the room’s key, and the open door and its cleared collider reach the client. The two files printed the following on 2026-10-05:

✓ |@spawnite/room| test/outsideDoor.test.ts > keeps a character with no key on its side of the door, and a press does nothing 520ms
✓ |@spawnite/room| test/outsideDoor.test.ts > keeps a wrong key in the bag and the door shut 27ms
✓ |@spawnite/room| test/outsideDoor.test.ts > opens the door with the key the character picked up, spends that key once, and lets the character through 151ms
✓ |@spawnite/room| test/outsideDoorRoom.test.ts > opens the room's door on the client's press, spends the room's key, and streams the open door back 499ms
Test Files 2 passed (2)
Tests 4 passed (4)

Every name on this page comes from @spawnite/engine. @spawnite/engine/core holds the same names without React’s components and hooks, for a file of simulation code alone, such as a plugin whose systems a room runs. Some plugins’ names are also on an entry of their own, such as @spawnite/engine/inventory. The rounds() plugin’s names are the one set found only on their own entry, @spawnite/engine/rounds. The World and Entity types come from koota, the library the engine’s entities are built on. Entity from @spawnite/engine is the component, so a file that needs both imports koota’s type under another name, as the door’s KootaEntity.

A Lock keeps nothing in the save: each time the scene loads, its locks mount shut. The bag survives a reload only where the game’s save lists inventory in include, as Saving progress says. So a game that saves the bag and spends its keys loses the key on a reload while the door shuts again, and the player cannot get through. Pick one of the following:

  • Leave consume off. The key stays in the bag and opens the door again after a reload.
  • Leave inventory out of the save. The key and the door both start over.

The key on the ground comes back each time the scene loads too, unless its Loot has an id and the save lists loot, which keeps it taken in a game played alone, as Loot says.

  • One Lock an entity, with one accepts. A door that takes either of two keys is two keys that share a tag.
  • A lock opens once and stays open while the scene runs. Nothing in the engine shuts it again.
  • The room’s reach for a use is 1.5 m whatever the Interact’s radius. A door whose prompt shows from farther away takes no key from there in a room.