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

Landscape

<Landscape map> mounts a map by name: its ground, the scatter standing on it, the navmesh baked from both and from the Entities’ fixed colliders and resting kinematic ones, and the daylight. All of it lives as long as the Landscape is mounted. The <CharacterSpawn> and every entity of the scene go inside it, and so does the Camera, in <Views>. The Landscape draws its own ground, sea, scatter and light in a <Views> of its own, so a room bakes the navmesh from the same ground and draws none of it.

A Landscape is a map inside the scene’s world, the one simulation the scene’s entities and systems run in: a scene has one simulation, and may mount two Landscapes in it, as Two Landscapes says.

“World” names two other things: the simulation, step.world in a system and world in every read, and a world that remembers, such as an island or a base, whose records and settings readHeldWorld(world) reads, as Worlds says.

The smallest scene a player walks in is the engine’s grid map, 8 m of plain floor, the player’s character and a camera. The engine registers two maps, grid and meadow; a game registers its own with registerMap, as Maps says, and a name nobody registered throws as the Landscape mounts:

packages/engine/test/outside/GridLandscape.tsx
import { Camera, CharacterSpawn, Views, Landscape } from "@spawnite/engine";
/** The smallest scene a player walks in: the engine's plain grid, the
* player's character at its middle, and a camera behind the character,
* which the page draws and a room never mounts. */
export function GridLandscape() {
return (
<Landscape map="grid">
<CharacterSpawn />
<Views>
<Camera />
</Views>
</Landscape>
);
}

Every world has one physics scene, which loads the physics, Rapier’s WebAssembly, at the first body the world declares: a Landscape’s ground, an entity’s collider, or a body the room streams. A game that declares none, such as one that shows a model under Lighting or a card game, never downloads it, alone or in a room, and its room’s process never loads it either. The navmesh loads recast for the first Landscape. A Game’s loading screen starts recast as it mounts, beside the scene’s files.

While the physics loads, the Landscape and its children wait inside a boundary of their own, so the rest of the scene mounts at once. A game played alone runs no step until the load lands, so its first step meets the ground and every body however long the download took. A page in a room steps on at the room’s pace, and its scene holds each body from the first step after the load. A Landscape mounted after that, a second one or the next scene’s, spawns its ground at once. Code that reads the physics before any body, such as a test, awaits loadRapier() first, and loadRecast() before it bakes a navmesh.

Its props are in LandscapeProps.

look picks a look: the sky the models light from, fog, exposure, grading and a post-processing stack, set together.

Landscape lights its map with the engine’s Lighting, and you can replace any of its parts while keeping the rest:

  • sky is the sky behind the scene. Left out, the engine draws its own sky, which follows the hour: the scattering sky with clouds, or the look’s painted sky, as Painting the sky says. { file, sunU } draws an equirectangular image in its place, turned so its sun stands on the engine’s, and intensity dims or brightens it. The scene takes its soft light from the sky it draws, as The sky and the sun says. anchors and haze place an image’s horizon and haze it, as A painted sky not drawn level says. false draws none, for a cave, a space station or a sky the game draws itself.
  • sun={false} leaves out the sun, and the moon after dark: the one sun light, which casts the shadow. The sky’s fill light stays.
  • fog takes a three.js Fog, linear between two distances, or FogExp2, which thickens with distance, in place of the look’s. false sets none. Make the fog once, with useMemo or outside the component.
  • shadowDistance is how far along the view the sun’s shadow reaches, in metres, and shadowMapSize the side of each cascade’s map. Left out, the quality level sets both; set here, they hold at every level. A top-down or strategy camera that stands far above its ground reaches farther and splits later, as The sun’s shadow shows.
  • farShadowMapSize and farShadowDistance size the cached shadow of what stands still past the cascades, and its reach round the camera; the quality level sets them too. Past the cascades says what stands still.
  • cachedShadowMapSize sizes the sun’s cached shadow of what stands still near the camera, which the cascades then leave to it; the quality level sets it too. The cached shadow says how it draws again.
  • lightMask darkens regions of the world by a texture laid over the ground from above: the sun, the sky’s fill and the fog, as A light mask says.
  • canopy lights the shade under a canopy by a texture laid over the ground from above, with a share of the sun’s light the leaves pass down, as Light under a canopy says.
  • lighting={false} mounts none of it: no sun, sky, fill, exposure, fog or look stack. The game lights the scene itself, with its own <Lighting> or its own lights. A look then sets only the hour the Landscape opens on and its wind. TypeScript refuses any of the props above beside lighting={false}.

bounceLight={false} stops the drawn ground lighting the scene from below with its own colour, under the engine’s lighting or a game’s own <Lighting>, as The light off the ground says. It works beside lighting={false} too, since the bounce belongs to the ground.

A scene under the sea keeps the sun for the light that reaches down, draws no sky, and fills the distance with blue. The engine’s tests mount this file:

packages/engine/test/outside/UnderwaterLandscape.tsx
import { useMemo } from "react";
import { FogExp2 } from "three";
import { Landscape } from "@spawnite/engine";
const water = "#1a4a6a";
// Under the sea: no sky, the sun kept for the light that reaches down,
// and a blue fog that thickens with distance into a blue backdrop.
export function UnderwaterLandscape() {
const fog = useMemo(() => new FogExp2(water, 0.06), []);
return (
<Landscape map="meadow" sky={false} fog={fog}>
<color attach="background" args={[water]} />
</Landscape>
);
}

Unity keeps the skybox material, the sun source and the fog, linear or exponential, as separate entries of its Lighting settings. Unreal places the sky atmosphere, the sky light, the directional light and the height fog as separate actors that a level keeps or deletes. Godot’s WorldEnvironment holds the background, sky or plain colour, and the fog, beside a DirectionalLight3D node. Roblox’s Lighting service takes a Sky and an Atmosphere and its own fog values. Landscape takes the same three parts as three props, each replaceable on its own, and keeps the engine’s for whatever a game leaves out.

The scatter is what stands on the ground: trees, rocks, bushes and grass, placed from the map’s seed. A kind that blocks a walker, a tree or a rock, always stands in full: on every device, and in a room on the server and every client, so all of them stop the character at the same trunk. scatterShare thins only what decorates, a bush or a tuft of grass, which the character walks through. When a scene passes no scatterShare, the quality level in force sets the share, and a game changes each level’s share with Game’s quality prop; a scene that passes its own keeps it at every level.

Bushes and tufts shrink away into their feet past a distance from the eye that the quality level sets, 60 m at High, and a pass draws none past it; trees and rocks draw at every distance, since they are the map’s shape. So the map camera’s far zoom draws the ground, the trees and the rocks.

Each kind draws as one instanced draw per pass, of only the copies that pass’s camera holds: the screen draws what the view sees, and each of the sun’s two cascades what its own box holds. The copies stand still, so a tree over them, built once, finds each pass’s set without testing every copy. Each pass keeps its set, and finds it again only on a frame when its camera moved, turned or changed shape, or the scatter changed, so a still frame sends the GPU no new list.

Three props change what the Landscape draws its map with, each on its own:

  • ground is the ground’s material.
  • sea is the water’s, under material in the sea’s options, which Sea describes.
  • scatter is the scatter’s, each kind’s in turn. A bush or a tuft hands over the engine’s copy that shrinks it with distance, and a tree or a rock a copy of its file’s material, never the loaded file’s own.

