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

Carrying

Carrying gives every character a stack of goods that rides on its body, and every player in the room sees it. A station moves goods between that stack and the station’s own store as a character walks in or presses at it. The world keeps custody of every good, so a disconnect, a respawn or a room that ends mid-carry loses none: the goods go to the world’s recovery store, which a station can give from. Tycoon and delivery games, such as Burger Please and its kind, play this loop with almost no key presses, so it suits a phone.

A game lists carrying() in its plugins, after behaviours(), which it requires. Everything on this page comes from @spawnite/engine/carrying, the plugin’s own entry. A good is not an inventory item: a bag holds slots a player sorts, and a stack holds goods in the order they arrived, top last.

The following game is a juice yard, the loop of a juice tycoon in small. A player presses a tree to pick limes, walks into a press with them, walks through its spout for bottles, and sells them at a counter or loads them onto a boat. The boat takes only the bottles its order lacks, and a golden lime stays golden in its bottle:

packages/engine/test/outside/juiceYardGame.ts
import {
createQuery,
defineGame,
definePlugin,
defineSystem,
defineTrait,
Phase,
readEach,
setTrait,
transact,
type AuthoritativeStep,
type Entity,
type World,
} from "@spawnite/engine/core";
import { behaviours } from "@spawnite/engine/behaviours";
import { characters } from "@spawnite/engine/characters";
import {
addCargo,
carrying,
carryingSaves,
CargoPathPattern,
CargoRefusal,
countCargo,
defineCargo,
defineCargoContainer,
removeCargo,
type CargoTransferCheckContext,
type CargoTransferContext,
} from "@spawnite/engine/carrying";
// A juice yard, Juice Island's loop in small: limes picked from a tree,
// pressed one by one into bottles, and sold at a counter or loaded onto a
// boat that takes only what its order lacks. A golden lime stays golden
// in its bottle.
/** The crate a carrier's bottles ride in, twelve to a crate, so a full
* one looks full. */
const bottleCrate = defineCargoContainer("bottle-crate", {
model: "crate",
capacity: 12,
floor: 0.15,
scale: 0.45,
});
export const lime = defineCargo("lime", { model: "lime", tags: ["fruit"] });
export const limeJuice = defineCargo("lime-juice", {
model: "bottle-lime",
tags: ["bottle"],
container: bottleCrate,
});
/** A press: its own store is the hopper, and its spout is the store of
* the entity whose `SpoutTrait` names it. Seconds a bottle takes, and
* when the next is due. */
export const PressTrait = defineTrait("yardPress", {
name: "",
seconds: 2,
readyAtSeconds: 0,
});
/** A spout: the press it pours from, by name. */
export const SpoutTrait = defineTrait("yardSpout", { press: "" });
/** A boat's order: how many bottles it still lacks. */
export const OrderTrait = defineTrait("yardOrder", { lacks: 0 });
/** The counter's till: coins taken. */
export const TillTrait = defineTrait("yardTill", { coins: 0 });
/** Coins one bottle sells for. */
export const bottlePrice = 6;
const presses = createQuery(PressTrait);
const spouts = createQuery(SpoutTrait);
/** The spout that pours from the press named `name`, or null. */
export function findSpout(world: World, name: string): Entity | null {
let found: Entity | null = null;
readEach(world, spouts, ([spout], entity) => {
if (spout.press === name) found = entity;
});
return found;
}
/** Turns the hopper's oldest lime into one bottle in the spout, every
* `seconds`, all or nothing: a full spout pauses the press with the lime
* still in its hopper. */
function pressLimes(step: AuthoritativeStep) {
const now = step.tick * step.deltaSeconds;
readEach(step.world, presses, ([press], hopper) => {
const spout = findSpout(step.world, press.name);
if (!spout || now < press.readyAtSeconds) return;
if (countCargo(hopper) === 0) return;
transact(step, () => {
const [fruit] = removeCargo(step, hopper, {
filter: { cargo: [lime] },
}).items;
if (!fruit) throw new Error("The hopper is empty.");
const bottle = addCargo(step, spout, {
cargo: limeJuice,
...(fruit.data && { data: fruit.data }),
});
if (bottle.count !== 1) throw new Error("The spout is full.");
});
setTrait(step, hopper, PressTrait, {
readyAtSeconds: now + press.seconds,
});
});
}
/** A boat takes a bottle only while its order lacks one more than the
* bottles this transfer took already, so a crate of twelve loads four
* onto a boat that lacks four. */
export function checkOrder({ to, accepted }: CargoTransferCheckContext) {
const order = to?.get(OrderTrait);
if (!order || accepted.length < order.lacks) return null;
return { refusal: CargoRefusal.Refused, detail: "The order is full" };
}
/** What a transfer into the counter or a boat does beside it, in the same
* transaction: the counter sells what it took, and a boat's order lacks
* that much less. */
export function onYardTransfer({ writer, to, items }: CargoTransferContext) {
if (to?.has(TillTrait)) {
removeCargo(writer, to, { count: items.length });
const till = to.get(TillTrait)!;
setTrait(writer, to, TillTrait, {
coins: till.coins + items.length * bottlePrice,
});
}
const order = to?.get(OrderTrait);
if (to && order)
setTrait(writer, to, OrderTrait, { lacks: order.lacks - items.length });
return null;
}
const yard = definePlugin(() => ({
name: "juice-yard",
systems: [
defineSystem({
name: "press",
phase: Phase.Rules,
description: "Presses each hopper's limes into its spout.",
run: pressLimes,
}),
],
}));
export const game = defineGame({
name: "juice-yard",
start: "yard",
worldSaves: { ...carryingSaves },
plugins: [
characters(),
behaviours(),
carrying({
kinds: [lime, limeJuice],
capacity: 12,
checkCargoTransfer: checkOrder,
onCargoTransfer: onYardTransfer,
// The guide in the yard's own blue, its path dashed, with no
// ring on the ground.
guide: {
highlight: { fillColor: "#7ee0ff" },
marker: false,
path: { color: "#7ee0ff", pattern: CargoPathPattern.Dashes },
},
}),
yard(),
],
});

