A real, published package: the user picks games in the mod’s settings, each becomes a card with the live player count and a 48-hour trend, and removing a game removes its card. It uses a settings.list of settings.game rows, widget handles created and removed on the fly, the steam library’s callback lookups, a when-gated sparkline, and a preview.luau for the Workshop thumbnail. Every file is --!strict.
mod.json
{
"api": 3,
"id": "steam-player-count",
"title": "Steam Player Count",
"version": "0.7",
"description": "Live player counts for the Steam games you pick - one widget per game.",
"entry": "main.luau",
"preview": "preview.luau",
"scopes": ["steam"]
}
steam installs perch.steam. No network scope: Perch fetches from Steam itself.
displays.luau
--!strict
--- One game widget's fields. `chart` binds whole to ui.sparkline.
export type fields_t = {
game: field_ref_t<string>,
count_text: field_ref_t<string>,
icon: field_ref_t<string>,
trend: field_ref_t<boolean>,
chart: record_ref_t<chart_record_t>,
}
--- The schema every game widget (and the preview) declares.
local function schema(): fields_t
return {
game = field.string(""),
count_text = field.string("-"),
icon = field.image(),
trend = field.boolean(true),
chart = field.record { points = field.numbers(), up = field.boolean(true) },
}
end
local function card(f: fields_t): node_t
return ui.column { gap = ui.gap.xs, flex = 1, pad = { 4, 10, 4, 10 },
ui.row { gap = ui.gap.s, align = ui.align.center,
ui.image { src = f.icon, w = 24, h = 24, radius = 6, flex = 0, fit = ui.fit.cover,
fallback = ui.box { bg = ui.bg.well, w = 24, h = 24, radius = 6 } },
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 },
ui.text { text = "players online", role = ui.role.label, color = ui.color.half },
ui.sparkline { points = f.chart, h = 22, flex = 0, when = f.trend },
}
end
return { schema = schema, card = card }
The schema lives beside the display that binds it, so main.luau and preview.luau both call displays.schema() and can never drift from the fields_t type. The ui.image has a fallback box that shows until the icon lands; the sparkline is gated on the trend toggle.

main.luau
--!strict
local displays = require("displays")
type game_row_t = { appid: string? }
type settings_t = { games: { game_row_t }?, trend: boolean? }
type chart_t = { points: { number }, up: boolean }
type payload_t = { game: string?, count_text: string?, icon: string?, trend: boolean?, chart: chart_t? }
type tracked_t = { w: widget_t<displays.fields_t>, data: payload_t }
perch.settings.declare {
__order = { "games", "trend" },
games = settings.list("Games", { add_label = "Add game", item = { appid = settings.game("Game") } }),
trend = settings.toggle("Show the trend line", { default = true }),
}
local games: { [string]: tracked_t } = {}
local trend: boolean = true
local started: boolean = false
The settings page: a list whose single item is a settings.game row, so Add opens the game picker directly, and a toggle. s.games arrives as { { appid = "730" }, { appid = "440" } }. Each tracked game keeps its handle and the whole of its current data.