Each takes a function handed the engine’s material, which returns either that material patched, such as with an onBeforeCompile of the game’s, or a material of the game’s in its place. A patched material keeps what the engine’s does: the ground’s paint of each cell, its detail and the quality level’s changes to it, and a bush’s shrink with distance. A material of the game’s replaces all of it: the engine goes on updating its own material out of sight, and the mesh goes on drawing the game’s. Either way, the ground still holds the walkers up and answers a ray, such as a click to walk. The engine’s program key carries the hook’s text, so a patched ground and a plain one never share a program.

The ground’s material is a three.js MeshStandardMaterial, so a patch splices the standard material’s chunks, such as #include <emissivemap_fragment>. Its mesh lies turned a quarter turn about x, so a shader reads world positions, modelMatrix * vec4(transformed, 1.0), rather than the mesh’s own position. The sea’s material is a MeshStandardMaterial too, with the water’s colour, roughness and normal spliced in.

The function runs once each time a Landscape mounts, and never again while it stands: a quality change patches the engine’s material in place. Define it once, outside the component. A material the engine handed over and the function returned patched is still the engine’s, which it disposes of with the Landscape. A material of the game’s is the game’s to dispose: one made at the module’s top lives for the page, and one the game makes for each Landscape is disposed in the cleanup of the component that mounts that Landscape.

false draws none of it, for a game that draws its own. Each of these turns off the drawing only: what the simulation reads stays.

  • ground={false} draws no ground. The engine’s ground still holds the walkers up, answers a ray such as a click to walk, and bakes the navmesh, unseen, so the game’s own ground draws over the same shape.
  • sea={false} draws no water. The sea’s height still rules the ground’s materials, the scatter and the navmesh.
  • scatter={false} draws none of the map’s scatter, nor its props of a scatter kind. A tree or a rock still blocks a walker where it stands, and the ground’s ScatterTrait still lists every placement, for a game that draws them itself.
  • grass={false} grows no grass, whatever the materials’ covers say. Otherwise grass takes the blades’ shape, as Grass says.

The meadow under snow keeps the engine’s shaders and lays white over their colour before the light, its lake is frozen, and the snow buries its grass. The engine’s tests mount this file:

packages/engine/test/outside/Snowfield.tsx
import { Landscape, type ModelMaterial } from "@spawnite/engine";
// The meadow under snow: the ground and the scatter keep the engine's own
// shaders, with white laid over their colour before the light, and the
// lake is frozen, so the Landscape draws no water. The snow buries the
// grass, so none grows.
/** Patches the material the engine hands over and returns it, so the
* ground keeps its paint and the quality level's changes, and a bush
* keeps shrinking with distance. Made once, outside the component. */
const snow: ModelMaterial = (engine) => {
const own = engine.onBeforeCompile.bind(engine);
engine.onBeforeCompile = (shader, renderer) => {
own(shader, renderer);
shader.fragmentShader = shader.fragmentShader.replace(
"#include <emissivemap_fragment>",
"diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.92, 0.95, 1.0), 0.7);\n#include <emissivemap_fragment>",
);
};
return engine;
};
export function Snowfield() {
return (
<Landscape
map="meadow"
ground={snow}
scatter={snow}
sea={false}
grass={false}
/>
);
}

Unity’s terrain takes a material of the game’s, and its trees and details their prefabs’ materials. Unreal’s landscape takes a landscape material, and its foliage types their meshes’ materials. Godot’s terrain add-ons and its MultiMeshInstance3D each take a material override, and Roblox’s terrain takes a MaterialVariant for each of its materials. Each of them hands the game the whole material. Landscape hands over the engine’s own as well, so a game can patch it and keep the engine’s paint, or replace it.

GroundPaint draws part of the ground in one of the map’s materials while it is mounted, over the map’s own paint, such as a bought plot’s tilled soil, a path laid or a field tilled. Mount it inside the Landscape with a material and one of two shapes, as GroundPaintArea says:

  • A rectangle: a center and a size in metres, and a rotationRadians about its center. A positive angle turns +x toward -z, as three.js’s rotation.y does.
  • Positions: a list of points in metres, each painting the map cell under it, such as the cells a player has tilled. A point on a cell’s edge paints the cell to its east and south, and a point off the map paints nothing.

A material the ground cannot draw throws, naming the fix. So does a number that is not finite.

Each cell whose centre lies inside the rectangle takes the material with no overlay, so the edge follows the map’s cells, 1 m on most maps, and blends with its neighbours as a painted cell does. A cell on the unturned rectangle’s west or north edge is inside, and one on its east or south edge is not, so two rectangles that abut share no cell. A rectangle past the map’s edge paints the cells inside the map.

replace names the materials a paint may cover, as Roblox’s Terrain:ReplaceMaterial takes a source material: a path over grass but not over road. A cell shows its overlay where the overlay’s blend is over one half, and its base otherwise. Where two paints cover one cell, the one with the higher sortOrder draws, as an Unreal decal’s Sort Order does; of two at the same order, the material later by code point draws. A paint’s replace reads the cells as the paints below it left them.

The ground, its bake and its grass follow the paint, and the ground lifts it as the component unmounts. The map’s file, the physics, the navmesh and the scatter do not change. A paint that brings a material new to the ground counts toward the 16 the ground draws at once, from the first cell it covers. The new material draws its swatch colour until its textures load. Its slot stays after it lifts, so laying it again loads nothing, until a new material needs the room.

The ground entity is each page’s own, so nothing streams the paint. Mount it from state the room streams, such as a trait on the game’s own entity, and every page draws the same, a page that joins later included. A new list each render, such as replace={["grass"]} written inline, paints the ground once. The engine’s tests mount this file:

packages/engine/test/outside/BoughtPlot.tsx
import { GroundPaint, Landscape } from "@spawnite/engine";
// A plot on the meadow that turns to tilled soil once it is bought.
// `isBought` comes from state the room streams, such as a trait on the
// game's own entity, so every page mounts the same paint, and a page that
// joins later draws it too.
export interface BoughtPlotProps {
isBought: boolean;
}
export function BoughtPlot({ isBought }: BoughtPlotProps) {
return (
<Landscape map="meadow">
{isBought && (
<GroundPaint material="dirt" center={[12, -8]} size={[16, 8]} />
)}
</Landscape>
);
}

A path and a tilled field, from a second file the tests mount:

packages/engine/test/outside/FarmPaths.tsx
import { GroundPaint, Landscape } from "@spawnite/engine";
// A gravel path laid at an angle across the meadow's grass, leaving any
// other material it crosses, and the cells a player has tilled drawn
// over the path. `tilled` comes from state the room streams, such as a
// trait on the game's own entity, so every page draws the same field.
export interface FarmPathsProps {
tilled: readonly (readonly [x: number, z: number])[];
}
export function FarmPaths({ tilled }: FarmPathsProps) {
return (
<Landscape map="meadow">
<GroundPaint
material="gravel"
replace={["grass"]}
center={[0, 10]}
size={[30, 2]}
rotationRadians={Math.PI / 6}
/>
<GroundPaint material="dirt" positions={tilled} sortOrder={1} />
</Landscape>
);
}

Roblox’s Terrain:FillBlock fills a box with a material and streams the edit to every client. Unity’s TerrainData.SetAlphamaps and Phaser’s layer.fill write a rectangle of a terrain’s or a tile map’s cells, and the game streams its own state. Godot’s terrain add-ons set a control map’s pixels and refresh it. Unreal paints its landscape’s layers in the editor only, and spawns a decal at run time.

