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

Orders

An order sends an NPC, or the player’s own character, to a station that a player chose, such as the tree the player tapped. The NPC walks there, works the station a stage at a time, and goes back to the routine and the step the order interrupted. Clash of Clans sends the next free builder this way, and RimWorld gives a colonist a job.

A game lists orders() in its plugins, after npcs(), which it requires. Everything on this page comes from @spawnite/engine/orders, the plugin’s own entry. An NPC that takes orders is a worker, the order’s steps are stages, and the station the stage works lets the worker through by its order.

A player taps a tree, the nearest idle picker walks to it and picks its apples, carries them to the crate, and goes back to sweeping the path. The tree and the crate are carrying stations whose targets are orderTargets, so the game writes no target rule:

packages/engine/test/outside/orchardOrdersGame.ts
import {
defineGame,
defineTrait,
readTrait,
route,
routines,
type Entity,
type World,
} from "@spawnite/engine/core";
import { behaviours } from "@spawnite/engine/behaviours";
import { characters } from "@spawnite/engine/characters";
import { machines } from "@spawnite/engine/machines";
import { npcs } from "@spawnite/engine/npcs";
import {
carrying,
carryingSaves,
CargoStationTrait,
CargoDirection,
countCargo,
defineCargo,
hasCargoRoom,
} from "@spawnite/engine/carrying";
import {
defineOrder,
endOrder,
orders,
setOrderStage,
takeOrders,
} from "@spawnite/engine/orders";
// An orchard: a player taps a tree, the nearest idle picker walks to it,
// picks its apples into a basket, carries them to the crate, and goes
// back to sweeping the path. The tree and the crate let a picker through
// by its order, with `orderTargets`, so the game writes no target rule.
export const apple = defineCargo("apple", { model: "apple", tags: ["fruit"] });
/** On each tree, which a tap finds and a harvest names. */
export const TreeTrait = defineTrait("orchardTree");
/** A harvest's stages: at the tree, then on the way to the crate. */
export enum HarvestStage {
Pick = "pick",
Haul = "haul",
}
/** The one crate the orchard keeps, which every harvest ends at. */
function findCrate(world: World): Entity | null {
for (const station of world.query(CargoStationTrait))
if (
readTrait(station, CargoStationTrait)?.direction ===
CargoDirection.ToStore
)
return station;
return null;
}
/** A tap on a tree with apples: pick it, then haul to the crate. */
export const harvest = defineOrder("harvest", {
stages: HarvestStage,
plan: ({ station }) =>
station?.has(TreeTrait) && countCargo(station) > 0
? { stage: HarvestStage.Pick, station }
: null,
work: ({ step, worker, order, isArrived }) => {
if (!isArrived) return;
// At the tree, the station hands the picker its apples one by
// one; with the basket full or the tree bare, it hauls.
if (order.stage === HarvestStage.Pick) {
const tree = order.stageStation;
if (hasCargoRoom(worker, 1) && tree && countCargo(tree) > 0) return;
setOrderStage(step, worker, {
kind: harvest,
stage: HarvestStage.Haul,
station: findCrate(step.world),
});
} else if (countCargo(worker) === 0) endOrder(step, worker);
},
reach: 1,
});
/** A picker sweeps the path between orders, and takes each order from
* where it stands. */
export const pickerRoutines = takeOrders(
routines("picker").routine("sweep", (r) =>
r.walk(route({ west: [-4, 0, 4], east: [4, 0, 4] }), {
repeat: Infinity,
yoyo: true,
}),
),
{ reach: 0.5 },
).start("sweep");
export const game = defineGame({
name: "orchard",
start: "orchard",
plugins: [
behaviours(),
characters(),
machines(),
npcs(),
carrying({ kinds: [apple] }),
orders({ kinds: [harvest] }),
],
worldSaves: { ...carryingSaves },
});

The scene mounts the tree, the crate and the picker. A tap on the tree sends the give command, and the HUD shows a line for a refusal:

