Editor scripting
Editor scripting
Every .lua file in a package's editor/ folder runs in the editor when the
project opens. It can add menus, panels, Scene-view overlays and world-space
handles; read and edit the scene with real undo; remember things between
sessions; and — if the package asked for it — talk to a server.
New to packages? Start at packages.md. This page is the reference.
local brush = 2.0
local panel = ed.window("Grass", function()
gui.heading("Brush")
brush = gui.slider(brush, 0.1, 20, "radius")
if gui.button("Scatter here") then
ed.undo()
scene.create("Grass")
end
end)
ed.menu("Grass/Brush…", function() panel:show() end)
ed.shortcut("Ctrl+G", function() panel:toggle() end)
ed.onSceneDraw(function()
handles.color(0.3, 1.0, 0.5)
handles.wireDisc(ed.camera().pos, vec3(0, 1, 0), brush)
end)
Three rules worth knowing first
Your script never touches the editor directly. Reads come from a mirror of the scene rebuilt once a frame; writes are queued and applied after the frame. That is what makes it safe to edit the scene from inside a panel that is being drawn.
gui.* exists only while your panel is being drawn. It is handed to the
function you gave ed.window / ed.overlay and taken away when that function
returns. Calling it from a timer or a menu item raises, with a message saying so.
A package gets what it declared. http and sys are simply absent unless
package.ron asks for them. So are io, os beyond the clock, require of
anything outside the package, and load/dofile.
ed — the editor
Panels, menus and shortcuts
ed.window(title, drawFn) |
a floating panel. Returns a handle |
ed.tab(title, drawFn) |
a dock tab, dragged and split like the editor's own panels. Returns a handle |
ed.overlay(name, drawFn) |
a panel pinned inside the Scene view. Returns a handle; starts visible |
ed.overlay(name, options, drawFn) |
the same, placed and framed the way you ask |
ed.menu(path, fn) |
a menu item. "Grass/Brush…" puts Brush… under a Grass menu |
ed.pickFile(options, fn) |
the OS's file picker. fn(paths) gets a list, or nil if cancelled. Needs Files |
ed.shortcut(keys, fn) |
"Ctrl+L", "Ctrl+Shift+F5". A bare letter is not accepted — the editor's own single-key bindings own the unmodified keyboard |
A panel handle answers :show(), :hide(), :toggle(), :isOpen() and — for a
window — :focus(). A window starts hidden; open it from your menu item.
Which of the three to reach for
They are not three styles of the same thing.
ed.windowfloats above everything and can be moved anywhere. Right for something you call up, act on, and dismiss.ed.tabis handed to the dock. It can be dragged into any panel, split beside the viewport, stacked with the Inspector, and it comes back where you left it — across a reload of your package and across a restart of the editor. Right for anything somebody keeps open beside the scene rather than in front of it: settings, a list, a report.ed.overlaydraws inside the Scene view itself, over the level. Right for something that is about what is on screen.
A tab starts closed, and where it sits is the user's arrangement — your package can open and close it but cannot place it. The editor remembers the position by your package id and the tab's title, so renaming a tab renames it in place; changing your package's id gives it a new slot.
local settings = ed.tab("My Tool Settings", function()
gui.label("Everything here docks like a normal panel.")
end)
ed.menu("My Tool/Settings…", function() settings:show() end)
A common pairing is an overlay that draws over the scene with a button that pops the same content out into a tab — draw into a shared function, and call it from both.
Asking for a file
ed.pickFile({ title = "Choose an image", label = "Images",
extensions = { "png", "jpg", "jpeg" } }, function(paths)
if not paths then return end -- cancelled
local bytes = ed.read(paths[1])
end)
multiple = true allows more than one. The callback runs on a later frame, on
the main thread — the picker is the operating system's and takes as long as
somebody takes, so this is a request rather than a call that returns a path.
Cancelling gives nil, never an empty list: "no" and "nothing" read the same in
Lua and only one of them is true.
Absent entirely from a package that did not declare Files.
Overlay options
corner |
"topLeft", "topRight" (the default), "bottomLeft" or "bottomRight". The left stacks start below the viewport toolbar, wherever it has been dragged to; the bottom stacks grow up from the bottom edge and are independent of the top ones, so both corners on one side can be used without either moving the other. fill is ignored on a bottom overlay — one pinned to the bottom that also took the whole column would be pinned to the top |
bare |
true draws no frame and no title — for an overlay that paints its own |
width |
pixels, 40–900. Default 260 |
fill |
true takes the full height of the viewport. One filling overlay ends its stack |
ed.overlay("My HUD", { corner = "topLeft", bare = true, width = 330 }, function()
gui.rectFilled(0, 0, 320, 40, 0.05, 0.06, 0.08, 0.9, 5)
gui.textAt(10, 12, "drawn, not themed", 13, 1, 1, 1, 1)
gui.reserve(320, 40)
end)
Reach for fill when the overlay is the tall thing on screen — a list, a log, a
conversation. Without it an overlay is only as tall as what it drew, so
gui.available() inside one reports the content rather than the room, and a
panel cannot size itself to the space it has.
Reach for bare when the overlay is a heads-up display rather than a panel: a
grey slab behind it hides the level the readout is about, which is the one thing
a Scene overlay must not do. If you take bare, you own the whole look — paint a
background behind anything you expect to be readable over a bright scene.
Hooks
ed.onUpdate(fn) |
every editor frame, before anything is drawn |
ed.onSceneDraw(fn) |
where handles.* works |
ed.onSceneOpen(fn) / ed.onSceneSave(fn) |
|
ed.onSelectionChange(fn) |
fires once per actual change |
ed.onPlay(fn) / ed.onStop(fn) |
before the scene changes under you |
ed.onUnload(fn) |
the project or the package is going away |
Waiting
ed.after(seconds, fn) |
run it once, later |
ed.every(seconds, fn) |
run it again and again |
t:cancel() |
stop one; both hand back a handle |
local poll
poll = ed.every(2, function()
http.get(url .. "/progress/" .. id, function(r)
if r.body.complete then poll:cancel() ; show(r.body) end
end)
end)
They run on the editor's clock — the same one ed.time() answers with — so
nothing fires while the editor is not drawing, and a timer is not a way to
measure real time. What they are for is "in two seconds" and "every half
second".
A repeat keeps its period rather than drifting a frame at a time, and it never catches up: a minute spent in a modal dialog costs you one firing, not a hundred and twenty. A timer may cancel itself, or another, from inside its own callback.
Everything a package registers goes away on ⟲ Reload, timers included.
Reading the editor
ed.project() |
{ root, name, scene, engineVersion } |
ed.camera() |
The Scene view's camera as drawn this frame: pos, forward, up, right (vec3s), near, far, ortho (boolean), width / height (the Scene view in pixels) and aspect (width / height). In perspective fovYDeg is the vertical angle the view spans and orthoHeight is nil; in orthographic orthoHeight is the world height it covers and fovYDeg is nil. frustum = { left, right, bottom, top } is exact: tangents at unit distance in perspective, world units in orthographic, along right and up (see below) |
ed.playing() |
|
ed.time() / ed.dt() |
seconds since the editor started, and this frame |
ed.package |
{ id, name, version, root, path(rel) } — your own package |
The Scene view is usually off centre. The editor draws the whole window
with the Scene camera, and the Scene tab shows the part of that picture its
rectangle covers. With panels on one side, the middle of what the author sees
is not straight down forward. frustum is the exact shape either way (an
off-axis frustum: left ≠ -right or bottom ≠ -top). A service that only
takes a symmetric camera is exact when the view is centred. Otherwise,
fovYDeg and aspect are the angle and shape the view spans.
Doing things
ed.undo() |
mark the edits that follow as one undo step |
ed.saveScene() / ed.openScene(rel) |
|
ed.play() / ed.stop() |
|
ed.message(title, body) |
a modal with an OK button |
ed.lookAt(point [, distance]) |
glide the Scene camera to a place — see below |
ed.openUrl(url) |
Browser permission. http:// and https:// only; the address is parsed first, and one with a username, whitespace, or a quote, angle bracket, backtick, caret, pipe or backslash in it is refused with the reason |
ed.repaint() |
draw again promptly (for an animating panel) |
ed.log(…) / ed.warn(…) / ed.error(…) |
to the Console, tagged with your package's name. print does the same |
ed.randomBytes(n) |
n bytes of real randomness, as a string (1–1024) |
math.randomis a generator seeded from the clock — right for a puff of smoke, wrong for anything somebody gets to guess at. Useed.randomBytesfor a sign-in challenge, a nonce, a token or an id that must not collide. Lua strings are byte strings, so the result is raw bytes and turning them into hex or base64url is yours to do.
Taking somebody to a place
ed.lookAt(vec3(12, 0, -4)) -- ten metres back from there
ed.lookAt(hit.point, 3) -- closer
The same move the F key makes — the view angle is kept and the camera glides
rather than jumping — aimed at a point instead of at the selection. Any tool
with a list of places in it needs this: a search result, a lint hit, a
measurement, a suggested position. The alternative is selecting a node in order
to move a camera, which is an edit to somebody's selection made behind their
back.
Nothing is selected, nothing is changed, and it does not care whether there is anything at the point.
Remembering things
Three stores, because "remember this" means three different things:
| Scope | Lives in | |
|---|---|---|
ed.prefs |
this person, every project | the editor's config folder |
ed.store |
this project, everybody | <project>/.floptle/packages/ |
ed.session |
until the editor quits | memory |
An API key belongs in prefs. A per-scene annotation belongs in store. A
"have I already asked?" flag belongs in session.
Each answers get(key [, default]), set(key, value) and keys(). Values are
strings, numbers and booleans; anything structured goes through json.encode
first, which keeps the files readable.
ed.prefs.set("apiKey", key)
local key = ed.prefs.get("apiKey", "")
Files
ed.read(rel) |
text, or nil. Your own folder always; elsewhere in the project needs Files |
ed.readBytes(path) |
raw bytes, or nil. Same rule, plus anything ed.pickFile gave you |
ed.exists(rel) / ed.list(rel) |
same rule |
ed.write(rel, text) |
project-relative. Always needs Files |
Nothing reaches outside the project, and nothing may climb out with ...
ed.read is text: a PNG comes back as nil, the same answer you get for a file
that is not there. Use ed.readBytes for anything binary — Lua strings are byte
strings, so #bytes is the length and string.byte indexes it.
A file the user picked is a file you can open. ed.pickFile returns paths
from anywhere on the machine, and ed.readBytes accepts the ones it handed you
during this session — the OS dialog is the permission, since the user chose that
exact file for this purpose. Paths you make up are still scoped to your package
and the project, and the grant is matched on the resolved path, so .. cannot
walk out of it.
ed.pickFile({ title = "Choose a floor plan", label = "Images",
extensions = { "png", "jpg" } }, function(paths)
if not paths then return end
local bytes = ed.readBytes(paths[1]) -- readable: the user picked it
end)
require("lib/helper") loads another Lua file from your own package, once,
into the same environment, and returns what it returned. Paths are relative to
the package root, not to editor/.
Keep your library files out of
editor/. Everything undereditor/runs on its own when the package loads — that is whateditor/means — so a module kept there is executed once as a script and again when something requires it. Put them beside it and require them by path:my-package/ editor/main.lua runs lib/client.lua required by main.lua as require("lib/client")
scene — the node graph
Nodes are identified by a number. Reads come from this frame's mirror; writes are queued and applied after the frame, so a value you set is visible on the next one.
scene.all() / scene.roots() |
every node id / the top-level ones |
scene.find(name) / scene.findAll(name) |
names are not unique, and findAll says so |
scene.children(id) |
|
scene.info(id) |
everything about one node — see below |
scene.bounds(id) |
{ min, max, center, radius } in world space |
scene.raycast(origin, dir [, maxDist]) |
{ node, point, normal, distance }, or nil |
scene.gravity([at]) |
{ x, y, z, uniform } — the acceleration bodies fall by |
scene.gravity([at]) answers the one question a jump model is made of. It is
the acceleration the sim would actually apply, not a constant: gravity in this
engine comes entirely from the scene's GravityVolume nodes (plus any
CelestialBody), so a scene with no gravity volume has zero gravity — a
space level — and one with a Down volume has whatever strength that volume was
given. Guessing -9.81 is wrong in both directions.
at is a world point and defaults to the origin. Pass one whenever uniform is
false: a Radial volume is a planet well and a CelestialBody is a real µ/r²
source, so "the level's gravity" is not a single vector in those scenes and the
value you get is the value there. When uniform is true the answer is the
whole answer and at does not matter.
local g = scene.gravity()
if g.uniform then
print(("gravity %.2f m/s²"):format(-g.y))
else
print("this scene has wells — sample at the player: ", scene.gravity(p).y)
end
Read-only, like nav and tilemap — nothing here sets gravity.
scene.info(id) returns { id, name, kind, parent, children, pos, worldPos, rot, scale, radius, extents, ui, tags, layer, visible, scripts, asset }. pos is local; worldPos
has the parents applied. kind is a stable name — "mesh", "camera",
"pointLight", "terrain", "tilemap", "empty"… — that will not change
because a node type gained a field.
ui is { element, text, interactive, disabled } only on a node that is a UI
element — if n.ui then is the test, the same shape extents uses. A UI
element is an ordinary node carrying a spec, so its kind reads "empty",
which leaves nothing else able to tell a button from a folder. element is one
word — "button", "slider", "text", "image", "scroll" or "panel" —
rather than the raw flags it is derived from; text is the label it draws,
where it draws one; interactive is whether it takes a click at all (a panel
does not, a button does); disabled is the greyed-out state.
extents is the node's oriented half-extents in world units — read it with
rot when which way a thing faces matters, which is most of the time for a
placement or measurement tool. bounds is the world-aligned box around that same
oriented box, so a crate turned 45° reports a wider bounds than its extents,
correctly.
A node with no measurable geometry — a folder, a light, a camera — has no
extents, and itsboundsfalls back to its bounding sphere, which is loose on anything long and thin.
scene.raycast tests each node's oriented box, not its triangles — exact for
the built-in shapes, right to within its import bounds for a model, and wrong for
a doorway in a wall. It is enough for what tools do with a ray: find the ground
under a point, pick what is in front of the camera, snap to a surface.
local hit = scene.raycast(vec3(x, 100, z), vec3(0, -1, 0))
if hit then place(hit.point, hit.normal) end
Hidden nodes are not in the way, and a ray that starts inside something hits it at distance 0.
Edits:
scene.setName(id, name) |
|
scene.setPos(id, v) / scene.setScale(id, v) / scene.setRot(id, x, y, z, w) |
|
scene.setVisible(id, on) |
|
scene.setParent(id, parentId or nil) |
keeps the node where it is standing |
scene.create(name [, parentId]) |
an empty node |
scene.destroy(id) |
the node and its whole subtree |
scene.spawnPrefab(path [, at]) |
Reading and writing the whole node
The setters above name one property each, and a tool that builds a level needs
the rest of them. scene.set and scene.add take the node document — the
same shape a .ron scene, a prefab and the clipboard all use — so anything a
node can be, a package can write.
-- Read one. `scene.doc` answers the whole node, in the same shape you write.
local doc = scene.doc(selection.active())
doc.name = doc.name .. " copy"
doc.transform.translation = { x, y, z }
scene.add(doc) -- a real copy: its mesh, material, collider, tags
scene.docreads a node in the selection. That is a real limit and worth knowing rather than discovering: a node's document is every component it has, serialised, and building the whole scene's would mean rebuilding all of them on every frame of a gizmo drag in any project that had such a package installed. The selection is what a tool acts on and it is a handful of nodes.Asking for one that is not selected raises, saying so, rather than answering nil — a read that quietly returns nothing is how a tool places an empty node and reports success. Use
scene.docsbelow,selection.set({id})first, orscene.info(id)for the summary, which is always available for every node.
Reading many nodes' documents
scene.docs(ids, done) reads the document of any node, however many, without
touching the selection.
scene.docs(scene.all(), function(docs, missing)
for id, doc in pairs(docs) do
if doc.name:match("^GameObject") then rename(id, doc) end
end
end)
A tool that renames across a level, collects every user-facing string, or
proposes a reorganisation has to read nodes nobody has selected, and
selection.set is not a way around that — it is an edit to somebody's selection
made in order to perform a read.
The cost that keeps scene.doc to the selection is a per-frame one:
rebuilding every document on every frame of a gizmo drag. This is not that. It
is one read of the ids you asked for, served on the next frame like mesh.read,
costing nothing until you ask and nothing again afterwards — so it belongs on a
button, not in onUpdate.
done(docs, missing) — docs keyed by id, and missing listing the ids that
had no node. The missing ones are reported rather than dropped: a node
destroyed between the ask and the answer is an ordinary thing that happens, and
a batch that quietly answers with fewer nodes than it was given is how a tool
reports renaming forty having renamed thirty-eight.
scene.maxDocs is how many one call serves; more than that raises rather than
hitching, and says to ask in batches.
-- Change what you name, and nothing else.
scene.set(id, { tags = {"cover", "movable"}, layer = "props" })
scene.set(id, { rigidbody = { mode = "Static" }, collidable = true })
scene.set(id, { transform = { translation = {4, 0, 2} } }) -- keeps the rotation
-- Build a room and everything in it: one call, one undo step.
ed.undo()
scene.add({
name = "Guard Post",
transform = { translation = {12, 0, -8} },
children = {
{ name = "Crate", matter = { Primitive = { shape = "Cube", color = {0.6,0.5,0.4} } },
tags = {"cover"}, collidable = true },
{ name = "Lamp", matter = { PointLight = { color = {1,0.9,0.7}, intensity = 8 } } },
},
})
scene.set is a patch. Only the keys you name change — which is what lets a
tool tint a light without knowing what else that light is, and what stops a tool
written for 0.64 from silently clearing a field 0.70 adds. Nested keys merge one
level, so naming transform.translation leaves the rotation and scale alone; a
list is a value, so tags = {"cover"} sets the tags to exactly that.
A key that is not a node property is refused and named, with the property you
probably meant. Nothing is half-applied: a typo three nodes down inside a
children list costs a Console line, not half a room.
The node keeps its id. An id you took last frame still names the same node after a write, and its children stay under it.
scene.info(id) is the quickest way to see what a node of some kind carries —
the document uses the same names, and every field of it is in docs/scripting.md
alongside the node types themselves.
Four keys are the scene file's and not yours:
id,parent_id,parentandattachment. They link nodes together by position in a list, and a package re-pointing one wires the scene to something else without warning. Use the idsscene.*gives you, andscene.setParent.
selection.get(), selection.active(), selection.set(ids) and
selection.clear() do what they say.
handles — drawing in the world
Queued from ed.onSceneDraw and painted over the Scene view. Immediate mode: the
list empties every frame, so a tool that stops drawing stops appearing.
handles.color(1, 0.6, 0.2) -- 0–1 floats, inherited by everything after
handles.width(2)
handles.wireCube(centre, vec3(2, 2, 2)) -- size is the FULL extent
handles.label(centre, "spawn")
color · width · line(a, b) · polyline(points [, closed]) ·
poly(points) (filled) · wireCube(centre, size) · wireSphere(centre, r) ·
wireDisc(centre, normal, r) · arrow(from, to) · dot(at [, px]) ·
label(at, text [, size]).
Positions are vec3(x, y, z) or any {x=, y=, z=} or {1, 2, 3} table.
Handles paint over the scene rather than into it — an authoring aid hidden behind the wall it is measuring is no use — and they are not drawn in the Game view, which is meant to show what a player would see.
nav — the baked navmesh
Where a character can walk, as the level's bake describes it. nav.ready() is
false until somebody adds a Nav Mesh node and presses Bake, and every call
here answers nil until then — which is the ordinary state of a new project
rather than an error, so a tool that runs on every scene has to cope with it.
This is the reading half of the scripting nav API and nothing
that moves: no nav.agent, no nav.obstacle, no opening and closing links.
There is no simulation running for those to act on, and an obstacle carved into
the editor's own bake would be a level edit made by a panel.
Everything is in world coordinates, so a level a million units from the origin needs no arithmetic at your end.
nav.ready() |
is there a bake to ask |
nav.settings() |
the character it was baked for: radius, height, maxSlope, stepHeight, cellSize, maxDrop (the tallest ledge it steps off), maxJump (the widest gap it clears), minRegionArea (the smallest patch of ground kept), plus area in square metres and areaCount, the number of polygons |
nav.areas() |
the walkable surface — see below |
nav.links() |
the portals between those rectangles |
nav.ground() |
{ {name, cost}… }, the kinds of ground the level named |
nav.offLinks() |
every off-mesh link — each carries a kind of "placed" (a Nav Link node), "drop" or "jump" (worked out by the bake), and a generated boolean saying the same |
nav.nearest(p [, max]) |
the closest standable point, or nil |
nav.onMesh(p) · nav.regionOf(p) · nav.reachable(a, b) |
|
nav.path(a, b) · nav.distance(a, b) · nav.raycast(a, b) |
|
nav.random(seed [, near, radius]) |
repeatable for a given seed |
nav.sampler([near, radius]) |
the same neighbourhood gathered once, for many draws |
Reading the surface
nav.areas() hands back one flat array of numbers and a count, not an array
of tables. A real bake is thousands of rectangles, and one Lua table each
exhausts mlua's auxiliary slots and takes the editor down with it. The stride is
a constant so the arithmetic is written once:
local a, n = nav.areas()
local ground = nav.ground()
for i = 0, n - 1 do
local o = i * nav.AREA_STRIDE
local minX, minZ, maxX, maxZ = a[o+1], a[o+2], a[o+3], a[o+4]
local yMin, yMax, region = a[o+5], a[o+6], a[o+7]
local cx, cy, cz = a[o+8], a[o+9], a[o+10]
local kind = ground[a[o+11]].name -- "walkable", "water", …
end
region groups rectangles that can reach each other, so two with different
regions are two places you cannot walk between. nav.LINK_STRIDE does the same
job for nav.links(), whose entries are from to leftX leftY leftZ rightX rightY rightZ — from and to index the areas array, one-based.
Two things are called links.
nav.links()is the thousands of portals the bake derived between neighbouring rectangles.nav.offLinks()is the handful an author placed by hand:{ id, name, from, to, bidirectional, cost, duration, enabled, ground }. The names are inherited and worth checking before you read either.
Points come back as vectors you can hand straight to handles:
ed.onSceneDraw(function()
local a, n = nav.areas()
handles.color(0.3, 0.8, 1, 0.5)
for i = 0, n - 1 do
local o = i * nav.AREA_STRIDE
handles.wireCube(vec3(a[o+8], a[o+9], a[o+10]),
vec3(a[o+3] - a[o+1], 0.05, a[o+4] - a[o+2]))
end
end)
tilemap — a 2D level's floor
The reading half of the scripting tilemap handle
(node:tilemap()), the way nav is the reading half of the scripting nav
API: no set, no resize, no fill. An edit to somebody's level made by a
panel is a different conversation, and scene.set already exists for a
package that genuinely means to write.
local tm = tilemap.of(id) -- nil for a node whose kind is not "tilemap"
if tm then
local cols, rows = tm:size()
end
tilemap.of(id) |
a handle for that node, or nil if it is not a tilemap |
tm:size() |
cols, rows |
tm:tileSize() |
the world edge length of one square |
tm:tileset() |
the project-relative .tileset.ron path, or nil |
tm:get(x, y) |
the cell index at that square, or nil (outside the grid, or empty) |
tm:at(x, y) |
cell, rotDeg, flipX — the full answer, orientation included |
tm:solid(x, y) |
whether the tileset says that square collides — the one that matters; the rest is decoration without it |
tm:tags(x, y) |
the tileset's tags for that square, as a list |
tm:hasTag(x, y, tag) |
the common case of the above without a table per call |
tm:cellAt(worldPoint) |
x, y, or nil off the map — through the node's own transform, so a moved, turned or scaled tilemap needs no arithmetic at your end |
tm:worldAt(x, y) |
the world position of that square's centre — the inverse of cellAt |
Grid coordinates are 0-based from the top-left, the same convention the
scripting API uses. x/y outside the grid answer false/nil/empty rather
than wrapping — a loop that runs one past the edge is a bug in the caller's
bounds, and wrapping would hide it by answering about the far side of the map.
A tileset describes tile types — collision, tags, autotile groups — not the
grid itself, so tm:solid/tm:tags answer false/empty for a square whose
tilemap names no tileset, or names one that has not loaded, rather than
raising: a level mid-import is not an error state.
gui — widgets
Only inside a draw callback. Widgets return their new value:
name = gui.textField(name, "your name")
enabled = gui.checkbox(enabled, "enabled")
amount = gui.slider(amount, 0, 100, "amount")
if gui.button("Go", "starts the thing") then go() end
Text — label(text [, tip]) · heading · small · monospace ·
colored(text, r, g, b [, a]) · wrapped · link(text) → clicked
Buttons — button(text [, tip]) → clicked · smallButton ·
checkbox(value, text [, tip]) → value · toggle(value, text) → value ·
radio(selected, text) → clicked · selectable(selected, text) → clicked
Values — slider(value, min, max [, label]) · drag(value [, speed [, label]]) ·
textField(value [, hint [, grabKeyboard]]) · passwordField(value) ·
textArea(value [, rows]) ·
combo(label, options, index) → index (1-based) · colorEdit(r, g, b) → {r, g, b}
Layout — horizontal(fn) · vertical(fn) · group(fn) · indent(fn) ·
scroll(fn) · collapsing(title, fn) · enabled(on, fn) · width(px, fn) ·
height(px, fn) · separator() · space([px]) · flexibleSpace() (pushes
what follows to the far end of a row) · rightAligned(fn) ·
available() → {w, h}
Two ways to put something at the right-hand end of a row, and they differ.
flexibleSpace()reads naturally — everything after it is pushed right — but the space it can claim depends on how wide what follows turns out to be, which is only knowable once the row has been laid out. It is measured at the end of the row and used on the next frame, so a row whose trailing content has just changed width is right one frame later.
rightAligned(fn)lays its contents out from the right edge, so there is nothing to measure and nothing to be a frame behind. The cost is that the contents read back to front — the first thing you add sits furthest right:gui.horizontal(function() gui.label(name) gui.rightAligned(function() if gui.smallButton("✕") then remove() end -- furthest right gui.small(count .. " items") -- to its left end) end)Reach for
rightAlignedwhen the trailing content changes width — a count, a status, a time — and forflexibleSpacewhen it does not.
Feedback — progress(fraction [, text]) · spinner() ·
helpBox(text [, "info" | "warn" | "error"])
Type — font(name, fn) · hasFont(name) → boolean. See
Your own typeface.
Pass true as textField's third argument to take the keyboard this frame —
what a panel opened by a shortcut wants, so it can be typed into straight away.
Painting, for charts, heatmaps and anything there is no widget for. Coordinates are pixels from the panel's top-left.
rectFilled(x, y, w, h, r, g, b [, a [, round]]) ·
rectOutline(x, y, w, h, r, g, b [, a [, px]]) ·
line(x1, y1, x2, y2, r, g, b [, a [, px]]) ·
circle(x, y, radius, r, g, b [, a]) ·
poly({x1, y1, x2, y2, …}, r, g, b [, a]) — a filled convex polygon, from a
flat run of coordinate pairs. Fewer than three points draw nothing; a concave
outline fills as its convex hull, so split one into triangles. The same rule as
handles.poly, in a panel instead of the world. ·
textAt(x, y, text [, size [, r, g, b [, a]]]) ·
measure(text [, size]) → {w, h} — how big that string will be, in the same
font textAt would draw it in. Laying anything out by hand needs it: without a
measurement the only option is characters × an assumed width, which is wrong for
every proportional face and badly wrong for an i beside a W. ·
reserve(w, h) — claim space so the next widget does not draw over what you
painted, which is the call everybody forgets. ·
cursor() → {x, y} — where the next widget would go, in these same
coordinates.
textAt and measure both draw in whatever face the enclosing
font scope selected, so painted text matches the widgets
around it.
Painting more than one thing needs cursor(). The origin is the panel's
top-left and it does not move as widgets are added, so a second painted card
lands exactly on top of the first however much space you reserved between them.
Offset everything you paint by the cursor and a list works:
local at = gui.cursor()
gui.rectFilled(at.x, at.y, 200, 40, 0.05, 0.06, 0.08, 0.9, 4)
gui.textAt(at.x + 8, at.y + 12, "one row of many", 13, 1, 1, 1, 1)
gui.reserve(200, 40) -- now the cursor has moved on
Input — mouse() → {x, y, inside} · clicked() ·
keys() → {shift, ctrl, alt, enter, escape}
mouse() gives the pointer in the same coordinates the painting calls take —
relative to the panel's top-left — so a painted control can be hit-tested
against where it was drawn. inside is whether the pointer is over the area
this layout may draw into, which is the question worth asking before drawing
the thing being tested:
local at = gui.cursor()
local cx, cy = at.x + 9, at.y + 9
local m = gui.mouse()
local over = m.inside and ((m.x - cx)^2 + (m.y - cy)^2) <= 81
gui.circle(cx, cy, 9, 1, 0.3, 0.3, over and 1.0 or 0.85)
if over and gui.clicked() then clear() end
gui.reserve(18, 18) -- claim the space, or the next widget paints over it
Hit-test before reserving — cursor() is where the control is about to go,
and reserving first moves it out from under the pointer. And do reserve
afterwards: a control drawn under something else cannot be clicked however
correct its geometry is.
textField returns two values: the text, and whether it was submitted with
Enter. With keys() that is enough to build the usual chat behaviour — Enter
for a newline, Shift+Enter to send, or the other way round:
local text, submitted = gui.textField(text, "Ask me anything…")
local k = gui.keys()
if submitted and (k.shift or k.ctrl) then send() end
Extra return values are dropped in Lua, so x = gui.textField(x) is unchanged.
Will this icon draw?
gui.hasGlyph(text) — whether every character in text has an outline in the
editor's font stack.
A glyph no bundled font maps renders as a tofu square, and nothing warns you: the label just looks broken to whoever opens the editor. There is no way to tell by looking at your own source, because the character is right there in it.
local CLEAR = gui.hasGlyph("✖") and "✖" or "clear"
Answered from the fonts' character maps — the fact the renderer acts on. Plain ASCII is always true, so a fallback never has to be checked before it is used. It reports on the editor's proportional stack rather than on a face your package ships, which makes it conservative in the safe direction: a word where an icon would have worked, never a box where a word would have.
Two characters that look interchangeable often are not. On the bundled stack
✕(U+2715) and✓(U+2713) do not draw;✖(U+2716) and✔(U+2714) do.
Your own typeface
A tool that arrives with a brand should be able to keep it. Ship the font in your package and name it:
// package.ron
fonts: [ (name: "Heading", path: "fonts/YourFace-Black.ttf") ]
gui.font("Heading", function()
gui.heading("Lumen")
gui.small("LIGHTING TOOLS")
end)
.ttf, .otf and .ttc. The path is inside your package folder and cannot
leave it — which is why shipping a face needs no permission: reading a file
of your own is what require already does.
Everything drawn inside the closure uses that face, and only the family
changes — sizes are left alone, so gui.heading inside is still bigger than
gui.label inside. It applies to monospace too: a package that ships a mono
face and asks for it means it.
There is no gui.setFont. A face is chosen for the length of a closure, like
every other nesting call here, because a mode that outlives the panel that
switched it on is a mode somebody forgets to switch off.
The name is yours alone. Two packages may both ship a
"Heading"and neither sees the other's — names are scoped to the package that declared them.
A face that will not load is one line in the Console naming your package and the
file, and the panel draws in the editor's type. Never a row of tofu, and never
once a frame. gui.hasFont(name) is the same answer in advance, for a tool that
would rather draw its wordmark as an image than as the wrong type.
A display face usually ships an alphabet and little else, so the editor's own stack sits behind yours as a fallback — a heading with an arrow or an emoji in it still draws.
mesh — the triangles behind a node
Everything else here answers with a box. This answers with the geometry: what a node is actually made of, or what is in a model file.
mesh.read(id, function(m, err)
if not m then ed.warn(err) return end
ed.log(m.vertices .. " vertices, " .. m.triangles .. " triangles")
end)
mesh.read("assets/models/chair.glb", function(m) … end) -- a file, by path
A callback, like http, and for the same reason: the first read of a model
is a file off disk. Your callback runs on a later frame, on the main thread. A
read that cannot be answered still calls back — with nil and a reason, never
silently.
m.positions |
{x, y, z, x, y, z, …} |
m.normals / m.uvs |
the same shape, empty where the source has none |
m.indices |
triangle corners, zero-based |
m.vertices / m.triangles |
how many, so you can loop without dividing |
m.source |
"model", "map" or "primitive" |
Flat arrays, not a table per vertex. A table each costs one of Lua's ~8000 registry slots and the editor crashes when they run out, so a hundred-thousand vertex model has to arrive like this. Walk it by index:
for t = 0, m.triangles - 1 do
local a = m.indices[t * 3 + 1] -- Lua is 1-based…
local x = m.positions[a * 3 + 1] -- …but an index is 0-based
end
Indices are zero-based even though Lua's tables are not, because every mesh format and every consumer of one counts from zero. Converting would make
positions[indices[i] * 3 + 1]wrong in a way nothing would report.
Positions are in the node's own space, which is what a mesh file holds and
what an exporter wants — scene.info(id) carries the transform to place them
with. Returning world space would bake the current transform into data you might
be about to save.
No permission is needed: it reads what is in the scene, which the same package
can already measure and draw. Reading a file outside your package still needs
Files.
Terrain has no fixed triangles — it is meshed per chunk at the detail it is
viewed at — so mesh.read says so rather than picking a level of detail for
you. Sample it with scene.raycast. mesh.maxTriangles is the ceiling on one
read.
http — talking to a server
Needs the Network permission. Always non-blocking: the callback runs on a later
frame, on the main thread, where the rest of this API is safe to use.
http.get(url, function(res)
if res.ok then handle(res.body) else ed.error(res.error) end
end)
http.post(url, json.encode(body), { headers = { ["Content-Type"] = "application/json" } },
function(res) … end)
get · delete · post(url, body, …) · put · patch. The optional opts
table takes headers and timeout (seconds). The reply is
{ ok, status, body, error } — a 4xx or 5xx is an answer, so you get the status
and the body with ok false rather than a transport error.
Eight requests may be in flight at once and a reply is capped at 8 MB.
A package runs in the developer's own editor, so it may reach localhost and
the local network — the browser sign-in above depends on it. Link-local
addresses (169.254.x, fe80::) are refused, as they are for a game's
http.*: nothing a package needs lives there.
Signing in through a browser
local port = http.listen(0, function(req)
ed.log("token: " .. (req.query.token or "?"))
end)
ed.openUrl("https://example.com/auth?redirect=http://127.0.0.1:" .. port)
http.listen(port, fn) binds 127.0.0.1 only and returns the port actually
bound — pass 0 to let the machine pick a free one. It answers the first request
that arrives, hands your callback { path, query, body }, and closes. It closes
itself anyway after five minutes, when the package reloads, and when the project
does. http.stopListening() closes it early.
Streaming — a progress bar that moves
A long job on a server usually offers Server-Sent Events: one open connection the server writes to as it goes, instead of you asking "done yet?" every second.
local s = http.stream(url, { headers = { Authorization = key } },
function(frame) -- once per event
if frame.event == "progress" then
pct = json.decode(frame.data).pct
ed.repaint()
end
end,
function(res) -- once, when the connection closes
if not res.ok then pollInstead() end
end)
Each frame is { event, data }. data is the text the server sent — decode
it yourself, since a server may stream JSON, plain text or nothing at all. An
event with no name is "message", which is what the protocol says it means.
Comments and keepalives never reach you. Servers hold a connection open
through proxies by sending : keepalive every few seconds; that is protocol, not
data, and a package should not have to know it exists.
The handle answers s:cancel() and s:isOpen(). A stream closes itself when the
server closes it, when the package reloads, when the project does, and after 90
seconds of complete silence — not even a keepalive — which is a dead connection
rather than a quiet one. Cancelling takes effect within about ten seconds on the
network side; your callbacks stop immediately.
onEndis not "it worked". It says the connection closed, andres.oktells you whether it closed the way it meant to. A server that has no streaming endpoint answers with a status instead, which arrives asres.ok == falseandres.status == 404— that is your cue to fall back to polling, and it is worth writing that fallback, because a stream is the thing most likely to be blocked by somebody's proxy.
Four streams may be open at once. If a server sends frames faster than the editor
draws, frames are dropped rather than queued without limit, and res.error says
how many when the stream ends.
The rest of the environment
vec3(x, y, z) · vec2(x, y) — plain {x=, y=, z=} tables.
json.encode(value) / json.decode(text) / json.null / json.array(t) /
json.isArray(v).
json.null encodes to JSON null. Setting a field to Lua nil removes the
key rather than nulling it, so an API that reads an absent field as "leave
this alone" and a null as "clear it" needs the sentinel to say the second one.
Decoding is not symmetric on purpose: a JSON null still arrives as nil, so
if body.field then keeps meaning what it always did.
json.array(t) marks a table as a list. Lua has one table type and JSON has
two, so the encoder guesses from the shape: keys 1..n and nothing else is an
array. That is right for every list with something in it and cannot be right for
the empty one — {} is both an empty list and an empty object. It stays an
object, because an empty body posted to an API that reads objects has to. So the
empty list needs saying out loud:
http.post(url, { decision = "reject", selected_ids = json.array{} }) -- []
http.post(url, { decision = "reject", selected_ids = {} }) -- {} — wrong type
json.array() with no argument builds a new empty list. json.array(t) returns
the same table it was given, so local ids = json.array{} then ids[#ids+1] = x
reads normally and sends a list whether or not anything went in.
json.decode marks every array it builds, so read → edit → send back keeps
the types it was handed without your remembering which fields were lists:
local body = json.decode(res.body)
for i = #body.ids, 1, -1 do body.ids[i] = nil end
json.encode(body) --> {"ids":[]} — still a list
One thing to know: the mark lives on the table, so body.ids = {} throws it away
along with the table that carried it. body.ids = json.array{} is the
replacement that keeps it.
json.isArray(v) answers the question the shape used to leave open: would
json.encode write this as a JSON array? It is how json.decode("[]") and
json.decode("{}") are told apart, which they could not be before.
A table that is marked as a list and also carries a name (t.name = "x") is
refused by json.encode with a message naming the key, rather than having the
key silently dropped.
ed.copy(text) puts a string on the system clipboard — the thing a copy
button is made of. Everything a panel computes that somebody wants elsewhere
goes through it: a node path to paste into a search box, a request id for a bug
report, a snippet from a service that belongs in a file your package never
opened. Before it, the only exit was ed.write — a file, a path and an
overwrite policy for four lines somebody wanted to paste.
It needs no permission: nothing leaves the machine and nothing arrives.
Browser guards ed.openUrl because that hands a string to the network; this
hands it to the person at the keyboard, who asked for it by pressing the button.
It is a write and there is no read. Nothing in this API can see what is on the clipboard, and that absence is a decision, not an oversight — the clipboard holds whatever the person last copied from anywhere, and a package that renders a panel has no claim on it.
The editor shows a toast saying how much was copied, so a copy button confirms itself and you do not have to build the feedback. Text over 1 MB is refused with a message rather than truncated: half a snippet pastes without complaint, which is worse than a failure you can see.
It is the one system clipboard — the same one Ctrl+C uses. Copying from a
package replaces whatever was on it, including a set of scene nodes the person
had copied to paste into another editor window. Only the last copy of a frame is
written, and an identical repeat is skipped, so calling ed.copy from a hook
that runs every frame does not fight with the rest of the machine.
gui.button(row, { text = "⎘ Copy", onClick = function() ed.copy(block.code) end })
sys.openUrl(url) and sys.platform — Browser permission.
print goes to the Console, tagged with your package's name, and prints tables
one level deep rather than table: 0x….
Standard Lua: assert error ipairs next pairs pcall xpcall select
type tostring tonumber rawget rawset rawequal rawlen
setmetatable getmetatable unpack, plus string, table, math,
coroutine, bit (LuaJIT's signed 32-bit semantics, for hashing and packing —
provided on every build, whichever Lua it runs), and os.time /
os.clock / os.date / os.difftime.
vec3 here is always the exact one — 64-bit and mutable — whatever the open
project's own script_vec3 says. That setting is about the game's scripts; an
extension runs in the editor's Lua, which does not change under it.
There is no _G. The environment is an allow-list, not a view of the real
globals, so there is nothing to reach through. To probe for something optional,
just read it — an unknown name is nil:
local bit = bit
if not bit then … end
When something goes wrong
An error is reported to the Console once and the callback that raised stops being called — a panel that raises every frame would otherwise fill the Console faster than you can read it. The panel draws the error in place of its contents, and the package's row in 📦 Packages carries it too.
Fix the file and press ⟲ Reload all. A reload throws the whole Lua state away
and builds it again, so nothing survives it except ed.prefs, ed.store and
ed.session — and the panels you had open come back open.