Log inGet started

Observing a running world

Updated 9 September 2026

The wrong instinct is to run something, wait, and look. A screenshot is never the same frame as the call that preceded it, so "run it, then capture" aims at a moment you cannot see and mostly misses. Polling in a loop is the same guess wearing a clock.

Attach a probe instead. A probe is an instrument on code that is already running. You say what to watch, which condition matters, and what to compute at that instant; the answer reaches you on a later tool call. You keep working in between.

Nothing is written to source, nothing reloads, and cancelling removes every trace. That is the difference from the old habit of adding a log line to a component: no file changes, no reload, and nothing to remember to undo.

The three questions

QuestionVerb
"when this function runs, what did it get and what did it return?"probe.onCall
"when execution reaches this line, what was in scope?"probe.onLine
"when this value becomes something else, tell me"probe.onChange
probe.onCall("modules.combat.applyDamage", {
    notice = "a hit killed something",
    when   = "ret.killed",
    run    = "return ret.target .. ' at ' .. tostring(ret.hp) .. 'hp'",
})

Then carry on. On some later call the answer arrives:

probe prb_1 at modules.combat.applyDamage — a hit killed something (x3)
  { value="goblin at -40hp", "wolf at -12hp", hit="1","2","3" }

Three parts, always: which probe and what it is attached to, your own words for what you are watching, and whatever your body returned. The text stays fixed while the values vary, so repeated hits collapse into one line with a count instead of a wall to read.

You never wait

This is the point, and it is worth being explicit about. There is no blocking call here and no timeout to choose. You arm a probe and go back to work. If the thing never happens you are not stuck waiting for it — you simply are not told, and probe.list shows a probe with no hits, which is itself the answer.

That is also why a probe reports by default. A probe that stayed quiet unless you wired up reporting is a probe you would believe never fired.

Reaching the thing you actually care about

  • A function on a required module: onCall, by module.field. It follows the function, so any caller reaching it is seen.
  • A local inside a module, or a helper a component keeps to itself: onLine, by chunk and line. This is the one that reaches code no name can address.
  • A value — a component field, a light's intensity, a position: onChange.

onChange samples once per frame, so a value held for only a frame or two is an edge it may catch going up and miss coming down, and one that changes and changes back inside a frame it cannot see at all. For something that short-lived, probe the CODE that sets it with onLine: a line fires every time it runs, whatever the frame rate.

Use when to name the moment rather than every step toward it. value >= 5 fires the first time it reaches five, not on each increment on the way.

A line probe's when is evaluated inside the frame that stopped, so it reads that frame's own variables by their own names and types: doubled >= 6. The body sees the same frame through locals, rendered as text — right for reporting, wrong for arithmetic. Compare in when, report from locals.

The state you did not put the world in

The case that costs the most time is the one you never think to check: the match already ended, the victory screen is up, and every value you are reading means something different from what you assume. Nothing tells you, so you debug the numbers instead of the state.

Arm a probe on the transition and it tells you.

probe.onChange("runtime_data.world.get('match/phase')", {
    notice = "the match changed phase",
    run    = "return tostring(previous) .. ' -> ' .. tostring(value)",
})

What it costs, and cancelling

Every probe carries a hit limit and a cooldown, both defaulted low, so one on a per-frame function cannot drown you. Hits the cooldown holds back are counted and shown by probe.list, and a probe that reaches its limit disarms and says so.

onCall costs one extra call on the function it wrapped. onChange reads its expression once per frame. onLine costs nothing on an unarmed line, but a breakpoint drops the enclosing function out of native code until that script reloads, and cancelling does not bring native code back — so on a hot function, cancel when you are done.

probe.cancel("all") when you are finished. Nothing survives the engine stopping either way.

Where to go next

  • probe toolbox — every verb, its arguments and its cost.
  • core/troubleshooting — when the question is "why does this look wrong" rather than "when did this happen".
  • core/scripting-and-taskstask.wait, frame counts, and the cooperative thread a probe body runs on.