Perch
Lua APIThe Lua API

The Lua API

The globals a mod is written against, how the API is typed, what needs a scope, and the limits every function shares.

6 min readUpdated Sep 9, 2026

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

GlobalHoldsPage
widgetwidget.new, which creates a widget handleWidgets
fieldthe schema constructors a widget declares its data withFields
uithe display builders and the enums (ui.row, ui.text, ui.role, ui.glyph, …)Components
settingsthe settings-row constructorsSettings
timertimer.new and timer.atSignals and timers
keythe keyboard enum for hotkeysInput
surfacenotch_primary, notch_secondary, notch_ambient, page, transientWidgets
urgencyambient, event, transientWidgets
percheverything else: lifecycle signals, utilities, and the scoped librariesthe 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, never false or an empty table.
  • A table with keys 1..n becomes an array; any other table becomes an object with string keys. An empty table is always an empty array. Nesting deeper than 32 levels becomes null.
  • 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

LimitValueOver it
time per handler50 ms of wall clockthe call aborts with budget: callback exceeded its time allowance
memory per mod64 MiBLua raises not enough memory
w:push rate40 per rolling 4 spushes are parked, merged per key, delivered within 250 ms
timers250 ms repeating, 16 ms one-shotshorter periods are raised to the floor
storage1 MiB per modthe write is reverted
HTTPhttps only, pinned hosts, 30 per rolling minute, 8 MiB bodythe callback gets nil and a reason
draw ops per painter run2,048extra ops are ignored
display tree512 nodes, depth 16rejected 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.log lines go to the mod’s log window when you run it from Perch Studio, and to %APPDATA%\perch\perch\logs\perch.log under the mods category.
esc
Type to search
navigate open