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

Building

building() lets players build on a grid. A player aims at a cell, sees a ghost of the piece there, turns it, and sees before the player pays whether it may stand and why not. Later the player moves it, puts it in storage, takes it out again or demolishes it. Areas inside one world say who may do which, and one rule of the game’s own changes any case. Each piece is a world record, so a checkpoint costs what changed, and the pieces draw in one batch for each model.

List the plugin in the game’s plugins and import its names from @spawnite/engine/building. The engine’s names its rules use, such as setTrait, defineTrait, Refusal and readPlayerId, come from @spawnite/engine/core. Its handles hang on its factory, as every plugin’s do: building.commands.place and building.actions.rotate.

To an agent it reads like Godot’s GridMap: a cell size, a palette of pieces, a cell and an orientation for each. To a player it plays like Minecraft’s building: look at a face, see where the piece goes, click. Roblox, Unity and Unreal ship no building system, so each of their games writes its own; Rust’s tool cupboard and Valheim’s ward are the claims this kit’s areas copy.

definePiece(name, options) declares a piece at module scope and returns its handle. A piece is a prefab with a place on the grid, so it takes every prefab option, traits, body, collider and the rest, and these of its own:

Its own options are slot (PieceSlot.Inside, Floor or Wall, Inside by default), footprint in cells at rest, height in cells, grounded for a piece that stands on the ground under it, and variants, named tints a placement picks from. PieceOptions has each with its default.

The pieces of a survival world’s cabins are walls, floors and furniture on a 2 m grid. The doorway’s own collider leaves a hole to walk through:

packages/room/test/outside/claims.ts (part)
export const floor = definePiece("floor", {
model: "floor",
slot: PieceSlot.Floor,
});
export const roof = definePiece("roof", {
model: "roof",
slot: PieceSlot.Floor,
variants: { red: "#a33b2f", slate: "#5b6470" },
});
export const wall = definePiece("wall", {
model: "wall",
slot: PieceSlot.Wall,
});
/** A wall with a door's hole in it: two jambs and a lintel, so a player
* walks through its middle. */
export const doorway = definePiece("doorway", {
model: "doorway",
slot: PieceSlot.Wall,
collider: [
{ shape: box({ size: [0.2, 2, 0.6] }), offset: [0, 1, -0.7] },
{ shape: box({ size: [0.2, 2, 0.6] }), offset: [0, 1, 0.7] },
{ shape: box({ size: [0.2, 0.4, 2] }), offset: [0, 1.8, 0] },
],
});
export const windowWall = definePiece("windowWall", {
model: "windowWall",
slot: PieceSlot.Wall,
});
export const fence = definePiece("fence", {
model: "fence",
slot: PieceSlot.Wall,
});
export const gate = definePiece("gate", {
model: "gate",
slot: PieceSlot.Wall,
});
export const ramp = definePiece("ramp", { model: "ramp" });
export const torch = definePiece("torch", { model: "torch" });
/** A lantern post for the road, two levels tall. */
export const post = definePiece("post", { model: "post", height: 2 });

A piece with a model draws in the kit’s batch for that model, once the page registers the model as Models says; give view instead for a piece that needs an entity view of its own, such as a gate that swings. A piece with no collider takes a box that fits its slot. Its model stands its lowest point on the piece’s place, and at rest faces +z.

A cell is [x, y, z], whole numbers, cellSize metres across and levelHeight metres tall. origin is cell [0, 0, 0]’s low corner, so cell x spans origin plus x to x + 1 cells. Each cell has three kinds of slot, so a floor, four walls and a chest share one cell, as Fortnite’s grid and Rust’s sockets give:

  • Floor: the cell’s bottom plane. A roof over a room is the floor of the cell above it.
  • Wall: an edge between two cells. The edge has one key whichever cell names it, so two walls never stand in one edge.
  • Inside: the cell’s volume: furniture, decorations, chests, ramps and blocks. A tall inside piece also fills the floors it passes through.