packages/engine/test/outside/Orchard.tsx
import { useState } from "react";
import { createQuery } from "@spawnite/engine/core";
import {
CommandStatus,
Entity,
findEntityUnderRay,
Hud,
Npc,
sendCommand,
Text,
useAuthorityEffect,
useCanvasTap,
useEntity,
useWorld,
Views,
} from "@spawnite/engine";
import {
addCargo,
addCarrier,
CargoDirection,
CargoStation,
countCargo,
} from "@spawnite/engine/carrying";
import {
addWorker,
OrderRefusal,
orders,
orderTargets,
} from "@spawnite/engine/orders";
import { apple, harvest, pickerRoutines, TreeTrait } from "./orchardOrdersGame";
// The orchard's scene: a tree and the crate, each a station that lets an
// ordered picker through, the picker, and the tap that sends it, with the
// HUD's line when every picker is busy.
/** The tree's apples, as the world opens with none. */
function Apples() {
const tree = useEntity();
useAuthorityEffect(
(authority) => {
if (countCargo(tree) === 0)
addCargo(authority, tree, [{ cargo: apple }, { cargo: apple }]);
},
[tree],
);
return null;
}
/** Marks its NPC as a worker that takes orders, with a basket. */
function Worker() {
const npc = useEntity();
useAuthorityEffect(
(authority) => {
addCarrier(authority, npc, { capacity: 4 });
addWorker(authority, npc);
},
[npc],
);
return null;
}
const trees = createQuery(TreeTrait);
/** What the HUD says for each refusal a player should read. */
const refusalLines: Partial<Record<string, string>> = {
[OrderRefusal.NoFreeWorker]: "Every picker is busy",
[OrderRefusal.NothingToDo]: "Nothing to pick",
};
/** A tap on a tree sends the give command; a refusal shows its line. */
function TreeTaps() {
const world = useWorld();
const [line, setLine] = useState("");
useCanvasTap({
findTarget: (ray) => findEntityUnderRay(world, ray, { query: trees }),
onTap: ({ target }) => {
if (!target) return;
void sendCommand(world, orders.commands.give, {
order: harvest.name,
station: target,
}).then((result) =>
setLine(
result.status === CommandStatus.Refused
? (refusalLines[result.reason] ?? "")
: "",
),
);
},
});
return (
<Hud>
<Text>{line}</Text>
</Hud>
);
}
export function Orchard() {
return (
<>
<Entity key="tree" position={[6, 0, -4]} traits={[TreeTrait()]}>
<CargoStation
direction={CargoDirection.ToCarrier}
targets={orderTargets}
capacity={6}
/>
<Apples />
</Entity>
<Entity key="crate" position={[-6, 0, -4]}>
<CargoStation
direction={CargoDirection.ToStore}
targets={orderTargets}
capacity={12}
/>
</Entity>
<Npc name="Pip" position={[0, 0, 4]} routines={pickerRoutines}>
<Worker />
</Npc>
<Views>
<TreeTaps />
</Views>
</>
);
}

addWorker and addCarrier run on the authority, so the room marks the picker and gives it a basket. The test beside the files, orchardOrders.test.ts, mounts the scene and plays an order to its end.

defineOrder(name, options) declares a kind at module scope and returns its handle, and orders({ kinds }) installs it. A name is lower case words joined by hyphens. A kind takes the following:

  • stages: a string enum of its stages.
  • refusals: a string enum of the kind’s own reasons to refuse, such as { NotYours: "not-yours" }.
  • plan: whether the station has work, and the first stage. It reads world, station, the station the order names, and player, who gives it. It returns the stage, null for nothing to do, or one of the kind’s refusals. It is a pure read.
  • work: each step the worker holds the order, travel included. It moves the order on with setOrderStage and ends it with endOrder.
  • reach: metres on the ground from the stand that count as there, 1.2 by default.
  • maxPerStation: how many orders of the kind may name one station at once, 1 by default, or Infinity.
  • chooseWorker: the game’s rule for which worker takes it, such as a skill. By default, the idle worker nearest the first stage.

An order names one station for its whole life: the one it was given for, such as the tapped tree, which the record keeps as order.station and maxPerStation counts. Each stage works a station of its own, order.stageStation, such as the crate a harvest hauls to. A stage names station, the station it works, stand, where the worker stands, and reach. With no stand, the worker walks to the station; with neither, the stage works where the worker stands. setOrderStage(step, worker, { kind, stage, station, stand }) replaces the whole stage, so a station or a stand it leaves out is none.

work reads the following:

  • isArrived: whether the worker stands within the stage’s reach on the ground.
  • gaveUp: why the stage’s walk gave up, or null.
  • isStationGone: whether the stage’s station was destroyed. A destroyed order.station reads null and ends nothing, so a harvest whose tree goes still hauls its apples; a work that wants it gone checks order.station.
  • stageSeconds: the seconds since the stage began, for a stage worked on a timer.

A work that sets another stage on a give-up or a gone station goes on; one that sets none ends the order.

addWorker(writer, worker) marks an NPC that takes orders, as addCarrier marks a carrier. Its routines are built with takeOrders(builder, { reach }), which adds the order’s walk and the way back, as the orchard’s pickerRoutines shows.

The reach is how far short of a stand the walk may stop. A stage’s reach is at least where the walk can end: this reach, and 1 + √2 times the default capsule’s radius of 0.35 m, about 0.85 m, since a walk may end that far short at the navmesh’s edge. A development world throws where it is shorter, since the worker would stop short of the stage for good. An NPC with no routines takes orders too, for a game that moves its NPCs itself.

