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

Map objects

A map object is a kind of prop a game declares, such as a lamppost, a fence or a field of flowers, that a map file then places by name. defineMapObject declares the kind once: the fields an entry sets, what its entity stands with, and how it draws. Unity’s prefabs, Godot’s packed scenes and Roblox’s models fill the same place in a level, and Luminati’s level editor keeps such a catalogue for its own objects.

The map file names the kind in a prop’s kind, as it names a shape or a scatter kind, so the room, every page, spawnite map check and the map builder all read the same entry.

Declare each object at module scope, in a module that src/game.ts imports, so the room, each page and the cli find it by its kind. A kind is lower case words between hyphens, as a registry id is, and never a built-in kind such as box:

packages/engine/test/outside/Lampposts.tsx (part)
import {
box,
colorField,
defineMapObject,
Landscape,
MapObjectPlacement,
registerMap,
type MapObjectViewProps,
} from "@spawnite/engine";
import { z } from "@spawnite/schema";
const lamppostFields = z.strictObject({
height: z._default(z.number().check(z.gte(2), z.lte(5)), 3.2),
glow: colorField({ default: "#ffb46b" }),
});
function LamppostView({ fields }: MapObjectViewProps<typeof lamppostFields>) {
return (
<group>
<mesh position={[0, fields.height / 2, 0]} castShadow>
<boxGeometry args={[0.16, fields.height, 0.16]} />
<meshLambertMaterial color="#2c2a30" />
</mesh>
<mesh position={[0, fields.height + 0.15, 0]}>
<boxGeometry args={[0.32, 0.32, 0.32]} />
<meshLambertMaterial
color={fields.glow}
emissive={fields.glow}
/>
</mesh>
</group>
);
}
export const lamppost = defineMapObject("lamppost", {
category: "lights",
icon: { glyph: "✦", color: "#ffb46b" },
help: "A lamp on a post.",
fields: lamppostFields,
// What the entity stands with, in a room and headless too: no mesh.
build: ({ fields }) => ({
collider: {
shape: box({ size: [0.2, fields.height, 0.2] }),
offset: [0, fields.height / 2, 0],
},
bounds: {
size: [0.4, fields.height + 0.3, 0.4],
offset: [0, (fields.height + 0.3) / 2, 0],
},
}),
view: LamppostView,
});

An object takes the following:

  • category, icon and help: the palette’s group, the one character and colour the map builder’s 2D map marks it with, and one sentence the palette shows.
  • fields: a z.strictObject of what an entry sets, each field with its default. JSON must hold every field, since the map file keeps them.
  • build: what the entity stands with, from the entry alone. It returns a collider or a body as a prefab takes them, traits, and the bounds a shot frames and the editor selects. It runs in the room, headless and on every page, so it builds no mesh.
  • approachPoint, in what build returns: where a character stands to reach the object, at its feet, in the object’s own metres, such as a house’s door step. The map builder’s ready-to-play check walks to it. null says nothing walks to it, such as a tree in a fenced wood. Left out, any side of the bounds a character can stand at will do.
  • view: how the object draws on a page. A room and a headless mount never mount it.
  • snap: the metres a placement snaps to, 0.5 unless you set it.
  • rotates: false for an object the editor never turns, such as a rock that turns by its seed. A line takes no rotates, since its two ends set its facing.
  • scales: false for an object that sizes by its fields, such as a house drawn at a fixed number of texels a metre. Only a point scales.

The view and the build take the same context: the fields with every default filled, the seed, the placement, and two reads of what lies under the object. readGroundHeight(x, z) is the ground’s height under a point of the object’s own frame less the height its origin stands at. findWaterHeight(x, z) is the water’s top there on the same terms, or null where no water stands.

The map builder’s inspector reads each field’s control from its schema: a number, a toggle for a boolean, a select for an enum, a text box for a string. readMapObjectFields(object) lists the controls as the inspector draws them, with each field’s bounds and default, and readMapObjects() lists every object the game declared, for a palette. Both come from @spawnite/engine/devtools, with findMapObjectProblem(props), the check the room and place_props run. Three helpers mark a field whose schema alone cannot say:

  • colorField({ default }): a colour string, drawn as a swatch.
  • angleField({ default, stepRadians, minRadians, maxRadians }): radians, drawn as a dial that turns by stepRadians, 15 degrees unless you set it, held between the two limits where you give them. A default of null makes the field nullable, so an entry may leave the angle to the object, such as a roof’s pitch its seed picks.
  • linesField(): lines of text, such as a sign’s.