A placement is a cell, quarterTurns from 0 to 3 about y, and a variant. A quarter turn turns as three.js does, +x toward -z. A footprint’s odd side centres on the cell; an even side runs toward +x or +z at rest. The aim keeps an even footprint’s middle where it was, so a 2 by 2 spins in place. A wall at rest stands on its cell’s low-x edge; its turns move it round the cell’s edges.

The aim picks the slot each kind of piece takes from what the ray met: an inside piece goes in the cell beside the face it met, a floor on the level plane nearest a top it met, and a wall on the cell edge nearest the point. Aimed at a wall’s top, a wall stacks and a floor becomes the roof. findAimedSlot(world, piece, hit, quarterTurns) answers the same for a game’s own tool.

building(options) takes the palette and the grid, who builds outside every area, where a removed piece goes, and the game’s rules. This island’s decoration pays for a purchase from the island’s treasury, lets moving and taking out of storage go free, and never demolishes, so nothing placed is lost:

packages/room/test/outside/decor.ts (part)
/** The island's one treasury, and the coins in it. */
export const TreasuryTrait = defineTrait(
"decorTreasury",
{ coins: 0 },
{ types: { coins: "uint32" } },
);
const treasuries = createQuery(TreasuryTrait);
/** The island's treasury, or null before the island is set up. */
export function findTreasury(world: World): Entity | null {
let found: Entity | null = null;
readEach(world, treasuries, (_traits, entity) => {
found = entity;
});
return found;
}
/** The treasury's record, so its coins outlive a restart. */
export const treasurySave = defineSave({
scopes: ["world"],
each: TreasuryTrait,
version: 1,
});
/** Sets the island up once: its treasury with `coins`, and the press yard,
* a working zone nobody builds in. */
export function setUpIsland(step: AuthoritativeStep, coins: number) {
if (findTreasury(step.world)) return;
spawn(step, { traits: [TreasuryTrait({ coins })] });
spawnBuildArea(step, {
position: [10, 0, 10],
shape: { size: [6, 6] },
access: BuildAreaAccess.Closed,
owner: null,
guests: [],
});
}
const tooPoor = { refusal: Refusal.NoResource, detail: "Coins" } as const;
/** A piece's price; a piece the table leaves out throws, naming it, rather
* than selling for nothing. */
function readPrice(piece: string): number {
const price = decorPrices[piece];
if (price === undefined)
throw new Error(
`decor.ts: the piece "${piece}" has no price in decorPrices: add one.`,
);
return price;
}
/** The decoration: a purchase checked against the treasury on the player's
* page and the room, and paid on the room; moving and taking out of
* storage free; no demolish, so nothing placed is lost. */
export const decoration = building({
pieces: decorPieces,
cellSize: 1,
storage: PieceStorage.World,
checkPlace: ({ world, action, piece }) => {
if (action !== BuildAction.Place) return null;
const coins = findTreasury(world)?.get(TreasuryTrait)?.coins ?? 0;
return coins < readPrice(piece.name) ? tooPoor : null;
},
onPiecePlace: ({ step, action, piece }) => {
if (action !== BuildAction.Place) return null;
const treasury = findTreasury(step.world);
const coins = treasury?.get(TreasuryTrait)?.coins ?? 0;
const price = readPrice(piece.name);
if (!treasury || coins < price) return tooPoor;
setTrait(step, treasury, TreasuryTrait, { coins: coins - price });
return null;
},
checkAccess: (context) =>
context.action !== BuildAction.Demolish && readBuildAccess(context),
});
/** The island: set up on the room's first step. */

The options set the lattice, cellSize, levelHeight, origin and bounds. outside says who builds outside every area, storage where a removed piece goes, and reach how far the player builds. Two limits keep a world in its room: maxPieces for the world, 20,000 by default, and maxPiecesPerPlayer for each player, so one player cannot fill a shared world. Four hooks hold the game’s rules: checkAccess, checkPlace, which runs for a placement, a move and a take-out alike, onPiecePlace and onPieceRemove. BuildingOptions has each with its default.

