Skip to content

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 is grid.width × grid.depth tiles.

  • 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.
  • 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)).
  • 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 · unit
    To doFunction
    Scene coordinates → screenprojectPoint([x, y, z], scene, rotation)
    Screen → scene coordinates on the plane at height zunprojectPoint([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 ​

ts
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.json file. Validate it with parseScene(json) when reading, and check it again with validateScene(scene) after editing.
  • createScene() without a name gives meta.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.
  • validateScene checks 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.
ts
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 fieldMeaning
name · namesDisplay name in English, and translations next to it ({ ko: '책상' }). Read with assetName(asset, locale)
partsA list of boxes (parts). Drawing, silhouettes, depth sorting and collisions all come from the part boxes
a part's screenAn animated screen on that face (code, chart, dashboard, status, alert, video)
a part's decalsImages and text printed on that face (logos, name plates, patterns). See below
footprint · heightWidth × depth taken on the floor, and height
surface · surfaceHeightOther things can be placed on top (desk → monitor). They sit at the height of the top that holds them
groundA 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 · defaultStateNamed states. They change part materials, animations, opacity and screens. Each state has a label (and labels for translations)
posesOther poses (a person's sit). Each pose has its own footprint and parts
seatsPlaces to sit (chairs, sofas, benches, wheelchairs). With pose: 'lie' it is a place to lie down (beds)
spotsPlaces to stand and use something (in front of a water cooler or a control panel)
walkablePeople can walk through it (doors)
hollowThe 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 fieldMeaning
asset · position · rotationWhat, where, and which way it faces
stateState name (if the app passes states, that wins)
colorChanges the color of tint parts
pose · seatSeated person: pose: 'sit'; lying person: pose: 'lie'; seat: { on: 'furniture id', index: 0 }. People and furniture are separate objects
behaviorWith { 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.

ts
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
  ],
})
  • face is top, +x or +y. Vertical faces are drawn only when they face the viewer after rotation (the same rule as screens).
  • size is the share of the face it takes (default 0.8); fit: 'fill' stretches it to fill the face (patterns).
  • turn rotates 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. image is 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.

ts
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 trackEvery 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
loadsItems 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
fadeFade in and out over this many seconds
Keyframe trackMoves a value of target (object id or object id/part id) with [progress 0–1, value] keys
Keyframe propPosition 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.

ts
// 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) is sleep.
  • Built-in activities: work meet rest sleep drink operate print read wander idle (plus exit and away, used by the engine). If an asset uses a new name, routines that list that name pick that spot. Display names come from activityLabel(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().time and subscribe: 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 (LiveSceneLayer uses 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. useBehaviorPlayback keeps a command log and, when the scene is edited and the world is rebuilt, replays the commands at the same times.

Released under the MIT License.