findGroundMaterial(world, position) returns the material of the ground’s cell under a point after every paint over it, such as "dirt" on a bought plot. Use it for a footstep’s sound, or for a rule that asks what grows where. { unpainted: true } returns the map’s own material under every paint, and { ground } picks a Landscape where two stand. The read returns undefined where no Landscape stands and off the map.

A cell shows the map’s overlay where the overlay’s blend is over one half, and its base otherwise, the same rule replace reads. The read returns one material, as Roblox’s Humanoid.FloorMaterial does, where Unity’s TerrainData.GetAlphamaps returns each layer’s weight. It reads the Landscape’s ground at x and z, under a bridge or the sea too, and not the surface a foot stands on.

The room mounts the scene too, so the room’s ground holds the same paints, and a system reads them there. A paint lands as its component commits, after the state it mounts from. A rule that decides an outcome, such as whether a plot is tilled, reads that state rather than the paint. The engine’s tests run this file:

packages/engine/test/outside/footsteps.ts
import {
findGroundMaterial,
type GroundPosition,
type World,
} from "@spawnite/engine/core";
// The sound a footstep plays, from the ground under the foot after every
// GroundPaint over it: a tilled plot's dirt sounds soft, a laid path's
// gravel crunches.
const stepSounds: Readonly<Record<string, string>> = {
dirt: "step-soft",
sand: "step-soft",
gravel: "step-crunch",
};
/** The footstep sound at a point, or none off the ground. */
export function findStepSound(
world: World,
position: GroundPosition,
): string | undefined {
const material = findGroundMaterial(world, position);
if (material === undefined) return undefined;
return stepSounds[material] ?? "step-grass";
}

To see the paints on a running page, spawnite play paints lists each paint over a cell or a rectangle, in the order they draw. spawnite map query reads the map’s file, which holds no paint.

findGroundHeight(world, position, options?) returns the height of the ground’s surface under a point, in metres above the origin. Use it to stand copies on a hillside in a view, or in a system that places something on the ground. position is any value with x and z, such as a Vector3, and its y is ignored.

The read returns undefined where no Landscape stands, off the map, and at a point that is not a number, so a point past the edge places nothing rather than a guessed height. Where two Landscapes stand, pass { ground } to pick one, or the read throws. findGroundUnder(world, point) returns the ground under a point across every Landscape, by the rule under Two Landscapes, to pass as { ground }. With one, { ground } is optional.

It reads the surface the character walks on, at x and z, under a bridge or the sea too. After a terrain edit lands, it reads the new surface. Unity’s Terrain.SampleHeight and Unreal’s GetHeightAtLocation read the same, and Unreal also returns nothing off the landscape.

A terrain edit stands the same ground on a new surface. In a view, key the useMemo that places the copies on that surface, read with useTrait(ground, GroundTrait), as the sample does. The copies then stand again after an edit, and only then, as the InstancedModel page asks of its placements. The engine’s tests run this file:

packages/engine/test/outside/HillsideFirs.tsx
import {
type Entity,
findGroundHeight,
InstancedModel,
type InstancePlacement,
GroundTrait,
useLandscapeEntity,
useTrait,
useWorld,
type World,
} from "@spawnite/engine";
import { useMemo } from "react";
// A row of firs up the meadow's slope, each standing on the ground under
// it. Inside a <Landscape>, which owns the ground the firs read.
/** One fir every 6 m along x at z = -20, at the ground's height there.
* A point off the map has no height, so no fir stands there. */
export function placeFirs(world: World, ground: Entity): InstancePlacement[] {
const firs: InstancePlacement[] = [];
for (let x = -48; x <= 48; x += 6) {
const height = findGroundHeight(world, { x, z: -20 }, { ground });
if (height !== undefined)
firs.push({ position: [x, height, -20], turn: x });
}
return firs;
}
export function HillsideFirs() {
const world = useWorld();
const ground = useLandscapeEntity();
// A terrain edit stands the ground on a new surface: read again then,
// and only then, since a new list sorts and writes every copy again.
const surface = useTrait(ground, GroundTrait)?.surface;
const firs = useMemo(
() => placeFirs(world, ground),
[world, ground, surface],
);
return <InstancedModel model="spruce" placements={firs} />;
}

The ground grows grass blades on its own wherever its material grows them, as Unreal’s Landscape Grass Type grows from a layer and Roblox’s Grass terrain grows its decoration. The built-in grass grows green blades and dry-grass straw-coloured ones; any material grows them with a cover in its entry, in the game’s src/maps/materials.json or the map’s own materials, and a density of 0 grows none:

{
"materials": {
"grass": { "cover": { "density": 1.5, "height": 0.45 } },
"moss": {
"from": "dirt",
"cover": { "height": 0.12, "colour": "#3f5a2a" }
}
}
}
  • density, from 0 to 16, is how thick it grows: 1 is 220 blades a square metre, the default. Past 2 a cover thickens only near the camera, as Grow a dense lawn near the camera says. The scatter brush’s grass multiplier scales it per cell, up to 16, so the brush that thins tufts thins the blades too.
  • height is the tallest blade in metres, 0.32 when left out.
  • colour is the blades’ colour; the material’s swatch, tinted as the material is, when left out.
  • sway: false holds the blades still; they sway when left out.
  • Fifteen more fields shape the blades, their colour and the ground under them, as Ground maps, grass blades and foliage says.

The blades shrink where a cell’s grass gives way to another material, and grow from the cells a stroke paints, popping in as the scatter does. They draw in square patches of 6 m, only the patches within reach of the eye, each thinning with its distance, as the quality level sets: at Low a sparse field out to 12 m, where the ground’s texture carries the grass beyond it, and none at Minimum. The blades within 0.45 m of a character bend aside and stand shorter, and stand again once a jump lifts the character off them. A pointer’s ray passes through them to the ground.

They bend in the wind at Medium and High, and stand still at Low, under Reduced motion and on a material whose cover says sway: false; the player’s Plant sway setting wins over the level and Reduced motion. Still, the shader skips the wind’s work. They bend in the look’s wind, a light breeze when the look sets none, which blows on the effects’ clock: the devtools’ pause and a pausing modal hold it, and the devtools’ speed leaves it at the wall clock’s pace.

A game that paints its own ground over the engine’s shapes the blades with grass: a function handed the ground’s surface that returns GLSL the blade’s vertex shader runs after the engine’s own, as three’s onBeforeCompile splices a chunk. Holdfast gives a cover to every material its map resolves to, dirt and stone included, so the engine grows blades everywhere, and its shape trims them by the layout its paint draws. They give way to its roads, its cobbled ring and its worn earth, and take its paint’s colour. shape reads bladeRoot, where the blade stands once its clump pulls it in, bladeSeed, groundUp and coverShare, and changes bladeHeight, bladeColour, bladeTipColour, bladeRootColour and bladeDryness. Left alone, the tip and the root follow bladeColour by the material’s ramp, and a dry blade’s tip turns to straw.

The ground between near blades and past the last one takes the material’s own colours, not a shape’s. A shape that recolours the blades by place leaves the ground as the material sets it. functions holds what shape calls, and uniforms its numbers, colours and textures, each declared for it.

