Perch
Building modsThe package

The package

The folder layout, every mod.json field, the entry file, require, preview.luau, glyph files, and what fails a load.

6 min readUpdated Sep 9, 2026

The folder

A package is a folder whose name is the mod’s id. Perch loads packages from three places: the builtin set that ships with Perch, Steam Workshop subscriptions, and the package Perch Studio hands over on Run in Perch. The Workshop is the distribution channel; there is no mods folder to drop files into.

  • steam-player-count/
    • mod.jsonrequired
    • main.luaurequired (or whatever `entry` names)
    • displays.luauoptional, by convention the display functions
    • preview.luauoptional, the Workshop thumbnail
    • glyphs/
      • trend.svgoptional vector art for ui.shape

require reaches only .luau files in the package root. Other files are ignored by the loader.

mod.json

Pure data, no code. Widgets and settings are created in Luau, never here.

{
  "id": "steam-player-count",
  "title": "Steam Player Count",
  "version": "0.7",
  "api": 3,
  "author": "hlpdev",
  "description": "Live player counts for the Steam games you pick, one widget per game.",
  "entry": "main.luau",
  "preview": "preview.luau",
  "scopes": ["steam"],
  "palette": { "brand": "#66c0f4" }
}
FieldRequiredMeaning
idyesmust equal the folder name (manifest id must match the package folder name); Workshop items also match [a-z0-9_-]{1,32}
titleyesthe name users see: the Installed mods card, the gallery badge, the Priority Stack badge
versionyesshown on the card; Perch does not interpret it
apiyes3. Any other value fails the load with mod.json api must be 3
authornoshown on the card; defaults to unknown
descriptionnoone line under the title
hintnoa subtitle on the mod’s settings page
entrynothe Luau file to run; default main.luau
previewnothe preview script for the Workshop thumbnail; absent means the Perch logo card
scopesnothe capabilities the mod asks for, shown verbatim on the Workshop page. See Scopes
palettenoup to 8 named colours, each #rrggbb, used in trees as "@brand"
themesnothemes this package contributes. See Themes

Unknown keys are ignored by the loader; Perch Studio’s validator flags them, which is what you want while editing.

The Installed mods page listing packages with title, description, scopes and widget count

The palette

Named colours declared here can be remapped by a user’s theme; hex literals in trees cannot.

ui.text { text = f.count, color = "@brand" }
ui.box { bg = "@brand", radius = 6 }

The entry file

The loader runs one Luau file, once, when the package loads. It creates objects and connects handlers, and returns nothing.

--!strict
local displays = require("displays")

perch.settings.declare {
  trend = settings.toggle("Show the trend line", { default = true }),
}

local w = widget.new { id = "card", label = "My card", urgency = urgency.ambient,
  fields = { count = field.string("-") } }
w:place(surface.page, displays.card)
w:register()

local poll = timer.new(60000)
poll.on_fire:connect(function() refresh() end)

perch.on_start:connect(function()
  w:present({ count = "-" })
  poll:start()
end)

Nothing is wired by name: a function called on_start that is never connected to perch.on_start is dead code. The entry runs under the same budget as a handler; keep it to declarations and connections.

The nine globals (perch, ui, field, settings, widget, timer, key, surface, urgency) are injected before the entry runs. Never require them, and do not shadow them with locals.

require

require("name") loads <package>/name.luau once and caches the result. Names are letters, digits and underscores; there are no subfolders. displays.luau conventionally exports a fields_t type and returns a table of display functions:

--!strict
export type fields_t = { game: field_ref_t<string>, count_text: field_ref_t<string> }

return {
  card = function(f: fields_t): node_t
    return ui.column { gap = ui.gap.xs, flex = 1, pad = { 4, 10, 4, 10 },
      ui.text { text = f.game, role = ui.role.h2 },
      ui.text { text = f.count_text, role = ui.role.huge, size = 34, color = ui.color.accent_warm },
    }
  end,
}

preview.luau

An optional script that gives the Workshop item a real thumbnail. Perch Studio runs it with no scopes granted and renders what it returns through the real renderer, in that surface’s chrome.

--!strict
local displays = require("displays")

return {
  surface = surface.page,
  fields = displays.schema(),
  display = displays.card,
  data = { game = "Team Fortress 2", count_text = "1,436,921" },
}

fields is the schema the display binds against, display the function, data the payload, validated against fields. See Testing and publishing.

Glyph files

Vector art lives in .svg files under the package, referenced from ui.shape:

ui.shape { src = "glyphs/phone.svg", w = 13, h = 13 }
ui.shape { pick = f.icon, srcs = { sun = "glyphs/sun.svg", rain = "glyphs/rain.svg" }, w = 34, h = 23 }

At load Perch compiles each file to sanitized path data. Only <path> elements and the root viewBox survive; fill, stroke, stroke-width, stroke-linecap and stroke-linejoin are kept, and currentColor becomes current. Export shapes as paths: a file with no <path> fails with svg holds no <path> elements (paths only: convert shapes to paths on export); a file without a viewBox with svg needs a viewBox.

What ships

A published Workshop item is one file, mod.pak: every package file under its package-relative path, with the .luau files stored as source. Studio compiles each file while packing as a syntax check and stops the publish on a file that does not parse. Perch compiles the source again at load. Bytecode is never accepted from a Workshop package.

What fails a load

Every failure is contained to the package and printed on its card under Settings, Installed mods, and in the Studio log window.

FailureMessage
no manifestmod.json not found
a manifest that is not a JSON objectmod.json is not a valid JSON object
an api that is not 3mod.json api must be 3
an id that differs from the foldermanifest id must match the package folder name
a bad palettepalette is capped at 8 colors, palette.<name>: colors are #rrggbb
a bad themetheme ids are [a-z-]{1,24}, theme <id>: unknown token '<key>' (colors+shadows only), theme <id>: bad value for '<key>'
a bad prop or argument in your Luauthe raise, with file and line: main.luau:14: ui.text: unknown prop 'colour'
a widget without a schemawidget.new: a widget declares `fields = { name = field.string(), … }
a tree problemthe validator path: card: display.children[2]: component 'text' has no prop 'colour'
an SVG problem<file>: svg file not found, <file>: svg needs a viewBox
bytecode in a Workshop package<file>: bytecode is not accepted from a Workshop package; publish source
esc
Type to search
navigate open