A prop of an object’s kind stands at its position, [x, z] on the ground there or [x, y, z] held at a height, with its fields. A field the entry leaves out takes its default:

packages/engine/test/outside/Lampposts.tsx (part)
// A map file, as src/maps/green.json would hold it: the engine's
// greybox set, a lamppost and a fence on the grass.
registerMap("green", {
blockSet: "greybox",
seed: 1,
cells: ["gggggggg", "gggggggg", "gggggggg", "gggggggg"],
heights: ["11111111", "11111111", "11111111", "11111111"],
legend: { g: "grass" },
props: {
lamp: {
kind: "lamppost",
position: [-2, 0],
fields: { glow: "#ffd28a" },
},
rail: { kind: "fence", position: [-3, 1.5], end: [3, 1.5] },
},
});
export function Green() {
return <Landscape map="green" lighting={false} />;
}

findMapObject(entity, lamppost) reads a placed entity’s kind, seed and fields, typed by the object’s schema, and returns null for an entity of another kind.

An entry also takes a seed, which its view reads to vary each copy, such as the flowers in a bed. Left out, the map’s seed and the entry’s name give one, so the same entry draws the same on every page. place_props writes a new seed into each object it places, so two copies differ.

An object stands in one of three ways, its placement:

  • MapObjectPlacement.Point, the default: at its position, turned by the entry’s yaw and sized by its scale. Its front faces +z at a yaw of 0, as glTF’s and Unity’s models do, so a house’s door faces the diorama camera, and a positive yaw turns it as three’s rotation.y does.
  • MapObjectPlacement.Line: from its position to the entry’s end, such as a fence or a bridge. The object faces along the line, so its own +x runs from the start to the end. Its placement gives the length across the ground and the rise from the start’s ground to the end’s.
  • MapObjectPlacement.Rectangle: over the entry’s size, a width and a depth about its position, such as a field of flowers. Its placement gives the size.

The fence in the map above is a line:

packages/engine/test/outside/Lampposts.tsx (part)
const fenceFields = z.strictObject({});
function FenceView({
placement,
}: MapObjectViewProps<typeof fenceFields, MapObjectPlacement.Line>) {
// The line runs from the entry's position along its own +x.
return (
<mesh position={[placement.length / 2, 0.65, 0]} castShadow>
<boxGeometry args={[placement.length, 0.08, 0.06]} />
<meshLambertMaterial color="#8a6a4a" />
</mesh>
);
}
export const fence = defineMapObject("fence", {
category: "props",
placement: MapObjectPlacement.Line,
icon: { glyph: "#", color: "#8a6a4a" },
help: "A rail between two points.",
fields: fenceFields,
build: ({ placement }) => ({
collider: {
shape: box({ size: [placement.length, 0.9, 0.12] }),
offset: [placement.length / 2, 0.45, 0],
},
bounds: {
size: [placement.length, 0.9, 0.2],
offset: [placement.length / 2, 0.45, 0],
},
}),
view: FenceView,
});

The room refuses to open on a map whose object entries its declarations refuse, and place_props refuses them before it writes the file. Each refusal names the entry’s key and the field:

  • A kind the game never declared.
  • A field the object does not have, or a value its schema refuses.
  • An end on an object that is not a line, or a line with no end.
  • A size on an object that is not a rectangle, or a rectangle with no size.
  • A yaw on a line, or on an object declared with rotates: false, a scale on anything but a point, and a scale other than 1 on a point declared with scales: false.

defineMapObject itself throws for a kind that is not lower case words between hyphens, a built-in kind, or a second declaration of one kind. It throws too for fields that are not a strict object or that JSON cannot hold, a default its own field refuses, and an icon of more than one character.

Value Spawnite Luminati Others
Where it lives a kind declared in code a catalogue in its editor Unity’s prefab asset, Godot’s packed scene, Roblox’s model in ServerStorage
Fields a zod schema a field list per kind Unity’s serialized fields, Godot’s @export, Roblox’s attributes
Placements point, line, rectangle point, line, rectangle a point; lines and areas through an editor tool
Snap 0.5 m 0.5 m, interior props 0.25 m Unity’s grid snap 0.25, Godot’s 1 m
Build and drawing apart, so a room builds no mesh one function a server keeps the whole prefab