Only a page calls the function: when the grass first draws, again for each new surface, such as after a terrain edit, and again when the quality level turns the grass back on. A room and a headless run never call it, so they build nothing for grass they never draw. The shape’s dispose runs once the grass stops drawing that shape, so it frees what the function made, such as a texture Holdfast bakes of its roads; the engine disposes nothing of the shape itself. A uniform given as a three.js { value } object is shared with the blades, so a frame that sets its value moves them; a plain value is read once. Define the function once, outside the component: one made anew each render shapes the grass anew each render, which a development page warns of. The engine’s tests mount this file:

packages/engine/test/outside/Fort.tsx
import { Landscape, type GrassShape } from "@spawnite/engine";
// Grass that gives way to a fort's yard: the blades shrink to nothing
// between 30 and 38 m from the middle. Only a page calls the function,
// so a room builds nothing for grass it never draws. Made once, outside
// the component.
export const fortGrass: GrassShape = () => ({
shape: "bladeHeight *= smoothstep(30.0, 38.0, length(bladeRoot.xz));",
});
export function Fort() {
return <Landscape map="meadow" grass={fortGrass} />;
}

A map is a value the map schema in @spawnite/schema accepts: a seed, a size and a cell, its hills, regions, paths and rim. registerMap(name, values) parses it and keeps it under the name; it throws on a value the schema refuses, at registration rather than at mount. readMap(name) throws on a name nobody registered, which a <Landscape map> with that name does as well, into the game’s boundary, and on a map whose file has not loaded yet. The engine registers two maps, each a square centred on the origin:

  • grid, the map a <Landscape> with no map mounts: a flat floor 8 m across, from −4 to 4 m on x and z, of the grid material, one grey with a darker line every metre. It has no hills, no scatter, no grass and no water, and it loads no texture and no model, so a scene that shows a character or an effect opens fast. Roblox’s default place is the same flat Baseplate.
  • meadow, the nice map: 100 m across, from −50 to 50 m, with hills, a level clearing in the middle, a textured ground, the scatter and grass. A scene names it with <Landscape map="meadow">. Every texture its ground draws carries a license in the asset library: the platform’s own, or CC0.

Either is a map like a game’s own: gridMap and terrainMap in @spawnite/schema hold their values, and a game’s map can copy one.

A map that names a blockSet is a block map: rows of cells, heights and a legend in place of the heightfield, as Block maps says. <Landscape map> stands it as a BlockMap and reads its tops for everything else: the props, the scatter, picking, the physics bounds and the navmesh. It draws no heightfield, sea or grass, so the ground, sea and grass props are refused beside a block map. The heightfield’s own tools, such as edit_terrain and spawnite map terrain, refuse a block map by name.

The ground a Landscape stands, its GroundTrait’s surface, is one of two kinds, told apart by surface.kind. Reads every ground answers, such as readHeightAt and findWaterAt, need no check. Code that reads a heightfield’s own values, its size, its paths or its sea, narrows first: requireHeightfieldSurface(surface) and requireHeightfieldMap(readMap(name), name) return the heightfield and throw on a block map. add_map writes a new one with blocks: its size in columns and rows, the block it fills with, the height and a border.

A block map file also takes a diorama block: the hour it opens on, the weather, the camera’s fov, pitch, minZoom and maxZoom, and whether it is an interior. add_map writes the diorama look’s values, an hour of 17.2 under clear skies.

A game keeps its maps as JSON files under src/maps/, one per map, and registers them all from src/game.ts with registerMaps(import.meta.glob("./maps/*.json")). The page, the room and spawnite simulate all load that module, so they share one registration, which names each map after its file: src/maps/blockout.json is blockout. A file named meadow.json takes the place of the engine’s meadow. add_map writes a new file, and a tool rewrites one safely, where it could not rewrite a TypeScript module. spawnite map check, spawnite map describe and spawnite map query read src/maps/<map>.json when the game has it, and the game’s only map file when no --map is given; a game with several map files names one.

On a block map they read its cells: spawnite map describe gives its size, its tops’ heights, each block’s share and its void and water cells, and spawnite map query --at gives a point’s cell, block, top and water. spawnite map check names a prop over no block and an object entry its declaration refuses. A block map has no materials or regions, so map query --where is refused, and edit_terrain, edit_material and spawnite map terrain refuse one: its cells and heights are the file’s own rows.

The glob is lazy, so no map file is in the page’s first load. Each map is a file of its own, which a <Landscape> loads the first time it mounts the map, beside the physics, while the loading screen covers the game and counts the file in its bar, as it counts a model. A scene’s first frame waits for no map, and a large map adds nothing to the code a player downloads before the game starts. The game’s materials.json loads at once, and every map waits for it. Unity’s Addressables, Godot’s ResourceLoader and PlayCanvas’s assets without preload load a level’s data the same way, after the game has started. A map built in code stays in the code that registers it: registerMap(name, values) keeps it.

loadMap(name) loads a map outside a Landscape, such as in a test that reads it or to start a map’s load before its scene, and answers one promise for every call. A map whose file does not load rejects with a MapLoadError. Its failure is import-failed where the import failed, with the import’s error as its cause, or invalid where the schema refuses the file. A Landscape that waits for the map throws the error into the game’s boundary, and no ground stands: a page shows the game’s error screen, and a room does not open the scene.

A Landscape whose map changes to a map that has not loaded keeps its ground on the map it shows until the new one lands, and its children stay mounted. On a dev server, a write to a map’s file loads it again in the same way.

A map’s edits from the map editor live in its own file, under a terrain key. The key holds a grid of height offsets over the map’s base, a dot where no brush touched. The devtools’ Terrain section, spawnite map terrain and the MCP’s edit_terrain write it through the one writer every map file goes through, which changes only its own key. registerMaps loads the block with its map, so a game’s code does not change; the first edit of the engine’s meadow writes the map into the game as src/maps/meadow.json.

The ground bakes the base, then adds each edited point’s offset, and each scatter family’s multiplier per cell, from 0 to 2, scales how thick it grows there; an instance the editor erased, named by its spot, stands nowhere. New terrain, from a stroke’s end or an agent’s command, stands the ground on a new surface without reloading the page, and the physics, the navmesh, the scatter and the props follow it. A block written for another size or cell is refused at load, naming spawnite map terrain resample.

The rim is a bank an actor climbs, and the map’s edge behind it is a wall: nothing walks or jumps off the map, so a game needs no check for a fall. Each wall stands the map’s size above its highest ground, 100 m on meadow and 8 m on grid; a jumpHeight above that would clear it.

A map’s sea is one height in metres below which the ground is under water, the way Roblox’s Sea Level fills water up to a height and Unreal’s ocean is a plane at one. Every map has one. Left out, it stands 1 m below the lowest point of the map’s base, the heights before any terrain offset, so the map is dry until its ground is dug below that, and the Landscape draws no water while all the ground stands above it. The Landscape draws the water at that height with the water material’s ripples and waves, lighter in the shallows and broken into foam where the ground rises out of it. The water stands under the camera out to its far distance, so it meets the horizon, and past the map’s edge it is deep, with no shallows or foam.

The water is lit as the ground is, as the water material says, so it darkens at dusk and at night. The sea prop sets its look, and SeaOptions lists each field. The engine’s tests mount this file:

