Moving people
There are three ways to move people. You can mix them in one scene.
| Way | How | When |
|---|---|---|
| Routines | Give the object behavior: { routine }. The engine picks spots, walks there, sits, works and rests | A living office or factory in the background |
| Commands | The app calls command(id, …): "go over there", "sit in chair 3", "leave" | Showing real events such as badge entries, calls or meetings |
| Scene data | Change the object's position or pose. The change is bridged with walking and sitting (in a straight line, no pathfinding) | Editors, occasional teleports |
Routines and commands are planned by the same engine (createBehaviorWorld). So commanded people also walk around furniture and walls, make way for people walking their routines, and never pass through desks or chair backs when sitting.
Routines
{ "id": "kim", "asset": "isora/people/office-worker", "position": [3, 4, 0.1], "rotation": 0,
"behavior": { "routine": "office", "desk": "chair-3" } }routine: a built-in routine id or an id defined in the scene'sroutines.desk: the chair this person sits in first when working. Nobody else uses it.Built-in routines (
DEFAULT_ROUTINES)id Name What they do officeOffice worker Work 30–60 s ↔ water, rest, wander focusedFocused Work for long stretches, now and then print or get water relaxedRelaxed Work briefly, rest and meet for long meetingMany meetings Meetings ↔ work factoryField work Operate equipment ↔ rest visitorVisitor Wander, rest, read documents office-dayOffice worker (commutes) Arrive 09:00, out for lunch 12–13, leave 18:00 (when the scene has a clock)factory-shiftShift work 08:00–17:00, rest at lunch manualManual control Stays put until a command arrives Routine names are English; Korean names are in
names.ko. Read them withroutineName(routine, locale).New routines: for each step (
steps), list activity weights and how long to stay (seconds). A schedule (schedule) turns on when the scene has aclock.
scene.clock = { start: '08:50', minutesPerSecond: 2 } // 0 s = 08:50, 2 minutes per second
scene.routines = [{
id: 'support',
name: 'Customer support',
names: { ko: '고객 지원' }, // optional translations
steps: [
{ activities: { work: 3, meet: 1 }, duration: [40, 80] },
{ activities: { drink: 1, rest: 1 }, duration: [10, 20] },
],
schedule: [
{ from: '18:00', to: '09:00', away: true }, // leave through a door: off work
{ from: '12:00', to: '13:00', steps: [{ activities: { rest: 1 }, duration: [60, 60] }] },
],
}]Playback: <IsoScene playBehaviors behaviorSpeed={2} />, or let the app own useBehaviorPlayback and pass it as behavior, as shown below.
Commands
Setup
The app owns behavior playback and passes it to <IsoScene>. The app and the view share the same playback, so commands from the app show up right away.
import { IsoScene, useBehaviorPlayback } from '@isora/react'
function Floor({ scene, registry }) {
// People without a behavior can still be commanded if listed in control (they stay put until commanded).
const behavior = useBehaviorPlayback(scene, registry, { control: ['visitor-1'] })
useEffect(() => {
// When a badge entry comes in, send that person to their desk.
return badgeFeed.subscribe(({ personId, deskId }) => {
behavior.command(personId, { type: 'do', spot: deskId })
})
}, [behavior.command])
return <IsoScene scene={scene} registry={registry} behavior={behavior} showActivities />
}behavior.command(id, commands)commands at the current playback time. Returnsnullfor an unknown person.- Who can be commanded: people with a
behavior, plus people listed incontrol. You can also writebehavior: { routine: 'manual' }in the scene.
Command types
| Command | What it does |
|---|---|
{ type: 'go', to: [x, y] } | Walk there and stand. to is the corner of the person's footprint (0.5×0.5) — if you know the center, use [cx − 0.25, cy − 0.25]. If the place is not standable, the nearest standable place; if someone is already there, a free place around it (send several people to one place and they gather around it) |
{ type: 'go', to, activity: 'drink' } | Go there and stand doing that activity — drinks coffee standing even without a spot (bubble and animation included) |
{ type: 'do', spot: 'chair-3' } | Do the spot's activity at a free spot of that furniture or equipment. spot is a furniture id or a spot id (chair-3#seat0) |
{ type: 'do', activity: 'drink' } | Go to the nearest free spot for that activity and do it |
{ type: 'do', activity: 'sleep' } | Go to a free bed and lie down (climbing in from the side or the foot of the bed) |
{ type: 'do', activity: 'rest', spot: 'sofa-1' } | Do that activity at a spot of that furniture. It may differ from the spot's own activity — { spot: 'chair-3', activity: 'rest' } is dozing at the desk |
{ type: 'idle' } · { type: 'idle', activity: 'drink' } | Stop where they are (seated if seated). Steps aside if someone needs to pass. With activity, keeps doing that |
{ type: 'leave' } | Leave by the nearest door and drop out of the scene (hidden). On the next command, comes back in through the same door to do it |
{ type: 'routine' } | Stop following commands and go back to the routine (stay put on the manual routine) |
Every command (except
routine) can take aduration(seconds).Give
goordoanorElse, and it is done instead when the command cannot be carried out (no free spot, no path). They can be chained.ts// Sleep in a bed. If the beds are full, on a sofa; if the sofas are full too, doze at their own desk. { type: 'do', activity: 'sleep', orElse: { type: 'do', activity: 'rest', orElse: { type: 'do', spot: 'chair-3', activity: 'rest' } } } // Drink at a free spot in the break room. If full, gather around the fridge and drink there. { type: 'do', activity: 'drink', orElse: { type: 'go', to: [15.5, 2.6], activity: 'drink' } }Commands can be chained in an array. When one finishes, the next one starts.
- Without
duration: if more commands follow, move on as soon as you arrive; if it is the last command, keep doing it until the next command comes.
- Without
// Patrol: visit three places, rest 5 seconds at the water cooler, then back to the routine
behavior.command('guard-1', [
{ type: 'go', to: [2, 9] },
{ type: 'go', to: [14, 9] },
{ type: 'go', to: [14, 2] },
{ type: 'do', activity: 'drink', duration: 5 },
{ type: 'routine' },
])
// Call a meeting: send three people to the nearest meeting spots (chairs at a table). The table itself has no spot; the chairs around it are meet spots.
for (const id of ['kim', 'lee', 'park']) behavior.command(id, { type: 'do', activity: 'meet' })
// Leaving and arriving
behavior.command('kim', { type: 'leave' })
behavior.command('kim', { type: 'do', spot: 'chair-3' }) // comes back in through the door they left by, to their seatResult
const result = behavior.command('kim', { type: 'do', spot: 'chair-3' })
// { start: 12.4, arrive: 19.8, end: Infinity, ok: true }| Field | Meaning |
|---|---|
start | When they started moving on the command. If they were in the middle of sitting down or standing up, when that motion finished |
arrive | When they reach the first command's spot and start the activity (for a seat, after fully sitting down) |
end | When the first command ends. Infinity if it continues until the next command |
ok | false if the first command — including orElse — could not be done and they stopped where they were: unknown spot, no free spot, no path |
Driving people from data: useActorCommands
To turn constantly changing data such as server state or sensor values into people's movement, declare what each person should be doing now. Only people whose value changed get a command; the same value is not sent again (it is fine to build new objects on every render).
import { useActorCommands, useBehaviorPlayback } from '@isora/react'
const behavior = useBehaviorPlayback(scene, registry, { control: agents.map((a) => a.id) })
useActorCommands(
behavior,
Object.fromEntries(
agents.map((agent) => [
agent.id,
agent.busy
? { type: 'do', spot: agent.chair } // busy: at their own desk
: { type: 'do', activity: 'rest', orElse: { type: 'do', spot: agent.chair, activity: 'rest' } },
]),
),
)- People dropped from the list keep doing their last command. When they come back, a new command is sent.
- The return value maps person id → the result (
CommandResult) of the last command sent. - A real example:
example/claude-office— shows the Claude Code sessions on this machine as people in an office (an AI agent activity dashboard).
When a command arrives
- Whatever they were doing is cut off at that moment. If walking, they take a new path from where they are; if seated, they stand up first.
- If they are in the middle of sitting down or standing up, they finish that motion first (they never turn around half-seated).
- They give up the spot they were heading to. Someone else can take it right away.
- A command can seat someone at another person's own seat (
desk). A spot someone is sitting in or heading to cannot be taken (ok: false). - Commands never go earlier than the time already drawn. An earlier time is pulled forward to the drawn time.
Core only (without React)
import { createBehaviorWorld } from '@isora/core'
const world = createBehaviorWorld(scene, registry, { seed: 1, control: ['guest'] })
world.frameAt(10) // plan up to 10 s
world.command('guest', { type: 'go', to: [1, 5] }, 10) // command at 10 s
const frame = world.frameAt(15)
frame.overrides.guest.position // position at 15 s
frame.people.guest // { activity: 'idle', phase: 'walk', controlled: true, … }Use it for server-side simulation or snapshot tests. The same commands at the same at give the same result whenever you compute them.
Reading who is where, doing what
What useBehaviorPlayback() returns (changes every tick):
| Field | Meaning |
|---|---|
people[id] | { activity, phase, spot?, until, controlled? } — activity, phase, spot in use, when it ends, whether following commands |
overrides[id] | This tick's position, rotation, pose, seat |
hidden | People outside the scene (off work, out for lunch) |
states | States of things other than people (monitor off on an empty desk, door open) |
ids | Ids of people who move |
time | Playback time (seconds) |
world | The whole plan. Read any time with world.frameAt(t) |
clock | The playback clock |
phase is one of idle (before the first departure), walk, wait (stopped to make way, or waiting while seated), stand-up, sit-down, act (doing the activity at a spot) and away (outside the door).
To show an activity's name, use activityLabel(people[id].activity, locale) (English by default, 'ko' for Korean).
Getting events
Get events such as arriving or leaving by comparing people with the previous tick.
const previous = useRef(behavior.people)
useEffect(() => {
for (const [id, now] of Object.entries(behavior.people)) {
const before = previous.current[id]
if (now.phase === 'act' && before?.phase !== 'act') onArrived(id, now.spot, now.activity)
if (now.phase === 'away' && before?.phase !== 'away') onLeft(id)
}
previous.current = behavior.people
}, [behavior.people])Name tags above heads
<IsoScene
scene={scene}
registry={registry}
behavior={behavior}
renderOverlay={({ top }) =>
[...behavior.ids].map((id) => {
const at = top(id) // above the head in this frame (follows while walking)
return at && <text key={id} x={at[0]} y={at[1] - 4} textAnchor="middle" fontSize={8} pointerEvents="none">{id}</text>
})
}
/>Playback control
Make behavior and timelines share one clock, and let the app play, pause, change speed and seek.
const [clock] = useState(() => createTimelineClock())
const behavior = useBehaviorPlayback(scene, registry, { clock })
useEffect(() => {
clock.play()
return () => clock.pause()
}, [clock])
<button onClick={() => clock.pause()}>Pause</button>
<button onClick={() => clock.setSpeed(4)}>4×</button>
<button onClick={() => clock.seek(0)}>Restart</button>
<IsoScene scene={scene} registry={registry} behavior={behavior} clock={clock} playTimelines />- When you pass a clock, play, pause and speed follow the clock, and
behaviorSpeedandtimelineSpeedare ignored.playTimelinesandplayBehaviorsonly turn playback on or off. When you passbehavior,playBehaviorsis ignored too (the app's playback is drawn as is). - Get the time of day with
clockMinutes(scene.clock, clock.getState().time)(formatClockgives'09:12'). - Seeking forward (
seek) plans up to that time, so an hour's worth takes a few hundred ms.
Caveats
- Commands live inside the world. When the scene is edited and the world rebuilt,
useBehaviorPlaybackreplays past commands at the same times, but opening a different scene (a differentmeta.id) clears the log. - Commands given after rewinding (
seek) also go in at the latest time already drawn. Plans for past times are not changed. - With reduced motion (
prefers-reduced-motion), playback stops at 0 s, so commands do not show any movement. - People do not move between floors. Stairs and elevators are not supported yet.