Perch
Lua APISteam

Steam

perch.steam: the installed library, the running game, and callback lookups for player counts, art, store details, news, history and search, plus the Steam types. Scope steam.

5 min readUpdated Sep 9, 2026

Perch talks to Steam’s web APIs itself, caches the answers and enforces the request floors. A Steam mod needs only the steam scope.

ScopeInstalls
steamperch.steam.installed(), .running(), .on_game_change; the lookups .app_info, .artwork, .store_details, .news, .player_count, .player_history, .search; and one landing signal per lookup

The Steam Player Count card

Every appid is a string of digits, which is what a settings.game row stores. A number answers nothing.

perch.steam.installed, perch.steam.running

perch.steam.installed(): { steam_app_t }
perch.steam.running(): steam_app_t?

The local library, read from the app manifests; and the game Perch currently detects as running, or nil. Both synchronous.

perch.steam.on_game_change

perch.steam.on_game_change: signal_t<game_change_t>

Fires the moment the running game starts or stops. Connect it instead of polling running().

perch.steam.on_game_change:connect(function(g: game_change_t)
  if not g.running then w:retire() return end
  w:present({ game = g.name, icon = "" })
  perch.steam.artwork(tostring(g.appid), function(art: steam_artwork_t) w:push({ icon = art.icon }) end)
end)

The lookups

perch.steam.app_info(appid: string, cb: (steam_app_info_t) -> ()): ()
perch.steam.artwork(appid: string, cb: (steam_artwork_t) -> ()): ()
perch.steam.store_details(appid: string, cb: (steam_store_details_t) -> ()): ()
perch.steam.news(appid: string, count: number?, cb: (steam_news_t) -> ()): ()
perch.steam.player_count(appid: string, cb: (steam_player_count_t) -> ()): ()
perch.steam.player_history(appid: string, cb: (steam_player_history_t) -> ()): ()
perch.steam.search(term: string, cb: (steam_search_t) -> ()): ()

Each takes its callback last and returns nothing. If Perch already knows the answer the callback runs at once, inside your call; otherwise a fetch starts and the callback runs when it lands. A failed fetch calls nothing and the last good value stands. Calling a lookup with no callback just starts the fetch.

perch.steam.player_count(appid, function(pc: steam_player_count_t)
  w:push({ count_text = perch.text.number(pc.count) })
end)
LookupAnswersRefresh floor
app_infowhat the local library knows at once (name, icon, installed), with store = false; for a game that is not installed name is "" until the store answers on on_app_info300 s
artworkthe store art for any appid, installed or not: the square client icon and the capsulescached per session
store_detailsthe store listing: title, description, price, genrescached per session
newsthe newest count announcements (1 to 20, default 5)15 min
player_countthe live player count60 s per app
player_historyup to 48 hourly samples, oldest first; bind points to a ui.sparkline1 h
searchup to 25 store results for a term, normalised (lowercased, 64 bytes)5 min per term

search drops a term under 2 characters, or a call inside 1 second of the previous one, and then never calls back. Drive it from a debounce, not a keystroke.

The landing signals

perch.steam.on_app_info: signal_t<steam_app_info_t>
perch.steam.on_artwork: signal_t<steam_artwork_t>
perch.steam.on_store_details: signal_t<steam_store_details_t>
perch.steam.on_news: signal_t<steam_news_t>
perch.steam.on_player_count: signal_t<steam_player_count_t>
perch.steam.on_player_history: signal_t<steam_player_history_t>
perch.steam.on_search: signal_t<steam_search_t>

One per lookup, carrying the same payload the callback delivers. Connect one when a single listener per kind suits a mod that tracks many apps, and for answers that arrive without a pending callback: the store title of a game that is not installed lands on on_app_info after app_info already answered with the local name.

perch.steam.on_app_info:connect(function(info: steam_app_info_t)
  local g = games[info.appid]
  if g and info.name ~= "" then g.w:relabel(info.name) g.w:push({ game = info.name }) end
end)

The complete package this comes from is read through in A complete mod.

Types

export type steam_app_t = { appid: string, name: string, installed: boolean? }
export type game_change_t = { running: boolean, appid: number, name: string }

An app in the library, and the payload of on_game_change (appid and name are empty when nothing is running).

export type steam_app_info_t = { appid: string, name: string, icon: string, header: string, installed: boolean?, store: boolean }
FieldMeaning
namefrom the local library if installed, else from the store once known, else ""
icona file:// URL of the local library icon, else the store’s small capsule, else ""
headerthe wide store header URL, or "" until the store answers
installedin this machine’s library
storethe store has answered
export type steam_artwork_t = { appid: string, icon: string, portrait: string, header: string, capsule: string, hero: string, background: string }

Every slot is its declared shape or ""; Perch never substitutes one shape for another. icon is the square client icon (works for games that are not installed); portrait the tall library capsule; header the wide store header; capsule the small wide capsule; hero the library banner; background the page background.

export type steam_store_details_t = { appid: string, name: string, description: string, developers: { string }, publishers: { string }, genres: { string }, released: string, coming_soon: boolean, free: boolean, price: string, discount: number, metacritic: number }

price is the formatted current price or ""; discount the percent off and metacritic the score, both 0 when there is none.

export type steam_news_t = { appid: string, items: { steam_news_item_t } }
export type steam_news_item_t = { title: string, url: string, author: string, contents: string, feed: string, date: number }

Newest first; date is epoch seconds; contents the plain announcement body.

export type steam_player_count_t = { appid: string, count: number, at: number }
export type steam_player_history_t = { appid: string, points: { number }, at: { number } }

at is epoch seconds: one value for the count, one per sample for the history.

export type steam_search_t = { term: string, results: { steam_search_result_t } }
export type steam_search_result_t = { appid: string, name: string, icon: string }

The normalised term, and its results.

esc
Type to search
navigate open