Cookbook
Recipes, not systems. The console ships mechanisms — solvers, curves, palette swaps, a transform stack — and refuses to ship policies: no default skeleton, no particle system, no house look. Every recipe below is a dozen lines you copy into your cart and mutate until it is yours. Two carts using this page should not look related.
Everything here obeys the three laws: state lives in globals, _draw
mutates nothing, and nothing reads a clock but frame().
Motion without a tween engine
Do not store positions that change every frame — store the frame the motion started, and make position a pure function of now. Snapshots and rewinds get it right for free, because a timestamp is plain data.
-- _update, the moment the door is told to open:
G.door_t0 = frame()
-- _draw, every frame after:
local k = tween(G.door_t0, 24, "back_out") -- 0..1, eased, clamped
spr("door", 300, mix(90, 42, k))
nil t0 reads as finished, so an object that never moved needs no special
case. Chain windows for multi-part motion: a lid that pops then settles is
one tween driving a ramp({0,0, 0.6,-30, 1,-24}, k).
Hit-stop
The cheapest juice in the book: on a heavy hit, freeze the simulation a few frames while the draw keeps presenting the impact.
-- _update, when the axe lands:
G.freeze_until = frame() + 4
-- top of _update:
if G.freeze_until and frame() < G.freeze_until then return end
frame() advances host-side even when _update returns early, so the
freeze ends by itself. Scale the stop to the hit — 2 frames for a jab, 8
for a finisher — and it reads as weight, not lag.
Screen shake
-- _update: G.shake = 6 on impact, then decay it
G.shake = math.max(0, (G.shake or 0) - 0.5)
-- _draw: pure jitter from the frame counter, then reset the camera
local function h(n) return (n * 2654435761 % 997) / 997 - 0.5 end
if G.shake > 0 then
camera(h(frame() * 2) * G.shake, h(frame() * 2 + 1) * G.shake)
end
-- ...draw the world...
camera()
That h is the workhorse of this whole page: a pure integer hash onto
[-0.5, 0.5). It is randomness with no state — same n, same answer —
which is what _draw is allowed to have. (math.random advances an rng
stream, so it belongs in _update only.)
Particles with no particle system
Store one record per burst, not per particle. Each particle is a pure function of (burst, index, age) — a hundred sparks cost three numbers of state and survive snapshot/restore mid-flight.
-- _update, at the impact:
table.insert(G.sparks, { x = x, y = y, t0 = frame(), seed = G.frame_seed })
G.frame_seed = (G.frame_seed or 0) + 1
-- and prune dead bursts:
for i = #G.sparks, 1, -1 do
if frame() - G.sparks[i].t0 > 30 then table.remove(G.sparks, i) end
end
-- _draw:
local function h(n) return (n * 2654435761 % 997) / 997 end
for _, s in ipairs(G.sparks) do
local age = (frame() - s.t0) / 30
for i = 1, 14 do
local a = h(s.seed * 131 + i) * 6.2832
local v = 1.5 + h(s.seed * 173 + i) * 2.5
local d = v * age * 30
local c = age < 0.4 and P.sun or age < 0.75 and P.ember or P.bark
px(s.x + math.cos(a) * d, s.y + math.sin(a) * d + age * age * 40, c)
end
end
Vary the verbs, not the plumbing: gravity for sparks, drift for smoke,
circ shrinking with age for smoke puffs, a line from last position for
rain streaks.
Parallax
map.draw takes a scroll origin — hand different layers different
fractions of the camera and depth appears:
map.draw({"stars"}, G.cam * 0.25, 0)
map.draw({"mesas"}, G.cam * 0.5, 0)
map.draw({"track", "props"}, G.cam, 0)
Palette moods
One world drawn twice is two moods for one remap (see the sprites page
for variant vs remap):
-- gen/sprites.lua
sprite.remap("dusk", { sky = "grape", paper = "blush", moss = "soil" })
-- _draw
map.draw({"world"}, G.cam, 0, G.hour >= 18 and { remap = "dusk" } or nil)
The same mechanism is a damage flash (remap = "white_out" for 3 frames
after G.hurt_t0), a poison status, a flashback scene, four team colors
from one soldier sprite.
Rigs are content
The console will never ship a skeleton — a rig decides what a creature
is, and that is your cart's whole personality. What it ships is ik2,
which turns "where is the foot" into "where is the knee" for any
two-segment limb in any orientation. A walking creature is feet placed by
gait logic and everything else solved:
local function biped(x, y, pose, face)
-- pose is YOUR vocabulary: {lean=, step=, crouch=} — invent words.
-- bend = -face points both knees FORWARD (human); +face is a hock
-- (goat, bird, beast) — one sign is a whole phylum.
local hx, hy = x + pose.lean * 4, y - 16 + pose.crouch * 4
for side = -1, 1, 2 do
local fx = x + side * 3 + pose.step * side * 5
local kx, ky, ex, ey = ik2(hx, hy, fx, y, 9, 9, -face)
line(hx, hy, kx, ky, P.ink); line(kx, ky, ex, ey, P.ink)
end
circ(hx, hy - 10, 4, P.ink, true) -- head; add arms the same way
end
Poses are plain tables, so mix(stand, lunge, k) is your animation
blender, with k from a tween. A quadruped is the same solver four
times, diagonal pairs half a phase apart:
local function beast(x, y, gait) -- gait: 0..1, wraps
for i = 0, 3 do
local ph = (gait + (i % 2) * 0.5) % 1 -- trot: diagonals together
local sx = x + (i < 2 and 12 or -12)
local lift = math.max(0, math.sin(ph * 6.2832)) * 4
local kx, ky, ex, ey = ik2(sx, y - 14, sx + math.cos(ph * 6.2832) * 5, y - lift, 8, 8, 1)
line(sx, y - 14, kx, ky, P.soil); line(kx, ky, ex, ey, P.soil)
end
rect(x - 14, y - 22, 28, 10, P.soil, true) -- body over the legs
end
Change the segment lengths and phase offsets and the same twelve lines are a horse, a beetle, or something with no name. That is the point.