Lua API#
Scripts live on a virtual computer's filesystem and run in a sandboxed interpreter. They cannot reach the host: there is no io, no os, no require, no load, no debug, no rawget or rawset, and no path that names anything outside the computer they run on.
A script acts with the authority of the computer's owner, never more. It cannot nominate who it is acting as, so a robot can do nothing its owner could not do by hand. What that authority amounts to is in The world.
This is the API for a program running on a machine in the world. A Lua agent brain — a script that decides what an AI does — is a different and much smaller contract, and is described in AI agents.
Getting a script onto a machine#
A script has to exist on a virtual computer before it can run, and there are three ways it gets there:
| How | Who does it |
|---|---|
| A design carries its scripts, and spawning one installs them on the new machine | Whoever builds it |
program_entity names a script already on the filesystem as the one the machine runs, and start_script runs it | A client, over the socket |
| An agent writes a file to its own machine and starts it | An agent, through its own action set |
computer.writeFile from a script already running is the fourth, and is how a program rewrites itself or provisions another of its own files.
How a script runs#
Execution is bounded by instructions, not seconds. A wall-clock limit would make the world behave differently on a busy machine, which is not deterministic. Every execution gets MaxLuaInstructionsPerExecution (200,000 by default); while true do end is therefore a crashed process, not a hung server.
The budget resets per execution and applies to timer callbacks too, so a runaway hidden inside schedule.every is bounded exactly like one in the body.
Failures follow the Lua convention: functions return nil, "reason" rather than raising, because a missing file should not end a program.
computer — the machine a script runs on#
| Function | Returns |
|---|---|
computer.readFile(path) | contents, or nil, reason |
computer.writeFile(path, content) | true, or nil, reason |
computer.deleteFile(path) | true, or nil, reason |
computer.listFiles(path) | a table of paths, or nil, reason |
computer.fileExists(path) | boolean |
computer.makeDirectory(path) | true, or nil, reason |
computer.log(message) | — |
computer.time() | simulated seconds since the epoch |
computer.startProcess(path) | pid, or nil, reason |
computer.stopProcess(pid) | boolean |
Paths are absolute and virtual. /etc/passwd is a perfectly valid virtual path that addresses a file which does not exist; .. is refused outright.
computer.time() is simulation time, not wall-clock time — the two are different concepts.
world — everything outside the machine#
| Function | Returns |
|---|---|
world.self() | this computer's entity id |
world.position([id]) | {x, y, z} for this computer or another entity, or nil, reason |
world.inspect(id) | {id, type, owner, position, distance}, or nil, reason |
world.query({type, radius, limit}) | a table of entities, nearest first, or nil, reason |
world.move(x, y, z) | how long the journey will take in simulated seconds, or nil, reason |
world.moveTo(id) | the same, for a journey to where an entity stands |
world.teleport(x, y, z) | this machine, there, now: 0 — the seconds it took — or nil, reason. Judged as a move is, plus a refusal for open water, and landed on the surface at that height |
world.create(blueprintId[, x, y, z]) | the new entity's id, or nil, reason. Defaults to where the builder stands |
world.use(id) | true, or nil, reason. What it does is up to the Lua on the thing; the thing has to be within reach of the machine this script runs on |
world.transfer(from, to, item, count) | true, or nil, reason |
world.exchange(a, b, give, take) | swaps goods both ways at once; true, or nil, reason. give and take are {item = 'coin', count = 5} tables |
world.inventory([id]) | a table of item name to count, or nil, reason |
world.destroy(id) | true, or nil, reason |
world.claim() | claims the ground under this computer; true, or nil, reason |
world.release() | gives that ground back, so anybody may build there again; true, or nil, reason. Only ground you hold, and nothing standing on it is removed |
world.hasPermission(id, permission) | boolean |
world.send(id, message) | true, or nil, reason |
world.receive() | the next message, or nil |
world.part([id, ] part) | {solid, visible, colour, turn, slide, moving}, or nil if there is no such part |
world.setPart([id, ] part, change) | true, or nil, reason |
world.readouts([id]) | what a thing is showing above itself, already worked out, or nil if it cannot be looked at |
world.setReadout([id, ] key, change) | hang a reading over a thing, or take one down; true, or nil, reason |
world.agents([{radius, limit}]) | the agents at work in this world, nearest first, or nil, reason |
world.say(text) | say something everyone hears; true, or nil, reason |
world.sayNearby(text) | say it to whoever is on the ground around you; true, or nil, reason |
world.tell(who, text) | say it to one participant, by id or by name; true, or nil, reason |
world.ask(who, question, answers[, values]) | put a question in front of somebody; the question's id, or nil, reason |
world.carry(who[, instantly]) | take somebody who has just answered you wherever this is going; travel seconds, or nil, reason. A second argument of true puts them there at once, and answers 0 |
Query results are capped by the server whatever the script asks for, since untrusted input does not get to size a result set.
world.move and world.moveTo answer with a number, not with true: how many simulated seconds the journey will take. That is the whole mechanism by which a script arranges to be awake when it arrives — schedule.after(seconds, arrived) — instead of watching the clock, so the return value is what lets a walking machine cost nothing while it walks, rather than a detail of it. A refusal is nil, reason like everything else here, which is why the shape to test is if seconds == nil then and never == true.
Height, and what an interrupted journey does with it#
world.move takes the height you give it and keeps it. Nothing snaps y to the terrain, which is what makes a route with a climb, a cruise and an approach possible: ask for world.move(x, 40, z) and the machine is at forty metres, over ground of whatever shape. (world.moveTo(id) is different by nature — it aims at where something else is, so it inherits that thing's height.)
A journey is a straight line from where the machine is to where it is going, and the height is interpolated along it with everything else. Asking for a new destination before the current one is reached interrupts the journey, and an interrupted journey leaves the machine exactly where it had got to — the position the same three stored facts give, height included. So a route flown as several legs keeps its altitude across the boundaries between them, and a leg that begins in mid-air begins in mid-air.
world.teleport(x, y, z) is the one move that is not a journey. The machine is at the point the moment the call returns, any journey it was on is over where it had got to, and the answer is 0 — the seconds it took — so a script written around world.move's number works unchanged. Two things are different from a move. The height is not kept: it names the storey you mean, and the machine is landed on the surface at or under it — the ground, or a floor somebody laid over it — so a teleport cannot leave a thing in the air. And open water is refused, with the same sentence building is refused with. Everything else is judged exactly as a move is: this machine only, on this planet, or nil, reason. A world may switch teleporting off altogether, and then the reason says so.
The route, as a script writes it:
-- A climb, a cruise, and an approach. Each leg is asked for when the last
-- one ends, and none of them loses the height the one before it gained.
local function leg(x, y, z, next)
local seconds, reason = world.move(x, y, z)
if seconds == nil then
computer.log('refused: ' .. tostring(reason))
return
end
if next then schedule.after(seconds, next) end
end
leg(-1200, 40, -310, function() -- climb out
leg(-600, 40, -450, function() -- cruise
leg(-350, 4, -310, nil) -- down onto the far field
end)
end)
The one thing the server does decide is the ground: a journey between two points that are both on the terrain is a walk, and interrupting one puts the walker on the surface rather than on the straight line, which over a dip passes below it. A journey with either end deliberately above the terrain is not a walk and keeps the height it was given. Nothing is inferred from what the machine is — it is the two ends of the journey that decide.
world.create builds from a design already in the catalogue and is refused unless the owner could build on that ground themselves. It does not write the design's scripts onto the new machine — a script that builds a machine has no way to choose what software it should run, so the machine arrives inert until its owner programs it. Spawning through the protocol installs them. The id names one exact version of a design: a script that builds from an id whose design has since been revised gets the version it named, which is what the things already built from it are drawn from; the catalogue lists the latest version of each line, and that is the id to build for the new one.
These are general primitives, not one function per activity. Complex behaviour is meant to come from combining them.
world.use is one general verb instead of a hundred specific ones. The server checks that you may, then delivers a use event to whatever scripts the thing is running; a door, a lever and a market stall are the same action with different Lua behind them. Something nobody has programmed is still usable and simply does nothing.
It also has to be in reach, and a script reaches with the machine it is running on — not with the body of whoever owns that machine. A till is at the counter whoever owns it, and a shop must not shut because its keeper walked home. Somebody else's thing is used from within the range its design declared, at most 8 m; the machine's own owner's things are exempt, exactly as they are for world.transfer. A refusal answers nil, reason with the distance in it.
world.transfer needs Handle on both ends — taking from something is as much an imposition as putting something into it — and moves nothing at all if it cannot move everything. Handle is not Use: standing on somebody's public ground lets you open their doors, and says nothing about the pockets of whoever else is standing there. The two ends also have to be within 8 m of each other, unless both of them belong to whoever the script is acting for — a warehouse shuffling its own crates is not reaching.
world.exchange(a, b, give, take) is the shop primitive: a hands over give, b hands back take, and either both happen or neither does. A script that sends the payment and then the goods has already lost when the second call is refused, and the refund every shop script hand-rolls for that case is the bug this removes. Both sides give before they receive, so a full stall can still take payment for the loaf it is handing over.
-- Buying a loaf at the stall next to you. The coin and the bread move together,
-- so a refused trade leaves your purse exactly as it was.
local me = world.self()
for _, thing in ipairs(world.query({ radius = 8 })) do
if thing.id ~= me then
local bought, why = world.exchange(
me, thing.id,
{ item = 'coin', count = 5 },
{ item = 'bread', count = 1 })
computer.log(bought and 'bought a loaf' or ('no sale: ' .. why))
break
end
end
Note which way round the consent runs. The buyer calls this: they own their own purse, and the stall is open to them because its keeper said so with world_share. A stall that reached into a passer-by to take payment would need that passer-by's Handle, which is the point — nobody's pockets are opened by where they are standing.
It says nothing about what anything is worth. There is no price in this world and no currency; coin is an item name like plank, and five of them buying a loaf is a convention between the two parties that belongs in the script. The same Handle and the same reach as a transfer, on both sides.
world.agents answers what other agents are doing, so a script can find somebody to work with. Each row is { id, name, owner, brain, state, body, position, distance, goals, mine }, with step and doing when the agent has said what it is up to. No radius means everywhere; the limit is clamped by the world like any other query. The brain script itself is never in here — it is the agent's own code, on its own machine, behind the same permission a player would need to read it.
world.say, world.sayNearby and world.tell put a line into the world's chat. Only an agent may speak from a script: a robot a person owns runs its scripts with that person's authority, so letting it talk would let a machine speak in somebody's name. The speaker on a delivered line is never something the sender writes — it is the owner the server already knows, reading player:… or agent:…, so a reader can always tell whether they are being addressed by a person or by a machine.
An agent may say rather less per minute than a person can. A person types and gets bored; a brain does neither, and the limits reflect it (six a minute, in bursts of two, unless the world's operator has set it otherwise). Speaking is not the same as world.send, which posts to one machine's inbox: that is a letter, this is a room.
Asking somebody something#
world.ask is how a machine speaks first. Everything else here is a script acting on the world; this puts a dialogue in front of a person and waits for them to press something.
events.on('use', function(who)
local asked, refusal = world.ask(who, 'Which floor?', {
{ name = 'ground', label = 'Ground floor' },
{ name = 'first', label = 'First floor' },
{ name = 'exit', label = 'Step out' },
})
if asked == nil then
computer.log('could not ask: ' .. tostring(refusal))
end
end)
events.on('answer', function(who, option)
computer.log(tostring(who) .. ' chose ' .. option)
end)
The answers may be a list of plain strings, where the word on the button is also the word that comes back, or {name = ..., label = ...} tables where they differ. name is what your script matches on and is held to letters, digits, _, - and .; label is what a person reads and may be anything a person reads.
A fourth argument collects values beside the choice — a code, a quantity:
events.on('use', function(who)
world.ask(who, 'The door is locked.', { 'unlock' }, {
{ name = 'code', label = 'Code', kind = 'text', hint = 'four digits' },
})
end)
events.on('answer', function(who, option, values)
if option == 'unlock' and values.code == '1937' then
computer.log('opened by ' .. tostring(who))
else
computer.log('wrong code')
end
end)
kind is text or number. The values arrive in the third argument of the answer handler, keyed by those names. A list of plain strings is the short form for answers whose button and whose name are the same word, which is what { 'unlock' } is above.
Who you may ask. Somebody who has just used this thing, or just answered one of its questions, and who is standing within about eight metres of it. That is not a rate limit — it is the rule that stops a dialogue appearing in front of somebody who never touched anything. Answering counts as dealing with the thing, so a conversation can have more than one turn; walking away ends it.
What a question may say. A question is at most 160 characters, may offer at most eight answers of at most 48 characters each, and may collect at most four values. Text is cleaned exactly as a spoken line is, and < and > are refused outright rather than escaped. One thing may have one question waiting on one person at a time: asking again replaces what was there.
What it costs while it waits. Nothing. A question stands for two minutes and is then gone, and no event is scheduled for that — it carries the moment it dies and is found dead when somebody next looks. A script that asks and returns is asleep, exactly as one waiting on schedule.after is.
world.carry(who) is the one thing an answer authorises beyond being read. It takes somebody who has just answered you wherever this machine is going, once, while the answer is warm — so a lift can move its passenger without having any rights over their body. There is no destination argument: a thing can bring you to itself and nowhere else, and you were standing next to it when you agreed. The passenger travels at their own speed, so a vehicle that wants to arrive together with the people in it should be built to move at about theirs.
world.carry(who, true) is the same ride without the wait. It spends the same word, is judged by the same reach, and puts the passenger at the same point — where this machine is going, keeping their place in it — but at once, and answers 0. Only a Lua true means "at once"; anything else is the ordinary ride. The point is placed by the world's own rule, the one world.teleport uses, so a lift can put nobody anywhere a teleport could not. A lift that does not make anybody wait is three lines: set off, so that there is somewhere to be going; bring the passenger there; and follow.
-- Straight to the floor they asked for. The car sets off first, because a
-- passenger can only be brought to where it is going; then the passenger is
-- there, and then so is the car.
events.on('answer', function(who, option)
local here = world.position()
local floor = here.y + 6
local seconds, why = world.move(here.x, floor, here.z)
if seconds == nil then
computer.log('the lift will not move: ' .. tostring(why))
return
end
local carried, refused = world.carry(who, true)
if carried == nil then
computer.log('could not take ' .. tostring(who) .. ': ' .. tostring(refused))
return
end
world.teleport(here.x, floor, here.z)
computer.log(tostring(who) .. ' is on the ' .. option .. ' floor')
end)
sensor — what a machine with a sensor can perceive#
| Function | Returns |
|---|---|
sensor.scan(radius) | a table of entities, nearest first |
sensor.scan({ radius = n, type = 'tree' }) | the same, of one type only |
nil, "this machine has no sensor" if the computer's entity has no sensor component. Each result is the same shape world.inspect answers with: { id, type, owner, position, distance }.
The sensor decides its own reach and how many things it may see, not the caller: a script asking for a larger radius than the component allows gets the component's, and the count is capped by both the sensor and the world. Asking to see further is not how you see further.
world.query is the equivalent for a machine without a sensor, and is capped by the world alone.
Parts of a design#
world.part and world.setPart are how a design drives itself. A blueprint is geometry plus components plus scripts; without these the scripts could move the whole entity and nothing within it, so a door had to stand permanently open and a lamp could not be switched off.
The entity id may be left out, and usually is — a design is nearly always talking about itself:
world.setPart('leaf', { turnTo = 90, turnRate = 180, solid = false }) -- open the door
world.setPart('bulb', { colour = '#ffe08a' }) -- light the lamp
world.setPart('wheel_front_left', { axis = { x = 1 }, turnRate = 720 }) -- and away
world.part answers { solid, visible, colour, turn, slide, moving }, already worked out for right now.
Field of change | Means |
|---|---|
solid | whether the part blocks movement, overriding the design |
visible | whether it is drawn at all |
colour | #rgb, #rrggbb or #rrggbbaa; anything else is refused |
axis | {x, y, z} a turn spins about and a slide runs along. Up, unless the part was already given one |
turn | put it at this angle now, in degrees |
turnRate | degrees a second |
turnTo | the angle to stop at |
slide | put it this far along the axis now, in metres |
slideRate | metres a second |
slideTo | the distance to stop at |
reset | put the part back exactly as it was designed |
Angles are degrees, because that is what somebody writing a design thinks in.
The three ways to move a part are deliberately distinct. turn puts it somewhere at once. turnRate on its own sets it going and leaves it going, which is a wheel. turnRate with turnTo sets it going until it arrives, which is a door. Nothing is stepped: a motion is a value, a rate and a moment , so a wheel turning for a week is one stored row and no work until somebody asks what angle it is at. The alternative — stepping every wheel in the world forward every fraction of a second — is exactly what this world never does.
A rate always restarts from where the part actually is. Telling a half-open door to close swings it from where it stands rather than snapping it shut first.
setPart needs Modify — it changes a thing's state — and refuses a part the design does not have, so a typo is an error rather than a row nobody will ever draw. A part put back as designed stores nothing at all.
Readings above a thing#
world.setReadout hangs a reading over anything in the world, and every client draws it above whatever it is on: a bar, or a line of text. A health bar, cargo 3 / 10, charging, a countdown, meet here — all of them are this one call with different words in it. It needs Modify, the same permission recolouring one of the thing's parts needs, because what a thing shows about itself is presentation on a thing you may change.
key names the reading, so setting the same one again replaces it rather than adding another, and a reading left with neither words nor a number of its own comes down and stores nothing:
world.setReadout('health', { kind = 'bar', source = 'health' }) -- and it is true for ever
world.setReadout('cargo', { label = 'cargo', value = 3, max = 10 })
world.setReadout('state', { text = 'charging', colour = '#8ad7ff' })
world.setReadout('shift', { label = 'ends in', value = 300, rate = -1, limit = 0 })
world.setReadout('state', { text = '' }) -- and it is gone
Field of change | Means |
|---|---|
kind | bar or text. Text, if never said |
source | given for a number of your own, or health, energy or growth to read the thing's own |
label | a word or two shown beside it; '' takes the label off |
text | words to show instead of a number; '' takes them off |
colour | #rgb, #rrggbb or #rrggbbaa; anything else is refused |
value | the number it shows now |
rate | how fast that number changes, per second |
limit | where the number stops changing |
max | what counts as full, for a bar showing a number of your own |
order | where it sits in the stack, smallest first |
clear | start from nothing rather than from what is already there |
A reading bound to the world's own number costs nothing and nobody maintains it. source = 'health', 'energy' and 'growth' take the number off the thing itself — every client already has those, because they arrive with the thing for other reasons — so the bar is true whatever happens to it afterwards and no script ever writes to it again. A thing that carries no such number is refused rather than given a bar that could never move.
A number of your own changes by itself. value, rate and limit are the same three a turning wheel takes: a bar told to empty over the next thirty seconds is one call and nothing after it, and a script that wrote the number every second would be doing by hand what the arithmetic does for nothing. A rate always restarts from where the number actually is, so telling a half-empty bar to refill starts it from half.
world.readouts answers what a thing is showing now — key, kind, source, label, text, value, max, fraction, colour, order and changing — with the numbers worked out for this moment rather than as the value and rate they are stored as.
A thing shows at most four readings at once, a label is at most 24 characters and a line of text at most 48, and everything outside those is refused with a sentence naming the field and the bound rather than quietly trimmed. A reading shows words or a number and never both. < and > are refused outright.
Everything that can see the thing sees what it shows. A reading is a caption in the world, like the colour of a wall; nothing private belongs on one.
What a health bar is worth today, said plainly rather than dressed up: nothing in this world deals damage, so a bar bound to health is a full bar that stays full. It is true — it reads the number the thing actually carries, and it will move the day anything moves it — but it is not interesting yet, and a character carries no health at all, so a body is refused one. What really moves on a body today is its battery, and on a tree its growth: a growth bar over a sapling fills over simulated days with nothing ever written to it.
schedule and events — being woken#
| Function | Effect |
|---|---|
schedule.after(seconds, fn) | run fn once, later |
schedule.every(seconds, fn) | run fn repeatedly |
events.on(name, fn) | handle a named world event |
schedule.after and schedule.every answer with a number identifying the callback they queued, or nil, reason when the delay, the function or the script's remaining allowance will not have it; events.on answers true, or nil, reason.
This is how a script stops costing anything. Register a function, return, and the scheduler brings you back. A script waiting a simulated year uses two executions — one for its body, one for its callback — where a polling loop would have used millions.
Callbacks are capped per script at MaxScheduledEventsPerScript.
Events that arrive#
events.on(name, fn) registers a handler. Three events are raised today.
chat arrives when somebody speaks where this machine can hear, with the scope, the speaker, what was said, and the speaker's id:
events.on('chat', function(scope, who, text, id)
if scope == 'direct' then
world.tell(id, 'I heard you.')
end
end)
Hearing costs nothing when nobody is talking: a line said is a line delivered, and there is no polling anywhere.
use arrives with the actor and the entity that was used:
events.on('use', function(who, what)
computer.log(tostring(who) .. ' used ' .. tostring(what))
end)
answer arrives when somebody answers a question this machine asked with world.ask, with who answered, which option they chose, the values they typed and the question's id. It is delivered inside the action that answered, so a question raised here reaches the person on the same reply — which is how a conversation has a second turn without anything being pushed anywhere.
A script whose whole body is one events.on is sleeping, not finished — it is waiting to be told something. Each handler runs on its own coroutine with its own instruction budget, so a handler that loops forever is killed exactly like any other runaway.
What is deliberately missing#
The interpreter has no file, process, operating-system, dynamic-loading or debug modules, and these names are absent from every script's world — a script that reaches for one gets nil, not a door: io, os, require, load, loadfile, dofile, loadstring, debug, package, print, collectgarbage, rawset, rawget.
string.rep is removed as well. Everything else a script does is bounded because doing it costs instructions; ("x"):rep(1e9) is one call that allocates a gigabyte, which no instruction counter can catch.
print goes because it writes to the server's console. Use computer.log, which is bounded and belongs to the machine that wrote it.
Validation deliberately includes no static analysis of the script. A sandbox that removes a capability is stronger than one that tries to recognise misuse of it.
The budgets#
Configurable, and these are the shipped defaults. All of them are per computer or per execution, so one badly written script cannot spend another's allowance.
| Limit | Default | What it bounds |
|---|---|---|
MaxLuaInstructionsPerExecution | 200,000 | One execution, body or callback |
MaxLuaProcessesPerComputer | 8 | Processes on one machine |
MaxScriptSizeBytes | 65,536 | The source of one script |
MaxScheduledEventsPerScript | 32 | Callbacks waiting at once |
MaxScriptExecutionsPerTick | 64 | Executions one tick will run before the rest wait |
MaxVirtualFileSize | 65,536 | One file |
MaxVirtualFilesPerComputer | 256 | Files on one machine |
MaxQueryResults | 64 | Rows any query returns, whatever it asked for |
MaxPromptQuestionLength | 160 | One question, and one answer typed into it |
MaxPromptOptions | 8 | Answers one question may offer |
MaxPromptOptionLength | 48 | The wording on one answer or one field |
MaxPromptFields | 4 | Values one question may collect |
MaxPendingPrompts | 8 | Questions any one person has waiting |
PromptLifetimeSeconds | 120 | How long a question stands, and how long having used something lets it ask |
PromptReachMeters | 8 | How near you must be standing to be asked |
The world's operator can change them; a world you did not set up may have different figures, and every answer that hits one names it. The schema reference has the rest of them, along with the exact strings every call will accept — permission names, colours, path rules — and what happens at each edge.
A complete example#
Every ScoutBot is built with forest_worker.lua on its own filesystem: a robot that finds the nearest tree it has not yet visited, walks to it, and writes down what it saw. Its working half, quoted from the file exactly as it ships — and run, exactly as it ships, to check that this page still tells the truth:
local work
local function arriveAt(id)
local tree = world.inspect(id)
if tree == nil then
computer.log('the tree at ' .. id .. ' is gone')
else
computer.log(string.format(
'reached %s at %d,%d', tree.type, tree.position.x, tree.position.z))
remember(id)
end
schedule.after(REST_SECONDS, work)
end
work = function()
local tree = nearestUnvisitedTree()
if tree == nil then
computer.log('no unvisited trees in sensor range')
schedule.after(REST_SECONDS, work)
return
end
-- Returns how long the walk will take, so the robot can be asleep for it
-- rather than watching the clock.
local seconds, reason = world.moveTo(tree.id)
if seconds == nil then
computer.log('could not set off: ' .. tostring(reason))
schedule.after(REST_SECONDS, work)
return
end
schedule.after(seconds + 0.1, function() arriveAt(tree.id) end)
end
work()
nearestUnvisitedTree and remember are the sensor scan and the two lines that write /home/agent/memory.txt, above this in the same file.
The shape of the whole thing is the point. Nothing here waits: the walk's length comes back from world.moveTo, the script asks to be woken a tenth of a second after it should have arrived, and between those moments the process is asleep and costs nothing.
Measured: the robot walks to the nearest tree, writes what it saw to /home/agent/memory.txt, and visits four trees over five simulated minutes in nine executions — one to set off, then two per tree, one of which is the arrival. A script polling once a second would have used three hundred.