Concepts
Coordinates
z (up)
│
│
╱ ╲ │ On screen, x goes down-right, y goes down-left, z goes up.
y ╱ ╲ x 1 tile = 1. Office furniture is modeled with 1 tile ≈ 1 m.Scene coordinates
[x, y, z]: a point on the floor grid. A scene isgrid.width × grid.depthtiles.Object position
position: the smallest x/y corner of the footprint, plus the height z it sits at. Not the center.- To place by center,
placeAt(scene, registry, assetId, [cx, cy], rotation)works out the corner and height.
- To place by center,
Object rotation
rotation: 0, 90, 180 or 270. At 90° and 270° the footprint's width and depth swap (rotatedSize).- A person faces +y at 0°, −x at 90°, −y at 180° and +x at 270° (
facing(dx, dy)).
- A person faces +y at 0°, −x at 90°, −y at 180° and +x at 270° (
View rotation (
<IsoScene rotation>): the direction the whole scene is viewed from. The data does not change; only the drawing turns.Screen coordinates: SVG user coordinates (before zoom and pan). The projection is
iso(x, y, z, unit):sx = (x − y) · cos30° · unit sy = (x + y) · 0.5 · unit − z · unitTo do Function Scene coordinates → screen projectPoint([x, y, z], scene, rotation)Screen → scene coordinates on the plane at height z unprojectPoint([sx, sy], scene, rotation, z)Screen → the floor below it (top of floor tiles and rugs) floorPointAt(scene, registry, [sx, sy], rotation)Floor coordinates of a click (React) <IsoScene onSceneClick={(point, object) => …}>
Scenes
interface Scene {
version: 1
meta: { id: string; name: string }
projection: { type: 'isometric'; unit: number } // unit: screen px per tile (default 25)
grid: { width: number; depth: number; snap: number }
assetPacks: Record<string, string> // pack id → version range
objects: SceneObject[]
paths?: ScenePath[] // paths cargo flows along
timelines?: Timeline[] // cargo spawning on paths, part keyframes
routines?: Routine[] // routines defined in this scene (added to the default routines)
clock?: SceneClock // time of day: what time 0 s is, and how many minutes pass per second
}- On disk it is a
.isora.jsonfile. Validate it withparseScene(json)when reading, and check it again withvalidateScene(scene)after editing. createScene()without a name givesmeta.name: 'Untitled scene'.- To build in code, use
createSceneBuilder(scene, registry, { packs }). Floors (floor), walls and doors (room), rows (row), placing on top (on) and seating (sit) follow the same stacking rules as Studio. validateScenechecks the JSON shape;lintScene(scene, registry)checks the layout: overlapping furniture, floating items, objects outside the grid, seats nobody can reach, trapped people, and activities whose routine has no spot to use. It is worth running once in a test before showing a scene.
const issues = lintScene(scene, registry)
expect(issues.filter((i) => i.severity === 'error')).toEqual([])
console.log(formatIssues(issues)) // ! [blocked-spot] chair-3: seat 0 has no free spot beside it, so nobody can sit there.Messages are in English by default. Pass { locale: 'ko' } for Korean (lintScene(scene, registry, { locale: 'ko' })) — see Languages.
Assets and objects
An asset (AssetDef) is a shape and its properties; an object (SceneObject) is one placed copy in the scene. The same asset can be placed many times.
| Asset field | Meaning |
|---|---|
name · names | Display name in English, and translations next to it ({ ko: '책상' }). Read with assetName(asset, locale) |
parts | A list of boxes (parts). Drawing, silhouettes, depth sorting and collisions all come from the part boxes |
a part's screen | An animated screen on that face (code, chart, dashboard, status, alert, video) |
a part's decals | Images and text printed on that face (logos, name plates, patterns). See below |
footprint · height | Width × depth taken on the floor, and height |
surface · surfaceHeight | Other things can be placed on top (desk → monitor). They sit at the height of the top that holds them |
ground | A floor tile. Always laid at z = 0 and drawn before everything else |
anchor: 'floor' | Always stands on floor tiles, never on top of desks or conveyors (people, walls, doors) |
states · defaultState | Named states. They change part materials, animations, opacity and screens. Each state has a label (and labels for translations) |
poses | Other poses (a person's sit). Each pose has its own footprint and parts |
seats | Places to sit (chairs, sofas, benches, wheelchairs). With pose: 'lie' it is a place to lie down (beds) |
spots | Places to stand and use something (in front of a water cooler or a control panel) |
walkable | People can walk through it (doors) |
hollow | The inside is empty (booths, tents, guard posts, L-shaped desks). Pathfinding blocks only where parts stand, not the whole footprint, so seats and spots inside can be used |
| Object field | Meaning |
|---|---|
asset · position · rotation | What, where, and which way it faces |
state | State name (if the app passes states, that wins) |
color | Changes the color of tint parts |
pose · seat | Seated person: pose: 'sit'; lying person: pose: 'lie'; seat: { on: 'furniture id', index: 0 }. People and furniture are separate objects |
behavior | With { routine, desk? }, the person moves by that routine during behavior playback |
State resolution order: states passed by the app → states set by behavior playback (monitor off on an empty desk, door open when someone walks through) → the object's state → the asset's defaultState.
Decals: images and text printed on faces
A logo on a crate, an equipment name plate or a corrugated steel pattern is printed on a face instead of stacking more parts.
box({
id: 'body',
size: [1.26, 1.44, 0.72],
material: '#f46800',
decals: [
{ face: 'top', image: '/icons/grafana.svg', size: 0.72 }, // logo on the lid
{ face: '+y', text: 'CI/CD', color: '#2b3a5a' }, // name plate on the front
{ face: '+x', image: ribsPattern, fit: 'fill' }, // pattern filling the whole face
],
})faceistop,+xor+y. Vertical faces are drawn only when they face the viewer after rotation (the same rule as screens).sizeis the share of the face it takes (default 0.8);fit: 'fill'stretches it to fill the face (patterns).turnrotates the image within the face. Use it to keep a logo upright on cargo that lies down 90° along a belt.- Text is sized to fit the face.
imageis a data URI, a relative path or an http(s) URL (external URLs trigger a warning when published as a pack).
Paths and timelines
paths are routes cargo travels along; timelines send cargo along those paths and move parts with keyframes.
scene.paths = [{ id: 'in', points: [[6, 18, 1.45], [6, 3.5, 1.45]], speed: 0.8 }]
scene.timelines = [{
id: 'line',
duration: 19,
loop: true,
tracks: [
{
type: 'spawn', asset: 'nullus/line/pallet-in', path: 'in', every: 19, fade: 0.6,
// The crane drops a crate where the pallet has gone 3.7 tiles. From there they travel together.
loads: [{ asset: 'nullus/line/oss-grafana', at: [0, 0, 0.18], from: 3.7 }],
},
{ type: 'keyframes', target: 'rig-in-0', prop: 'z', keys: [[0.1, 0], [0.24, -2.6], [0.36, 0]] },
],
}]| Spawn track | Every every seconds, creates cargo at the start of the path and carries it to the end. Long cargo lies along the direction of travel (the asset's x axis is the direction of travel) |
face | 'direction' turns the cargo so its front points where it is going instead of laying it down. Use it for vehicles drawn with +y as the front, like people — on a round-trip path the car never appears to drive backwards |
loads | Items riding on the cargo. at is the position relative to the cargo; from and to are how far along the path (in tiles) they appear and disappear |
fade | Fade in and out over this many seconds |
| Keyframe track | Moves a value of target (object id or object id/part id) with [progress 0–1, value] keys |
Keyframe prop | Position x, y, z; size w, d, h; opacity |
When several tracks target the same value, positions and sizes add up and opacities multiply. Temporary object ids start with ~.
Motion reads right only when it follows physics. A path's z is the world coordinate of the surface the cargo rides on (for a conveyor standing on floor tiles, add the tile's 0.1). A path must start and end within a stretch that has something to hold the cargo, and when you lower a hanging part, grow h by as much as you lower z so its top end stays attached. lintMotion(scene, registry) runs one loop and catches these.
// A robot arm lowering its gripper — the ram extends, so it never comes off the arm
{ type: 'keyframes', target: 'arm-1/wrist', prop: 'z', keys: [[0, 0], [0.5, -0.35], [1, 0]] },
{ type: 'keyframes', target: 'arm-1/wrist', prop: 'h', keys: [[0, 0], [0.5, 0.35], [1, 0]] },
{ type: 'keyframes', target: 'arm-1/gripper', prop: 'z', keys: [[0, 0], [0.5, -0.35], [1, 0]] },Activity spots
Once placed in a scene, the seats and spots of furniture and equipment become activity spots. Their ids are objectId#seat0 and objectId#spot0.
- A seat's activity is the one it declares (
sofa→rest); otherwise it is decided by what is in front of it: a desk with a monitor or laptop →work, a table →meet, nothing →rest. A place to lie down (a bed) issleep. - Built-in activities:
workmeetrestsleepdrinkoperateprintreadwanderidle(plusexitandaway, used by the engine). If an asset uses a new name, routines that list that name pick that spot. Display names come fromactivityLabel(activity, locale)('work'→'Work', or'일하기'in Korean). - A monitor or laptop on a desk is on only while someone is working (
work) at that seat. It turns off when the seat is empty or the person dozes there. findActivitySpots(scene, registry)returns the spots in a scene, plus where a person stands for each (approach) and how they look seated (seated).
Time
- Playback time: seconds, starting at 0. If the scene has a
clock,clockMinutes(clock, t)gives the time of day (in minutes). - Clock (
createTimelineClock()): play, pause, speed and seek. Behavior and timeline playback can share one.getState().timeandsubscribe: change every tick (80 ms). Use them for things that need not update often: a time-of-day display, bubble activities, inspectors.timeAt(performance.now()): the time for this frame. Use it for anything drawn every frame, such as walking people (LiveSceneLayeruses it).
- With
prefers-reduced-motion, behavior and timeline playback stop and draw at 0 s.
Determinism
People's motion is decided only by scene + seed + commands (including when each was given) + playback time.
createBehaviorWorld(scene, registry, { seed }).frameAt(t)gives the same picture for the same t no matter when, how often or in what order you call it. Server rendering, rewinding, fast-forwarding and screenshot tests all rely on this.- Commands (
command) are inputs too. The same command at the same time gives the same motion.useBehaviorPlaybackkeeps a command log and, when the scene is edited and the world is rebuilt, replays the commands at the same times.