Each hook takes one context. checkPlace takes a PlaceContext: who asks, the action, the piece and the placed or stored piece, where it would stand, cells, each cell it would fill, which a rule over the map reads, and each area those cells touch. onPiecePlace takes the same with step.

onPieceRemove takes a RemoveContext, and checkAccess a BuildAccessContext, which names who placed the piece as placedBy.

The game lists the kit’s saves too, so its pieces and areas outlive a restart: defineGame({ worldSaves: { ...buildingSaves } }). A room that holds a world and keeps no record of its pieces refuses every placement before anything is paid, and a development room throws, naming that line.

An area is an entity with BuildAreaTrait: a circle of radius metres or a box size metres across, each a column of any height, with an access, an owner and up to 64 guests. spawnBuildArea makes one and setTrait changes any field, such as a claim’s radius or a new guest. Every page holds every area. A cell belongs to an area where its centre lies inside it, and a wall’s edge to the areas of both cells beside it.

BuildAction is Place, Retrieve (out of storage), Move, Store and Demolish. The kit’s answer, readBuildAccess, is the following:

Where Place, retrieve Move, store, demolish
BuildAreaAccess.Private Its owner and its guests Its owner, any piece; a guest, the guest’s own
BuildAreaAccess.AddOnly Anyone Nobody
BuildAreaAccess.Closed Nobody Nobody
Outside every area, as outside says The held world’s owner and members; with no held world, every player The held world’s owner and members, any piece; with no held world, each player their own

A held world is the persistent world a room or a page holds, as Worlds describes; a match’s room holds none. Where a piece’s slots touch several areas, every one must allow it. A move is judged at both ends. The held world’s owner moves, stores and demolishes anywhere, to recover a griefed or stranded piece. A piece left outside every area when its claim shrinks or goes falls to outside.

checkAccess changes one case and hands the rest to readBuildAccess. It answers true, false for not-allowed, or a refusal with its own line, such as “Your side is the left”. A team’s side is such a rule, reading the player’s team from teams(). In these claims, a guest moves their own pieces but only a claim’s owner demolishes, a demolish gives half its timber back, and a road is add-only for lantern posts:

