Skip to content

Moving people ​

There are three ways to move people. You can mix them in one scene.

WayHowWhen
RoutinesGive the object behavior: { routine }. The engine picks spots, walks there, sits, works and restsA living office or factory in the background
CommandsThe app calls command(id, …): "go over there", "sit in chair 3", "leave"Showing real events such as badge entries, calls or meetings
Scene dataChange 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 ​

json
{ "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's routines. desk: the chair this person sits in first when working. Nobody else uses it.

  • Built-in routines (DEFAULT_ROUTINES)

    idNameWhat they do
    officeOffice workerWork 30–60 s ↔ water, rest, wander
    focusedFocusedWork for long stretches, now and then print or get water
    relaxedRelaxedWork briefly, rest and meet for long
    meetingMany meetingsMeetings ↔ work
    factoryField workOperate equipment ↔ rest
    visitorVisitorWander, 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 work08:00–17:00, rest at lunch
    manualManual controlStays put until a command arrives

    Routine names are English; Korean names are in names.ko. Read them with routineName(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 a clock.

ts
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.

tsx
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. Returns null for an unknown person.
  • Who can be commanded: people with a behavior, plus people listed in control. You can also write behavior: { routine: 'manual' } in the scene.

Command types ​

CommandWhat 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 a duration (seconds).

  • Give go or do an orElse, 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.
ts
// 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 seat

Result ​

ts
const result = behavior.command('kim', { type: 'do', spot: 'chair-3' })
// { start: 12.4, arrive: 19.8, end: Infinity, ok: true }
FieldMeaning
startWhen they started moving on the command. If they were in the middle of sitting down or standing up, when that motion finished
arriveWhen they reach the first command's spot and start the activity (for a seat, after fully sitting down)
endWhen the first command ends. Infinity if it continues until the next command
okfalse 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).

tsx
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) ​

ts
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):

FieldMeaning
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
hiddenPeople outside the scene (off work, out for lunch)
statesStates of things other than people (monitor off on an empty desk, door open)
idsIds of people who move
timePlayback time (seconds)
worldThe whole plan. Read any time with world.frameAt(t)
clockThe 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.

tsx
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 ​

tsx
<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.

tsx
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 behaviorSpeed and timelineSpeed are ignored. playTimelines and playBehaviors only turn playback on or off. When you pass behavior, playBehaviors is ignored too (the app's playback is drawn as is).
  • Get the time of day with clockMinutes(scene.clock, clock.getState().time) (formatClock gives '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, useBehaviorPlayback replays past commands at the same times, but opening a different scene (a different meta.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.

Released under the MIT License.