The scene places the stations. The game registers the models it names and mounts the scene, as Your first game shows:

packages/engine/test/outside/JuiceYard.tsx
import {
Entity,
Hud,
useAuthorityEffect,
useEntity,
Views,
} from "@spawnite/engine";
import {
addCargo,
CargoActivation,
CargoCounters,
CargoDirection,
CargoStation,
CargoStationStore,
countCargo,
} from "@spawnite/engine/carrying";
import {
lime,
limeJuice,
OrderTrait,
PressTrait,
SpoutTrait,
TillTrait,
} from "./juiceYardGame";
// The yard's stations: a tree the player presses to pick, a press the
// character walks into with fruit, its spout the character walks through
// for bottles, a counter and a boat the character walks into to sell, and
// the crate every lost good goes to. At the top of the screen, what the
// character carries.
/** The tree's first harvest, a golden lime among them, as the world opens
* with none: a world restored from its save keeps what it held. */
function Harvest() {
const tree = useEntity();
useAuthorityEffect(
(authority) => {
if (countCargo(tree) > 0) return;
addCargo(authority, tree, [
{ cargo: lime, data: { golden: true } },
{ cargo: lime },
{ cargo: lime },
]);
},
[tree],
);
return null;
}
export function JuiceYard() {
return (
<>
<Entity key="tree" position={[0, 0, 0]} model="lime-tree">
<CargoStation
direction={CargoDirection.ToCarrier}
activation={CargoActivation.Interact}
prompt="Pick"
capacity={6}
/>
<Harvest />
</Entity>
<Entity
key="press"
position={[0, 0, 8]}
model="press"
traits={[PressTrait({ name: "press" })]}
>
<CargoStation
direction={CargoDirection.ToStore}
accepts={{ tags: ["fruit"] }}
capacity={12}
intervalSeconds={0.15}
/>
</Entity>
<Entity
key="spout"
position={[2, 0, 8]}
traits={[SpoutTrait({ press: "press" })]}
>
<CargoStation
direction={CargoDirection.ToCarrier}
capacity={12}
/>
</Entity>
<Entity
key="counter"
position={[0, 0, 16]}
model="counter"
traits={[TillTrait()]}
>
<CargoStation
direction={CargoDirection.ToStore}
accepts={{ cargo: [limeJuice] }}
/>
</Entity>
<Entity
key="boat"
position={[6, 0, 16]}
model="boat"
traits={[OrderTrait({ lacks: 4 })]}
>
<CargoStation
direction={CargoDirection.ToStore}
accepts={{ tags: ["bottle"] }}
/>
</Entity>
<Entity key="crate" position={[-4, 0, 4]} model="crate">
<CargoStation
direction={CargoDirection.ToCarrier}
store={CargoStationStore.Recovery}
/>
</Entity>
<Views>
<Hud>
<CargoCounters />
</Hud>
</Views>
</>
);
}