isWorkerIdle(worker) says whether a worker may take an order: it holds none, its routine is in one it runs between events, and no conversation, no MachinePausedTrait and no interest culling holds it. findIdleWorker(world, { from, where }) finds the nearest idle worker that where passes, for a chooseWorker of the game’s.

A worker throttles rather than suspends while no player looks, so an order goes on out of view, unless the NPC names its own unseen.

addWorker marks a player’s character too, as Diablo’s click on a chest walks the character there and opens it. The player’s page walks it to each stage’s stand with the walk a click to walk sends, since the page steers the character and a walk the room started alone would part from the page’s prediction. The room judges the order:

  • A key press or a jump ends the order cancelled, and so does a walk the player sends elsewhere since the stage began, even where the stage needed no walk, or once the walk toward it set off. The player’s walk stands.
  • A walk that stops short of the stand is a partial give-up, and one that never comes, as where the page’s navmesh refused it, a no-route give-up after 3 s. The kind’s work reads either as gaveUp, and the order ends unreachable unless work sets another stage.
  • A downed character, or one whose player dropped and whose seat the room holds, ends the order failed. A downed character is no idle worker, and giveOrder and findIdleWorker give no order to a dropped player’s character either. A character that stands again with no death, as a fall into the sea respawns it, ends its order failed too.

A game that sends the player’s own character first names it with giveOrder(step, { kind, station, player, worker: findCharacter(step.world, player) }), and gives the order to a free NPC where that refuses with worker-busy, as Juice Island’s tap does. Its kinds’ chooseWorker then passes NPCs alone, so a free worker is never another player’s character.

Two calls give one:

  • The give command, for a page: orders.commands.give with the kind’s name and the station. It names no worker, since a page could name one the kind’s chooseWorker would never pick, such as another player’s.
  • giveOrder(step, { kind, station, player, worker }), for a game’s own command or system, on the step that decides outcomes. It returns { worker } or { refusal }. A game that lets a player pick a worker checks the pick in its own command’s reader and names it here.

It checks in this order: the kind’s plan, the station’s count of orders against maxPerStation, then the worker. It refuses with OrderRefusal:

Refusal Why
nothing-to-do plan returned null.
station-busy maxPerStation orders of the kind already name the station.
no-free-worker No worker is idle, or chooseWorker found none.
worker-busy The verb alone: the worker named is not idle.
not-worker The verb alone: the entity named is no worker.
declined The command alone: the kind refused with its own reason, which the refusal’s detail names.

giveOrder returns a kind’s own refusal as it is. A page reads the command’s result as Send the room a command says: result.reason is the refusal, and result.detail the kind’s own reason for declined. The orchard’s HUD maps each reason to its line. A game whose command carries its own payload gives orders from its own command’s reader, as Juice Island’s tap does.

orderTargets lets a player’s character through a station, and a worker whose order’s stage works that station now. A worker whose routine is held is let through nowhere. orderedWorker is the rule alone, to list beside other entries, and isOrderedTo(worker, station) is the same test for a game’s own rule that also admits others, such as hired NPCs at their job’s stations.

endOrder(step, worker, { reason }) ends an order, done by default. The worker’s walk stops, and its routine goes back to the step the order interrupted. The worker keeps what it carries; a game that wants the goods back answers the event, such as with returnCargo. OrderEndedEvent comes on the worker once for every order, with its kind, stations, stage, player and OrderEndReason:

  • done, cancelled and failed, from the game.
  • station-gone: the stage’s station was destroyed, and work set no other stage.
  • unreachable: the stage’s walk gave up, and work set no other stage.
  • worker-gone: the worker was destroyed.

The order goes on when its player leaves, as a builder finishes a building, and order.player reads null. While a worker holds an order its routine hears no game event: end the order, then send the event.

An order lives on its worker. A checkpoint keeps it with the worker’s routine. A world save that keeps no scene NPC brings the worker back idle, and carrying’s custody keeps the goods it held.

Roblox, Unity, Unreal, Godot, PlayCanvas and Phaser move an NPC, with MoveTo, a NavMeshAgent or a NavigationAgent, and leave the order to the game. Unreal’s Smart Objects come nearest: an agent claims a slot, uses it and releases it. RimWorld runs a job as a list of toils, with one pawn reserving a target by default, and ends a job succeeded, incompletable or interrupted.

Orders take the stages run one at a time, one order per station by default, and an end with a reason for every order. The stages are one work per kind in the game’s code rather than a fixed list, because a stage’s next station is often chosen as the worker goes, such as the nearest hopper with room.