byo.games

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.