Examples
Runnable carts in fred80/examples/. Open any cart in the editor with
F5 to run, or from the shell: fred-80 fred80/examples/tweens/main.lua.
All examples work on macOS, Windows, and the A6000 Vampire.
Animation
A box slides across the screen using different easing curves.
tween_new creates a tween from a start value to an end value over a duration (seconds).
tween_step advances it one frame and returns the current value plus a done flag.
tween_reset replays from the beginning without re-allocating.
tween_free releases the handle when you're done.
-- available easing strings:
-- "linear" "outQuad" "outCubic" "outBounce" "outElastic" "outBack" "inOutSine"
local tw
function _init()
tw = tween_new(1.2, 60, 560, "outBounce")
end
function _update()
local v, done = tween_step(tw)
bx = flr(v)
if done then tween_reset(tw) end -- auto-loop
end
function _draw()
cls(1)
rectfill(bx - 18, 222, bx + 18, 258, 87)
end
Quadratic and cubic Bézier curves with a dot riding each one.
bez_render pre-bakes a curve into a flat {x1,y1, x2,y2, …} table of N points —
call it once in _init so per-frame drawing is just table lookups, no floating-point math.
For a one-off point, bezier evaluates a single position at parameter t (0–1).
bez_render in _init.
Evaluating a cubic per-frame costs floating-point work every tick; a pre-baked table is a single array lookup.
local QUAD, CUBIC
function _init()
-- bez_render(steps, x0,y0, [ctrl…,] x1,y1) → flat {x,y, x,y, …}
QUAD = bez_render(48, 80,380, 320,60, 560,380) -- quadratic
CUBIC = bez_render(48, 80,140, 160,40, 480,40, 560,140) -- cubic
end
function _draw()
cls(1)
local t = time()
-- draw curves segment by segment (table lookup, not per-frame eval)
for i = 1, 46 do
local i2 = (i-1) * 2
line(CUBIC[i2+1], CUBIC[i2+2], CUBIC[i2+3], CUBIC[i2+4], 186)
line(QUAD[i2+1], QUAD[i2+2], QUAD[i2+3], QUAD[i2+4], 186)
end
-- moving dot: index into pre-baked table
local ci = flr((t * 0.42 % 1) * 47) * 2
circfill(flr(CUBIC[ci+1]), flr(CUBIC[ci+2]), 7, 87)
local qi = flr(((t * 0.42 + 0.5) % 1) * 47) * 2
circfill(flr(QUAD[qi+1]), flr(QUAD[qi+2]), 7, 91)
end
All five built-in screen transitions cycling open and closed.
These are plain globals — no require needed.
Call them at the end of _draw after the scene is painted.
Pass t = 0 for fully open, t = 1 for fully closed.
Pair with a tween_new to animate t.
local NAMES = { "fade", "wipe_h", "wipe_v", "iris", "iris_zip" }
local mode = 1
local tw, tv, phase
local function start()
tw = tween_new(0.9, 0, 1, "inOutQuad")
phase = 1; tv = 0
end
function _init() start() end
function _update()
if btnp("z") or btnp("x") then mode = mode % #NAMES + 1; start() end
local v, done = tween_step(tw)
tv = (phase == 1) and v or (1 - v)
if done and phase == 1 then phase = 2; tween_reset(tw) end
end
function _draw()
-- draw your scene first
for y = 0, 479, 8 do rectfill(0, y, 639, y+7, 160 + flr(y/32)) end
circfill(320, 240, 90, 87)
-- then apply ONE transition (tv = 0 open, 1 closed)
if mode == 1 then fade(tv)
elseif mode == 2 then wipe_h(tv)
elseif mode == 3 then wipe_v(tv)
elseif mode == 4 then iris(tv, 320, 240)
elseif mode == 5 then iris_zip(tv, 320, 240)
end
print(NAMES[mode], 4, 469, 5)
end
fb_fade(n) subtracts n from every pixel index in the framebuffer each frame — no cls() needed.
Pixels decay toward the floor colour (default index 1), leaving automatic motion trails.
The figure-8 path crosses itself so you can see the decay clearly: where the dot re-enters its own trail,
fresh bright pixels overwrite faded ones.
Clear just the HUD strip with rectfill so text stays sharp while the rest of the screen accumulates history.
function _init()
cls(1)
end
function _draw()
fb_fade(1) -- decay by 1/frame; no cls() — trail persists across frames
local t = time() * 0.24
-- figure-8 (lemniscate): crosses centre twice per loop
local ox = flr(320 + sin(t) * 220)
local oy = flr(240 + sin(t * 2) * 130)
circfill(ox, oy, 14, 91)
-- clear only the HUD strip, not the full screen
rectfill(0, 465, 639, 479, 1)
print(stat(0) .. "fps", 580, 469, 3)
end
Canvas & Colour
canvas_draw accepts an optional 7th argument controlling how non-transparent pixels are composited.
Without a mode argument the canvas is drawn at full opacity (index 0 pixels are always skipped — that's the transparency model).
Pass "stipple" for a true 50 % checkerboard dither — every pixel where
(cx XOR cy) & 1 is set is skipped. Use this for fog or lighting overlays.
"stipple" for real 50 % dithered transparency.
local scene, overlay
local mode = 1
function _init()
-- bake a colourful background into a canvas once
scene = canvas_new(640, 480)
canvas_set(scene)
for y = 0, 479, 6 do rectfill(0, y, 639, y+5, 128 + flr(y/14)) end
canvas_set()
-- dark overlay with transparent holes punched out
overlay = canvas_new(640, 480)
canvas_set(overlay)
cls(241)
circfill(200, 240, 110, 0) -- index 0 = transparent
circfill(460, 240, 90, 0)
canvas_set()
end
function _update()
if btnp("z") then mode = mode % 3 + 1 end
end
function _draw()
canvas_draw(scene, 0, 0) -- background
if mode == 1 then
canvas_draw(overlay, 0, 0) -- full opacity
elseif mode == 2 then
canvas_draw(overlay, 0, 0, 1, false, false, "stipple") -- 50% dither
end
end
pal(i, r, g, b) remaps palette entry i to a new 24-bit colour immediately.
Changes are permanent until you call pal again with new values — there is no automatic reset between frames.
This makes it ideal for hit-flash effects (remap then restore) and animated colour cycling (call every frame in _draw).
local flash_t = 0
function _update(dt)
if btnp("z") then flash_t = 0.2 end
flash_t = math.max(0, flash_t - dt)
end
function _draw()
local t = time()
-- colour cycle: remap 80-95 each frame
for i = 0, 15 do
local f = i / 15
pal(80 + i,
flr(128 + sin(t + f) * 127), -- R
flr(128 + sin(t + f + 0.33) * 127), -- G
flr(128 + sin(t + f + 0.66) * 100)) -- B
end
-- hit flash: remap a single sprite colour to white
if flash_t > 0 then
pal(87, 255, 255, 200) -- flash to white
else
pal(87, 200, 110, 20) -- restore orange
end
circfill(320, 300, 32, 87) -- drawn using remapped colour 87
end
Simulation
Fire-and-forget particle bursts. Define an emitter template once with emit_new,
then call emit_burst wherever you need an explosion or splash.
emit_update and emit_draw are global — they advance and render all active emitters in one call each.
Particles are pooled internally and recycled automatically.
local SPARKS
function _init()
SPARKS = emit_new({
cols = { 91, 87, 80, 72, 64, 3, 1 }, -- palette indices
size = 2, -- pixel radius
grav = 0.18, -- gravity per frame
vx = { -7, 7 }, -- random x velocity range
vy = { -9, -1 }, -- random y velocity range
life = { 20, 50 }, -- lifetime in frames
})
end
function _update()
if btnp("z") then
emit_burst(SPARKS, 320, 240, 40) -- 40 particles
end
emit_update() -- advance all emitters
end
function _draw()
cls(1)
emit_draw() -- draw all live particles
print("live=" .. emit_count(), 4, 469, 3)
end
Verlet particle system with controllable forces. Use part_new to create a pool,
part_spawn to place particles, then apply forces each frame —
part_gravity (constant downward push), part_attract (pull toward a point),
or part_repel (push away from a point). part_bounds reflects particles off the screen edges.
Move a crosshair cursor with the arrow keys to repel particles; hold Z to attract instead.
local ps, cx, cy
local CURSOR_SPD = 240 -- px/s
function _init()
ps = part_new(300, 0)
for _ = 1, 300 do
part_spawn(ps, rnd(560)+40, rnd(380)+40,
rnd(4)-2, rnd(4)-2)
end
cx, cy = 320, 240
end
function _update(dt)
-- move cursor with arrow keys
if btn("left") then cx = cx - CURSOR_SPD * dt end
if btn("right") then cx = cx + CURSOR_SPD * dt end
if btn("up") then cy = cy - CURSOR_SPD * dt end
if btn("down") then cy = cy + CURSOR_SPD * dt end
if btn("z") then
part_attract(ps, cx, cy, 200, 9000)
else
part_repel(ps, cx, cy, 200, 9000)
end
part_gravity(ps, 0, 0.12)
part_update(ps, 0.99)
part_bounds(ps, 0, 0, 639, 460, 0.35)
end
function _draw()
cls(1)
-- wave table: colours cycle by spawn position + time
part_draw(ps, {87, 91, 186, 15, 7}, nil, time() * 1.2)
line(cx-10, cy, cx+10, cy, 7)
line(cx, cy-10, cx, cy+10, 7)
circfill(cx, cy, 4, btn("z") and 186 or 91)
end
Verlet cloth simulation. cloth_init builds an internal grid of nodes;
cloth_update advances physics with configurable gravity, damping, wind, and constraint iterations;
cloth_draw renders the grid as horizontal line segments, one colour per row.
The top row is pinned — nodes below it drape freely.
local wind = 0
local ROW_COLS = { 176, 178, 180, 182, 184,
186, 184, 182, 180, 178 }
function _init()
cloth_init(18, 10, 28) -- 18 cols × 10 rows, 28px rest distance
end
local WIND_RATE = 3.6 -- units/s
function _update(dt)
if btn("left") then wind = max(wind - WIND_RATE * dt, -4) end
if btn("right") then wind = min(wind + WIND_RATE * dt, 4) end
-- cloth_update(gravity, damping, wind_x, constraint_iters)
cloth_update(0.45, 0.98, wind, 4)
end
function _draw()
cls(241)
cloth_draw(ROW_COLS) -- one palette index per row
end
A magnetic particle field with per-particle trail history.
repulsor_init allocates the pool (particle count × trail length).
repulsor_update applies a repel zone around a point, an attract zone outside it,
and a spring pulling particles back toward their rest positions.
The trail buffer is C-side — zero Lua table overhead, safe on A6000.
local WAVE_COLS = { 248, 154, 166, 191, 15, 186 }
local TRAIL_COLS = { 15, 162, 164, 176, 180, 192, 3, 1 }
local rx, ry = 320, 240
function _init()
repulsor_init(300, 16) -- 300 particles, 16-frame trail
end
local MOVE_SPD = 240 -- px/s
function _update(dt)
if btn("left") then rx = max(rx - MOVE_SPD * dt, 20) end
if btn("right") then rx = min(rx + MOVE_SPD * dt, 619) end
if btn("up") then ry = max(ry - MOVE_SPD * dt, 20) end
if btn("down") then ry = min(ry + MOVE_SPD * dt, 459) end
repulsor_update(rx, ry,
60, -- repel strength
35*35, -- repel radius squared
110*110, -- attract radius squared
0.0008, -- attract strength
0.0004, -- spring (rest-position pull)
0.97) -- velocity damping
end
function _draw()
cls(1)
repulsor_draw(WAVE_COLS, TRAIL_COLS, time() * 1.5)
end
Physics
Rigid body dynamics with spring joints. body_new creates a body at a position with a radius
and mass. Apply impulses with body_force, integrate with body_update,
and bounce off walls with body_aabb_collide or body_circle_collide.
spring_new connects two bodies with a rest length and stiffness;
spring_update applies constraint forces iteratively.
A very high mass body acts as a fixed anchor.
local B, S = {}, {}
function _init()
B[1] = body_new(240, 200, 14, 1.0) -- x, y, radius, mass
B[2] = body_new(400, 200, 14, 1.0)
B[3] = body_new(320, 60, 8, 9999) -- fixed anchor (very high mass)
S[1] = spring_new(B[1], B[2], 165, 0.4) -- rest=165px, k=0.4
S[2] = spring_new(B[3], B[1], 180, 0.15)
end
function _update(dt)
body_force(B[1], 0, 0.35) -- gravity
body_force(B[2], 0, 0.35)
for _, s in ipairs(S) do spring_update(s, 6) end
for i = 1, 2 do
body_update(B[i], 0.99)
body_aabb_collide(B[i], 14, 14, 625, 450, 0.4)
end
end
function _draw()
cls(1)
for _, s in ipairs(S) do spring_draw(s, 5) end
for i = 1, 2 do
local x, y = body_pos(B[i])
circfill(flr(x), flr(y), 14, 87)
end
end
Game Systems
Batch circle-vs-circle collision over flat position arrays — far faster than a Lua loop.
Store entity positions in separate x[] and y[] tables (not interleaved).
ent_hit_first writes the index of the first B entity hit by each A entity into a pre-allocated result table.
ent_nearest returns the index of the entity closest to a point.
local N_ENE = 8
local N_BUL = 24
local ex, ey = {}, {} -- enemy positions (flat arrays)
local bx, by = {}, {} -- bullet positions
local bvx, bvy = {}, {} -- bullet velocities
local b_live = {}
local dead_e = {}
local hits = {} -- result buffer, reused every frame
local nb_live = 0
local shoot_t = 0
local function reset()
dead_e = {}
for i = 1, N_ENE do
ex[i] = 60 + (i-1) * 75
ey[i] = 120 + (i%3) * 70
end
for i = 1, N_BUL do b_live[i] = false end
nb_live = 0
end
function _init() reset() end
function _update(dt)
shoot_t = shoot_t - dt
if btn("z") and shoot_t <= 0 then
for i = 1, N_BUL do
if not b_live[i] then
b_live[i] = true
bx[i] = 320 + rnd(40) - 20
by[i] = 440
bvx[i] = (rnd(6) - 3) * 60
bvy[i] = -(rnd(5) + 7) * 60
shoot_t = 0.07
break
end
end
end
if btnp("x") then reset() end
nb_live = 0
for i = 1, N_BUL do
if b_live[i] then
bx[i] = bx[i] + bvx[i] * dt
by[i] = by[i] + bvy[i] * dt
bvy[i] = bvy[i] + 720 * dt
if by[i] < -20 or by[i] > 500 then b_live[i] = false end
end
if b_live[i] then nb_live = nb_live + 1 end
end
-- ent_hit_first: for each bullet, find first enemy it overlaps
ent_hit_first(bx, by, N_BUL, ex, ey, N_ENE, 14*14, hits)
for i = 1, N_BUL do
if b_live[i] and hits[i] and hits[i] > 0 then
dead_e[hits[i]] = true
b_live[i] = false
end
end
end
function _draw()
cls(1)
for i = 1, N_ENE do
circfill(flr(ex[i]), flr(ey[i]), 14, dead_e[i] and 64 or 91)
end
local ni = ent_nearest(320, 440, ex, ey, N_ENE)
if ni and ni > 0 and not dead_e[ni] then
circ(flr(ex[ni]), flr(ey[ni]), 18, 35)
end
for i = 1, N_BUL do
if b_live[i] then circfill(flr(bx[i]), flr(by[i]), 4, 7) end
end
circfill(320, 440, 8, 35) -- launcher
rectfill(0, 465, 639, 479, 1)
print("Z: shoot X: reset bullets=" .. nb_live, 4, 469, 5)
end
Simple one-shot overlap tests for rectangles and circles. Both return a boolean — no manifold or MTV is generated. Use these for trigger zones, bullet hits, or broad-phase rejection before running more expensive logic.
-- AABB test
local touching = hit_rect(ax, ay, AW, AH, bx, by, BW, BH)
-- circle test
local touching = hit_circle(cx, cy, CR, ox, oy, OR)
function _draw()
local hit = hit_rect(ax, ay, 60, 50, bx, by, 80, 60)
rectfill(ax, ay, ax+60, ay+50, hit and 87 or 7) -- flash on overlap
rect(bx, by, bx+80, by+60, hit and 87 or 35)
end
Batch-draw a multi-tile sprite at many positions in a single AMMX-accelerated call.
A "grid sprite" is a rectangle of consecutive bank slots — for example a 2×3 character
occupies slots 0,1 (top row), 2,3 (middle), 4,5 (bottom), giving a 32×48 pixel sprite.
Pass a flat {x1,y1, x2,y2, …} table and all positions are drawn at once.
Requires sprites to be drawn in the sprite editor first — a blank bank produces no visible output.
-- pre-allocate position table once
local positions = {}
local n = 0
for row = 0, 3 do
for col = 0, 7 do
n = n + 1
positions[n*2-1] = 40 + col * 72 -- x
positions[n*2] = 100 + row * 80 -- y
end
end
function _draw()
cls(1)
-- draw a 2-wide × 3-tall grid from slot 0, at all positions
spr_grid(0, 2, 3, positions)
end
A marching formation drawn with a single spr_batch call.
spr_batch(slot, positions) draws N copies of one sprite from a flat position table —
one C-level loop, no per-sprite Lua overhead. A placeholder box-and-circle soldier
is drawn under the batch call so the demo runs even without sprites painted.
local N_COLS = 8
local N_ROWS = 4
local N = N_COLS * N_ROWS -- 32 soldiers
local pos = {} -- flat {x1,y1, x2,y2, …}, pre-allocated
local march_x = 0
local MARCH_SPD = 48 -- px/s
function _init()
for i = 1, N * 2 do pos[i] = 0 end -- must pre-allocate for spr_batch
end
function _update(dt)
march_x = march_x + MARCH_SPD * dt
local n = 0
for r = 0, N_ROWS-1 do
for c = 0, N_COLS-1 do
n = n + 1
pos[n*2-1] = (march_x + c * 64) % 760 - 60
pos[n*2] = 180 + r * 60
end
end
end
function _draw()
cls(48)
spr_batch(0, pos) -- 32 soldiers, one draw call
end
Audio
Pre-baked PCM audio — compose in Tommy, save, and the .pcm is baked automatically.
You can also bake manually from the FRED-80 shell: bake_audio notes.json music.pcm.
snd_load reads the .pcm into a slot (0–7).
snd_play(slot, loop, channel, vol) triggers it on an independent channel —
music and sfx can play simultaneously on separate channels.
On A6000 playback runs via SAGA DMA — zero CPU cost. On desktop it is software-mixed at the same API.
function _init()
pcall(snd_load, 0, "music.pcm") -- slot 0 → ch 0 (music)
pcall(snd_load, 1, "sfx.pcm") -- slot 1 → ch 1 (sfx)
end
function _update()
if btnp("z") then snd_play(0, true, 0, 80) end -- loop music on ch 0
if btnp("x") then snd_play(1, false, 1, 100) end -- sfx on ch 1 (both play at once)
if btnp("c") then snd_stop(0) end -- stop music only
if btnp("up") then snd_stop() end -- stop all channels
end
Input
Live keyboard visualiser. Every fixed key ("left", "z", "enter" …)
and every raw key ("space", "lshift", "f1" …) lights up when held.
Use this as a reference while building controls for your cart.
local FIXED = { "left", "right", "up", "down", "z", "x", "c", "enter" }
local RAW = { "space", "lshift", "lalt", "lctrl", "help", "f1", "f2", "f3" }
function _draw()
cls(1)
for i, k in ipairs(FIXED) do
local active = btn(k)
print((active and ">> " or " ") .. k,
48, 36 + (i-1) * 16, active and 35 or 5)
end
for i, k in ipairs(RAW) do
local active = btn(k)
print((active and ">> " or " ") .. k,
340, 36 + (i-1) * 16, active and 91 or 5)
end
end
Analogue joystick visualiser. Draws axis bars, an 8-button grid, and a D-pad compass for the hat switch. Plug in any SDL2-compatible gamepad and all inputs appear live. Useful as a hardware test on the A6000.
local JOY = 0 -- joystick index
function _draw()
cls(1)
print("JOYSTICK " .. JOY, 258, 10, 15)
-- axes: bar chart, centre = 0, right = positive
print("AXES", 40, 40, 7)
for i = 0, 3 do
local v = joy_axis(JOY, i) -- -32768..32767
local by = 40 + i * 22
rect(160, by, 480, by+14, 3)
line(320, by, 320, by+14, 3)
local bw = math.floor(v / 32768.0 * 160)
if bw >= 0 then
rectfill(320, by+1, 320+bw, by+13, 87)
else
rectfill(320+bw, by+1, 320, by+13, 64)
end
print("axis " .. i .. ": " .. v, 40, by+3, 5)
end
-- buttons
print("BUTTONS", 40, 140, 7)
for i = 0, 7 do
local bx = 160 + (i % 4) * 64
local by = 140 + math.floor(i / 4) * 28
local on = joy_btn(JOY, i)
rectfill(bx, by, bx+54, by+20, on and 87 or 2)
print("B" .. i, bx+18, by+6, on and 15 or 5)
end
-- hat switch (bitmask: 1=up 2=right 4=down 8=left)
local hat = joy_hat(JOY, 0)
print("HAT: " .. hat, 40, 210, 87)
if hat & 1 ~= 0 then print("UP", 200, 210, 7) end
if hat & 2 ~= 0 then print("RIGHT", 240, 210, 7) end
if hat & 4 ~= 0 then print("DOWN", 300, 210, 7) end
if hat & 8 ~= 0 then print("LEFT", 360, 210, 7) end
end
Camera & Map
Two-layer parallax scroll using camera() and tilemap_draw.
The foreground layer tracks the player at full speed; the background layer scrolls at 30%,
giving depth with no extra sprites. Paint map slots 0 and 1 in the MAP tab to see it in action.
local cam_x = 0.0
local bg_x = 0.0
local fg_tm, bg_tm
local SCROLL = 120 -- px/s when key held
local AUTO_SCROLL = 60 -- px/s constant drift
function _init()
bg_tm = mload(1) -- background map in slot 1
fg_tm = mload(0) -- foreground map in slot 0
end
function _update(dt)
local dx = AUTO_SCROLL
if btn("right") then dx = SCROLL end
if btn("left") then dx = -SCROLL end
cam_x = cam_x + dx * dt
bg_x = bg_x + dx * 0.3 * dt -- bg at 30% speed
if cam_x < 0 then cam_x = 0; bg_x = 0 end
end
function _draw()
cls(166)
if bg_tm then
camera(math.floor(bg_x), 0)
tilemap_draw(bg_tm, 0, 0) -- background layer
end
if fg_tm then
camera(cam_x, 0)
tilemap_draw(fg_tm, 0, 0) -- foreground layer
end
camera() -- reset for HUD
end
Integer pixel zoom from 1× to 4×. zoom(n) scales the viewport so every logical pixel becomes
an n×n block. Combined with camera() for panning, and pget to sample any
rendered pixel — useful for magnifying sections of a scene or building a debug overlay.
local cx, cy = 0, 0
local z = 2
local PAN_SPD = 240 -- px/s
function _update(dt)
if btn("left") then cx = cx - PAN_SPD * dt end
if btn("right") then cx = cx + PAN_SPD * dt end
if btn("up") then cy = cy - PAN_SPD * dt end
if btn("down") then cy = cy + PAN_SPD * dt end
if btnp("z") then z = z < 4 and z + 1 or 1 end
end
function _draw()
zoom(z)
camera(cx, cy)
cls(3)
rectfill(80, 80, 240, 200, 87)
circfill(320, 240, 60, 186)
rectfill(400, 300, 560, 400, 35)
camera()
zoom(1) -- reset before HUD
local col = pget(320, 240) -- sample centre pixel after draw
rectfill(0, 460, 639, 479, 1)
print("zoom=" .. z .. " centre px=" .. col, 4, 465, 5)
end
Procedural dungeon with a corner minimap. The minimap is a 48×36 canvas baked once at init —
one pset per tile. Every frame it's drawn scaled 3× into the bottom-right corner
with a viewport rectangle and player dot on top. The world view uses a smooth-follow camera.
Demonstrates using canvas_new as a persistent off-screen texture rather than a render target.
local MAP_W, MAP_H = 48, 36
local SCALE = 3
local map = {}
for i = 0, MAP_W * MAP_H - 1 do
local x, y = i % MAP_W, flr(i / MAP_W)
map[i] = (x == 0 or x == MAP_W-1 or y == 0 or y == MAP_H-1) and 1
or (rnd(1) < 0.15 and 1 or 0)
end
local MAP_COL = { [0]=3, [1]=208 }
local minimap
local px, py = MAP_W/2, MAP_H/2
local cam_px, cam_py = px*16, py*16
local function bake_minimap()
canvas_set(minimap)
for ty = 0, MAP_H-1 do
for tx = 0, MAP_W-1 do
pset(tx, ty, MAP_COL[map[ty*MAP_W+tx]])
end
end
canvas_set()
end
function _init()
minimap = canvas_new(MAP_W, MAP_H)
bake_minimap()
end
local SPEED = 120 -- px/s
function _update(dt)
local s = SPEED / 16 * dt
local nx, ny = px, py
if btn("right") then nx = nx + s end
if btn("left") then nx = nx - s end
if btn("down") then ny = ny + s end
if btn("up") then ny = ny - s end
if map[flr(ny)*MAP_W + flr(nx)] == 0 then px, py = nx, ny end
cam_px = cam_px + (px*16 - cam_px) * math.min(1, 9 * dt)
cam_py = cam_py + (py*16 - cam_py) * math.min(1, 9 * dt)
end
function _draw()
camera(flr(cam_px) - 320, flr(cam_py) - 240)
cls(1)
for ty = 0, MAP_H-1 do
for tx = 0, MAP_W-1 do
if map[ty*MAP_W+tx] == 1 then
rectfill(tx*16, ty*16, tx*16+15, ty*16+15, 208)
end
end
end
circfill(flr(px*16), flr(py*16), 6, 87)
camera()
-- minimap: baked once, drawn scaled each frame
local mx = 639 - MAP_W*SCALE - 4
local my = 479 - MAP_H*SCALE - 4
canvas_draw(minimap, mx, my, SCALE)
local vx = mx + flr((cam_px/16 - 20) * SCALE)
local vy = my + flr((cam_py/16 - 15) * SCALE)
rect(vx, vy, vx+flr(40*SCALE), vy+flr(30*SCALE), 15)
pset(mx + flr(px*SCALE), my + flr(py*SCALE), 87)
end
Math & Performance
Three demos in one screen: orbiting moons using sin/cos (0–1 full circle convention),
an aim arrow using atan2, and a value strip showing abs, mid, and
clamp side by side. 80 stars are drawn per frame with pset_batch_xy
to show the single-colour batch path.
-- sin/cos use 0..1 = full circle (not radians)
local a = (time() * speed + phase) % 1.0
local mx = CX + cos(a) * radius
local my = CY + sin(a) * radius * 0.45
-- atan2 returns 0..1 (same convention)
local a = atan2(comet_y - CY, comet_x - CX)
-- clamp vs mid
local mv = mid(-100, v, 100) -- clamp with three args
local cv = clamp(v, -130, 130) -- explicit clamp
Stress test for the batch sprite API: 200 bouncing 32×32 composite sprites (each made of 4 × 16×16 tiles)
plus 200 falling pixels via pset_batch.
All positions are written into pre-allocated flat tables every frame — no per-frame Lua allocation.
On the A6000 this runs at 50fps via the AMMX fast path.
-- tile def: {sprite, dx, dy, sprite, dx, dy, ...}
local TILES = {0,0,0, 1,16,0, 2,0,16, 3,16,16}
-- pre-alloc once; fill each frame
local pos = {}; for i = 1, N*2 do pos[i] = 0 end
pos[i*2-1] = n.x; pos[i*2] = n.y
spr_batch_composite(TILES, pos) -- 200 sprites, one call
-- pset_batch: {x,y,col, x,y,col, ...}
pset_batch(pdata) -- 200 pixels, one call
Demonstrates Gunnar's Green (palette index 240) as a per-pixel transparency mask. Any sprite pixel painted index 240 is skipped during blit, letting the background show through as a shaped hole. Draw sprite 0 in the SPRITE tab with some index-240 pixels to see the effect against the animated checkerboard background.
-- In the sprite editor: paint pixels with index 240 (Gunnar's Green).
-- Those pixels are transparent at runtime — the background shows through.
-- Index 0 (Aftonstjaerna Pink) is also transparent.
-- Use index 240 when you need a mask separate from colour 0.
spr(0, sx, sy) -- index-240 pixels punch holes into the checkerboard
Existing Carts
These carts ship with FRED-80 and cover the core API. Open them in the editor to browse the full source.
cls, print, rectfill, circfill, line. The minimal starting point — everything you need to draw text and shapes.
spr, spr_new, spr_blit, spr_get. Bank sprites from the SPRITE tab plus procedurally-created sprite objects.
Off-screen render targets. canvas_new(w, h) allocates a buffer;
canvas_set(c) redirects all drawing commands to it;
canvas_set() (no arg) restores the screen.
canvas_draw(c, x, y) blits the canvas each frame.
Use this to pre-bake expensive backgrounds once in _init rather than redrawing every frame.
image_load(path) loads a pre-converted .img file (raw indexed: 4-byte w/h header + palette indices)
and returns it as a canvas — use tools/spr_import.py to convert a PNG.
local bg
function _init()
bg = canvas_new(640, 480)
canvas_set(bg) -- redirect drawing to bg
cls(3)
for i = 1, 24 do
circfill(rnd(640), rnd(480), rnd(40)+10, 160+flr(rnd(32)))
end
canvas_set() -- restore screen
end
function _draw()
canvas_draw(bg, 0, 0) -- blit pre-baked layer every frame
circfill(320, 240, 30, 91)
end
-- load a .img file: image_load returns a canvas directly
-- local cover = image_load(CART_DIR .. "cover.img")
-- canvas_draw(cover, 0, 0)
Draw many pixels in a single C call instead of looping over pset.
pset_batch(t) takes a flat {x, y, col, x, y, col, …} table — use when pixels have mixed colours.
pset_batch_xy(col, t) takes a single colour and a flat {x, y, x, y, …} table —
two table reads per pixel instead of three, so prefer it when all pixels share one colour (star fields, monochrome effects).
local stars = {} -- fixed {x,y,...} — all white, cheapest path
local pts = {} -- animated {x,y,col,...} — mixed colours
function _init()
for i = 1, 300 do
stars[i*2-1] = flr(rnd(640))
stars[i*2] = flr(rnd(480))
end
end
function _update()
local t = time()
pts = {}
for i = 1, 120 do
local a = t + i * 0.28
pts[#pts+1] = flr(320 + cos(a) * (80 + i))
pts[#pts+1] = flr(240 + sin(a * 0.7) * (60 + i * 0.4))
pts[#pts+1] = 160 + (i % 32) -- colour varies per pixel
end
end
function _draw()
cls(1)
pset_batch_xy(7, stars) -- 300 white stars, 2 reads/pixel
pset_batch(pts) -- 120 coloured orbit points, 3 reads/pixel
end
mload, map_use, mget, mset, tilemap_draw, tilemap_parallax. Static tilemaps and AMMX-accelerated parallax scrolling.
btn, btnp, joy_axis, joy_btn. Keyboard and joystick input, held vs pressed detection, and axis values from analogue sticks.
dset(i, v), dget(i). 64 persistent number slots written to amico8.dat. Use for high scores, settings, and save state.