packages/room/test/outside/claims.ts (part)
/** The timber a player carries. */
export const TimberTrait = defineTrait("claimsTimber", { count: 0 });
/** The timber each piece costs. */
export const pieceTimber: Record<string, number> = {
floor: 2,
wall: 2,
roof: 3,
post: 4,
};
function readTimberCost(piece: string) {
return pieceTimber[piece] ?? 1;
}
/** Plants a lantern at `position`: a circle of `radius` metres that
* `owner` owns, with no guests yet. */
export function plantLantern(
step: AuthoritativeStep,
owner: PlayerEntity,
position: [number, number, number],
radius = 8,
): Entity {
return spawnBuildArea(step, {
position,
shape: { radius },
access: BuildAreaAccess.Private,
owner: readPlayerId(owner),
guests: [],
});
}
/** Lets `guest` build in `claim`. */
export function addClaimGuest(
step: AuthoritativeStep,
claim: Entity,
guest: PlayerEntity,
) {
const area = claim.get(BuildAreaTrait)!;
setTrait(step, claim, BuildAreaTrait, {
...area,
guests: [...area.guests, readPlayerId(guest)],
});
}
/** Opens a stretch of road at `position`, four metres wide and twenty
* long, where anyone adds a post. */
export function openRoad(
step: AuthoritativeStep,
position: [number, number, number],
): Entity {
return spawnBuildArea(step, {
position,
shape: { size: [4, 20] },
access: BuildAreaAccess.AddOnly,
owner: null,
guests: [],
});
}
/** Whether `context`'s player is a guest of a claim the
* player's piece touches. */
function isGuest(context: BuildAccessContext) {
const id = readPlayerId(context.player);
return context.areas.some((area) =>
area.get(BuildAreaTrait)?.guests.includes(id),
);
}
/** Whether a piece would stand in an add-only area. */
function isOnRoad(areas: readonly Entity[]) {
return areas.some(
(area) => area.get(BuildAreaTrait)?.access === BuildAreaAccess.AddOnly,
);
}
/** The claims: building inside a lantern's circle, posts on the road,
* timber to pay, half of it back on a demolish, and demolishing kept to a
* claim's owner. */
export const claims = building({
pieces: claimPieces,
cellSize: 2,
outside: BuildAreaAccess.Closed,
checkAccess: (context) =>
!(context.action === BuildAction.Demolish && isGuest(context)) &&
readBuildAccess(context),
checkPlace: ({ player, action, piece, areas }) => {
if (isOnRoad(areas) && piece !== post)
return {
refusal: BuildRefusal.Invalid,
detail: "Posts only on the road",
};
if (action !== BuildAction.Place || !player) return null;
const timber = player.get(TimberTrait)?.count ?? 0;
return timber < readTimberCost(piece.name)
? { refusal: Refusal.NoResource, detail: "Timber" }
: null;
},
onPiecePlace: ({ step, player, action, piece }) => {
if (action !== BuildAction.Place || !player) return null;
const timber = player.get(TimberTrait)?.count ?? 0;
const cost = readTimberCost(piece.name);
if (timber < cost)
return { refusal: Refusal.NoResource, detail: "Timber" };
setTrait(step, player, TimberTrait, { count: timber - cost });
return null;
},
onPieceRemove: ({ step, player, action, piece }) => {
if (action !== BuildAction.Demolish || !player) return null;
const timber = player.get(TimberTrait)?.count ?? 0;
const refund = Math.floor(readTimberCost(piece.name) / 2);
setTrait(step, player, TimberTrait, { count: timber + refund });
return null;
},
});

One pipeline judges a placement, on the player’s page for the ghost and on the room for the command, in this order:

  1. The request: a palette piece, cells inside bounds, a variant the piece declares.
  2. Reach, where reach is set, to where it goes and to where a moved piece stands: out-of-range.
  3. Access: not-allowed.
  4. The slots: occupied.
  5. Clearance: blocked where a character, a body or scenery stands in the slots’ space, or the ground is too steep, missing or buries the piece.
  6. For a new piece, maxPieces and maxPiecesPerPlayer: world-full and player-full.
  7. The game’s checkPlace.
  8. On the room alone, onPiecePlace and the spawn, in one transaction: a refusal puts back what the hook wrote.

BuildRefusal holds the kit’s reasons, occupied, not-stored, blocked, not-allowed, out-of-bounds, player-full and invalid. BuildRefusalReason adds CommandRefusal.WorldFull, Refusal.OutOfRange and Refusal.NoResource from @spawnite/engine/core. A room whose memory is nearly full refuses the spawn itself world-full too, and the commit’s transaction puts back what the payment took. buildRefusalLines holds a line for each, which a HUD spreads into its own and overrides. A game names its own case in the detail line, at most 64 characters, such as no-resource “Timber” or invalid “Posts only on the road”. findPlaceRefusal, findMoveRefusal and findRemoveRefusal answer the same checks for a game’s own HUD.

The ghost shows what the player’s page holds. The room may still refuse what it passed: a piece out of the player’s interest radius, another player’s character a little ahead of where the player’s page draws it, the world’s size and the payment are the room’s. The tool keeps the room’s refusal on the slot it came for until the player aims elsewhere.

