Inventory
Inventory gives an entity a bag: a fixed number of slots, each empty or holding one stack of an item. You register each item once by name, give the entity <Inventory slots={20} />, and the verbs add, remove, move, split and count the stacks. The player’s character needs none in a game that lists inventory() in its plugins: the plugin gives her a bag of 16 slots, or as many as <CharacterSpawn>’s bagSlots says, and the equipment slots. The player’s save keeps both where the game’s save includes inventory, as Saving progress says; a game that does not keeps no bag across a reload. A room gives her the same bag and slots, empty each time she joins, and restores them from her save where the game includes inventory.
Its props are in InventoryProps.
An item’s definition is registered by name, as a model is, and every copy shares it:
displayNameis what a player reads.modelis the registered model a Loot and a dropped copy are drawn as.coloris the colour of the glowing gem a Loot draws when the item names nomodel. It defaults to white.maxStackis the most one slot holds. It defaults to 1, so a sword takes a slot of its own.equipsays where it is worn and what it changes while it is, as Equipment describes.usesays what using it does, as Using an item describes.tagsare the words a target matches the item by:["key", "gold"].
A copy in a bag is an ItemStack: { item, count, data? }. item is the registered name and count is how many copies the slot holds. data holds only what differs for this copy, such as a rolled modifier.
Two stacks merge only when their item and their data are equal. A copy with rolled data never stacks with a plain one, so an item needs no flag to say it is unique.
The verbs
Section titled “The verbs”All take the entity and come from @spawnite/engine/core, so a system and a component call them alike:
addItem(entity, stack)fills the stacks it can merge with first, then empty slots, each up to the max stack. It returns the count it took, which is less than asked when the bag is full, so a pickup knows what to leave on the ground.removeItem(entity, { item, count })takes up tocountcopies, whatever their data, from the first slot on, and returns the count it took.moveStack(entity, { from, to })moves a slot’s stack into another slot. Onto a stack it can merge with, it tops that stack up to the max and leaves the rest where it was. Onto any other stack, the two swap.splitStack(entity, { from, to, count })moves part of a stack into an empty slot.countItem(entity, item)counts the copies across every stack.hasItem(entity, item)says whether the bag holds at least one.declareInventory(entity, slots)gives an entity a bag, for a system that spawns one without the component. A changed count keeps the stacks in the slots that remain.
Each verb writes a new list of slots, so a reader of InventoryTrait sees each change. A count that is not a whole number above zero throws, and so does an item nobody registered.
The store
Section titled “The store”useInventory mirrors the player’s bag each frame for a HUD, as useWallet mirrors her coins. Its slots hold one entry a slot, null where the slot is empty. It reads the bag of the player’s character, and it changes only when the bag does.
useItemCount(item) reads how many of one item that bag holds, across every stack and whatever each copy’s data, as countItem counts them in a system, and re-renders only when the count changes. A HUD’s key count is <Counter icon="key" label="Keys" value={useItemCount("key")} /> in a Panel, with no sum over slots of the game’s own.
<Bag /> in a Hud is the engine’s panel over that store: a column of buttons up the right edge, one a stack, each reading its display name and count. The right edge keeps it clear of the stick a thumb steers by at the bottom left and of an ActionBar’s arc at the bottom right. Its slot stands it in another of the HUD’s regions, as a Panel’s does, such as <Bag slot={Slot.TopLeft} />. A press wears an item that has an equip and uses any other, and an empty slot draws nothing. A game that wants another look writes its own from useInventory. Under a camera that captures the cursor, as in a shooter, no click reaches the bag’s buttons, so a game shows it as a screen the player opens and frees the cursor while it shows, as every game with an inventory does: useFreeCursor(open) beside it, or a Panel with freeCursor around the game’s own bag. In development, the engine warns of a bag shown in play with nothing freeing the cursor. Roblox draws a default Backpack bar a game can turn off and replace, and Minecraft draws its own inventory screen.
Equipment
Section titled “Equipment”<Equipment /> gives an entity the engine’s equipment slots, the values of EquipmentSlot: head, body, hands, feet, ring1, ring2, neck, mainhand and offhand. The list is fixed, as Minecraft’s is, and a game leaves the slots it does not use empty.
An item that can be worn names its slot and what it changes in equip:
slotis anEquipmentSlot, or"ring"for whichever ring slot is free.modifiersholds aStatModifier’sflat,percentandmoreby stat name. A copy’s owndata.modifiersreplaces its definition’s, so a ring rolled stronger than its kind is one copy’s data.
Two verbs move an item between the bag and a slot:
equipItem(entity, from)wears the stack in bag slotfrom, and adds each modifier to the entity’s stats with the sourceequip:<slot>. What the slot wore goes into bag slotfrom, and its modifiers come off. It returns false, and changes nothing, for an empty slot, an item with noequip, or an entity without<Equipment />. It wears the whole stack, so three rings stacked in one bag slot go on as one ring, and their modifiers apply once.unequipItem(entity, slot)puts what the slot wears into the bag’s first empty slot and removes its modifiers by that source. It returns false, and the item stays on, when the bag has no empty slot.
The source is the slot’s, so two identical rings in ring1 and ring2 are two sources, and taking one off leaves the other’s modifiers. useInventory carries what the player’s character wears as equipment, by slot. Unmounting <Equipment /> removes the slots, what they wear, and the modifiers that put on the stats.
Minecraft keys a worn item’s attribute modifiers by its slot and removes them when the item leaves it. Unreal’s Lyra keeps a handle for each grant, and Unity’s common Kryzarel pattern removes by the source object. Equipment keys by slot, as Minecraft does, because a slot is a string a save and a room carry as they are, where a handle or an object is not.
import { Entity, Equipment, Inventory, Stats, registerItem,} from "@spawnite/engine";
registerItem("ring-of-speed", { displayName: "Ring of speed", equip: { slot: "ring", modifiers: { speed: { percent: 0.1 } } },});
export function Wearer() { return ( <Entity> <Stats speed={{ base: 6 }} /> <Inventory slots={20} /> <Equipment /> </Entity> );}Using an item
Section titled “Using an item”An item’s use says what using it does. Its effects are a closed set that the step runs on the server, on the entity that uses the item:
{ type: "heal", amount }addsamountto its health, never past the maximum, asdealHealingby its user, so a view of heals hears it.registerItemthrows for anamountbelow 0 or not finite, naming the item.{ type: "modify", stat, flat, percent, more, seconds }adds a stat modifier under the sourceuse:<item>. Withseconds, it is a buff that ends when they run out. A second use of the item replaces what the first put on, so a buff restarts its seconds rather than stacking.{ type: "coins", amount }paysamountcoins into its wallet, asaddCoinsdoes. The step checks every coin effect of a use before it runs the first effect, so a use whose coins the wallet could not hold throws and changes nothing.
consume: true spends one copy from the slot the use named. cooldownSeconds is how long before the same entity can use the item again. The cooldown runs in the entity’s CooldownsTrait under item: and the item’s name. Items that name the same cooldownGroup share one cooldown under that key: using one holds them all.
activateItem(entity, { slot, target }) asks the step to use the stack in bag slot slot, on target when one is named. The step runs each use before the behaviours: the effects, then the target, then the copy it spends and the cooldown. A use does nothing, and spends nothing, in any of these cases:
- The slot is empty.
- The item is still cooling down.
- The item has no effects, and no target accepts it.
The step runs a use only where the simulation has the say: a client’s world fed from a room runs none.
An effect the set lacks is the game’s own: a trait and a server system keyed on the item’s tags, as the entity page says for every consequence.
import { Button, Hud, Panel, Slot, activateItem, registerItem, useCharacter,} from "@spawnite/engine";
registerItem("potion", { displayName: "Potion of healing", maxStack: 10, use: { effects: [{ type: "heal", amount: 30 }], consume: true, cooldownSeconds: 1, },});
/** Drinks what the first slot of the player's bag holds. */export function DrinkButton() { const character = useCharacter(); return ( <Hud> <Panel slot={Slot.Bottom}> <Button onPress={() => { if (character) activateItem(character, { slot: 0 }); }} > Drink </Button> </Panel> </Hud> );}Targets
Section titled “Targets”A target says which items it takes. Its accepts is a list of tags, separated by spaces, and an item used on it matches when the item’s tags hold every one. An empty accepts takes every item, since there is no tag for the item to miss.
The engine’s own target is the Lock: <Lock accepts="key gold" /> opens when an item with both tags is used on it. Locks and keys covers it, with a door that opens on a key the player picked up, built and tested end to end.
A game’s own target is a trait with an accepts field, made a target once with registerItemTarget(trait), and a server system. When an item matches, the step emits the ItemAcceptedEvent event on the target, naming the item, and the system reads it with readEvents:
import { trait, type World } from "koota";import { ItemAcceptedEvent, readEvents, registerItemTarget, type SystemStep,} from "@spawnite/engine";
/** Soil that takes a seed, and the seed it grows. */export const SoilTrait = trait({ accepts: "seed", planted: "" });registerItemTarget(SoilTrait);
/** Plants each seed a soil took. */export function plantSeeds(_world: World, step: SystemStep) { for (const { entity: soil, record } of readEvents(step, ItemAcceptedEvent)) if (soil?.has(SoilTrait)) soil.set(SoilTrait, { planted: record.item });}Minecraft’s consumable and use_cooldown components are the model for use: a closed list of effects the server runs, a copy spent, and a cooldown by item. Stardew’s machines and Minecraft’s cow_food tag say on the target what it takes, as accepts does. Unreal’s Lyra has the target grant an ability, Roblox runs a script on each tool, and Unity and Godot leave use to code. Use takes Minecraft’s data and the target’s list of tags, because both are data an agent writes and the server runs.
Saving and replication
Section titled “Saving and replication”Where the game’s save includes inventory, the save keeps the player’s bag, one entry a slot and null where the slot is empty, and what she wears, by slot, under engine.inventory:
- The engine reads the character’s bag and her worn items there, each stack as it is:
{ item, count, data? }. An emptied bag is saved as empty, so a reload never brings back what she used. - The engine lays the saved bag and worn items on her once her spawn has set her up, so a game’s save code never names them. Her bag then has
<CharacterSpawn>’sbagSlotsslots, or more where the saved bag had more: a larger saved bag keeps its size and every stack in it, and a smaller one grows tobagSlots. - A stack whose item the game no longer registers is dropped as the save loads, with a warning in development, and the rest of the save loads.
- A worn item’s modifiers go back on her stats from the item as it is registered now, so a changed definition takes effect on the next load, and a dropped item’s modifiers go with it. The save’s
statshold theequip:<slot>modifiers too, and the load removes them before it adds the worn items’ own, so each counts once. - A game whose player has no character keeps a stack inside its own save with the engine’s codec,
itemStack.toSave(stack)anditemStack.fromSave(saved), and callsrestoreInventory(entity, inventory)andrestoreEquipment(entity, equipment)in its ownrestore. - A save with neither part loads as before.
Unity’s Opsive and Godot’s gloot save each stack as its definition’s id, its count and the values it overrides, as a save here keeps item, count and data. Unity’s Chop Chop sample skips an id it no longer knows. The load here drops one too, and says so in development, so the agent sees what a save lost. Roblox and Unreal leave the save to the game.
In a room, the room owns her bag and what she wears, and streams them to her alone:
- After each delta that changed them, the room sends her client an
ownedmessage with what changed of her character’sinventoryandequipment, and her page writes it onto her character.useInventorymirrors it there as it does alone. - Every other player’s stream carries neither, and neither counts toward the stream’s hash. Another player sees a worn item through the stats it changes, which every client receives.
- A client asks the room for a change to her bag with one of the inventory’s commands, one for each verb:
move,split,use,equip,unequipanddrop, each acting through her character. The room runs that verb on its own copy of her, in the order it took them, and its word to her alone carries what it made. - The room checks each command’s fields before it runs the verb, and the verb’s own checks refuse a slot her bag does not have.
- On her page,
moveStack,splitStack,activateItem,equipItem,unequipItemanddropItemcalled on her character send that command instead of changing her bag, so a game calls the same verbs alone and in a room. The page predicts nothing: the change shows when the room’s word to her arrives. There,equipItemandunequipItemcheck what her page holds and returntrueonce they have asked the room, which makes the change on its own copy of her, anddropItemreturns nothing. A verb called on another player’s character asks nothing, since a room changes a bag only on its owner’s word, and warns in development. A use’s target goes by its streamed id.
Unreal’s Lyra keeps the inventory on the player’s controller, which exists only on the server and its owner, and Roblox replicates a Backpack to every player. A bag here goes to its owner alone, as Lyra’s does, because what a player carries is hers.
What an agent reads
Section titled “What an agent reads”dumpState, the headless dump and the entity inspector show a bag under inventory and what an entity wears under equipment, and its hash covers them. The room’s stream to every client leaves both out, as Saving and replication says.
In the AI tree, a bag is a fact for each stack it carries, carrying Potion of healing ×3, and an action for each item that does something: use for one with a use or tags, wear for one with a slot. A worn item is a fact, wearing Ring of speed. An item goes by its display name, or by the name it was registered under where the game no longer registers it.
Where it runs
Section titled “Where it runs”Server. A bag holds what a player owns, what she wears changes her stats, and what she uses changes her health, her stats, her wallet or a lock.
What other engines do
Section titled “What other engines do”| Engine or game | Held item | Container | Stacking | A full bag |
|---|---|---|---|---|
| Roblox | a whole Tool, no count |
an unbounded Backpack | none | never full |
| Unreal, Lyra | a definition class; an instance with tag stacks | an unbounded list | never merged | never full |
| Unity, Opsive | a definition asset plus an amount | fixed slots, a list or a weight | up to 99, only when attributes are equal | returns the amount added |
| Godot, gloot | a JSON prototype plus overrides | a list, a grid or a weight | a max per prototype; equal overrides merge | returns false |
| PlayCanvas | none | none | none | none |
| Minecraft | an item type plus per-stack components | fixed slots | a max per type; equal components merge | takes what fits; the rest stays on the ground |
Inventory takes Minecraft’s shape: a definition per item, a stack that stores only what differs, fixed slots, and stacks that merge when their data is equal. Most players know that shape, and it maps onto a trait. Like Opsive’s AddItem, addItem returns what it took, so a full bag is an answer rather than an error. It leaves out a grid, a weight cap and a category filter, which no engine builds in and every game does differently.
An item takes two entries. Its definition goes in the game’s items file, which each scene that names the item imports:
registerItem("potion", { displayName: "Potion", color: "crimson", maxStack: 10,});A Loot of it lies in a scene with a <CharacterSpawn>, whose character takes it into her bag as she walks over it; the engine draws it, and <Bag /> shows it:
<Entity position={[4, 0, -2]}> <Loot item="potion" count={3} /></Entity>