defineCargo(name, settings) declares a kind at module scope and returns its handle, as defineAbility does. carrying({ kinds }) installs the kinds on the room and on every page. The name is lower-case words joined by hyphens, such as lime-juice, because a save and the stream carry it. A kind takes the following settings:

  • model is the registered model a good of this kind draws as.
  • look(item) picks a registered model for one good from its data, such as a golden lime’s gold. Left out, every good draws model.
  • tags are the words a station accepts it by: ["fruit"].
  • height is the metres one good adds to a drawn stack. It defaults to the model’s measured height.
  • container is the handle of a container its goods ride in, from defineCargoContainer, as the stack says. Left out, its goods stack loose.
  • displayName is what a player reads for one good, such as “Lime juice”. It defaults to the name with hyphens as spaces and a capital first letter.
  • image is a picture of one good by URL, for the counters. Left out, the engine renders one from model.

A good, a CargoItem, is { cargo, data? }: the kind’s name, and a plain JSON object of what differs for this one good, such as { golden: true }. A write takes a CargoInput, { cargo, data? }, whose cargo is the kind’s handle, so a typo in a kind’s name is a type error. A handle the world has not installed throws in development, naming the list to add it to, and a deployed room refuses it unknown-cargo.

A filter, { cargo?, tags? }, passes a good whose kind it lists or that carries a tag it lists, and an empty filter passes every good.

Every character the game spawns is a carrier of the plugin’s capacity, 10 by default. <CharacterSpawn> sets three things for the characters one spawn makes: carryCapacity, carryMix, and carryPlacement, which moves where their stacks draw. Any entity can carry, such as a cart or a worker NPC: addCarrier(writer, entity, { capacity, mix?, placement? }) makes it a carrier. On an entity that already carries, the same call changes its settings and keeps its goods. setCarryCapacity(writer, carrier, capacity) changes the capacity alone.

A capacity lowered under what a carrier holds keeps every good, and the carrier takes nothing more until it is under the capacity. mix: CargoMix.OneKind, or carryMix={CargoMix.OneKind} on a spawn, lets a carrier hold one kind at a time, as a tray of burgers does: it refuses another kind mixed until it is empty.

CarrierTrait holds the goods in its items list. Read them with readCargo(carrier) in a system, or useCarrier(entity?) in a view, which returns { items, capacity } for the given entity or the player’s own character. Only the verbs below write the list, because each write also updates the world’s custody ledger; a development world checks the two agree each tick.

<CargoStation direction={...} /> inside an entity makes it a station. direction says which way it moves goods:

  • CargoDirection.ToCarrier gives from its store to a carrier, oldest first, such as a tree or a spout.
  • CargoDirection.ToStore takes from a carrier into its store, the top of the stack first, such as a press’s hopper or a counter. It skips goods its accepts filter does not pass.

activation says what sets it off:

  • CargoActivation.Overlap, the default, moves one good every intervalSeconds, 0.15 by default, to each carrier whose body stands within radius metres, 1.5 by default. An interval of 0 moves all that fits in one tick.
  • CargoActivation.Interact shows an Interact prompt, prompt, and moves all that fits when the player presses.

A station’s own store holds capacity goods, and without one it holds up to 10,000, the most a carrier or a store holds; the world’s recovery store has no limit. store={CargoStationStore.Recovery} gives from or takes into the world’s recovery store instead, such as a lost-and-found crate. targets says who may use it, the characters players control by default, as a target filter does. Its props are in CargoStationProps.

A carrier that stands in two stations moves with the nearest that can move a good now, so an empty or full station near it never stops a farther one. Two carriers at one station never take one good twice. A disconnected player’s character takes nothing, a carrier and a station in two matches never trade, and a station out of a carrier’s scope never reaches it.

