Writing Prefab Scripts
Scripted prefabs let you generate the contents of a prefab from a small Lua script instead of hand-drawing everything once and copy-pasting it around the map. Declare a few parameters at the top of your script, then use those parameters to draw boxes and text labels. Every time you place a new instance of the prefab, change one of its parameters, or edit the script itself, the script re-runs and the instance's content is regenerated from scratch.
A quick note before you dive in: the editor UI for attaching a script to a prefab and editing it in-app isn't wired up yet. This guide documents the scripting language itself — the part that already works under the hood — so you know what to write once that UI lands.
What a script actually does
A prefab script is a normal Lua script. There's no generate() function or
special entry point — the whole file just runs top to bottom, every time.
Along the way it can:
- Declare the parameters the prefab exposes (
declareParams{...}). - Read the current values of those parameters (
params.<name>.value). - Create canvas objects (
CanvasObject.new(...)) and set their fields.
Whatever objects your script creates during that run become the instance's content. There's no separate "commit" step — creating an object registers it immediately.
The script re-runs automatically:
- When a new instance of the prefab is placed.
- Whenever that instance's parameter values change.
- Whenever the script's source is edited — this regenerates every existing instance of the prefab, not just new ones.
Declaring parameters
Parameters are declared once, with a call to declareParams:
params = declareParams{
{ name = "label_text", type = "string", default = "Interchange" },
}
Note it's a real function call (declareParams{...}, Lua's sugar for
declareParams({...})), not a bare table you assign to params yourself.
The table you pass in is a plain ordered list of field descriptors. What
declareParams hands back is different — a table keyed by name, so the rest
of your script reads values as params.label_text.value, not by position.
Five field types are supported, all flat — there's no list or object
type, so a param can't nest another param inside it:
type |
Extra keys | What .value looks like in Lua |
|---|---|---|
"string" |
default |
a string |
"number" |
default, min, max, decimals |
a number |
"boolean" |
default |
true / false |
"color" |
default (a hex string) |
a hex string, e.g. "#da291c" |
"select" |
options (a list of strings), default |
a string, always one of options |
Every field descriptor also takes an optional section — a plain string.
Fields sharing the same section get grouped into one collapsible section in
the properties panel, in the order they first appear; a field with no
section just renders as its own row. There's currently no way to declare a
variable-length repeated row of fields where the row contents are
user-authored per instance (the old list-of-object pattern) — that
capability was dropped along with list/object and hasn't been replaced
yet. If the row's length and contents instead come from existing map data —
one card per metro line, say — you don't need a declared list at all: loop
over Lines.all directly (see "Reading metro lines" below and Full example
2).
Naming convention: capitalize name and section values —
Label_Text rather than label_text, "Appearance" rather than
"appearance". name still doubles as the Lua accessor key
(params.Label_Text.value), so keep underscores instead of spaces there;
section is never accessed from Lua, so a plain title-case phrase like
"Route Colors" is fine.
Select
select is a string param constrained to a fixed set of choices, rendered as
a dropdown in the properties panel instead of a free text field:
params = declareParams{
{ name = "shape", type = "select", options = {"circle", "square", "diamond"}, default = "circle" },
}
options is required and can't be empty — a script that declares a select
param without one fails the whole run, same as any other bad declaration.
params.shape.value reads back as a plain string, exactly like string. If
a default isn't one of options (or is missing), the value falls back to
the first entry in options.
min, max, and decimals on a number param are just metadata describing
how the value should be presented and edited — the script engine doesn't
clamp anything to them itself.
Colors
Color values are always plain hex strings, both when you declare a default
and when you read params.<name>.value back in your script — "#DA291C".
6 digits means fully opaque (RRGGBB); if you need to control opacity too
you can use 8 digits, alpha first (AARRGGBB), e.g. "#80DA291C".
list and object — nesting
list needs an item describing the schema of every element. object
needs a fields list, and each entry in that list is itself a full field
descriptor (name + type + whatever else that type needs) — which means a
list of objects, or an object with a list field, or deeper, all just
fall out of the same recursive shape. This is what a "row of colored route
badges" — a list of {number, color} pairs — looks like declared:
params = declareParams{
{ name = "routes", type = "list", item = {
type = "object",
fields = {
{ name = "number", type = "string" },
{ name = "color", type = "color" },
}
}
},
}
Reading it back is a normal Lua loop over a table of tables:
for i, route in ipairs(params.routes.value) do
-- route.number is a string, route.color is a hex string
end
Creating canvas objects
local box = CanvasObject.new("box", {
x = -60,
y = -20,
width = 120,
height = 40,
})
CanvasObject.new(kind, fields) creates an object and optionally applies a
table of fields immediately. You can also set or change fields afterward:
local label = CanvasObject.new("text")
label.text = "Central"
label.textAlign = TextAlign.center
Right now only two kinds are supported:
Shared fields
Every generated canvas object supports the same placement fields:
| Field | Meaning | Default if unset |
|---|---|---|
name |
object name | "" |
x, y |
position, relative to the instance's anchor | 0, 0 |
width, height |
layout box size | kind-specific |
anchorX, anchorY |
where the position sits inside the box, 0-1 |
0.5, 0.5 |
lockAspectRatio |
preserve width:height while resizing | false |
rotation |
rotation, radians | 0 |
opacity |
0-1 |
1 |
zIndex |
stacking order | 0 |
"box" fields
| Field | Meaning | Default if unset |
|---|---|---|
width, height |
size | 160, 80 |
cornerRadius |
corner rounding | 8 |
fillColor |
fill color, hex string | "#ffffff" |
color |
alias for fillColor |
"#ffffff" |
borderColor |
border color, hex string | "#333333" |
borderWidth |
border thickness | 2 |
"text" fields
| Field | Meaning | Default if unset |
|---|---|---|
width, height |
text layout box size | 160, 40 |
text |
the label's text | "Label" |
fontFamily |
font family name | app default |
fontSize |
font size | 11 |
color |
text color, hex string | "#444466" |
textAlign |
TextAlign.left, TextAlign.center, TextAlign.right, etc. |
TextAlign.center |
verticalAlign |
TextVerticalAlign.top, TextVerticalAlign.center, TextVerticalAlign.bottom |
TextVerticalAlign.top |
labelPadding |
draw the white label halo behind text | false |
padding |
inset the text inside its layout box, an EdgeInsets (see below) |
none |
Any field you don't set just falls back to that default — you never have to set every field on every object.
TextAlign and TextVerticalAlign are declared enum tables. Use those enum
values instead of raw strings; an invalid enum value fails the whole
generation attempt so typos don't silently render with the wrong alignment.
EdgeInsets — padding
padding doesn't take a bare number or a loose table of sides; it takes an
EdgeInsets, built with EdgeInsets.new. There are two ways to call it, and
any side you leave out is 0:
EdgeInsets.new(20, 3, 0, 0) -- left, top, right, bottom
EdgeInsets.new{ left = 20, top = 3 } -- name only the sides you want
so a cell label inset 20 from the left edge of its box is:
CanvasObject.new("text", {
x = colX,
y = rowY,
width = colW,
height = rowH,
text = "7-13",
padding = EdgeInsets.new{ left = 20 },
})
Padding insets the text within the layout box you gave it — the box stays
where x/y/width/height put it. That matters when you're rebuilding a
hand-drawn panel: labels drawn in the editor usually carry their own padding
(a cell label is typically left = 14, top = 14), and get_properties
reports it, so you can copy those numbers across rather than trying to fake
the inset by nudging x.
Like the enum tables, EdgeInsets.new is strict: an unknown side name or a
non-number value fails the whole generation attempt rather than silently
resolving to zero.
There's no separate fontWeight/italic field anymore — bold, italic,
underline, strikethrough, inline color and explicit weight are all written
directly into text as micro-markdown instead, e.g.
text.text = "**Oxford Circus** [w300]zone 1[/]". See
Micro Markdown for the full syntax.
You can also read fields back off an object you created earlier in the same
script (e.g. box.width * 2) — property reads and writes both go through
the same object, so this works fine, it's just not typically needed.
Coordinate space
x/y on every object are local to wherever the prefab instance gets
placed — (0, 0) is the instance's own anchor point, not a fixed spot on
the map. The app adds the instance's actual position on top after your
script runs, so you never need to know or care where on the map the instance
actually ended up while you're writing the script.
Instance geometry
Every instance also has its own width/height and anchor point — the same
placement box you'd get on any other canvas object, editable directly in its
properties panel. Inside a script, this is a plain global table called
instance:
box.width = instance.width
box.height = instance.height
box.anchorX = instance.anchorX
box.anchorY = instance.anchorY
- Reading
instance.width/instance.heightgives you the instance's current stored size, so you can lay your generated content out relative to it instead of hardcoding numbers. - Reading
instance.anchorX/instance.anchorYgives you the instance's current anchor fractions, where0.5, 0.5is centered,0, 0is top-left, and1, 1is bottom-right. - Writing
instance.width/instance.heightduring your script lets you report a size back that the script computed itself — handy for anything whose natural footprint depends on its parameters (a variable-length row of items, say) rather than being fixed. Whatever you leave those fields as when the script finishes is what gets saved as the instance's size. - Writing
instance.anchorX/instance.anchorYworks the same way: the values left at the end of the script are saved as the instance's anchor. - If your script never touches
instanceat all, nothing changes — same value in, same value out.
Reading metro lines
Scripts get read-only access to the map's metro lines (the same lines you
see and edit in the Lines panel) through a global called Lines:
for i, line in ipairs(Lines.all) do
-- line.id, line.name, line.color, line.style, line.width, line.padding
end
local central = Lines.byId(3)
if central then
box.color = central.color
end
Lines.allis every line on the map, in order, as a normal 1-indexed table.Lines.byId(id)looks one up directly and gives younilif that id doesn't exist — handy for a prefab whose parameters reference a specific line by id rather than looping over all of them.- Each line's table has
id,name,color(a hex string, same as every other color field in this API),style(a string —"single","doubleTrack","road", or"dashed"),width, andpadding. - This is a snapshot handed to your script fresh every run — changing a
field on a table you got from
Lines(line.name = "whatever") only affects your own local copy, it never writes back to the real map. - It only covers line attributes, not which stations or segments belong to a line — that's not available to scripts yet.
Measuring text
measureText(text, options) tells you how much space a single line of text
would take up, without creating a "text" object for it — useful for sizing
a box around a label before you commit to laying it out:
local size = measureText("7-13", { fontFamily = "Inter", fontSize = 24 })
-- size.width, size.height
local badge = CanvasObject.new("box", {
x = 0, y = 0,
width = size.width + 20, -- pad the box out around the measured text
height = size.height + 10,
})
options is optional and takes the same font-ish fields a "text" object
does:
| Option | Meaning | Default if unset |
|---|---|---|
fontFamily |
font family name, or a custom font id off CustomFonts |
app default |
fontSize |
font size | 11 |
weight |
font weight (100-900) |
400 |
An unknown option name fails the whole generation attempt, same as
EdgeInsets.new. This only measures a single line — it doesn't wrap, so
for multi-line text you'd measure a representative line rather than the
whole string.
What's off-limits right now
- Only
"box"and"text"kinds exist.CanvasObject.new("image")(or anything else) fails — and because a script's output is all-or-nothing, one bad object call fails the entire instance, not just that one object. - No way to read stations, segments, or other canvas objects from a
script. A
canvasglobal for reading the rest of the scene tree (station names, other images, etc.) is planned but not available yet — scripts can read metro line info (see above) and create new objects, but can't inspect or react to anything else in the map. - Sandboxed. No file access, no network access, no
os,io,require,load/loadfile/dofile, and nocoroutine. The base Lua library plusmath,string, andtableare all available, so ordinary logic (loops, string formatting,math.floor, etc.) works normally, with one exception:#gives you the length of a table only. On a string it throws ("attempt to get length of a string value") and, because a failed run produces nothing, takes the whole instance down with it. Usestring.len(s)ors:len()instead. - Bounded execution. A script gets a time budget (2 seconds) and an object-count cap (2000 objects) per run. A runaway loop trips one of these and fails the run with an explanatory message rather than hanging.
When something goes wrong
If your script has a syntax error, throws a runtime error (e.g. indexing
nil), creates an unsupported object kind, or blows past the time/object
budget, the whole generation attempt fails: the instance ends up with no
generated content for that run (whatever it had before is cleared, nothing
new takes its place), and the error message is kept on the instance so it's
visible while you're fixing the script. The error is also always printed to
the console. There's no partial output — either the script runs clean start
to finish, or the instance produces nothing.
Full example 1 — a single labeled box
A minimal interchange badge: one box with a text label centered on it.
params = declareParams{
{ name = "Label_Text", type = "string", default = "Interchange", section = "Text" },
}
local box = CanvasObject.new("box")
box.x = -60
box.y = -20
box.width = 120
box.height = 40
box.cornerRadius = 6
box.color = "#ffffff"
box.borderColor = "#333333"
box.borderWidth = 2
local label = CanvasObject.new("text")
label.x = 0
label.y = 0
label.text = "**" .. params.Label_Text.value .. "**"
label.fontSize = 14
Change the Label_Text parameter on an instance and only that instance's
label text changes — the box shape stays as authored in the script.
Full example 2 — a row of route cards
A variable-length row without a declared list param at all — the row's
length and content come from Lines.all (every metro line on the map, see
"Reading metro lines" above) instead. The card row resizes to fit the
instance's own width, splitting it evenly across however many lines exist:
params = declareParams{
{ name = "Gap", type = "number", default = 11, min = 0, decimals = 0 },
}
local cardHeight = instance.height
local gap = params.Gap.value
local routeCount = #Lines.all
local cardWidth = 0
if routeCount > 0 then
cardWidth = (instance.width - gap * (routeCount - 1)) / routeCount
end
for i, line in ipairs(Lines.all) do
local routeNumber = string.gsub(line.name, "^Route ", "")
local x = (i - 1) * (cardWidth + gap)
CanvasObject.new("box", {
x = x,
y = 0,
width = cardWidth,
height = cardHeight,
anchorX = 0,
anchorY = 0,
color = line.color,
})
CanvasObject.new("text", {
x = x,
y = 0,
width = cardWidth,
height = cardHeight,
anchorX = 0,
anchorY = 0,
text = "**" .. routeNumber .. "**",
fontFamily = "Inter",
fontSize = 42,
color = "#FFFFFF",
textAlign = TextAlign.center,
verticalAlign = TextVerticalAlign.center,
})
end
instance.anchorX = 0
instance.anchorY = 0
Add or remove a line on the map and the card row grows or shrinks
automatically, no instance param edit needed — the only declared param here
(Gap) controls spacing, not row count.