local function chart_of(history: { number }): chart_t
local n = #history
return { points = history, up = n < 2 or history[n] >= history[n - 1] }
end
-- Merge a partial update into the game's data and show it. Before on_start a
-- present carries the whole state; after it, a push of just the change.
local function apply(appid: string, delta: payload_t)
local g = games[appid]
if not g then
return
end
for k, v in pairs(delta) do
(g.data :: any)[k] = v
end
if started then
g.w:push(delta)
else
g.w:present(g.data)
end
end
apply is the one place data moves: it merges the change into the game’s state and pushes it. The up flag is decided in Luau; the tree binds a ready chart record.
local function new_widget(appid: string, name: string): widget_t<displays.fields_t>
local w = widget.new { id = "g" .. appid, label = name, urgency = urgency.ambient, fields = displays.schema() }
w:preview { game = name, count_text = "1,436,921", icon = "", trend = true, chart = { points = { 1, 8, 3, 4, 2, 8, 4, 12 }, up = true } }
w:place(surface.page, displays.card)
w:register()
return w
end
local function on_count(pc: steam_player_count_t)
apply(pc.appid, { count_text = perch.text.number(pc.count) })
end
local function on_history(h: steam_player_history_t)
apply(h.appid, { chart = chart_of(h.points) })
end
local function on_artwork(art: steam_artwork_t)
if art.icon ~= "" then
apply(art.appid, { icon = art.icon })
end
end
local function on_app_info(info: steam_app_info_t)
local g = games[info.appid]
if g and info.name ~= "" and info.name ~= g.data.game then
g.w:relabel(info.name)
apply(info.appid, { game = info.name })
end
end
One typed handler per answer. Each ignores a game the user removed meanwhile.
local function add_game(appid: string)
local placeholder = "App " .. appid
games[appid] = { w = new_widget(appid, placeholder), data = { game = placeholder, count_text = "-", icon = "", trend = trend, chart = chart_of({}) } }
games[appid].w:present(games[appid].data)
perch.steam.app_info(appid, on_app_info)
perch.steam.artwork(appid, on_artwork)
perch.steam.player_history(appid, on_history)
perch.steam.player_count(appid, on_count)
end
local function remove_game(appid: string)
local g = games[appid]
if g then
g.w:remove()
end
games[appid] = nil
end
add_game is the whole dynamic-widget recipe: create the handle with its schema, seed a preview, place, register, present with placeholders, then ask Steam. A cached answer calls back at once; a fetched one when it lands.
perch.on_configure:connect(function(s: settings_t)
trend = s.trend ~= false
local want: { [string]: boolean } = {}
for _, row in ipairs(s.games or {}) do
if row.appid ~= nil and row.appid ~= "" then
want[row.appid] = true
end
end
for appid in pairs(want) do
if not games[appid] then
add_game(appid)
end
end
for appid in pairs(games) do
if not want[appid] then
remove_game(appid)
end
end
for appid, g in pairs(games) do
if g.data.trend ~= trend then
apply(appid, { trend = trend })
end
end
end)
-- The store's title for a game the local library did not know.
perch.steam.on_app_info:connect(on_app_info)
local poll = timer.new(150 * 1000)
poll.on_fire:connect(function()
for appid in pairs(games) do
perch.steam.player_count(appid, on_count)
perch.steam.player_history(appid, on_history)
end
end)
perch.on_start:connect(function()
started = true
poll:start()
end)
perch.on_configure fires before perch.on_start with the stored list, which re-creates every widget on boot, and again on every change. It diffs the list against games and re-pushes the trend gate when the toggle flips. The on_app_info signal catches the store’s title for a game that is not installed, which app_info cannot answer at once. One timer polls; Perch floors Steam fetches at 60 seconds per game anyway.
preview.luau
--!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",
icon = "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/f568912870a4684f9ec76277a1a404dda6bab213.jpg",
trend = true,
chart = { points = { 1, 8, 3, 4, 5, 8, 4, 12 }, up = true },
},
}
The Workshop thumbnail: the same card, demo data, framed in the page chrome by Perch Studio at publish.
What to take from it
- Settings drive widgets. A
settings.listplus a diff inperch.on_configureis the pattern for any “N of something”. - Declare once, bind by ref. One
schema()shared by every widget and the preview; the display bindsf.game, never a name it could misspell. - Keep the whole state. Presenting the full table before start and pushing deltas after it is robust to any order the answers arrive in.
- Ask with a callback, listen for the rest. A cached answer and a fetched one run the same function; the landing signal catches what a callback cannot.
- Push finished values.
perch.text.numberand theupflag are applied in Luau.
The source is on GitHub as hlpdev/perch-mod-steam-count.