A mod is Luau code written against nine globals that Perch injects before the entry file runs. This page covers what every library shares; the pages after it document one library each, with its functions, its signals and its types.
The globals
| Global | Holds | Page |
|---|---|---|
widget | widget.new, which creates a widget handle | Widgets |
field | the schema constructors a widget declares its data with | Fields |
ui | the display builders and the enums (ui.row, ui.text, ui.role, ui.glyph, …) | Components |
settings | the settings-row constructors | Settings |
timer | timer.new and timer.at | Signals and timers |
key | the keyboard enum for hotkeys | Input |
surface | notch_primary, notch_secondary, notch_ambient, page, transient | Widgets |
urgency | ambient, event, transient | Widgets |
perch | everything else: lifecycle signals, utilities, and the scoped libraries | the rest of this section |
Everything under perch that reaches outside the sandbox is gated by a scope. The other eight globals are pure Lua and always present.
Types
The API is declared once, as Luau types, in perch.d.luau. Perch Studio feeds that file to the language server, so completion, hovers and diagnostics come from the same declarations the runtime implements. Every page in this section ends with the types it introduces, written exactly as the declaration file spells them.
Every file of a mod starts with --!strict. Annotate what the runtime hands you and the editor checks the rest:
--!strict
type fields_t = { title: field_ref_t<string>, frac: field_ref_t<number> }
local w = widget.new { id = "card", label = "Card", urgency = urgency.ambient,
fields = { title = field.string(""), frac = field.number(0) } }
w:place(surface.page, function(f: fields_t): node_t
return ui.row { ui.text { text = f.title }, ui.bar { value = f.frac } }
end)
w:register()
perch.media.on_change:connect(function(m: media_state_t)
w:push({ title = m.title or "" })
end)
Naming follows one convention: types end in _t, and every identifier and payload key is snake_case. Generic types carry their parameter: signal_t<T...> fires with T..., field_ref_t<T> refers to a field holding a T, widget_t<F> is a widget whose fields table is F.
Perch does not type-check at load. The annotations are for the editor and cost nothing at runtime.
Three shapes that recur
Signals. Every event is a signal you connect a function to: perch.on_start:connect(fn), perch.media.on_change:connect(fn), t.on_fire:connect(fn). One signal per event, named on_ plus what happened. See Signals and timers.
Callbacks. A call that has to wait (the network, a Steam lookup) takes a callback as its last argument and returns nothing. A cached answer calls back at once; a fetched one calls back when it lands, on the main thread.
perch.steam.player_count(appid, function(pc: steam_player_count_t)
w:push({ count_text = perch.text.number(pc.count) })
end)
Functions on nodes. Taps, clicks, wheel notches and scrubs go to the function on the node that took them (on_click = function(ctx: input_context_t) ... end). See Input.
Scopes
A library that touches anything outside the sandbox exists only when its scope is listed in mod.json. Without the media scope there is no perch.media table at all, so a call raises the ordinary attempt to index a nil value, and there is no perch.media.on_change to connect to. The full table is on Scopes.
Always available, with no scope: widget, field, ui, settings, timer, key, surface, urgency, and under perch: the lifecycle signals, settings.declare, voice.on_intent, log, json, storage, time, hash, base64, uuid, locale, text.
Calling convention
Every perch.* function that leaves Lua crosses a JSON bridge. The rules that follow from that:
- A function with nothing to say returns
nil, neverfalseor an empty table. - A table with keys
1..nbecomes an array; any other table becomes an object with string keys. An empty table is always an empty array. Nesting deeper than 32 levels becomesnull. - Integral numbers stay integers; other numbers stay floats. Strings are byte strings; invalid UTF-8 is repaired on the way across.
- Functions you pass (a callback, an input handler, a painter) are held by reference and called on the main thread.
- The wrong argument type usually makes a bridge function a no-op returning
nil. The pages say when a function raises instead.
The pure-Lua globals are stricter: ui.* builders, field.* and settings.* constructors and the widget verbs validate at construction and raise in your file, on your line.
main.luau:14: ui.text: unknown prop 'colour'
main.luau:31: widget 'clock': unknown field 'zonez' (declare it in fields = { … })
The sandbox
One Luau state per mod. Available: the base library (minus loadstring, getfenv, setfenv, collectgarbage, gcinfo, newproxy), string, table, math, utf8, and require. Removed: io, os, debug, coroutine, bit32, buffer.
require(name) reads <package>/<name>.luau and nothing else. Names are [A-Za-z0-9_]; a missing file raises require: no module '<name>' in the mod package. The globals are never required; they are already there.
print goes nowhere you can see. Use perch.log.
Luau is a Lua 5.1 dialect: numbers are doubles, %d truncates, // and continue exist, goto does not, \u{...} escapes work.
Threading
All of your Lua runs on Perch’s main thread, one handler at a time. Anything that could block runs on a C++ worker and comes back later as a callback or a signal. You never see a thread and you can never stall the notch.
Limits
| Limit | Value | Over it |
|---|---|---|
| time per handler | 50 ms of wall clock | the call aborts with budget: callback exceeded its time allowance |
| memory per mod | 64 MiB | Lua raises not enough memory |
w:push rate | 40 per rolling 4 s | pushes are parked, merged per key, delivered within 250 ms |
| timers | 250 ms repeating, 16 ms one-shot | shorter periods are raised to the floor |
| storage | 1 MiB per mod | the write is reverted |
| HTTP | https only, pinned hosts, 30 per rolling minute, 8 MiB body | the callback gets nil and a reason |
| draw ops per painter run | 2,048 | extra ops are ignored |
| display tree | 512 nodes, depth 16 | rejected at load with the exact path |
A handler that hits the time budget earns a strike; a clean call resets the count; three in a row disable the mod for the session. Other runtime errors are logged as mod[<id>] <handler> failed: <error> and never disable anything.
Where errors show up
- A load failure (a bad manifest, a syntax error, an invalid tree, a builder raising) skips the mod and prints the error on its card under Settings, Installed mods.
- Runtime errors and
perch.loglines go to the mod’s log window when you run it from Perch Studio, and to%APPDATA%\perch\perch\logs\perch.logunder themodscategory.