A move that cannot happen is refused with a reason from the closed set CargoRefusal:

Refusal When
carrier-full The carrier holds its capacity.
store-full The store holds its capacity.
not-accepted Nothing offered passes the station’s accepts.
empty Nothing to move.
mixed A OneKind carrier was offered another kind.
unknown-cargo A kind this world has not installed.
refused The game’s checkCargoTransfer said no, with the words in its detail.

CargoRefusedEvent fires when a carrier’s refusal at a station changes. A HUD can show “Basket full” once as the player walks in full, and once more when the basket fills while the player stands there. Its record holds carrier, station, reason and detail. A view subscribes with useEvent(CargoRefusedEvent, ({ record }) => ...) and compares record.carrier with useCharacter(), the player’s own character.

No event fires when a refusal ends, when the carrier moves a good or walks out. The station only forgets the last refusal, so the next one fires again. A HUD shows the message for a few seconds, as a toast does. CargoMovedEvent fires for each good that moves, for the flight a page draws. It is cosmetic, so a page may miss one, and nothing a game keeps belongs in it.

Each verb takes the writer first, then the holder: a carrier, a store’s entity, or the world for its recovery store. The writes answer a CargoResult, { count, items, refusal?, detail? }:

  • addCargo(writer, holder, input | inputs) adds as many as fit. It makes goods.
  • removeCargo(writer, holder, { count?, filter? }) takes up to count, 1 by default, from the top of a stack or the oldest of a store. It consumes goods.
  • moveCargo(writer, { from, to, count?, filter?, station?, data? }) moves what fits in one transaction, and is what every station calls. It runs the game’s hooks, below. A move from a holder to itself throws. data, a function of each good, sets the data the good lands with, such as a fruit graded at its pick. checkCargoTransfer sees the good as it left, and onCargoTransfer and the move event see it as it landed.
  • returnCargo(writer, holder) sends everything a holder holds to recovery at the next tick. It throws inside transact, which could not take the goods back; moveCargo into a store can run there.
  • readCargo(holder) reads the goods, countCargo(holder, filter?) counts them, and hasCargoRoom(holder, count) says whether count more fit.
  • addCargoStore(writer, entity, { capacity? }) gives an entity a store with no station, such as a press’s spout that a system fills.

A count that is not a whole number above 0 throws, and so does a capacity that is not a whole number from 0 to 10,000, or unlimited.

carrying() takes two hooks for every transfer, a station’s and moveCargo’s alike, as building’s checkPlace and onPiecePlace do:

  • checkCargoTransfer(context) sees each good a transfer would move, and the goods of the same transfer it accepted already, before anything is written. It returns a refusal to leave that good where it is, or null to let it move. The yard’s checkOrder lets a crate of twelve load four onto a boat that lacks four.
  • onCargoTransfer(context) runs once per transfer, inside its transaction, after its goods moved. It writes what the game keeps beside the move with context.writer, such as a sale’s coins. A refusal or a throw puts the whole transfer back.

Both contexts name the transfer’s ends: from and to, each a carrier, a store’s entity, or null for recovery. carrier is the carrier on either side, and station is the station that set it off, or null for a verb’s own call. A sale that pays the player calls addCoins(context.writer, findWalletHolder(context.world, context.carrier), amount) where the carrier is not null. The yard pays a till on the counter instead.

A recipe that turns one good into another, as the press does, removes and adds inside transact, so a full spout leaves the lime in the hopper rather than losing it.

The world owns every good. When a carrier stops holding its goods, they go to the world’s recovery store, which readRecovery(world) reads and a Recovery station gives from:

What happens The goods
A player leaves, or the room closes Their carriers’ goods go to recovery.
A player disconnects Their goods go to recovery at the drop. A reconnect finds the stack empty and the crate holding them.
A character respawns Its goods go to recovery, unless returnOnRespawn: false.
A carrier is destroyed, or leaves with its level Its goods go to recovery.
A station is destroyed in play Its store’s goods go to recovery.
A station’s level unloads Nothing moves: the station’s record keeps its goods.
The room crashes The last checkpoint opens, and every carried good goes to recovery once.