packages/engine/test/outside/Lagoon.tsx
import {
Views,
WaterContactFoam,
Landscape,
type SeaOptions,
type LandscapeOptions,
} from "@spawnite/engine";
// A lagoon: an azure deep that darkens toward the horizon, broken into
// low-poly facets of strong tint, a turquoise shelf whose shallows span
// 3 m of depth and turn to the deep in its last metre, the bed through
// clear water, a wide foam skirt at the shore, and a rock the sea rings
// with foam where it crosses the water, on a map whose ground dips under
// its sea.
const lagoon: SeaOptions = {
color: "#0878bc",
horizonColor: "#05487a",
shallowColor: "#36d3cc",
shallowDepth: 3,
shallowEdgeSoftness: 0.35,
absorptionDistance: 0.9,
facetSize: 6,
facetContrast: 0.25,
foamDepth: 0.4,
};
export function Lagoon({ map }: Pick<LandscapeOptions, "map">) {
return (
<Landscape map={map} sea={lagoon}>
<Views>
<WaterContactFoam>
<mesh name="rock" position={[12, 0, 8]} scale={[3, 4, 3]}>
<dodecahedronGeometry />
<meshStandardMaterial color="#8a7a6a" flatShading />
</mesh>
</WaterContactFoam>
</Views>
</Landscape>
);
}

shallowDepth is the depth in metres over which the shallow colour turns to the deep one, so a gentle shelf reads as a wide band. facetSize breaks the surface into flat triangles of about that size, each of its own tint, which tilt as the waves pass, for a low-poly sea. infinite: false draws the water across the map alone, for a sea that should end at the map’s edge.

The sea’s surface moves: a calm swell of 0.25 m, 12 m crest to crest, runs along the look’s wind unless the game sets waveDirectionRadians, and the water rises and falls against the shore, the piles and the hulls. Waves that move the surface says what each field does, and waveHeight: 0 keeps the sea flat. The Landscape draws it on a grid of 1 m cells, 192 m a side, centred where the camera’s view meets the water and stepped by whole cells so no vertex slides. The lift fades out from 72 m to 94 m from that centre, while the light and the crests’ tint keep rolling to the horizon.

The waves cost a frame nothing that shows above the run-to-run noise. That is measured on Juice Island’s production build from its spawn camera, 1920 by 1080, on a desktop RTX 3070 on 2026-10-16, over three interleaved rounds of 12 s. At Low, a frame took 3.05 ms of GPU with the waves off and 3.01 ms with them on. At High, in the rounds under the machine’s lighter load, it took 3.89 and 4.16 ms off and 3.93 and 3.96 ms on. The grid draws 73,728 triangles where the flat sea draws 2, and waveHeight: 0 draws the flat sea. A phone was not measured.

shallowEdgeSoftness is the share of shallowDepth, at its deep end, over which the shallow colour turns to the deep. At 1, the default, it turns smoothly from the shore. At 0.35 the shelf keeps its shallow colour over most of its depth and meets the deep in a hard line at its edge, as a reef’s does, and at 0 the line is exact. horizonColor is the colour the deep turns to where the view meets the water at a low angle, so a sea darkens toward the horizon; left out, it is the deep colour. Stylized Water 2 names its own the same.

facetContrast sets how far each facet’s tint strays from the water’s colour, lighter or darker: 0.07 by default, a faint mosaic, and about 0.3 for a sea that reads as cut glass. foamDepth is the metres of depth the shore’s broken foam reaches past the solid line at the edge, 0.22 by default. On a beach that falls a metre in six, 0.4 lays a skirt about 2.4 m wide. PlayCanvas’s water names its own foamDepth.

The four cost a frame nothing that shows above the noise. Measured on Juice Island from its core camera at Low, 1920 by 1080, on a desktop RTX 3070 on 2026-10-14, over three runs each: 3.92 ms of GPU a frame with them set, and 4.16 ms at their defaults. A phone was not measured.

absorptionDistance makes the water clear: the bed, and what stands on it such as coral or a pile, shows through the shallows and fades with depth. It is the metres of water that let through a third of the light of what lies under it, so the bed is nearly gone at three times it. The water fades by the depth down to the bed, or down to where the view meets what a <WaterContactFoam> holds under it, so wrap coral, a wreck or a pile’s foot in one to keep it bright under shallow water. A rock’s flank under the water fades by its own depth, deeper toward its foot. The sea reads such a top down to three times the absorption distance, where 95 % of the bed’s light is gone. Unity’s water names its own control the same, and reads a depth texture along the view; this sea reads the capture from above, which a phone draws once. The ambient occlusion reads the clear sea’s surface, as it reads an opaque sea, so what stands under it takes no occlusion shade.

A clear sea draws after the opaque things, at a render order of -1 among the transparent ones, and writes its depth. A particle above it that draws after it draws on top, and one under it is hidden. Among transparent things three draws a lower group order first, and within one group order a lower render order first, so a transparent thing that sorts before the sea is drawn over by it. A custom sort and a transmissive material follow their own order.

<WaterContactFoam> rings what it holds with foam where it crosses the water, with a band of the shallow colour round it, as the shore has. Wrap rocks in the shallows, piles or a moored hull in it, inside Views. A thing wholly under the water takes no ring, and a clear sea reads its top, as the paragraph above says. contactFoamWidth and contactShallowWidth set the two widths in metres, 0.8 and 2 by default, up to 4 m on a map of 256 m or less. Unreal’s water captures its terrain from above in the same way, and Crest and Unity’s foam generators take the same opt-in shape.

The sea captures, from above, the outline of what the wrappers hold where they hold surfaces both over and under the water. A rock or a pile rings where it crosses the water, a bridge over the sea or a wreck under it alone takes no ring, and an overhang rings at its widest edge. A wrapped bridge straight over a wrapped wreck rings as one thing crossing. The capture runs again within a quarter of a second of a mesh moving, turning, scaling, showing, hiding or loading, or of an instanced copy moving in place. A batch whose copies move every frame, such as walkers, captures four times a second, so keep it outside the wrapper. A skinned pose and a morph keep the ring they had.

A surface with no underside, such as an island model’s ground, rings where it dips under the water within one of the capture’s texels, as a closed thing’s two faces do. The guess reads the surface’s slope across the texel, at most 0.3 m, so a sheet that stays within 0.3 m of the water’s level without crossing it can ring too.

The capture covers a square the map’s size. It stands over the map while the camera’s view takes any of it, so the map’s own rings stay whole. Past the map, it stands round the middle of what the view takes of the water, and moves in steps of an eighth of its side once that middle leaves a step round where it stood. A thing far out at sea, such as Juice Island’s lobby 800 m off its map, rings too. A view past the map wider than the square rings its middle alone. Crest’s foam data follows the camera too.

Both cost a steady frame nothing that shows above the run-to-run noise of about 0.2 ms of GPU. That is measured on Juice Island from one idle camera at Low, 1920 by 1080, on a desktop RTX 3070 on 2026-10-14. With the contact foam on, two builds took 1.69 and 1.86 ms of GPU a frame, and 1.80 ms with it off. Clear water took 2.85 ms on and 2.86 off, in a run under heavier load. Finding where the view meets what stands under the water between the capture’s texels, rather than at the texel it stepped to, added 0.5 to 1 ms of GPU a frame. That is measured on Juice Island from its opening camera, 1920 by 1080, on an M2 Pro at about 70 % CPU load on 2026-10-18: 34.2 and 34.7 ms before, 35.4 and 35.3 ms after, at the median. The capture draws only when what the wrappers hold changes, or a view past the map moves it a step, so a still view pays for it once; that frame was not timed apart, and a phone was not measured.

