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

tweens 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.

Controls
Zreplay current easing
Xcycle to next easing curve
APIs
tween_new(dur, from, to, easing) tween_step(tw) tween_reset(tw) tween_free(tw)
-- 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
bezier animation

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).

Controls
dots animate automatically
APIs
bez_render(steps, x0,y0, [cx1,cy1, [cx2,cy2,]] x1,y1) bezier(t, x0,y0, cx,cy, x1,y1)
A6000 tip: Always pre-bake paths with 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
transitions animation

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.

Controls
Z / Xcycle transition type
APIs
fade(t) wipe_h(t) wipe_v(t) iris(t, cx, cy) iris_zip(t, cx, cy)
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 animation

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.

Controls
figure-8 trail, auto-animated
APIs
fb_fade(n [, floor])
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

blend_modes canvas

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.

Controls
Zcycle between opacity modes
APIs
canvas_new(w, h) canvas_set([c]) canvas_draw(c, x, y [,scale, fh, fv, mode])
Note: numeric alpha (0–100) only thresholds at 50 on the 8bpp framebuffer — values below 50 are fully transparent, 50 and above are fully opaque. Use "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 colour

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).

Controls
Zflash the circle white
top strip auto-cycles
APIs
pal(i, r, g, b)
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

particles_emit 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.

Controls
Zexplosion at screen centre
Xburst at a random position
APIs
emit_new(config) emit_burst(e, x, y, n) emit_update() emit_draw() emit_count()
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
particles_part simulation

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.

Controls
Arrowsmove cursor
Zattract (default: repel)
APIs
part_new(max, trail) part_spawn(ps, x, y, vx, vy) part_gravity(ps, gx, gy) part_attract(ps, x, y, r, str) part_repel(ps, x, y, r, str) part_update(ps, damp) part_bounds(ps, x0,y0, x1,y1, rest) part_draw(ps, colour) part_draw(ps, {c1,c2,...}, nil, t)
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
cloth simulation

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.

Controls
Left / Rightadjust wind direction
APIs
cloth_init(cols, rows, rest_dist) cloth_update(gravity, damp, wind_x, iters) cloth_draw(row_colours)
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
repulsor simulation

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.

Controls
Arrowsmove the repulsor point
APIs
repulsor_init(n_particles, trail_len) repulsor_update(cx, cy, repel_str, repel_r2, attract_r2, attract_str, spring, damp) repulsor_draw(wave_cols, trail_cols, hue_shift)
A6000: All 300 particles × 16 trail positions live in a C-side array. This is why repulsor is a dedicated C API rather than a Lua particle table — at 50 fps managing 4800 table entries per frame would kill performance on the 68080.
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

physics 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.

Controls
Zkick ball 2 upward
APIs
body_new(x, y, r, mass) body_force(b, fx, fy) body_update(b, damp) body_pos(b) body_vel(b) body_aabb_collide(b, x0,y0, x1,y1, restitution) body_circle_collide(b1, b2, restitution) body_free(b) spring_new(a, b, rest, k) spring_update(s, iters) spring_draw(s, col) spring_free(s)
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

entities game

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.

Controls
Z (hold)shoot bullets
Xreset
APIs
ent_hit_first(ax,ay,na, bx,by,nb, r2, result) ent_hit_all(ax,ay,na, bx,by,nb, r2, result) ent_nearest(px, py, ex, ey, n)
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
collision game

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.

Controls
Arrowsmove box
Z / Xmove circle left / right
APIs
hit_rect(x1,y1,w1,h1, x2,y2,w2,h2) → bool hit_circle(x1,y1,r1, x2,y2,r2) → bool
-- 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
spr_grid game

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.

Controls
auto-scrolling grid positions
APIs
spr_grid(start, tile_w, tile_h, positions [,flip_h])
Setup: In the SPRITE tab, paint a character across slots 0–5. Slots 0+1 = top two tiles (32px wide), 2+3 = middle, 4+5 = bottom — a 32×48 sprite. The example draws an outline grid so you can see the layout without sprites painted yet.
-- 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
soldiers game

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.

Controls
soldiers march automatically
APIs
spr_batch(slot, flat_positions) spr_batch_composite(tiles, flat_positions)
Setup: Paint a soldier in sprite slot 0 (16×16) in the SPRITE tab for a real sprite. The placeholder boxes still show the formation and scroll behaviour.
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

snd (PCM) 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.

Controls
Zloop music (ch 0)
Xsfx over music (ch 1)
Cstop music only
Upstop all channels
APIs
snd_load(slot, filename) snd_play(slot, loop, channel, vol) snd_stop([channel])
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

input_test 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.

Controls
any keyhighlights on screen
APIs
btn(name)
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
joystick input

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.

Controls
gamepadmoves bars, lights buttons
APIs
joy_axis(joy, axis) joy_btn(joy, btn) joy_hat(joy, hat)
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

parallax camera

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.

Controls
LEFT / RIGHTscroll (auto-scrolls if idle)
APIs
camera(x, y) camera() mload(slot) tilemap_draw(tm, ox, oy)
Setup: Paint tiles in the SPRITE tab, then lay out two maps in the MAP tab — slot 0 as the foreground layer and slot 1 as the background layer.
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
zoom camera

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.

Controls
Zcycle zoom 1×→2×→3×→4×
Arrowspan
APIs
zoom(n) camera(x, y) camera() pget(x, y)
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
minimap camera

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.

Controls
arrowsmove player
APIs
canvas_new(w, h) canvas_set(c) canvas_draw(c, x, y, scale) pset(x, y, col)
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

math utility

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.

Controls
fully animated, no input needed
APIs
sin(t) cos(t) atan2(dy, dx) abs(v) mid(a, v, b) clamp(v, a, b) pset_batch_xy(col, xy)
-- 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
batch_test performance

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.

Controls
automatic, shows fps counter
APIs
spr_batch_composite(tiles, pos) pset_batch(data) stat(0)
-- 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
mask_test sprites

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.

Controls
arrowsmove the sprite
APIs
spr(n, x, y) PAL_MASK = 240
-- 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.

hellobasics

cls, print, rectfill, circfill, line. The minimal starting point — everything you need to draw text and shapes.

spritesbasics

spr, spr_new, spr_blit, spr_get. Bank sprites from the SPRITE tab plus procedurally-created sprite objects.

canvasbasics

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.

Controls
no input
APIs
canvas_new(w, h) canvas_set([c]) canvas_draw(c, x, y [,scale, flip]) image_load(path)
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)
pset_batchbasics

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).

Controls
no input
APIs
pset_batch({x,y,col, …}) pset_batch_xy(col, {x,y, …})
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
tilemapbasics

mload, map_use, mget, mset, tilemap_draw, tilemap_parallax. Static tilemaps and AMMX-accelerated parallax scrolling.

inputbasics

btn, btnp, joy_axis, joy_btn. Keyboard and joystick input, held vs pressed detection, and axis values from analogue sticks.

storagebasics

dset(i, v), dget(i). 64 persistent number slots written to amico8.dat. Use for high scores, settings, and save state.