reference
The complete API. Generated from the console prelude and test-locked against it — if a function isn't listed here, it does not exist.
the two environments — read this first
There are two different Lua environments with different drawing APIs:
main.lua(the game): runs_init()once, then_update()and_draw()at 60fps. Its drawing API is the short list below —poly,capsule,qcurveetc. do NOT exist here.gen/sprites.lua(optional, build-time): defines named sprites with a much richer canvas toolkit. Runs once at load. Games then draw those sprites withspr().
Complex shapes in _draw are built from tri/rect/circ/line, or
pre-rendered as sprites in gen/sprites.lua and stamped with spr().
screen and loop
768×432 pixels, 60fps, up to 32 named palette colors — or the same panel
rotated: orientation = "portrait" in game.toml gives 432×768 (the
fullscreen-phone mount; same pixels, same budgets). _draw must be pure:
render only, no state writes (verified sims skip draws; a draw that mutates
state will desync and fail verify). All game state lives in globals —
the console snapshots them (the snapshot law) and sim --assert/--trace
can only see globals.
views — what a seat may know
Every game has a view per seat: the data a player at that seat is entitled to. By default it is the game-state half of the snapshot ("your view defaults to your save") — right for full-information games, free of code. A hidden-information game declares its boundary by defining:
function _view(seat)
-- return a data table: numbers/strings/booleans/tables, no
-- functions, no cycles. Pure: no mutation, no rng, no input reads.
end
Views feed sandboxed bots (_input(view)), byo dump --view N, and any
future spectator surface. Bots always run sandboxed on the view — a codec
deep copy, so they provably cannot touch game state. Replays recorded by
sandboxed bots against a game-DEFINED _view carry an advisory
fair: seat N header: played from inside the declared boundary. This is
a fairness contract, not anti-cheat.
game API (main.lua)
Colors are palette indexes; use P.<name> (e.g. P.ink). fill args are
booleans (true = filled, false/nil = outline).
cls(c) -- clear screen to color
px(x, y, c) -- set pixel
pget(x, y) -> c -- read pixel
line(x0, y0, x1, y1, c)
rect(x, y, w, h, c, fill)
circ(x, y, r, c, fill)
oval(x, y, w, h, c, fill)
tri(x0, y0, x1, y1, x2, y2, c, fill) -- the polygon primitive of _draw
print(text, x, y, c) -- 6x8 font
spr(name, x, y, opts) -- opts: {flip_x, flip_y, scale, rot, frame,
-- remap = "name"} (sprite.remap below)
camera(x, y) -- draw offset (camera() resets)
clip(x, y, w, h) -- clip rect (clip() resets)
btn(name, seat) -> bool -- held: up down left right a b x y l r start select
btnp(name, seat) -> bool -- pressed this frame (seat: 1 default)
seats() -> n -- declared player count (game.toml `players`)
pointer() -> x, y, buttons -- buttons: 1 left, 2 right, 4 middle (bitmask)
pointerp(b) -> bool -- pointer button pressed this frame
text() -> string -- text typed this frame ("\b" backspace, "\n" enter)
frame() -> n -- current frame number
Reading a seat above the declared players count is an error, not false —
declare players = N (max 8) in game.toml and read only those seats.
game.toml's [controls] declares the control surface (dpad = true,
buttons = ["a","b"], pointer, text) — it drives the touch overlay
the console renders on phones (labeled, palette-colored, Game Boy layout
in portrait / handheld layout in landscape) and the on-screen hints.
Pointer-only games get no overlay: touch is the pointer. overlay =
"off" opts a game out of the pad entirely (a dense UI that predates
mobile); players can still force it with ?touch=1, or kill it with
?touch=0. No game ships touch code.
Name buttons, not keys. Game copy must reference the console's logical buttons — "press A", "B closes" — never keyboard letters. The shell teaches each device its physical mapping (keyboard: A=Z, B=X, X=C, Y=V, L=Q, R=E); the touch overlay labels buttons with the logical names, so on-screen prompts and the pad always agree.
lerp(a, b, t) -> n -- linear interpolate
approach(cur, target, step) -> n -- move toward target by step (tweening)
log(...) -- print to the sim log (your debugger)
Motion math — pure functions, deterministic, safe in _draw (they mutate
nothing). Available in gen/sprites.lua too, where they shape build-time art.
The console ships solvers and curves, never skeletons: what a creature IS —
its topology, its style — stays your code. Worked recipes (hit-stop, shake,
stateless particles, rigs): docs("cookbook").
ik2(x0,y0, x1,y1, l1,l2, bend) -> kx,ky, ex,ey
-- two-bone IK: root, target, segment lengths, bend side (+1/-1).
-- Returns the joint (knee, elbow) and the EFFECTIVE endpoint — the
-- target clamped to reach (l1+l2-0.5: a limb at its limit keeps a
-- visible joint). Draw root->knee, knee->endpoint. Arms, legs,
-- tails, tentacle segments — any two-segment limb, any orientation.
ease(name, t) -> v -- easing curve over t in [0,1], clamped.
-- names: linear smooth quad_in quad_out quad_in_out cubic_in
-- cubic_out cubic_in_out back_in back_out elastic_out bounce_out.
-- back/elastic overshoot in VALUE, never in t.
ramp({t,v, t,v, ...}, t) -> v -- piecewise-linear value over t: flat
-- ascending pairs, clamped at both ends. Color fades, particle
-- lifetimes, hand-shaped motion.
mix(a, b, t) -> value -- interpolate a into b: numbers lerp,
-- tables recurse, anything else (strings, flags, one-sided keys)
-- switches at t=0.5 — so mixing two POSES just works.
tween(t0, dur, name) -> 0..1 -- eased progress through a window that
-- opened at frame t0. Not a stateful player: store t0 in G when the
-- motion starts, call this in _draw. nil t0 reads as finished (1).
byo.complete{ goal = "name" } -- declare a goal completed (verify needs this)
-- optional seat = n attributes the finish
rng(stream) -> r -- named deterministic stream
r:float() r:int(lo, hi) r:chance(p) r:pick(list)
math.sin/cos/atan -- deterministic polynomial trig
math.random -- backed by an rng stream; fine to use
map.define(name, w, h) -- tile layers (sprite names per cell)
map.fill(name, x, y, csv) map.set(name, x, y, sprite)
map.mask(name, x, y, w, digits)
map.draw({layers}, px, py, {mask=, dim=, remap=})
Standard Lua available: string.*, table.*, math.* (deterministic
subset), pairs, ipairs, pcall, tostring, tonumber, type,
setmetatable. NOT available: io, os, require, load, coroutines.
budgets (the hardware)
- Compute: 4,000k VM instructions per
_update/_drawcall (25× for boot entries:gen/sprites.lua, main.lua's top level,_init). Over budget = game error, not a hang — the error names the entry, the frame, and readsover compute budget (4000k instructions). _drawcosts more instructions in console 0.4, and far less time. Draw calls are now recorded into a command buffer and handed to the host in one batch per frame instead of crossing the boundary one call at a time. Recording a call costs a little more Lua than making it did, so a draw-heavy_drawmeters roughly 1.3–1.5× higher than it did before — while the frame itself got about 2× faster in wall time._updateis unaffected. If you are comparing acpunumber against one from an older devlog, that is why. No compensating fudge has been applied to the meter: a number you can trust and reason about beats one that flatters a comparison.- The meter: every sim/test/bot/verify/tournament receipt carries a
cpuline, e.g.peak 480k/4000k (_draw @ frame 649) · avg 280k/frame · _init 630k/100000k— the worst frame entry against the per-frame cap, the average cost per simmed frame, and (when it out-costs the worst frame) the worst boot/bot/eval entry against its own cap. Instruction count is the console's clock: a run costs the same count on every machine. Tune against the meter, never against wall time. - What the budget promises: a budget-legal game holds 60fps on a
modest player device. Servers and CI may simulate slower than
realtime — that is the host's problem, never the game's. Long runs
print
…frame N/totalprogress lines, and a receipt ending inKILLED: host safety deadlinemeans the HOST gave up mid-run: the game was not judged. Onlyover compute budgetis a verdict on the game. - Size: ~48k token budget across all files (
checkreports usage). - Determinism: same seed + same inputs = same run, bit-exact. No wall-clock, no unseeded randomness.
sprite API (gen/sprites.lua)
Define sprites: sprite.def(name, w, h, [opts,] function(c, frame) ... end)
— opts {frames = n} for animation. c is a canvas with the rich toolkit:
c:px c:get c:line c:rect c:oval c:circ c:tri -- like the game API
c:poly(pts, col, fill) c:qcurve(x0,y0, cx,cy, x1,y1, r0,r1, col)
c:capsule(x0,y0,x1,y1,r,col) c:taper(...) -- stroke shapes
c:flood c:fill c:clear c:replace(from, to)
c:outline(col, {diag=}) c:outline_open(col, lit) c:rimtop(col)
c:shade(base, ramp, dir) c:bevel(target, hi, lo) c:form(target, hi, lo)
c:dither(col, level) c:noise(col, p, seed) c:checker(...)
c:feather(a, b, strength) c:featherr(...) c:glaze(...) c:mottle(...)
c:vgrad(x,y,w,h, ramp, blend) c:ridge(...) c:brush(cx,cy,r,col,jitter)
c:pattern(x, y, w, h, rows, map) c:stamp(other_sprite, x, y, opts)
c:sym_x() c:sym_y() c:flip_x() c:flip_y()
c:push() c:pop() -- transform stack
c:translate(dx,dy) c:rotate(a) c:scale(sx,sy) -- compose onto it
c:grain("noise"|"bayer"|"off") -- the cloth: how
-- glaze/feather/featherr/vgrad/form realize partial
-- coverage. noise scatters (default), bayer orders,
-- off cuts hard — the flat-graphic fabric
c:shift(x,y,w,h, n) -- move a region's
-- colors n steps along their .pal ramps: flat
-- lighting with no dither anywhere
c:silhouette(col) -- all opaque -> col
sprite.T -- transparent color
Transforms bend every later shape call: rotate a limb, mirror a wing,
scale a motif. push/pop scope them; unbalanced pops error. Rotated
rects/ovals become filled polygons; stamp follows position but does
not rotate its pixels.
Palette swaps — one drawing, many colorways:
sprite.variant(name, src, {old = new, ...}) -- build-time clone of src,
-- recolored (all frames)
sprite.remap(name, {old = new, ...}) -- named draw-time swap for
-- spr/map.draw {remap = name}
Both take palette NAMES ({ember = "sky"}), or {shift = n} to
generate the remap from the palette's declared ramps — the whole world
n steps lighter or darker in one line. A variant is a real sprite
(costs sheet space, free at draw). A remap recolors at draw time — one
name recolors a whole map.draw scene: night, flashback, damage flash,
team colors.
game.toml
[game]
name = "my-game" # kebab-case
console = "0.4"
players = 1 # seats, 1..8 — btn(name, seat) above this errors
goals = ["reach the end"] # names byo.complete must use
# orientation = "portrait" # 432×768 phone mount (default: landscape)
palette = "assets/palette.pal" # the game's own colors (omit → gloam-32)
[controls]
# what the touch overlay renders — declare exactly what the game reads
buttons = ["a", "b"] # from: a b x y l r ([] = dpad only)
# dpad = false pointer = true text = true overlay = "off"
palette files (.pal)
One color per line: name #rrggbb, with an optional trailing -- note.
Names are lowercase [a-z][a-z0-9_]*. Max 32 colors. Games access
them as P.name; a name the palette doesn't define is a runtime ERROR
naming the ones that exist (it used to render as color 0, so a game could
be entirely invisible while every logic check passed).
ink #12111a -- the dark everything sits on
sand #e8d7a8 -- simon: warmer, less yellow
ramp name = shadow mid light lines (after the colors they order)
declare VALUE RAMPS — the structure c:shift() and sprite.remap(n,
{shift = -1}) move along. A color sits in at most one ramp; palettes
without ramps simply can't shift.
turf0 #1e5c3a
turf1 #2e7d54
turf2 #43a06d
ramp turf = turf0 turf1 turf2
Every cart declares a palette — palette = "assets/palette.pal" in
game.toml. There is no house default to inherit; a cart without one does
not load. A new game is born with three colors (one dark, one midtone,
one light) rolled at random, so no two games start alike.
The palette is a design decision the game makes: start with as few
colors as the art needs, add one when a drawing actually needs it, and
name it for what it is in THIS game. The palette tool merges by name,
so adding a color is one line, and -- note labels what a color is for.
replays (.rpl)
seed: 0
goal: reach the end
0 right -- from frame 0, hold right
40 right a -- at frame 40, also hold a
130 - -- at frame 130, release everything
Lines are <frame> <buttons…>; held state persists until the next line;
- means nothing held. Pointer: p:x,y,buttons (held). Typed text:
t:<hex utf8> (fires on that exact frame). N players: | separates seat
columns — column i is seat i (0 right a | left | - | a). A line updates
only the columns it lists; omitted trailing columns keep their held state.
Device tokens (p:, t:) live in column 1.
Verify horizon: verification simulates until last input frame + 60.
The goal must byo.complete within that window, and the game must be
snapshot-clean. A replay with inputs only at frames 0 and 2 gives you a
62-frame sim — pad the final frame (2000 -) to run longer.
bots — the way to author replays
Don't hand-compute frame timings. Write a controller that reads game state
and runs inside the real sim (bot tool / byo sim --bot):
-- bots/seek.lua: _input(view) returns buttons for the next frame
function _input(view)
if view.player.x < view.goal_x then return "right" end
return ""
end
Return a string ("right a"), a table ({"right","a"}), or button bits.
Holding. If your bot has nothing new to decide for a while, say so:
return { buttons = "right", hold = 9 } -- don't ask me again for 9 frames
The console keeps pressing those buttons and skips both the view build and the
call until the window closes (max hold = 60). In a turn-based game most frames
are animation, and building a view for each of them is the single most expensive
thing a headless sim does — a 864-cell view at hold = 9 measured 8.6x
faster per frame. It is fairness-neutral: a bot that holds has strictly less
information than one that looks every frame, so it can never win by holding. A
rewind always re-consults; hold with no buttons means "nothing, and stop
asking".
Bots live in the reserved bots/ directory (bundled, outside the source
budget, not require-able) and run sandboxed: each gets its own env
(seat, frame(), log, its own rng streams) and reads only its
seat's view. Drive seats with --bot [SEAT:]FILE (repeatable); the sim
records every seat's presses as a normal N-column replay — if a goal
completes, it is verification-ready. --god (debug) loads a bot into
the game env instead; god runs never earn the fair: stamp.
Verification re-runs the recorded inputs without the bots.
checks.lua — the game's logic suite
A reserved root file (bundled, outside the source budget, never loaded by
the game): pure data describing short headless tests. byo test (or the
test tool) runs each one — boot, play the scripted input, then the
assert expression must be true in the game env.
return {
{ name = "walking right moves right",
input = "0 right\n30 -", -- inline replay text (or a replays/ path)
frames = 40,
assert = "G.x > 384" },
}
Publishing runs the suite: a version with failing checks does not go live. Add a check when you fix a logic bug, like a regression test.