A game keeps custody by listing the stores’ records: worldSaves: { ...carryingSaves }. The plugin keeps recovery and what each carrier held in its own world save, carrying.saves.custody. The two are kept together or not at all: a world that keeps one without the other refuses to be made, naming the line to change. A game whose saves keep no world, such as a match’s room, runs carrying for the room’s life alone.

A restart opens the last checkpoint the room committed, every record from one tick, with every carried good in recovery once. A good picked after that checkpoint is back on its tree, never in two places. A good of a kind the game no longer installs throws in development; a published game keeps it aside in the record until a version installs the kind again.

The plugin’s page component draws every carrier’s stack, with nothing for the game to place. A stack stands in front of the body, at a little under half its height, so the follow camera sees the character over it and a tall stack rises above the head. A body with no measured model, such as a cart, stacks from its origin. A good that moves flies along an arc from where it left to where it went in a quarter of a second. It lands on its own place in the receiving stack, at the size it draws there, and follows a carrier that runs while it flies.

Each carrier’s placement moves its stack from that anchor, in the body’s own axes: position in metres, rotation in radians and scale. Carried logs lie crosswise with carryPlacement={{ rotation: [0, Math.PI / 2, 0] }}.

A placement’s socket hangs the stack from a point on the body instead, as Roblox’s Attachment, Unreal’s sockets and Godot’s BoneAttachment3D do. The stack follows the socket’s place and turn, and position, rotation and scale move it in the socket’s axes. A socket is one of two kinds:

  • A HumanoidSocket, such as chest or right-hand, which every humanoid body holds: an avatar, a model with a rig, and the placeholder. Its axes are the character’s own at rest, y up and -z ahead, so one placement fits every body. back stands on the surface of the back at the chest’s height, as Roblox’s BodyBackAttachment does, so a basket rests on a thin rig and a thick capsule alike. Juice Island hangs its basket there with { socket: HumanoidSocket.Back, position: [0, -0.15, 0.25] }.
  • A node of the drawn model, by the name its file gives it, such as a cart’s bed, in that node’s own axes.

A body that holds no such socket draws the stack at the anchor in front, and a development page says so once. findSocket(entity, name) is the read underneath, for a view of the game’s own.

Every good’s model must have its bounds in the manifest, as spawnite add asset bakes them, so the stack can size it. A development page throws for one without; a published page stands it as a 0.2 m cube and logs a warning.

carrying({ stack }) takes three settings, and stack: false draws none, for a game that draws its own from useCarrier:

  • maxDrawn is the most goods a stack draws, 12 by default. A container left with none of its goods drawn draws nothing.
  • spacing is the metres between two goods or containers, 0.02 by default.
  • columns stands the stack in that many columns side by side across the body, 1 by default. Each good or container goes on the shortest column, so 2 halve a tall stack’s height, and the columns in use stand centred, so a lone crate stays in the middle.

A container is a model a kind’s goods ride in, such as a crate of bottles or a basket of fruit. defineCargoContainer(name, settings) declares one at module scope, and a kind takes its handle as container. Kinds that take the same handle share it, so one crate holds lime and orange juice. The yard carries its bottles in a crate. A container takes the following settings:

  • model is the registered model, an open one whose goods show. Its manifest must hold its bounds, as spawnite add asset bakes them; a development page throws for one without.
  • capacity is how many goods one container draws, and the next good opens another above it. It refuses nothing, which a carrier’s capacity does. It defaults to one layer of the container’s floor, so a full container looks full.
  • floor is the metres up the model, from its lowest point and before scale, that its goods stand on, 0 by default.
  • scale sizes the container and its goods together, 1 by default.
  • interior is the space the goods fill, seen from above, in the model’s metres before scale: { width, depth, center? }. center is its middle’s x and z in the model’s file, from the middle of the model’s bounds. Give it for a crate with handles, thick walls or an opening off its centre. By default the goods fill 85% of the model’s footprint.

The goods fill a grid over the interior, each cell as wide as the widest good it holds, layer on layer. A good wider or deeper than the interior throws in development, naming the kind and the container. Rows fill from behind the body, where a camera behind the character sees a part-full container’s goods. A container stands in the stack where its first good arrived, so a mixed carry of fruit and then bottles shows the basket under the crate.

The stack draws each model once for every carrier on the page, as one instanced batch, so its draws grow with the kinds of model carried, not the goods. On Juice Island’s dev page, twelve bottles in a crate added 12 draws where one bottle did, against 42 when each good drew its own. Twelve goods of six models added 44.