A capture past the map costs a still view nothing that shows either. On Juice Island’s lobby at Low, 393 by 852, on an M2 Pro under heavy load on 2026-10-19, the lobby’s foam took 1.60 and 1.94 ms of GPU a frame on and 1.67 and 1.54 ms off, at the median, from a still camera. A pan that moves the capture was not timed apart.

<Wake> lays the foam a moving thing leaves on the water: a ribbon of foam behind where you mount it, widening into a V and fading. Mount it under a boat at the stern, on the waterline, inside a view:

packages/engine/test/outside/Ferry.tsx
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import type { Group } from "three";
import { Wake } from "@spawnite/engine";
/** Metres a second the ferry crosses the bay. */
const ferrySpeed = 3;
/** A ferry crossing the bay east, its bow along its +z, with its wake
* from its stern on the waterline. */
export function Ferry() {
const ferryRef = useRef<Group>(null);
useFrame((_state, deltaSeconds) => {
if (ferryRef.current)
ferryRef.current.position.x += ferrySpeed * deltaSeconds;
});
return (
<group ref={ferryRef} rotation-y={Math.PI / 2}>
<mesh position={[0, 0.4, 0]}>
<boxGeometry args={[1.6, 0.8, 4]} />
<meshStandardMaterial color="#b07a48" />
</mesh>
<Wake position={[0, 0, -2]} width={1.4} />
</group>
);
}

The wake lays a point each time its mount has moved minVertexDistance, a quarter of a metre by default, at most 60 a second. A slow frame lays the points it passed, each where and when the mount passed it, so a page at 20 frames a second lays the strip a 60 one does. A turn inside one frame is lost. The wake lies on the sea’s waves at each edge, as findSeaHeight reads them, and with no Landscape at the height it was laid. A step of more than maxStepDistance in one frame, 10 m by default, such as a teleport, starts a new strip rather than drawing foam across the gap. Give the wake a new React key to clear it.

widthCurve and opacityCurve shape a stretch of the wake over its life, as Unity’s widthCurve and Roblox’s WidthScale and Transparency do. Each is a list of keys, each a share of lifetimeSeconds from 0 at the stern to 1 where the stretch is gone, and a value, straight between keys. The width curve multiplies width and spreadSpeed’s width, so [{ share: 0, value: 0 }, { share: 0.2, value: 1 }] opens the wake from a point. The opacity curve multiplies opacity, and fades to nothing by default.

material hands you the engine’s foam material, a lit MeshLambertMaterial, to patch or replace, as the sea’s material does: an unlit MeshBasicMaterial keeps the foam white at night. The wake writes its fade into the vertex colour’s alpha, so a material of your own sets vertexColors and transparent. Its texture repeats along the water each textureLength metres, 4 by default, with u from 0 to 1 across and v along. The wake lays an up normal and a tangent along u, so a material with a normalMap shades; the engine’s own material shades by its faces, which lie on the waves. Define the function outside a component: each new function is asked again. Every prop is in WakeProps.

v counts the repeats from where the wake began, so a boat that sails for days keeps its texture sharp: v drops now and then by the fewest whole repeats, up to 1,024, over which every map the material reads on it repeats. Each map’s repeat, rotation and wrap count, so a mirrored map drops by two and one at a repeat.y of 0.3 by ten, and none moves. Where no such number exists, such as for a clamped map or a map turned off the water’s axes, v keeps its count. It keeps it too for a ShaderMaterial and for a material whose shader you patch through onBeforeCompile, since the wake cannot see what they read.

The second texture coordinate, uv1, holds the same u and, as v, each stretch’s normalized age: the share of lifetimeSeconds it has lived, from 0 at the stern to 1 where it is gone, the share a curve’s key reads. A cap that starts or ends a strip ages from when it was laid, like any point, and its age blends into the taper beside it. A map with channel set to 1 reads it with no shader, so an alphaMap that is white at v 0 and darker above thins the foam as it ages, and a normalMap on channel 1 shades the way age rises. A shader you patch in through onBeforeCompile declares attribute vec2 uv1; inside #ifndef USE_UV1, since three declares it only where a map reads channel 1, and hands its y to the fragment shader in a varying of its own. Niagara’s ribbons hand a material the same value as NormalizedAge; Unity’s and Roblox’s trails hand it no age.

The wake draws in one draw call and runs on the effects’ clock, so the devtools’ pause holds it. Each page lays its own, so a page that joins late sees no earlier foam, and a room draws none. It takes the scene’s light and shadows as the sea’s own foam does, so it darkens with the water at night. Unity’s TrailRenderer and Roblox’s Trail draw a general ribbon and ship no wake; their trails fade over 5 s and 2 s. Leave a moving boat outside a <WaterContactFoam>: its wake is its foam.

A wake costs the GPU nothing that shows above the noise, lit or unlit. On Juice Island’s lobby at Low, 393 by 852, on an M2 Pro at about 50 % CPU load on 2026-10-20, the frame took 1.60 and 1.63 ms of GPU at the median with the sailing boat’s lit wake, 1.59 and 1.62 ms unlit, and 1.63 and 1.63 ms with no wake.

On the CPU, the lobby’s wake, 10 s of foam, took about 0.10 ms a frame in the development build, nearly all of it reading the sea’s height at each edge of each point.

Roblox’s terrain water takes a colour, a wave size and speed, a reflectance and a transparency, and darkens with the day. Unity’s water surfaces offer finite and infinite extents, and Unreal draws a far mesh past its ocean’s edge. Godot leaves water to community shaders, which take the scene’s fog and sky.

The ripples and the foam move on the effects’ clock, as the grass’s wind does: the devtools’ pause and a pausing modal hold them, and the devtools’ speed leaves them at the wall clock’s pace. The waves run on the world’s clock, so a system reads the waves the page draws. The scatter thins toward the shore over its last 4 m and stands nothing under water. The ground’s water rules, deeperThan and shallowerThan under the sea and shoreWithin beside it, match by the depth and the distance to the water. The navmesh leaves out every triangle of the ground that dips more than wadeDepth, half a metre, under the sea, so a walker that follows it, a chaser or a character sent walking, wades into the shallows and no further. On a coarse grid it stops up to a cell short of that depth.

The water has no collider and no swimming: a character the player steers walks on its floor. findSeaHeight(step, position) reads the sea’s surface at a point at the tick, its level plus its waves, the same on the room and on every page, for a float, a buoy or a swimmer. A view reads it with the world, at the time the page draws the sea, as Unreal’s GetWaveHeightAtPosition reads its water. The ground’s surface answers sea, the height it stands at, isUnderWaterAt and getShoreDistanceAt, metres to the nearest water within a cell of the coast. The map editor’s Sea field sets it. spawnite map terrain sea --level 1 and the MCP’s edit_terrain write it. spawnite map check gives its height and names a view, a path or a prop under water. spawnite map describe gives its height, the share of the map under water and the shoreline’s length.

A map’s views are named cameras: an eye, a target, and a vertical field of view, or an ortho span in metres for an orthographic view. A view is a bookmark an agent writes once, at the angle its concept art was drawn from, and shoots again after every edit so the shots compare. frameView(map, name) returns the camera for a name: a views entry as written, a region, a path or a prop framed from 45° above at two and a half radii, or map, which every map answers with the whole ground from straight above. It throws on a name the map has not got, listing the ones it has.

