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 an object
Section titled “Declare an object”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:
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,iconandhelp: 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: az.strictObjectof 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 acollideror abodyas a prefab takes them,traits, and theboundsa shot frames and the editor selects. It runs in the room, headless and on every page, so it builds no mesh.approachPoint, in whatbuildreturns: 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.nullsays 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:falsefor an object the editor never turns, such as a rock that turns by its seed. A line takes norotates, since its two ends set its facing.scales:falsefor 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.
Fields and the inspector
Section titled “Fields and the inspector”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 bystepRadians, 15 degrees unless you set it, held between the two limits where you give them. Adefaultofnullmakes 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.
Place it in a map file
Section titled “Place it in a map file”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:
// 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.
Points, lines and rectangles
Section titled “Points, lines and rectangles”An object stands in one of three ways, its placement:
MapObjectPlacement.Point, the default: at its position, turned by the entry’syawand sized by itsscale. 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’srotation.ydoes.MapObjectPlacement.Line: from its position to the entry’send, 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 thelengthacross the ground and therisefrom the start’s ground to the end’s.MapObjectPlacement.Rectangle: over the entry’ssize, a width and a depth about its position, such as a field of flowers. Its placement gives thesize.
The fence in the map above is a line:
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,});What is refused
Section titled “What is refused”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
endon an object that is not a line, or a line with noend. - A
sizeon an object that is not a rectangle, or a rectangle with nosize. - A
yawon a line, or on an object declared withrotates: false, ascaleon anything but a point, and ascaleother than 1 on a point declared withscales: 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.
The values and the other engines’
Section titled “The values and the other engines’”| 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 |