A stored piece is the same entity, kept: its record, its traits and its save id stay, so a damaged wall comes back damaged. It stands nowhere: its colliders meet nothing, the navmesh leaves it out, the kit draws none of it and it carries StoredPieceTrait. A game’s system over a piece’s own traits skips it with createQuery(ChestTrait, Not(StoredPieceTrait)). An engine behaviour a piece carries, such as Interact or a trigger, still acts where it stood, so a game vetoes storing such a piece in onPieceRemove.

  • remove with store: true stores a placed piece; store: false demolishes it.
  • move takes a stored piece out to a slot, or moves a placed one; a move in place turns or recolours it.
  • World storage is every builder’s; a player’s storage is seen by the player’s page alone.
  • spawnStoredPiece puts a new piece straight into storage, as a purchase made with real money or a reward does.

<BuildTool mode={BuildToolMode.Place} piece={palm} /> is mounted while the player builds. It aims from the pointer, or from the screen’s centre while the camera holds the cursor, and draws the ghost of the piece in hand, white where it may stand and red where it may not. Move picks up the aimed piece and puts it down; Remove stores or demolishes the aimed piece. The tool never frees or captures the cursor, and a drag that moves the camera never places. While it is mounted it claims every click, so the camera’s click-to-walk never walks the character to the piece being placed.

Input Mouse and keyboard Touch
Aim The pointer, or the screen’s centre while the cursor is captured A tap moves the ghost there
Rotate R The Rotate button
Confirm Left click The Place button
Cancel Right click or X The Cancel button

While it is mounted it draws the areas on the ground: red where nobody builds, blue where anyone adds, green where the player builds and grey where they may not; showAreas={false} hides them. Its props set what it does, the piece in hand and its variant, whether Remove stores, the ghost’s colors, how far it aims, and onCancel, called on a cancel so a game leaves build mode: BuildToolProps.

Cancel puts down a piece Move holds and clears a refusal the room gave; then it calls onCancel. The three keys are page actions, building.actions.rotate, place and cancel, so a player rebinds them and the engine draws their buttons on a phone while the tool is mounted, Rotate only while a piece is in hand. colors sets the ghost’s two colours. A HUD reads the tool’s state with useBuildTool: the aimed cell, the target piece Move or Remove aims at, the turn, the refusal with its detail, and whether a command is out, as BuildToolState lists. usePalette() lists the pieces for a catalog panel, and useStoredPieces() the stored pieces, grouped with their counts.

Each of the three keys is in the game’s key table, so a key another plugin holds refuses the world as it is made. building({ keys }) moves them, as characters({ keys }) moves the jump: keys: { rotate: ["KeyQ"] } turns with Q, and a game that draws its own tool drops all three with keys: { rotate: [], place: [], cancel: [] }.

A system, a bot, a reward or a script builds with the verbs the commands call: placePiece, movePiece, storePiece, demolishPiece and spawnStoredPiece, each answering { entity } or { refusal, detail }. They run every check but a player’s reach and access. A game that destroys a piece itself calls demolishPiece, so PieceRemovedEvent reaches its log. findPieceAt(world, cell, slot) reads what holds a slot.

The room emits PiecePlacedEvent, PieceMovedEvent and PieceRemovedEvent, each with the player or null, the piece, its name, its cell and its turn, so a log of purchases and moves reads them.

The kit’s saves keep each piece’s name, cell, turn, variant, who placed it and its storage. A piece’s own traits, such as a chest’s contents or a wall’s health, come back from the prefab’s defaults unless the game keeps them with an each save of its own, as Keep each entity of the world shows.

Each piece is an entity, about 2.4 KB of the room’s memory with a few small traits, so maxPieces defaults to 20,000. Raise it knowingly: a development room warns at three quarters of it. A world of hundreds of thousands of blocks keeps them as cells in a grid field rather than one entity each.

A placed piece’s colliders mark the navmesh tiles they touch, which rebake two a tick. A piece whose collisionMask leaves the NPC layer out stays out of the navmesh, so NPCs walk through it.