An agent matching the map to concept art works in a loop. It writes a view at the art’s angle, edits named entries, and checks the map’s facts with spawnite map check. It shoots the top-down map view and the concept view, and asks a second agent to score both against the art until they pass or ten rounds are spent.

A map’s props are named objects that stand at a written spot, as a level file in Unity, Godot or Unreal keeps its placed instances. Each prop is one of five kinds:

  • A shape: kind is box, sphere, cylinder or plane, with a size in metres across, tall and deep, 1 m each by default, and a color.
  • A scatter kind: kind is one of the scatter’s kinds, such as treeRound or rockBoulder. It stands with the scatter whatever the scatterShare, and one that blocks a walker blocks it as the scatter’s own do. It stands at the middle of its kind’s heights.
  • A map object: kind names an object the game declares with defineMapObject, such as lamppost, with its fields, as Map objects says.
  • The player start: kind is player-start, with a position and a yaw. A map holds one at most, and the file is refused with two, naming both.
  • A model: model is a registered model’s name, or a path the game serves, such as assets/statue.glb, which needs no registration. The engine reads a registered name as that name, and any other value with a / as a path. spawnite map check cannot see what a game registers, so it reads any value with a / as a path and reports a path with no file behind it in the game’s public/ folder. The path reads from the page’s own folder, a leading / included, because the games site serves each game under its own folder, such as /arena/.

Every prop takes a position: [x, z] stands it on the ground there, and [x, y, z] holds it at that height. yaw turns it, in degrees, and scale multiplies its size, as one number or one per axis; a scatter kind takes one number, because the scatter scales a kind evenly. A model prop draws its file unturned, so at a yaw of 0 its front faces +z, where an <Entity model> turns it to face -z. A shape or a model is an entity named for its prop in the devtools tree, on a page, in a room and headless alike; only what it draws is a view, which the page renders and a room and a headless mount skip. A shape or model prop stands still, so it casts into the sun’s far shadow map; "static": false leaves out one the game moves.

A shape or a model prop is solid by default, as a Roblox part and an Unreal static mesh are. Its entity holds a fixed body: a model’s is the collider its manifest baked, a tree’s trunk or a rock’s convex hull, as the manifest says. A box stands as its box, a sphere or a cylinder as the hull of its shape, and a plane as nothing. "collider": false turns it off, for a prop nothing should meet, such as wood chips or a decal. A model no manifest baked stands as nothing, and the engine warns once, naming the model.

"props": {
"tower": { "kind": "box", "position": [0, 0], "size": [4, 12, 4], "color": "#8a8f99" },
"oak": { "kind": "treeRound", "position": [8, -6], "yaw": 30 },
"statue": { "model": "assets/statue.glb", "position": [-6, 4], "scale": 1.5 }
}

A <CharacterSpawn> given no position stands the player on its Landscape’s player start, as Unreal’s game mode picks a PlayerStart actor. The player faces out of the start’s front, +z at a yaw of 0, unless the spawn gives a facing. A spawn’s own position wins over the start, and a map with no start keeps the spawn at the origin. The builder’s Player start tool moves it, and place_props writes it like any prop.

A shape or a model prop takes an idleMotion, the options of <IdleMotion>: a float that rides the water or a sway that swings on a hook. Only what it draws moves; its entity stays where the map stands it, and it counts as moving whatever its static says. A scatter-kind prop refuses the field, since the scatter draws it.

"boat": {
"model": "assets/boat.glb",
"position": [23, -0.6, 30],
"scale": 2,
"idleMotion": { "kind": "float", "rollRadians": 0.045, "pivot": [0, 0.3, 0] }
}

Gravity belongs to the world’s physics scene, not to a Landscape: every actor and every dynamic body falls at it, 9.8 m/s² down by default. A game sets it once with definePhysics({ gravity: [0, -20, 0] }), exported from src/game.ts as physics and passed to <Game physics>, as Plugins says. A system changes it in play with setGravity(step, gravity), which the room streams to every page. A character falls along its y component. The devtools’ gravity card sets the same value in a game played alone.

Each Landscape keeps a day clock, which the sun, the moon and the sky follow. Two props set it:

  • startHour is the hour the Landscape opens on, from 0 up to 24, with the minutes as a fraction: 18.5 is half past six in the evening. An hour past either end wraps into the day, so 25 opens at 1. A map whose diorama block names an hour opens on that hour instead, as each of the map’s level settings wins over the game’s props. With neither, the look’s hour is used, or 10.
  • dayLengthSeconds is the real seconds a whole day takes, 1200 by default.

The clock keeps the hour at one tick and the length of the day, and each read works the hour out from the tick it stands on. A game’s rules read the hour of the step they run in, and a read between steps reads the hour of the step the world runs next.

So nothing writes the clock while it runs: a room writes it only when a game sets the hour, the length or a pause, and each page works out the same hour from the same tick. A room, a headless run and spawnite simulate all move it as they step. The devtools’ pause holds it and their speed scales it.

Five functions read and change the clock:

  • findTimeOfDay(world) returns the hour, from 0 up to but not 24, and a view reads it too. It returns undefined where it finds no clock: in a scene with no Landscape, such as a menu, on a page in a room before the room’s clock has streamed in, and for a ground with no clock.
  • setTimeOfDay(authority, hour) puts the clock at an hour, wrapped into the day, and the clock keeps running from there. A jump in the hour is an outcome, so it takes the step’s authority, as dealDamage does. It throws in a world with no day clock, naming <Landscape> as the fix, and on an hour that is not finite.
  • setDayLength(authority, seconds) sets the real seconds a whole day takes, from the hour the clock stands at. It throws on a length that is not finite or is shorter than shortestDaySeconds, and names pauseDayClock for a clock that should stop.
  • pauseDayClock(authority) holds the hour where it stands, for a cutscene or a menu, and resumeDayClock(authority) runs it on from there.

Where two Landscapes each keep a clock, each takes { ground }, the ground entity of the Landscape whose clock to use, and throws without it.

A page may light its own sky at another hour than the clock’s, such as a night that turns as its waves go by. A view calls setLightingHour(world, hour), and the sun, the moon and the sky’s light draw at that hour on this page alone, while the clock that logic reads keeps running. null hands the lighting back to the clock, and so does the page’s last Landscape unmounting.

Nothing reads the page’s hour back, so a HUD that shows it keeps its own value. The call refuses a room’s world, which draws nothing: it throws in development and logs once in a built game. A Roblox LocalScript sets Lighting.ClockTime for its own client the same way, though there a write from the server lands over it.

The hours of the night are the game’s own. This plugin spawns a prowler at each lair as night falls and destroys them at dawn, both through the step, and leaves every other entity alone. A plugin runs in every scene of the game, so the lairs are what keep the night to one scene. Each is an entity the scene places, <Entity prefab={lair} position={[10, 0, 0]} />, and a scene that places none, such as the lobby, never sees a prowler. The plugin removes every prowler once no lair stands. Each lair keeps whether the last step was night there, so its prowler comes once a night however many fall, and at once in a scene that opens at night. The engine’s tests run it against a real room and alone:

