Working in the editor
Working in the editor
The in-engine IDE, the profiler, the gotchas, and why some calls refuse.
Part of the scripting guide · every call, as a reference
Contents
- 14. The in-engine IDE
- 15. Tips & gotchas
- 28. Where the frame went:
perf.* - 29. Options that refuse — and why an error is the kind answer
14. The in-engine IDE
Double-click a .lua in Assets (or use the Inspector's Scripting section) to
open it in the Scripting tab — a small but real code editor:
-
Find & replace —
Ctrl+Ffinds (seeded from your selection),Ctrl+Hadds a replace row,Enter/Shift+EnterorF3/Shift+F3step through matches (the current one is outlined),Aatoggles match case, and ⌕ all scripts lists every matching line across the whole project. Typing in the find field never yanks focus back into the code. -
Line editing — with nothing selected,
Ctrl+C/Ctrl+Xcopy / cut the whole current line.Ctrl+Dduplicates,Ctrl+Shift+Kdeletes,Alt+Up/Downmoves the selected lines,Ctrl+/toggles--comments over the selection, andTab/Shift+Tabindent / outdent a multi-line selection.Enterauto-indents (one level deeper afterthen/do/function). -
Navigation —
Ctrl+Ggoes to a line,Ctrl+B(or right-click) jumps to a definition, right-click also finds all references. The Console's double-click-to-source lands here too. -
Saving —
Ctrl+Ssaves,Ctrl+Shift+Ssaves all; closing a tab with unsaved changes asks first, and pressing Play auto-saves open edits so the run always matches what you see. -
Completion & docs — the popup opens by itself only after
.or:, where you're asking what fields something has;Ctrl+Spacesummons it anywhere else.↑/↓choose,Enteraccepts,Eschides it until the token changes — andTabalways indents, so completion can never eat a keystroke you aimed at your code. It understands member access on any variable —rb.frioffersfriction,anim:ploffersplay, andparams.offers this script's owndefaultskeys. The highlighted entry shows its doc and a usage example in the popup; hovering an API name in code shows the same. The § Docs page has a search box over the whole guide + API reference, with worked examples under the common entries. -
Formatting —
Alt+Shift+F(or the ⚏ Format button, or tick on save) re-indents the file by block depth and tidies whitespace. It changes nothing else — no re-flowed expressions, no realigned comments, no moved code — and it's idempotent, so format-on-save can't produce a second diff.--@noformatexempts a file; a line ending in--@keepkeeps its own indentation. -
Warnings — a
⚠ n warningsstrip under the editor expands into a clickable list of the mistakes Lua can't report:- an undeclared assignment —
sped = speed * dtcompiles, writes a global, readsnilforever, and says nothing. The warning names it and suggests the local you meant. Globals assigned at file scope are deliberate publications (§8) and are never flagged. - an unused local — usually a half-finished rename. Prefix with
_to keep it quiet. - upvalue pressure — on a build whose Lua caps upvalues per function, every
file-scope
localis one, and you get a warning before the cap that names the fix (group related state into one table). LuaJIT's cap is 60; Luau, which the engine runs by default, has none, so on a stock build this warning does not appear at all. - a hook that forgot the node —
function update(dt)looks right and is wrong: every lifecycle hook is called with the node first (§3), sodtis bound to the node and the first arithmetic on it raises, every frame, before anything visible has happened. From the outside that isn't an error, it's a script that does nothing at all.
--@nolintsilences a line; on its own line it silences the file. - an undeclared assignment —
Shortcuts
Ctrl+S |
save | Ctrl+Shift+S |
save all |
Ctrl+F |
find | Ctrl+H |
find & replace |
F3 / Shift+F3 |
next / prev match | Ctrl+G |
go to line |
Ctrl+C / Ctrl+X |
copy / cut line | Ctrl+D |
duplicate line |
Ctrl+Shift+K |
delete line | Alt+↑ / Alt+↓ |
move line(s) |
Ctrl+/ |
toggle comment | Tab / Shift+Tab |
indent / outdent |
Ctrl+B or F12 |
go to definition | Shift+F12 |
find references |
Alt+Shift+F |
format document | Ctrl+Space |
suggest |
Ctrl+W |
close tab |
The same list lives on the tab's § Docs page.
15. Tips & gotchas
- Run, then Play: scripts only execute while the game is playing (F1). Stop restores the scene to its pre-Play state.
- Drive bodies by velocity, not position. Setting
node.x/y/zon a Rigidbody node fights the physics step; setnode.vx/vy/vzinstead. - Edges vs. held: use
input.pressed/input.clickedfor one-shot actions (jump, fire) andinput.key/input.buttonfor held movement. - Errors appear at the top of the Scripting tab and in the Console, with the script name + line — double-click to jump to the source.
- Hot-reload: just save. The script re-runs in a fresh environment, so avoid relying on state surviving a reload mid-Play.
- A loop without an exit stops the script, not the editor. A pass into your
scripts may use 2 seconds of its own CPU time (500 ms on a dedicated
server); past that the pass is abandoned with an error saying how long it
ran, in CPU and wall time, and the script runs again next frame. A script
that overruns 3 passes in a row is stopped, and is not called again
until you edit its file, so one slow pass never loses a script for the rest
of a game, and a loop that never ends costs three budgets, not forever. The clock is the script thread's CPU time, so a machine busy
on every core slows a pass without using up its budget. A game that does
long work in one pass sets
script_budget_msinproject.ron, andfloptle runandfloptle playtake--script-budget-msfor one run. Everything else keeps running. The scripts share 512 MB between them — a table that only grows hits "not enough memory" in its own call, and the rest of the game goes on. - A path is relative to the project and stays inside it.
assets.getFile,assets.getContents,node.model =, a texture, a clip, a scene, a script name on a node: an absolute path or a..that would leave the project resolves to nothing, and the Console says which rule refused it.assets.getContentslists at most 20 000 files and says so when it stops.
28. Where the frame went: perf.*
Until 0.33.0 the engine kept a smoothed FPS number and nothing else. No attribution — not per script, not per subsystem, not per draw. So when a game got slow, the author's only available move was to file an engine ticket. That is not a hypothetical; it happened four times:
| filed as | actually was |
|---|---|
| "a crowded scene is unplayable" | component lookup was a linear scan |
| "cross-script wiring is slow" | findScript was a linear scan |
| "currently unplayable" | a scatter field asked for 117,000 props |
| "I can see through unloaded terrain" | mesh priority ignored world distance |
Every one cost a round trip through the engine to discover a number the game could have read itself. Three of the four were answerable from a count alone.
function start(node)
perf.enable(true)
end
function update(node, dt)
-- The WORST recent frame, not the average. A 40 ms hitch once a second adds
-- under a millisecond to a 60-frame mean, so the mean hides the thing you are
-- chasing.
if perf.worstMs("scripts") > 6 then
log("slow pass — the worst is " .. perf.slowestScript())
end
end
The buckets, in frame order: scripts mirror physics terrain scatter
particles audio animation models ui render. perf.buckets() returns
exactly that list, so a loop over it can never name one that does not exist.
models is what giving a node a model, or spawning a prefab that brings one,
costs the frame. The file is read and its images decoded in the background, so
this is the upload that follows, plus any import that cannot wait (opening a
scene). floptle run has no GPU and loads no models, so there it reads 0; the
game, the editor and floptle shot load them.
scripts is the whole of every script pass — the per-instance setup, the
reference params, the write flush, and the hooks themselves. mirror is the
ECS → Lua sync each pass runs before it calls anything, which is nested inside
a pass and subtracted out of scripts so the two do not count it twice. Until
0.84.2 scripts was the hook time alone, which on one shipped game accounted
5.3 ms of a 12.9 ms step and hid an optimisation pass for a release.
assets is first-use work: an image decoded and put on the GPU the first
time something draws it, a material shader compiled and its pipeline built the
first time a node wears it. A slow frame says which file it waited on:
for _, h in ipairs(perf.hitches()) do
for _, a in ipairs(h.assets) do
log(string.format("%s waited %.0f ms on %s", a.part, a.ms, a.path))
end
end
The parts are assetDecode (reading and decoding an image), assetUpload
(putting it on the GPU), shaderCompile (a .flsl compiled and checked),
pipelineCompile (the GPU driver building the pipeline it draws with, usually
the largest: a large material shader can take over a second the first time),
shaderInstall (a shader assets.warm built put in use) and materialBind
(binding the materials that wear a shader, after one compiles).
h.assets lists the slowest five of the frame, slowest first.
A game moves that work off the frame that needs it by warming the assets behind a loading screen:
assets.warm({ "textures/hull.png", "shaders/ocean.flsl", "materials/steel.ron" }, function(failed)
loadingDone = true -- `failed` lists any path that could not be prepared
end)
Images are decoded on a few worker threads and shaders compiled and built on
one more, one at a time, while the game keeps drawing; uploads happen a few
milliseconds a frame, and a warmed shader is put in use one a frame. Mesh
material, UI and screen shaders all warm; a sky or sdf shader is built into
its pass when something wears it, so it is listed in failed. Once it calls
back, the first frame that uses them does none of that work, and a frame that
draws something still warming waits for that work instead of doing it again. Models and sounds in the
list load as assets.preload loads them. The pipelines the driver builds are
also kept on disk between runs where the driver supports it (Vulkan), so a
shader seen before builds faster even unwarmed.
Per script, by name
perf.ms("scripts") tells you the pass is expensive. It does not tell you which
of your scripts is doing it, which is the actual question — so
perf.scripts() returns a row per script most expensive first, and
perf.slowestScript() is the one-liner you put in an assertion message.
for _, s in ipairs(perf.scripts()) do
print(string.format("%-24s %5.2f ms (worst %5.2f)", s.name, s.ms, s.worstMs))
end
Names are script file names, because that is what you call them.
What the number is, and what it is not. It is a wall clock around the call,
so it includes everything that happened while your script was on the CPU —
including things that were not your script. A garbage collection, or the
operating system taking the core away for a moment, lands on whichever script
happened to be running, and shows up as a large worstMs for a script that did
nothing unusual.
These are a breakdown of part of perf.ms("scripts"), not all of it: the
bucket is the whole pass and these are the time inside a hook. The difference is
what the engine spends reaching your hooks, and if it is large the thing to look
at is how many scripted nodes the scene has rather than any one script.
Two consequences worth knowing before you go optimising:
- A script with no hook for that pass is not listed at all. It used to be,
and a stall inside its empty span read as a 12–21 ms "peak" against a file
with no
updatein it. ms(the average) is the number to act on. A single highworstMsis at least as likely to be a collection as a slow script — look for one that is high consistently, or that moves when you change the script.
If the collector is what you are chasing, start with
floptle run --frames 600 --alloc: it says how much Lua heap a frame allocates
and which scripts it comes from. Vectors are one source — under exact every
vec3 a script builds is an object, so a + b, v:normalized(), node.pos
and node.worldPos each allocate, while scalar reads (node.x, v:length(),
v:dot(o)) allocate nothing — but they are not always the biggest one: on one
shipped game they were 2% of the frame's allocation, and the rest was tables
and strings the scripts built themselves. Let the readout name the script
before rewriting anything.
Counts
local c = perf.counts()
-- nodes, culled, instances, draws, chunks, props, particles
assert(c.props < 20000, "the forest is asking for too much")
Counts are free to keep, so perf.counts() works even while collection is off.
The draw counts are the exception and need it on: draws is the draw calls the
meshes cost, unbatched how many of those carried one instance, and
unbatchedBy why each did not share (uniqueMesh, texture, shader,
shaderParams, skinned). What batches and what does not is in
materials and textures.
The rest of the frame
Besides the work buckets, three name what is not the engine's own work, and one names what nothing measured:
| Bucket | Is |
|---|---|
assets |
textures, shaders and materials used for the first time |
gpuWait |
waiting for a swapchain image (the GPU still drawing an earlier frame, or with vsync the display not wanting one yet) and for readbacks |
present |
handing the finished image to the compositor |
untimed |
the time between frames no bucket measured |
A hitch whose time is in untimed happened outside everything the engine times:
another program, the OS, or work not yet given a bucket. perf.ms("other")
still answers untimed, its old name.
Where the engine knows what the time was, untimed says so in the frame's
parts: outsideFrame (between two frames: the window system's events, and the
OS or compositor not running or not asking for the game), frameCap (holding to
app.setFrameCap), sceneLoad (a scene swap), assemblySplit and assemblyRebuild (an assembly.split or assembly.rebuild
the engine carried out), and gpuOutOfMemory: on a frame the GPU ran out of
memory, the untimed time nothing else named, because the allocation that failed
is slow and nobody timed it. What is left over is still unnamed. Inside
scripts, scriptOutOfMemory is the collection after a script passed the
memory limit.
Every frame, exactly
perf.ms is a rolling average and perf.worstMs the worst of the last second.
A benchmark wants each frame's own numbers, including the frames its probe did
not see (a scene swap, a load), so perf.frames(n) returns the last n of the
300 the engine keeps, oldest first, unsmoothed:
for _, f in ipairs(perf.frames(60)) do
if f.ms > 33 then
log(string.format("frame %d: %.1f ms, physics %.1f, held by %s",
f.frame, f.ms, f.buckets.physics or 0, f.paced or "the engine"))
end
end
Each record has buckets, parts, scripts (hook time, costliest first),
calls (engine calls each script made), samples (the sampler's hottest
lines), bodies (the costliest physics bodies and assemblies), counts and
paced.
A frame is kept in perf.hitches() once it takes 50 ms; perf.hitchThreshold(33)
catches every frame that makes a 30 fps game miss, perf.hitchThreshold(20) one
aiming at 60.
Inside physics and render
perf.parts() splits the two biggest buckets. Physics: physicsBroadphase
(indexing the colliders), physicsStatic (bodies and assemblies against the
static world), physicsSweeps (fast movers swept to their first touch),
physicsJoints, physicsPairs (movers against each other) and physicsSleep.
perf.bodies(5) names the five bodies and assemblies whose own moves cost the
most, by node name. Render: renderCull (the on-screen test), renderInstancing
(the scene walked into instances) and renderPasses (the GPU passes encoded
and submitted, uploads included); perf.counts().draws and unbatched are the
batch counts.
Which line: the sampler
perf.scripts() says which script; the sampler says which line.
perf.enable(true)
perf.sampleScripts(true) -- every 0.25 ms of script time; or (true, 0.1)
-- …later:
for i, s in ipairs(perf.samples()) do
if i > 5 then break end
log(string.format("%s:%d in %s — %.1f ms", s.script, s.line, s.fn, s.ms))
end
Each sample carries the wall time since the one before, so a long engine call
(a sweep, a terrain query) is charged to the line that made it. The clock
starts afresh at every entry into Lua, a callback from spawn or
assembly.split included, so the engine's work before a callback is not
charged to its first line. Every frame
record and every hitch keeps its ten hottest lines, so a 559 ms frame says the
function it was in. The sampler makes a tight loop slower while it runs, which
is why it is off until asked for.
perf.scripts() rows also carry engineMs and calls: last frame's raycasts
and sweeps made from that script, so "27 ms, 25 of it in spherecast" is one
read.
Is it the display?
A frame spent waiting for the display is not the game being slow, and a window
a compositor paces at 20 fps (hidden, or on a locked session) looks exactly
like a slow game to anything that only measures frames. perf.pacing() says
which it is:
local p = perf.pacing()
-- p.presentMode "fifo", p.refreshHz 60, p.limitedBy "compositor", p.pacedFrames 58 of p.frames 60
if p.limitedBy == "compositor" then log("the window is being paced; these numbers are not the game") end
limitedBy is display (vsync, about one refresh), compositor (fewer frames
than the display shows), frameCap, gpu, or nil when the engine's own work
filled the frame.
The setup (presentMode, vsync, refreshHz, frameCap) is there from the
first start; refreshHz is 0 until the display has said its rate. The verdict
(frameMs, waitMs, gpuMs, limitedBy) is about the last finished frame, so
until one has finished frames is 0 and they read 0 and nil.
GPU time
The buckets above are the CPU's side of a frame. The GPU's side is
perf.gpu(): the time each render pass took on the player's own GPU, for the
last frame it finished timing.
perf.enable(true)
-- …a second later:
local opaque = perf.gpuMs("opaque + lighting") -- sun shadows are paid for here
if opaque and opaque > 8 then
local light = find("Lighting"):getcomponent("Light")
light.shadowSteps = 24
light.shadowDistance = 20
end
perf.gpu() returns { total = ms, passes = { {name=, ms=}, ... } }, in the
order the passes ran. Both answer nil for the first few frames after
perf.enable(true), and always on a GPU whose driver cannot time passes, so a
governor has to treat nil as "no answer" rather than as zero.
The mirror bucket
Scripts read the scene through a copy of it that the engine brings up to date
before each pass. Changing a component's value (a camera's fovY, a light's
intensity, node.visible, a post-process setting) re-reads only that node, so
tweening one every frame is cheap. Spawning and destroying a node add or remove
that node alone, so a decal, a casing or a one-shot effect costs about one
node's worth. Renaming, reparenting, retagging and attaching a script patch
that node and the lists that name it (find, findTagged, children), so
streaming plants in under a tagged parent costs the plants, not the level.
What still rebuilds the whole copy, in time proportional to the whole scene,
is the scene being replaced, a node id handed to a new node in one step, or
more than 8,192 changes landing between two passes (a level load does that).
When a rebuild happens on every frame for half a second, the Console says so
and names the cause.
If mirror is high, check two numbers:
local c = perf.counts()
print(c.mirrorRebuilds, c.mirrorRefreshed) -- last frame
print(perf.mirrorCause()) -- "first sync", or what forced the last rebuild
perf.counts().rays and rayMs are last frame's script raycasts and the time
they took, inside scripts. A frame that spends milliseconds there is casting
more rays than it needs, or casting them one at a time where raycastMany
would do.
sweeps and sweepMs are the same for spherecast, capsulecast and
boxcast, each call counted once. A chase camera that keeps itself out of a
craft with a dozen sweeps a frame shows up here rather than only as its
script's time.
mirrorRebuilds above zero on every frame means something replaces the scene,
or a node's id, every frame, and perf.mirrorCause() names what.
Many NPCs: put the far ones to sleep
Every script instance's hooks run every frame, and every awake rigidbody is simulated every tick, whether or not the player is anywhere near it. On one shipped level, 64 placed enemies cost 17 ms of physics and 7 ms of script per tick with all of them thinking. That's more than the whole frame budget.
Past the distance where anyone would notice, stop both:
function fixedUpdate(node, dt)
if (find("Player").worldPos - node.worldPos):length() > 80 then
node.vel = vec3()
node.asleep = true -- the solver stops simulating the body
script.sleep{ wakeWithin = 70 } -- the engine stops calling this script
return
end
-- …think…
end
A sleeping script costs nothing a frame: the engine doesn't call it at all, so
it can't check the distance itself. Instead it wakes on its own terms:
wakeWithin wakes it when the active camera comes that close, and seconds
wakes it after that much game time. A collision or trigger on its node also
wakes it, and so does script.wake(node, "enemy") from another script, for
instance the one that just shot it.
For NPCs in view but far away, script.setRate(10) runs fixedUpdate ten
times a second instead of sixty, handing it the time since its last call.
It is off by default, and reading it while off is an ERROR
A profiler that is itself a frame cost gets turned off, and then it does not
exist. So nothing is collected until perf.enable(true) — either from a script,
or by opening the editor's ⏱ panel.
But that makes "off" and "free" the same shape, and
assert(perf.ms("scripts") < 4) would then pass in a smoke test that measured
nothing. So every timing getter raises while collection is off and tells you
to call perf.enable(true). Same reasoning as the pin = "topCenter" fix: a
wrong answer that looks like a right one is worse than an error.
An unknown bucket name raises too, and names every accepted value.
In the editor
The ⏱ button in the play toolbar opens the same numbers, per bucket and per script, with the worst column coloured. Opening it starts collection; closing it stops — unless a script asked for it, in which case it stays on so a game's own budget check keeps working.
accountedMs() is the buckets added up. It is called accounted and not total
because vsync, the OS and the GPU finishing are outside every bucket; a number
claiming to be the frame time without being it would be worse than not offering
one.
29. Options that refuse — and why an error is the kind answer
Every options table in this engine is closed. A key it does not read is an error, not a shrug:
scatter.create{ asset = "trees/pine.glb", perchunk = 6 }
-- scatter.create: no option called `perchunk` (did you mean `perChunk`?)
node:setCamera{ target = "minimap", width = 0 }
-- node:setCamera: `width = 0` is outside 8 – 4096
audio.play("engine.ogg", { mode = "spacial" })
-- audio.play: `mode = "spacial"` is not a name I know — it takes spatial, 3d,
-- distance, flat, 2d
Every message names three things: the property, the value it got, and what it accepts. Any one of the three missing sends you back to re-reading your own file, which is the part that costs an afternoon.
Why this is worth a breaking change
43% of every bug ever filed against this engine — 32 of 74 — was one shape: the engine answered something it did not understand instead of refusing it.
scatter.create{ collide = true }was parsed, stored, and read by nothing. For two releases. A game asked for solid props and got props you walk through.pin = "topCenter"meant top-left. Silently. Four HUD elements in one corner and a report that read like a layout bug.- A typo'd
perchunktook the default, forever, with nothing to see. scene.load(name, { addative = true })destroyed the running scene instead of layering onto it —additive = falseis what a misspelling reads as.
Every one of those was found by somebody playing, and each was fixed on its own. The counter-argument — that refusing is a breaking change — was settled the first time: a game that asked for solid props and got props it walked through was worse off than a game that got an error. Silence is not compatibility.
What keeps it true
One list per call, in the code, read by both the check and the write — so a key that is accepted is a key that does something. One shared parser per enum, so the list an error offers is the list the engine acts on. And two tests: one calls every registered options table with a bogus key and fails if it is accepted, the other scans the source and fails when a new options table appears that neither checks its keys nor is excused in writing.
So this does not decay back. That is the actual deliverable — not the 32 fixes, which were already made one at a time.