Clone
1
Prefab Scripting Guide
gitea-actions edited this page 2026-08-23 11:42:22 +00:00

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:

  1. Declare the parameters the prefab exposes (declareParams{...}).
  2. Read the current values of those parameters (params.<name>.value).
  3. 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.height gives 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.anchorY gives you the instance's current anchor fractions, where 0.5, 0.5 is centered, 0, 0 is top-left, and 1, 1 is bottom-right.
  • Writing instance.width/instance.height during 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.anchorY works the same way: the values left at the end of the script are saved as the instance's anchor.
  • If your script never touches instance at 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.all is every line on the map, in order, as a normal 1-indexed table. Lines.byId(id) looks one up directly and gives you nil if 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, and padding.
  • 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 canvas global 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 no coroutine. The base Lua library plus math, string, and table are 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. Use string.len(s) or s: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.