packages/room/test/outside/night.ts
import {
addTrait,
ChaseTrait,
createQuery,
definePlugin,
definePrefab,
defineTrait,
destroy,
findEntity,
findTimeOfDay,
HealthTrait,
Not,
spawn,
TargetableTrait,
TransformTrait,
updateEach,
type AuthoritativeStep,
defineSystem,
Phase,
Reach,
} from "@spawnite/engine/core";
// Prowlers that hunt at night, built as a creator's plugin is, from the
// engine's public entry alone: as night falls the room spawns one at each
// lair, each chasing the nearest player's character, and as day breaks it
// removes the ones still standing and no other entity. Their traits
// stream, so every page draws them, and a game played alone runs the
// same. The lairs are entities a scene places, so a scene with none, a
// lobby or a menu, never sees a prowler, and a prowler left behind goes.
/** Marks a prowler, so the dawn removes this plugin's own; the stream
* carries it. */
export const ProwlerTrait = defineTrait("outsideProwler");
/** The hour night falls, and the hour day breaks. */
export const duskHour = 19;
export const dawnHour = 6;
/** Marks a lair, where a prowler comes from at dusk. */
export const LairTrait = defineTrait("outsideLair");
/** One lair: a scene places it with `<Entity prefab={lair} position>`. */
export const lair = definePrefab("outsideLair", { traits: [LairTrait] });
/** On each lair: whether the last step was night there, so its prowler
* comes once a night however many fall, and at once in a scene that
* opens at night. Named, so a checkpoint restores it; the room alone
* keeps it. */
const LairNightTrait = defineTrait(
"outsideLairNight",
{ isNight: false },
{ reach: Reach.Authority },
);
const prowlers = createQuery(ProwlerTrait);
const lairs = createQuery(LairTrait);
const newLairs = createQuery(LairTrait, Not(LairNightTrait));
const watchedLairs = createQuery(TransformTrait, LairNightTrait);
/** One prowler: a model that chases the nearest player's character and
* can be targeted. `visor-bot` is the example template's own model; a
* game draws its prowlers with any model it registers. */
export const prowler = definePrefab("outsideProwler", {
traits: [
ProwlerTrait,
HealthTrait({ current: 20, maximum: 20 }),
ChaseTrait({ speed: 3, reach: 1 }),
TargetableTrait,
],
model: "visor-bot",
});
export const night = definePlugin(() => ({
name: "night",
// No runsOn: the room alone spawns, and a game played alone is its
// own room.
systems: [
defineSystem({
name: "prowl",
phase: Phase.Rules,
run: prowlAtNight,
}),
],
}));
function prowlAtNight(step: AuthoritativeStep) {
const world = step.world;
const hour = findTimeOfDay(world);
// No Landscape in the scene, such as a menu, keeps no night.
const isNight = hour !== undefined && (hour >= duskHour || hour < dawnHour);
for (
let placed = findEntity(world, newLairs);
placed;
placed = findEntity(world, newLairs)
)
addTrait(step, placed, LairNightTrait);
updateEach(step, watchedLairs, ([{ position }, lairNight]) => {
if (lairNight.isNight === isNight) return;
lairNight.isNight = isNight;
if (isNight) spawn(step, prowler, { position });
});
// Day, or a scene with no lair, such as a lobby or a menu: no prowler
// stands.
if (isNight && findEntity(world, lairs)) return;
for (
let standing = findEntity(world, prowlers);
standing;
standing = findEntity(world, prowlers)
)
destroy(step, standing);
}

Roblox’s Lighting.ClockTime holds the same number, from 0 up to 24, and stands still until a script moves it; its TimeOfDay is the same hour as a string, such as "18:30:00". In Unity, Unreal and Godot, a game or a plugin drives the sun, and a networked one sends a start and a rate that each client runs on. Here the clock runs on its own from the step’s tick, because the hour decides what spawns and must be the same in a room, on every page and in a headless run.

A scene can mount two Landscapes at once. Each dresses, draws and bakes only its own ground, and puts its scatter’s rows under its own row in the devtools tree. Each ground’s terrain is a fixed body in the world’s one physics scene, so an actor walks on whichever terrain it stands on, and a character spawned in the second Landscape stands on its ground. The two share the scene’s gravity: a game that needs two separate physics spaces runs two Games. The devtools frame the Landscape the player’s character is in, or the first Landscape mounted when the character is in none: frameView and frameShot read that Landscape’s map. The inspector of a Landscape’s row reads and sets the day clock of the Landscape the selected row sits in, or the first Landscape’s with no row in a Landscape selected, and titles its card with that Landscape’s row label, such as Landscape #3.

A character’s hair and cloth and the camera’s eye keep above the ground under each point they ask about. That is the first ground a ray cast straight down from half a metre above the point meets, of those whose map reaches it, else the lowest. Off every map it is the same at the largest map’s edge. Unlike findGroundHeight, it picks a ground rather than throwing. A heightfield is no solid below its surface, so a point in the gap between two grounds reads the lower one.

findGroundUnder(world, point) names that ground in a system or a plugin. useGroundHeight() hands the same reader to a view of your own, such as one that lays a body along the slope. VrmView’s ground takes another reader, or null for a plane through the feet.

The ground is one entity with the ground, scatter, obstacles and navmesh traits on it. Its terrain is one fixed body on that entity, holding the heightfield, the wall along each edge and the scatter’s hulls, which a map edit replaces alone, so every other body keeps its pose and velocity. The day clock is another entity, which carries InLandscapeTrait naming its ground. useLandscapeEntity() reads the ground from anywhere inside the Landscape, for UI that wants the map. useLandscapeLevel() reads its level settings as they stand, the map builder’s edits over the file’s, again each time they change, such as an interior that hides what draws outdoors alone.

The page draws the ground in chunks of 128 m a side, each a mesh of its own, so the screen, each of the sun’s cascades and each of the ground’s bakes draws only the chunks its camera holds. Each chunk draws at one of four levels of detail, which keep it within 5 mm, 5 cm, 20 cm and 50 cm of the grid’s heights. Each render picks, for every chunk, the coarsest level whose error spans no more than the quality level’s groundPixelError of the canvas’s CSS pixels where the chunk comes nearest the camera. At 1 pixel, 1080 pixels high and a 60 degree view, that switches at about 47, 187 and 468 m. A chunk goes coarser only once its error is under 0.8 of that, so a camera standing at a switch does not flicker between two. The distance is the camera’s to the chunk, so turning the camera switches nothing. Toward a frame’s edge the error can stand over the budget, by up to one over the cosine of the angle off the view’s middle: 1.5 times at a 60 degree view’s corner at 16 by 9.

Every level keeps its chunk’s edge at the grid’s own points, so two chunks meet exactly at any two levels, with no crack and no skirt. The levels are made on a worker thread, off the page’s own: first the three coarse levels of every chunk, nearest the camera first, then the finest. A chunk draws its whole grid until its levels land. A page with no worker, or whose worker fails, makes them between its frames instead. The physics heightfield, the navmesh and a pointer ray read the whole grid, and every chunk draws its grid while a terrain tool is held, so a stroke shows as it is drawn.

On the forest edge at 1.5 km and Low, from the spawn camera, the ground’s chunks cut the frame’s triangles from 5.7 M to 2.2 M on the screen and from 8.7 M to 1.4 M in the shadow maps, measured on an RTX 4070 SUPER on 2026-10-17. The levels add a draw a chunk in view, about 30 more to the screen there, and about 12% more index memory than the grid’s.