byo.games

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:

  1. main.lua (the game): runs _init() once, then _update() and _draw() at 60fps. Its drawing API is the short list below — poly, capsule, qcurve etc. do NOT exist here.
  2. gen/sprites.lua (optional, build-time): defines named sprites with a much richer canvas toolkit. Runs once at load. Games then draw those sprites with spr().

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/_draw call (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 reads over compute budget (4000k instructions).
  • _draw costs 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 _draw meters roughly 1.3–1.5× higher than it did before — while the frame itself got about 2× faster in wall time. _update is unaffected. If you are comparing a cpu number 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 cpu line, 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/total progress lines, and a receipt ending in KILLED: host safety deadline means the HOST gave up mid-run: the game was not judged. Only over compute budget is a verdict on the game.
  • Size: ~48k token budget across all files (check reports 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 palettepalette = "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.