<CargoCounters /> in a <Hud> shows what the player’s character carries: one counter a kind, its picture and how many, in the order carrying({ kinds }) lists the kinds. It draws nothing while the character carries nothing. It takes slot, Slot.Top by default, order, size, a className for its panel and classNames for each counter’s parts, as Counter takes them. entity shows another carrier’s goods, such as a cart’s.

A kind’s picture is its image, or a picture the engine renders of its model in the material it registered with, once a model, after the page goes idle. Until the picture comes, and on a page that cannot render one, the kind’s displayName stands in. A game that wants its own look builds it from useCargoCounts(entity?), useCargoImage(kind) and readCargoDisplayName(kind).

While a player’s own character carries goods, the guide shows where they go, on the player’s page alone. Every station that would take one of the character’s goods now lights up, and a path on the ground leads the character to the nearest. Both go the moment the character holds nothing. A station that takes none of the character’s kinds, gives goods, keeps them from the character by its targets, or has no room for them stays dark.

The guide draws three parts, each on by default:

  • highlight washes each mesh under the station’s <Entity> with a fill that pulses, and outlines it. Put the model a player drops on under the station’s <Entity>, inside <Views>. A station that draws nothing there shows its ring alone.
  • marker lays a ring on the ground at the station’s radius, where the character’s feet must stand.
  • path follows the Landscape’s navmesh from the character’s feet to the nearest station, as a ribbon on the ground with amber chevrons running toward it. It fades in at the character’s feet and out before the station’s radius. A Landscape with no navmesh, or no way there, draws no path, since a straight line would lead the character through what stands between. The path goes round a navmesh modifier, such as a flower bed with no collider, where a way round is cheaper.

carrying({ guide }) sets each part, and false leaves a part out, or the whole guide. The yard draws its guide in blue, with a dashed path and no ring.

The highlight takes fillColor, fillOpacity, outlineColor, outlineOpacity, outlineThickness in CSS pixels, throughScenery and pulseSeconds. With throughScenery, the fill shows through a roof in front of the station, and the outline stays behind it. The marker takes color, opacity and width, a share of the radius. The path takes color, opacity, width in metres, pattern, a game’s own texture in its place, patternLength, scrollSpeed and fadeDistance. The outline needs WebGL: under the experimental WebGPU renderer it is off, and the console says so once.

findCargoDestinations(world, carrier) is the read underneath: the stations that would take a good the carrier holds now, nearest edge first. A game that draws its own guide reads it with guide: false. It is a read, not a reservation: another carrier may fill a station first, and the game’s checkCargoTransfer still decides.

The guide costs a few draws: two for each mesh it lights, one for each ring and one for the path. On Juice Island at Low from its core camera, with one hopper lit and a path to it, it added 4 draws and about 300 triangles. Its GPU time was smaller than the spread between runs: the scene pass’s mean read 2.86 ms without it and 2.60 ms with it, three runs each on an RTX 3070 on 2026-10-15. Postprocessing’s OutlineEffect lighting the same hopper, measured on 2026-10-14, took the GPU frame from 3.16 ms to 5.74 ms, since at Low it adds a composer that draws the scene twice. The guide draws on the page’s own character alone, so a room of players pays for one.

Each of the five engines gives attachment tools, and leaves the stack, the transfer rules and custody to the game:

  • Roblox welds a part to an Attachment, and a game replicates what it holds with an attribute and keeps it in its own DataStore code.
  • Unity parents a Transform to a bone, and Netcode reparents a NetworkObject.
  • Unreal attaches an actor to a socket and replicates the attachment, and a USaveGame the game writes keeps it.
  • Godot attaches with a BoneAttachment3D and replicates with a MultiplayerSynchronizer.
  • PlayCanvas and Phaser parent one entity to another.

Carrying supplies the three things they leave out, and keeps their placement controls as placement and their attachment points as its socket. None of the five ships a carried stack’s columns or a container: idle-arcade games such as Burger Please carry one column, and the default is one. For what a player holds, Roblox’s backpack shows a tool’s TextureId, or its name where it has none, and Lyra’s quickbar an icon and a stack count; the counters follow both.