API Reference

FRED-80 carts run on standard Lua 5.4 — all Lua standard libraries (math, string, table, coroutine) are available. The functions below are FRED-80 built-ins.

A6000 tip: Prefer the built-in sin/cos/atan2, clamp/mid/flr over the math library — they use lookup tables and are significantly faster on the 68080.

System
time()
Returns elapsed seconds since the cart started.
time() → number
-- bob a sprite up and down
local x = 320 + sin(time() * 0.5) * 100
stat(n)
Returns engine diagnostic values.
stat(n) → number
nReturns
0Current FPS
print(stat(0) .. "fps", 580, 469, 3)
Math
Angles use the 0..1 full-circle convention — 0 = 0°, 0.25 = 90°, 0.5 = 180°.
sin(t)
Lookup-table sine. Fast on A6000 — integer table, no floating-point cost.
sin(t) → number
ParamDescription
tAngle in 0..1 full-circle range
local y = 240 + sin(time() * 0.3) * 80
cos(t)
Lookup-table cosine. Fast on A6000 — integer table, no floating-point cost.
cos(t) → number
ParamDescription
tAngle in 0..1 full-circle range
local x = 320 + cos(time() * 0.3) * 80
atan2(y, x)
Returns the angle of vector (x, y) in 0..1 range. Use to aim things toward a point.
atan2(y, x) → number
-- aim from center toward mouse
local angle = atan2(my - 240, mx - 320)
local tx = 320 + cos(angle) * 40
local ty = 240 + sin(angle) * 40
line(320, 240, tx, ty, 7)
rnd([n])
Returns a random float in [0, n). Defaults to 1.0.
rnd([n]) → number
local x = rnd(640)   -- random x position
local slot = flr(rnd(8))  -- random int 0..7
flr(x)
Floor — rounds down to the nearest integer.
flr(x) → integer
local tile_x = flr(x / 16)
ceil(x)
Ceiling — rounds up to the nearest integer.
ceil(x) → integer
local rows = ceil(count / 8)
abs(x)
Returns the absolute value of x.
abs(x) → number
if abs(dx) < 4 then snap() end
min(a, b) / max(a, b)
Returns the smaller or larger of two values.
min(a, b) → number
max(a, b) → number
x = max(0, x)        -- clamp to left edge
x = min(x, 624)     -- clamp to right edge
mid(a, b, c)
Returns the median of three values. Handy for clamping — mid(lo, val, hi).
mid(a, b, c) → number
x = mid(0, x, 624)   -- keep x within screen
clamp(v, lo, hi)
Clamps v between lo and hi.
clamp(v, lo, hi) → number
health = clamp(health + 10, 0, 100)
Input
btn(name)
Returns true while the named button is held down.
btn(name) → boolean
nameDescription
"left" "right" "up" "down"D-pad directions
"z" "x" "c"Action buttons
"enter" "escape"Menu buttons
if btn("right") then x = x + 2 end
btnp(name)
Returns true only on the first frame the button is pressed. Use for single-trigger actions like jumping or shooting.
btnp(name) → boolean
if btnp("z") then jump() end
joy_axis(joy, axis)
Returns the raw axis value from a joystick.
joy_axis(joy, axis) → integer (-32768..32767)
ParamDescription
joyJoystick index 0–3
axisAxis index (0=X, 1=Y typically)
local ax = joy_axis(0, 0) / 32768   -- normalise to -1..1
x = x + ax * 3
joy_btn(joy, btn)
Returns true if a joystick button is pressed.
joy_btn(joy, btn) → boolean
if joy_btn(0, 0) then fire() end
joy_hat(joy, hat)
Returns the raw hat switch bitmask.
joy_hat(joy, hat) → integer
BitDirection
1Up
2Right
4Down
8Left
local h = joy_hat(0, 0)
if h & 1 ~= 0 then move_up() end
Drawing
cls([c])
Clears the screen to a palette colour.
cls([c])
ParamDescription
cPalette index 0–255. Default 0 (transparent/black)
cls()     -- clear to black
cls(3)    -- clear to colour 3
camera([x, y])
Sets the global scroll offset. All draw calls shift by (-x, -y). Call with no arguments to reset to (0, 0).
camera([x, y])
camera(cam_x, cam_y)    -- scroll world
tilemap_draw(tm, 0, 0)
camera()                -- reset for HUD
print("100 pts", 4, 4, 7)
zoom([n])
Sets pixel scale 1–8. All coordinates and sizes are multiplied by n. Default 1.
zoom([n])
zoom(2)
spr(0, 160, 120)   -- draws at 32x32 pixels
zoom(1)
pset(x, y, c)
Draws a single pixel. Use pset_batch_xy when drawing many pixels per frame.
pset(x, y, c)
ParamDescription
x, yScreen position in pixels
cPalette index 0–255
pset(320, 240, 7)   -- white pixel at center
pget(x, y)
Returns the palette index of the pixel at (x, y). Returns 0 if out of bounds.
pget(x, y) → integer
local c = pget(mx, my)   -- sample colour under mouse
pal(i, r, g, b)
Remaps palette entry i to a 24-bit RGB colour at runtime. Takes effect immediately. Indices 0 (transparent) and 240 (collision mask) are protected.
pal(i, r, g, b)
ParamDescription
iPalette index 1–255 (0 and 240 are protected)
r, g, bRed, green, blue 0–255
-- flash player red on hit, then restore
pal(7, 255, 0, 0)
spr(player_slot, px, py)
pal(7, 240, 240, 240)
Experimental — still under development. pal_bank/scene/scene_import/scene_save below swap the live 256-colour palette wholesale instead of remapping one entry at a time like pal() above. They work on desktop/web today; the real-hardware A6000/SAGA CLUT push path for scene() specifically has a known open bug and is not yet reliable on real A6000 hardware. Treat these as preview API, subject to change, until that's resolved.
pal_bank([n])
Switches the entire live 256-colour palette between two pre-baked banks (0 or 1) at once, instead of remapping individual entries with pal(). With no argument, returns the currently active bank.
pal_bank([n]) → integer (when called with no argument)
ParamDescription
nBank to switch to — 0 or 1 only
pal_bank(1)         -- switch to bank 1's palette
local active = pal_bank()   -- 0 or 1
scene([n])
Generalises pal_bank(0/1) to up to 16 artist-named palettes ("scenes") — switches the live 256-colour palette to scene n's baked CLUT in one call. With no argument, returns the currently active scene index (-1 if none is active).
scene([n]) → integer (when called with no argument)
ParamDescription
nScene slot 0–15. A negative value deselects, restoring whichever pal_bank was active before.
scene(0)              -- activate scene 0
local s = scene()     -- currently active scene, -1 if none
scene_import(n, master_idx)
Adds one colour from the master palette into scene n's own palette, building it up one call at a time (call it once per colour you want in that scene). Each scene has a limited slot budget shared across all scenes' colours.
scene_import(n, master_idx) → slot (integer) or nil if that scene's colour budget is full
ParamDescription
nScene slot 0–15 to import into
master_idxColour index in the master palette to add
local slot = scene_import(0, 182)
if slot then
    -- use `slot` as the colour index when drawing in this scene, e.g.:
    -- rectfill(x, y, x+8, y+8, slot)
end
scene_save()
Persists all 16 scene slots for the running cart to its own scenes.dat, so colours built up with scene_import() survive past this run. Without calling this, a cart would have to rebuild its scene palettes from scratch every launch.
scene_save()
scene_save()   -- call once after building up your scenes with scene_import()
pset_batch(data)
Draws N pixels in a single Lua→C call. Much faster than calling pset() in a loop.
pset_batch(data)
ParamDescription
dataFlat table: {x1,y1,c1, x2,y2,c2, ...}
pset_batch({10,20,7, 11,20,7, 12,20,3})
pset_batch_xy(c, data)
Draws N pixels all in the same colour. Faster than pset_batch when colour is uniform (e.g. starfields).
pset_batch_xy(c, data)
ParamDescription
cPalette index for all pixels
dataFlat table: {x1,y1, x2,y2, ...}
local stars = {}
for i = 1, 80 do
  stars[i*2-1] = rnd(640)
  stars[i*2]   = rnd(480)
end

function _draw()
  pset_batch_xy(15, stars)   -- 80 stars, one C call
end
line(x0, y0, x1, y1 [, c])
Draws a Bresenham line between two points.
line(x0, y0, x1, y1 [, c])
ParamDescription
x0, y0Start point
x1, y1End point
cPalette index. Default 7
line(0, 0, 639, 479, 7)
rect(x0, y0, x1, y1 [, c])
Draws a rectangle outline. Avoid on the hot path on A6000 — pre-bake static UI into a canvas instead.
rect(x0, y0, x1, y1 [, c])
rect(10, 10, 100, 50, 7)
rectfill(x0, y0, x1, y1 [, c])
Draws a filled rectangle. Avoid on the hot path on A6000 — pre-bake static UI into a canvas instead.
rectfill(x0, y0, x1, y1 [, c])
rectfill(0, 460, 639, 479, 1)   -- HUD bar
circ(cx, cy, r [, c])
Draws a circle outline.
circ(cx, cy, r [, c])
circ(320, 240, 50, 7)
circfill(cx, cy, r [, c])
Draws a filled circle.
circfill(cx, cy, r [, c])
circfill(320, 240, 20, 8)   -- filled red circle
print(str, x, y [, c])
Draws text using the built-in 8×8 pixel font. \n advances to the next line.
print(str, x, y [, c])
ParamDescription
strString to draw
x, yTop-left position
cPalette index. Default 7
print("SCORE: " .. score, 8, 8, 7)
print("GAME OVER\npress Z", 260, 200, 8)
fb_fade(amount [, floor])
Subtracts amount from every palette index in the framebuffer each call, creating a decay/trail effect without cls(). AMMX-accelerated on A6000. floor clamps the minimum palette index so pixels never decay into index 0 (transparent pink) — default 1. Pass floor=0 only if you have remapped palette entry 0 to a dark colour.
fb_fade(amount [, floor])
ParamDescription
amountPalette index to subtract per frame (1–255). Default 1.
floorMinimum index after decay. Default 1.
function _draw()
  fb_fade(3)       -- decay all pixels, skip cls()
  circfill(ox, oy, 6, 91)   -- bright dot leaves colour trail
end
Sprites
The sprite bank holds 128 slots (0–127), each 16×16 pixels. Painted in the SPRITE tab, saved as sprites.spr. Colour 0 is always transparent.
spr(n, x, y [, flip_h, flip_v])
Draws a sprite from the bank at (x, y). Colour 0 is transparent.
spr(n, x, y [, flip_h, flip_v])
ParamDescription
nSprite slot 0–127
x, yScreen position (top-left of sprite)
flip_hMirror horizontally. Default false
flip_vMirror vertically. Default false
spr(0, player.x, player.y)
spr(2, enemy.x, enemy.y, true, false)  -- flipped
spr_batch(n, pos)
Draws slot n at N positions in a single Lua→C call. AMMX-accelerated on A6000 at zoom=1. Use instead of a spr() loop.
spr_batch(n, pos)
ParamDescription
nSprite slot 0–127
posFlat table: {x1,y1, x2,y2, ...}
local bullet_pos = {}

function _draw()
  -- fill bullet_pos with live positions...
  spr_batch(BULLET_SLOT, bullet_pos)
end
spr_batch_composite(tiles, pos)
Draws a multi-tile composite sprite (up to 16 tiles) at N positions in one call. Use for large sprites made from multiple bank slots.
spr_batch_composite(tiles, pos)
ParamDescription
tilesFlat table: {slot,dx,dy, slot,dx,dy, ...} up to 16 tiles
posFlat table: {x1,y1, x2,y2, ...}
-- 2x2 tank from 4 bank slots (32x32 total)
local TANK = {0,0,0, 1,16,0, 2,0,16, 3,16,16}
spr_batch_composite(TANK, tank_positions)
spr_flag(n [, mode])
Sets a persistent blend mode for slot n. Applied every time spr(n,...) is called. Pass no mode to reset to normal.
spr_flag(n [, mode])
modeDescription
"stipple"Checkerboard transparency
"blend50"50% mix with background
"blend25"25% mix with background
spr_flag(GHOST_SLOT, "blend50")   -- always semi-transparent
spr_flag(GHOST_SLOT)              -- reset to normal
spr_alpha(n, x, y, mode)
One-off draw with an explicit blend mode, without changing the slot's persistent flag.
spr_alpha(n, x, y, mode)
spr_alpha(SHIELD_SLOT, sx, sy, "blend50")
spr_new(w, h, pixels)
Creates a custom Sprite object from a flat pixel table (palette indices, row by row). Colour 0 is transparent. Automatically compiled as opaque if no transparent pixels, enabling faster blitting.
spr_new(w, h, pixels) → Sprite
ParamDescription
w, hWidth and height in pixels
pixelsFlat table of palette indices, 1-based, row by row
local px = {}
for i = 1, 16*16 do px[i] = 0 end
px[8*16+8+1] = 7   -- white dot at (8,8)
local dot = spr_new(16, 16, px)
spr_blit(spr, x, y [, flip_h, flip_v])
Draws a custom Sprite object created with spr_new().
spr_blit(spr, x, y [, flip_h, flip_v])
spr_blit(dot, 320, 240)
spr_get(n, px, py)
Returns the palette index of pixel (px, py) in sprite slot n.
spr_get(n, px, py) → integer
local c = spr_get(0, 8, 8)
hget(n, px, py)
Returns 1 if the hitbox layer is set at (px, py) of slot n, else 0.
hget(n, px, py) → 0 or 1
if hget(PLAYER, 8, 8) == 1 then -- center is collidable
end
spr_is_opaque(n)
Returns true if slot n has no transparent pixels. Use to branch between fast opaque blitting and transparency handling.
spr_is_opaque(n) → boolean
if spr_is_opaque(slot) then
  spr_batch(slot, positions)
end
spr_debug(n, x, y)
Draws a debug overlay showing transparent pixels (pink), hitbox pixels (green), and pixels that are both (Gunnar's Green). Use during development to verify hitbox coverage.
spr_debug(n, x, y)
spr_debug(PLAYER, player.x, player.y)
spr_hit(n1, x1, y1, n2, x2, y2)
Pixel-perfect collision test between two sprites. Returns true if their hitbox layers overlap. Paint hitboxes in the SPRITE tab using the M toggle.
spr_hit(n1, x1, y1, n2, x2, y2) → boolean
if spr_hit(PLAYER, px, py, ENEMY, ex, ey) then
  take_damage()
end
Canvas
Canvases are offscreen pixel buffers. Pre-bake anything static into a canvas in _init() and draw it with a single canvas_draw() per frame.
canvas_new(w, h)
Creates an offscreen canvas w×h pixels. All pixels start at 0 (transparent). Maximum 640×480.
canvas_new(w, h) → Canvas
local bg = canvas_new(640, 480)
canvas_set([c])
Redirects all drawing into canvas c. Call with no argument to restore drawing to the main screen.
canvas_set([c])
canvas_set(bg)
rectfill(0, 0, 639, 479, 2)
canvas_set()   -- back to screen
canvas_draw(c, x, y [, scale, flip_h, flip_v, alpha])
Composites a canvas onto the current draw target. Fastest at scale=1, no flip, alpha=100 (opaque) — uses a pre-clipped row-copy path.
canvas_draw(c, x, y [, scale, flip_h, flip_v, alpha])
ParamDescription
cCanvas object
x, yTop-left position on screen
scaleInteger ≥ 1. Default 1
flip_h, flip_vMirror flags. Default false
alphaOpacity 0–100. Default 100. Values below 100 are slow on A6000
canvas_draw(bg, 0, 0)           -- fast full-screen blit
canvas_draw(portrait, 10, 10, 3)  -- 3x scaled portrait
image_load(path)
Loads a .img file from disk and returns it as a Canvas. Convert PNGs first with tools/img_import.py.
image_load(path) → Canvas
local bg = image_load(CART_DIR .. "background.img")
Tilemaps
8 map slots (0–7), each 64×32 tiles. Tiles are 16×16 pixels. Paint maps in the MAP tab; they save to map0.datmap7.dat.
mload(slot)
Loads map<slot>.dat from the cart folder, builds a pre-rendered Tilemap using the current sprite bank, and returns the Tilemap object.
mload(slot) → Tilemap
function _init()
  tm = mload(0)
  map_use(tm)
end
tilemap_new(tw, th, cols, rows, tiles, data)
Creates a Tilemap from scratch without a file.
tilemap_new(tw, th, cols, rows, tiles, data) → Tilemap
ParamDescription
tw, thTile width and height in pixels
cols, rowsMap dimensions in tiles
tilesArray of Sprite objects (1-based)
dataFlat cols×rows array of tile indices (0 = empty)
tilemap_draw(tm, x, y)
Draws tilemap tm with its top-left at (x, y). Fast scanline copy path at zoom=1.
tilemap_draw(tm, x, y)
tilemap_draw(tm, 0, 0)
tilemap_parallax(tm, x, y)
Composites tilemap tm treating colour 0 as transparent. Use for layered parallax scrolling.
tilemap_parallax(tm, x, y)
-- two-layer parallax scroll
camera(flr(bg_x), 0)
tilemap_parallax(bg_tm, 0, 0)
camera(flr(fg_x), 0)
tilemap_draw(fg_tm, 0, 0)
camera()
tilemap_set(tm, col, row, idx)
Sets the tile at 1-based (col, row) and updates the pre-rendered buffer immediately.
tilemap_set(tm, col, row, idx)
tilemap_set(tm, 3, 2, 5)   -- place tile 5 at column 3, row 2
tilemap_get(tm, col, row)
Returns the tile index at 1-based (col, row).
tilemap_get(tm, col, row) → integer
local t = tilemap_get(tm, 3, 2)
map_use(tm)
Registers tm as the active map for mget/mset.
map_use(tm)
map_use(tm)
mget(x, y)
Returns the tile index at 0-based tile (x, y) of the active map. Returns 0 if out of bounds.
mget(x, y) → integer
local tile = mget(flr(px/16), flr(py/16))
mset(x, y, idx)
Sets the tile at 0-based (x, y) of the active map. Updates immediately.
mset(x, y, idx)
mset(5, 3, 0)   -- clear tile at (5,3)
Isometric Tilemaps
A separate tile-projection mode from the orthogonal tilemaps above: cells sit on a diamond grid instead of a rectangular one, and each cell can carry its own elevation and ramp direction. Each tile is just an ordinary sprite pre-drawn to look isometric (a diamond top face, optionally with shaded side walls for a "cube" look) — there's no real 3D math per pixel. Placement is one formula per cell:
sx = (col - row) * (tile_w / 2)
sy = (col + row) * (tile_h / 2) - height * height_unit
Increasing col moves a tile right+down on screen; increasing row moves it left+down — that's what turns a rectangular grid into a diamond. height just lifts a tile's sprite up in pixels, so elevation is a visual illusion, not real 3D. Cells are always painted back-to-front in ascending (col+row) order — that's the only "occlusion" happening: a cell nearer the camera is drawn after, and therefore on top of, anything behind it.
tilemap_new_iso(tile_w, tile_h, height_unit, cols, rows, tiles, data, heights, ramps)
Builds an iso Tilemap from scratch, same shape as tilemap_new() plus two extra flat cols×rows arrays for elevation and ramp direction.
tilemap_new_iso(...) → Tilemap
ParamDescription
tile_w, tile_h, height_unitTile size and elevation-lift-per-height-unit, in pixels
cols, rowsMap dimensions in tiles
tilesArray of Sprite objects (1-based)
dataFlat cols×rows array of tile indices (0 = empty/hole, blocks nothing on its own)
heightsFlat cols×rows array, elevation per cell (0 = ground)
rampsFlat cols×rows array, 0 = flat, 1..4 = sloped N/E/S/W (if your tile art supports it)
-- minimal from-scratch iso map: a 4x4 flat plaza, all ground level
local TW, TH, HU = 32, 16, 8
local cols, rows = 4, 4
local data, heights, ramps = {}, {}, {}
for i = 1, cols * rows do
    data[i], heights[i], ramps[i] = 1, 0, 0
end
tm = tilemap_new_iso(TW, TH, HU, cols, rows, { ground }, data, heights, ramps)
iso_mload(slot)
Loads the fixed 32x32 grid the editor's TAB_MAP iso mode painted (slot 0-7, saved to <cart>/iso<slot>.dat), builds the Tilemap from whatever's currently in the sprite bank, and returns it. Use this to hand-paint a level in the editor instead of generating one in Lua.
iso_mload(slot) → Tilemap
function _init()
    tm = iso_mload(0)
end
Rendering a map you painted in the editor: switch TAB_MAP to iso mode (the "2D/ISO" toggle), paint tiles/elevation/ramps with PAINT/RAISE/LOWER/RAMP/RAISE+/ERASE, then in your cart:
local iso = dofile(CART_DIR .. "isometric.lua")
local tm = iso_mload(0)   -- slot 0, matching whichever tab you painted in
local map = iso.new(tm, ox, oy)   -- ox,oy: a fixed origin, or see iso.center_bounds below

function _draw()
    cls(0)
    map:draw()
end
There's no Lua getter for a loaded map's per-cell height or ramp (only tilemap_get(tm, col, row) for the tile index, 0 = empty) — so iso.center_bounds's height_of callback can't read back real elevation for a hand-painted map the way it can for one you built with your own heights array. If your level is flat, just return a constant; if it has real elevation variance, either pick a fixed ox,oy by eye, or track heights yourself in a parallel Lua table as you design the layout, rather than relying on reading it back from the painted grid.
Tile art formats (set per-cart via TAB_SPRITE's ISO CUBE toggle): Flat (default) is 2 sprite-bank slots per tile side by side, 32x16, just the diamond top face. ISO CUBE is 4 slots in a 2x2 block, 32x32: the same top face plus two shaded side walls below it, so a raised tile reads as a solid block instead of a flat card.
fred80/lib/isometric.lua — raw tilemap_iso_* calls are usable directly, but every hand-authored iso cart ends up needing the same handful of things layered on top, so this wrapper bundles them. Bring your own copy into a cart's folder for standalone export, or dofile the shared one.
local iso = dofile(CART_DIR .. "isometric.lua")
local map = iso.new(tm, ox, oy)   -- ox,oy: world position cell (1,1)@height 0 projects to
map:draw()
Draws the baked terrain.
map:draw()
map:project(col, row, height)
World pixel position of a cell (1-based, matches tilemap_get/set). col/row/height may be fractional (e.g. mid-hop between two cells).
map:project(col, row, height) → x, y
map:draw_actor(spr, col, row, height, flip_h, flip_v)
Draws a moving sprite at the correct depth relative to the terrain, re-drawing any nearby terrain cell that should occlude it — the one thing a plain spr_blit at a projected position can't do for you.
map:draw_actor(spr, col, row, height, flip_h, flip_v)
map:pick(mx, my)
Screen point (e.g. from mouse()) → col, row, height, or nothing if no cell is under the cursor.
map:pick(mx, my) → col, row, height
map:set(col, row, tile_idx, height, ramp)
Live terrain edit (raise/lower/re-tile a cell); rebakes immediately.
map:set(col, row, tile_idx, height, ramp)
iso.sort_actors(list)
Given { {col=,row=,...}, ... }, returns a new array sorted back-to-front by col+row — the order to draw multiple actors in relative to each other.
iso.sort_actors(list) → list
iso.new_anim / iso.start_hop / iso.tile_pos / iso.snap_anim
Smooth eased movement between grid cells (a "hop-tween"), for a moving actor.
local anim = iso.new_anim(col, row)     -- settled at (col,row); pass a 3rd arg for height too
iso.start_hop(anim, new_col, new_row)   -- begin an eased glide toward a new cell
local col, row = iso.tile_pos(anim)     -- current interpolated position, every frame
iso.snap_anim(anim, col, row)           -- instant relocate, no glide (e.g. respawn)
anim.t runs 0→1 over however many frames the hop should take (anim.t = math.min(1, anim.t + 1/HOP_FRAMES) each _update()); iso.ease is the smoothstep curve driving the interpolation.
iso.center_bounds(cols, rows, tile_w, tile_h, height_unit, height_of, cx, cy)
ox,oy such that a layout's screen-space bounding box centers on point (cx,cy) — every hand-authored iso cart needs this before iso.new() even exists, since ox,oy have to be known first. height_of(col,row) returns that cell's height, or false to exclude it (e.g. a hole).
iso.center_bounds(...) → ox, oy
local ox, oy = iso.center_bounds(cols, rows, tile_w, tile_h, height_unit,
    function(c, r) return data[r * cols + c + 1] end,
    320, 240)   -- screen point to center on
Example: a hopping actor on an iso map
local iso = dofile(CART_DIR .. "isometric.lua")
local TW, TH, HU = 32, 16, 8
local tm, map, player_spr
local px, py = 2, 2
local anim

function _init()
    -- ...build tm via tilemap_new_iso() or iso_mload(0)...
    local ox, oy = iso.center_bounds(cols, rows, TW, TH, HU, function() return 0 end, 320, 240)
    map = iso.new(tm, ox, oy)
    anim = iso.new_anim(px, py)
    player_spr = spr_new(TW, TH, pix)   -- your own actor sprite
end

function _update()
    anim.t = math.min(1, anim.t + 1 / 8)
    local nx, ny = px, py
    if btnp("right") then nx = px + 1 end
    -- ...other directions, bounds/collision checks...
    if nx ~= px or ny ~= py then
        iso.start_hop(anim, nx, ny)
        px, py = nx, ny
    end
end

function _draw()
    cls(0)
    map:draw()
    local col, row = iso.tile_pos(anim)
    map:draw_actor(player_spr, col + 1, row + 1, 0, false, false)
end
Authoring via MCP — an AI assistant connected over MCP (see Native AI) can paint an iso layer directly without going through the editor's mouse UI. These four tools edit the editor's raw grid state (the same thing TAB_MAP's mouse tools write to iso<slot>.dat), not a runtime Tilemap — a cart then picks the result up via iso_mload(slot).
ToolDescription
iso_set(col,row,tile,height,ramp,slot)Sets one cell's tile/height/ramp (0-based col/row; slot 0-7 defaults to whichever TAB_MAP has active)
iso_get(col,row,slot)Reads one cell back as {tile, height, ramp}
iso_fill_height(col,row,height,slot)Floods a new elevation across the 4-connected region sharing the starting cell's current height
iso_info(slot)Grid dimensions (fixed 32x32), max height, and which slot is active
Sound
sfx(freq [, dur, wave, vol, channel, env])
Plays a synthesised sound effect.
sfx(freq [, dur, wave, vol, channel, env])
ParamDescription
freqHz number, or note string e.g. "A4"
durDuration in seconds. Default 0.5
wave0=Square, 1=Sine, 2=Triangle, 3=Sawtooth, 4=Noise
volVolume 0–100. Default 100
channel0–7. Default -1 (first free channel)
envOptional table with ADSR, LFO, filter keys (see below)
env keyRangeDescription
pw0.0–1.0Pulse width / duty cycle (square only)
amsAttack time
dmsDecay time
s0–100Sustain level
rmsRelease time
lfoHzLFO rate (0 = off)
lfd0.0–1.0LFO depth
lft1/2/3LFO target: 1=pulse width, 2=volume, 3=both
flt0/1/2Filter: 0=off, 1=low-pass, 2=high-pass
fcHzFilter cutoff
fq0.1–10Filter resonance (Q)
sfx(note("C4"), 0.2, 0, 80)                          -- short blip
sfx(110, 1.5, 0, 90, 0, {a=10, d=200, s=60, r=300}) -- ADSR pad
sfx(220, 1.5, 0, 90, 0, {flt=1, fc=600, fq=3.0})    -- low-pass
note(name)
Returns the frequency in Hz for a note name. Middle C is "C4". Supports sharps and flats.
note(name) → number
sfx(note("A4"), 0.3, 0, 80)   -- 440 Hz
sfx(note("C#4"), 0.3, 1, 80)
sfx(note("Gb5"), 0.3, 2, 80)
music(seq [, loop, channel])
Plays a note sequence. Each entry is {freq, dur, [wave], [vol], [env keys...]}. A frequency of 0 is a rest.
music(seq [, loop, channel])
local melody = {
  {note("C4"), 0.2, 0, 70},
  {note("E4"), 0.2, 0, 70},
  {note("G4"), 0.4, 0, 80},
  {0, 0.1},              -- rest
}
music(melody, true)      -- loop forever
music_stop([channel])
Stops music on the specified channel. Default stops all channels.
music_stop([channel])
music_stop()    -- stop all music
music_stop(2)   -- stop channel 2 only
audio_stop([channel])
Stops all sound (sfx and music) on the specified channel. Default stops all channels.
audio_stop([channel])
audio_stop()   -- silence everything
snd_load(slot, path)
Loads a pre-baked .pcm file into a sound slot. PCM files are baked from Tommy compositions using bake aud in the shell.
snd_load(slot, path)
ParamDescription
slotSound slot index, 0–15 (16 slots total)
pathPath to .pcm file
snd_load(0, "music.pcm")
snd_play(slot, loop, channel, vol)
Plays a loaded PCM sound. Zero CPU cost on A6000 — uses SAGA DMA hardware playback.
snd_play(slot, loop, channel, vol)
ParamDescription
slotSound slot loaded with snd_load()
looptrue to loop continuously
channelVoice channel — 0–23 on desktop/web (a 24-voice pool), 0–15 on real A6000/SAGA hardware. Pass -1 to auto-pick a free voice instead of choosing one yourself (desktop/web only) — if every voice is currently busy it round-robins across the pool rather than always stealing the same one, so several simultaneous one-shot sounds don't cut each other off.
volVolume 0–100
function _init()
  snd_load(0, "music.pcm")
  snd_play(0, true, 0, 80)   -- loop on channel 0
end

-- several simultaneous one-shots, e.g. explosions landing close together:
-- let the engine pick a free voice instead of managing a channel pool by hand
snd_play(1, false, -1, 90)
snd_play_pitched(slot, channel, target_hz, root_hz, vol)
Plays a loaded PCM sound resampled to a target pitch — the same sample can be retuned to any note instead of needing one baked file per pitch. Used for e.g. a single kick/tone sample played at different notes for a drum/bass line.
snd_play_pitched(slot, channel, target_hz, root_hz, vol)
ParamDescription
slotSound slot loaded with snd_load()
channelVoice channel — same range and -1 auto-pick behaviour as snd_play() above
target_hzFrequency to retune the sample to
root_hzThe sample's own natural/baked pitch, in Hz — target_hz == root_hz plays it unpitched
volVolume 0–100
-- one kick sample, several notes of a bassline
snd_play_pitched(2, 0, note("C-3"), 55.0, 80)
snd_play_pitched(2, 0, note("E-3"), 55.0, 80)
snd_stop([channel])
Stops PCM playback. Pass a channel number to stop that channel only (0–23 desktop/web, 0–15 real A6000/SAGA hardware). Omit to stop all channels.
snd_stop([channel])
ParamDescription
channelVoice channel to stop. Omit to stop all channels.
snd_stop(0)   -- stop channel 0 only
snd_stop()    -- stop all channels
Tweens
Up to 16 simultaneous tweens. Always call tween_free() when done to release the slot.
tween_new(dur, start, end [, easing])
Creates a tween and returns its slot ID.
tween_new(dur, start, end [, easing]) → id
ParamDescription
durDuration in seconds
start, endStart and end values
easing"linear", "inQuad", "outQuad", "inOutQuad", "inCubic", "outCubic", "inSine", "outSine", "inOutSine", "outBounce", "inBounce", "outElastic", "inElastic", "outBack", "inBack"
local slide = tween_new(0.4, 660, 480, "outBack")
tween_step(id)
Advances the tween by one frame. Returns the current value and a done flag.
tween_step(id) → value, done
local x, done = tween_step(slide)
canvas_draw(panel, flr(x), 100)
if done then tween_free(slide) end
tween_reset(id)
Rewinds the tween to its start value without freeing the slot.
tween_reset(id)
tween_reset(slide)
tween_free(id)
Releases the tween slot.
tween_free(id)
tween_free(slide)
Bezier Curves
Pure polynomial math — no trig, safe on the 68080. Use bez_render() for anything called every frame.
bezier(t, x0,y0, x1,y1, x2,y2 [, x3,y3])
Returns x, y along a quadratic (3 points) or cubic (4 points) bezier curve at position t.
bezier(t, ...) → x, y
ParamDescription
tPosition along curve 0..1
x0,y0 … x2,y2Control points for quadratic curve
x3,y3Optional 4th point for cubic curve
bx, by = bezier(t, 100,240, 200,50, 300,240)
bez_render(n, x0,y0, x1,y1, x2,y2 [, x3,y3])
Pre-bakes n evenly-spaced points along the curve into a flat integer table {x1,y1, x2,y2, ...}. Do this once in _init() to avoid per-frame float math on A6000.
bez_render(n, ...) → table
local PATH
function _init()
  PATH = bez_render(32, 20,240, 160,40, 300,240)
end

function _update()
  local i = flr(enemy.t * 31) * 2
  enemy.x = PATH[i + 1]
  enemy.y = PATH[i + 2]
end
Physics
Verlet-integration rigid bodies and spring constraints. Bodies accumulate forces each frame; call body_update() once per frame to integrate. Always free bodies and springs when done.
body_new(x, y [, r, mass])
Creates a point-mass body at position (x, y). Returns a handle.
body_new(x, y [, r, mass]) → handle
ParamDescription
x, yInitial position
rCollision radius. Default 8.
massMass. Default 1.0.
local ball = body_new(320, 100, 8, 1.0)
body_force(handle, fx, fy)
Adds a force vector to the body for this frame. Accumulates — call multiple times for multiple forces (gravity, wind, player input).
body_force(handle, fx, fy)
body_force(ball, 0, 0.5)   -- gravity
body_update(handle [, damp])
Integrates velocity, applies damping, resets accumulated forces. Call once per frame per body.
body_update(handle [, damp])
ParamDescription
dampVelocity multiplier per frame. Default 0.98.
body_force(ball, 0, 0.5)
body_update(ball, 0.99)
body_pos(handle)
Returns the body's current position.
body_pos(handle) → x, y
local bx, by = body_pos(ball)
circfill(bx, by, 8, 9)
body_vel(handle)
Returns the body's current velocity.
body_vel(handle) → vx, vy
local vx, vy = body_vel(ball)
body_circle_collide(handle, ox, oy, or [, restitution])
Tests and resolves collision between this body and a static circle at (ox, oy) with radius or. Call after body_update().
body_circle_collide(handle, ox, oy, or [, restitution])
ParamDescription
ox, oy, orStatic circle centre and radius
restitutionBounce factor 0–1. Default 0.5.
body_circle_collide(ball, 320, 400, 60, 0.7)
body_aabb_collide(handle, x0, y0, x1, y1 [, restitution])
Resolves collision between this body and a static axis-aligned rectangle. Use for walls, floors, platforms.
body_aabb_collide(handle, x0, y0, x1, y1 [, restitution])
body_aabb_collide(ball, 0, 0, 639, 479, 0.6)   -- screen boundary
body_free(handle)
Releases the body slot. Always call when a body is no longer needed.
body_free(handle)
body_free(ball)
spring_new(a, b, rest_len, stiffness)
Creates a spring constraint between two body handles. Returns a handle.
spring_new(a, b, rest_len, stiffness) → handle
ParamDescription
a, bBody handles to connect
rest_lenNatural length of the spring in pixels
stiffnessSpring constant. 0.1 = loose, 1.0 = rigid.
local s = spring_new(head, tail, 30, 0.4)
spring_update(handle [, iters])
Applies spring forces to both connected bodies. Call after body_update() for each connected body. More iterations = stiffer behaviour at the cost of CPU.
spring_update(handle [, iters])
ParamDescription
itersConstraint solver iterations. Default 4.
spring_update(s, 6)
spring_draw(handle [, col])
Draws a line between the two connected bodies. Useful for debugging.
spring_draw(handle [, col])
ParamDescription
colPalette index. Default 7.
spring_draw(s, 11)
spring_free(handle)
Releases the spring slot.
spring_free(handle)
spring_free(s)
Emitter Particles
Fire-and-forget VFX — no Verlet physics, just velocity, gravity, and a colour fade over lifetime. Up to 16 templates, 512 live particles total. Use this for explosions, hit sparks, and trails; use the Verlet particle system below when you need forces.
emit_new(t)
Defines a particle template and returns a handle (0-15).
emit_new(t) → handle
FieldDescription
colsArray of palette indices. Colour steps evenly across lifetime. Default {7}.
size1 = single pixel, 2 = 2×2 block. Default 1.
gravPer-frame downward acceleration added to vy. Default 0.1.
vx{min, max} horizontal velocity range. Default {-2, 2}.
vy{min, max} vertical velocity range (negative = up). Default {-4, -0.5}.
life{min, max} lifetime in frames. Default {20, 35}.
expl_em = emit_new({
    cols = {104, 96, 88, 87, 3},
    size = 2, grav = 0.10,
    vx = {-5, 5}, vy = {-6, -0.5},
    life = {28, 48},
})
emit_burst(h, x, y, count)
Spawns count particles at (x, y) using template h. Excess beyond 512 live particles are silently dropped.
emit_burst(h, x, y, count)
emit_burst(expl_em, enemy.x, enemy.y, 24)
emit_update()
Advances all live particles one frame. Call once per frame in _update.
emit_update()
emit_draw()
Draws all live particles to the current render target. Call in _draw after cls().
emit_draw()
emit_clear()
Immediately kills all live particles.
emit_clear()
emit_count()
Returns the number of currently live particles.
emit_count() → number
Verlet Particles
Force-accumulating Verlet particle systems. Each system (ps) is independent — create multiple for different effect layers. Up to 8 systems.
part_new(max, trail)
Creates a particle system with up to max particles and a trail length per particle (0 = no trail). Returns a system handle.
part_new(max, trail) → ps
ps = part_new(200, 0)   -- 200 particles, no trail
part_spawn(ps, x, y [, vx, vy, col])
Spawns one particle at (x, y) with optional velocity and colour index. Returns the particle index, or -1 if the system is full.
part_spawn(ps, x, y [, vx, vy, col]) → index
part_gravity(ps, gx, gy)
Applies a gravity force to all live particles this frame. Accumulates with other forces. gy > 0 = downward.
part_gravity(ps, gx, gy)
part_attract(ps, x, y, radius, strength)
Pulls all particles within radius toward (x, y).
part_attract(ps, x, y, radius, strength)
part_repel(ps, x, y, radius, strength)
Pushes all particles within radius away from (x, y).
part_repel(ps, x, y, radius, strength)
part_spring(ps, k)
Pulls each particle toward its rest position (its spawn position) with spring constant k.
part_spring(ps, k)
part_update(ps [, damp])
Integrates one Verlet step and resets accumulated forces. damp reduces velocity each frame (0 = stop instantly, 1 = no damping). Default 0.98.
part_update(ps [, damp])
part_bounds(ps, x0, y0, x1, y1 [, restitution])
Reflects particles off a rectangular boundary. restitution 0..1 controls bounce energy (0 = stick, 1 = full bounce). Default 0.4.
part_bounds(ps, x0, y0, x1, y1 [, restitution])
part_bounds(ps, 0, 0, 639, 479, 0.6)
part_draw(ps, col [, trail_cols, t])
Draws all live particles. col can be an integer (flat colour) or a table of colours (spatial wave indexed by position + t). trail_cols is an optional table of colours for the trail, indexed from newest (1) outward.
part_draw(ps, col [, trail_cols, t])
part_collect(ps, x, y, radius)
Kills all particles within radius of (x, y) and returns how many were collected. Use for pickup / absorption effects.
part_collect(ps, x, y, radius) → count
part_cull(ps, x0, y0, x1, y1)
Kills all particles outside the given rectangle. Call after scrolling to discard off-screen particles.
part_cull(ps, x0, y0, x1, y1)
part_count(ps)
Returns the number of live particles in system ps.
part_count(ps) → number
part_kill(ps, i) / part_clear(ps)
part_kill kills particle i in system ps. part_clear kills every particle in the system.
part_kill(ps, i)
part_clear(ps)
-- force field: particles orbit the mouse
function _update()
    local mx, my = mouse()
    part_repel(ps, mx, my, 80, 4000)
    part_spring(ps, 0.03)
    part_update(ps, 0.90)
end

function _draw()
    cls(0)
    part_draw(ps, 7)
end
Transitions
Built-in full-screen transitions, baked into the binary — no require needed. All take t in 0..1 (0 = fully open, 1 = fully closed). Combine with tween_new to animate.
fade(t)
Overlays a black canvas at opacity t. At t=1 the screen is completely black.
fade(t)
wipe_h(t) / wipe_v(t)
A black bar sweeps left-to-right (wipe_h) or top-to-bottom (wipe_v), fully covering the screen at t=1.
wipe_h(t)
wipe_v(t)
iris(t, cx, cy)
A circular iris closes centred at (cx, cy). Fully closed at t=1.
iris(t, cx, cy)
iris_zip(t, tx, ty)
Like iris, but the closing point travels toward (tx, ty) as t increases.
iris_zip(t, tx, ty)
local tr = tween_new(0.6, 0, 1, "inOutQuad")

function _draw()
    draw_scene()
    local v, done = tween_step(tr)
    fade(v)                  -- fade out over 0.6s
    if done then next_scene() end
end
Cloth Simulation
Native C Verlet cloth grid. One global cloth per cart. The top row is pinned in place.
cloth_init(cols, rows [, rest])
Initialises a cols×rows grid of nodes with rest spacing rest pixels (default 28). The top row is pinned. Call once in _init.
cloth_init(cols, rows [, rest])
cloth_init(16, 12, 40)   -- 16x12 grid, 40px rest spacing
cloth_update([gravity, damp, wind_x, iters])
Steps the simulation. gravity = downward acceleration per frame (default 0.4); damp = velocity damping 0..1 (default 0.99); wind_x = horizontal force (default 0); iters = constraint solve iterations (default 3).
cloth_update([gravity, damp, wind_x, iters])
cloth_update(0.4, 0.99, sin(time() * 0.3) * 0.5, 3)
cloth_draw([cols])
Draws the cloth as lines. cols is a table of palette indices, one per cloth row, colouring rows top to bottom.
cloth_draw([cols])
cloth_draw({72, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 64})
Repulsor Field
A magnetic/ferrofluid particle field with a repulsor at a controllable point. One global instance per cart.
repulsor_init(count, trail)
Initialises the repulsor with count particles and trail length per particle (0 = no trail).
repulsor_init(count, trail)
repulsor_init(400, 20)
repulsor_update(cx, cy, repel_str, radius_sq, attract_sq, attract, spring, damp)
Steps the simulation. Particles within sqrt(radius_sq) of (cx, cy) are repelled with repel_str; within sqrt(attract_sq) they're attracted with attract; all particles spring back toward their rest position with spring. damp reduces velocity per frame.
repulsor_update(cx, cy, repel_str, radius_sq, attract_sq, attract, spring, damp)
repulsor_update(mx, 240, 80, 900, 12100, 0.0008, 0.0004, 0.97)
repulsor_draw(wave_cols, trail_cols, t)
Draws particles. wave_cols is a table of palette indices cycled spatially by position + t. trail_cols is a table from newest (1) to oldest for trail colouring.
repulsor_draw(wave_cols, trail_cols, t)
repulsor_draw({248, 154, 166, 191, 15}, {15, 162, 176, 192, 1}, time())
Entities
Fast C-side batch collision and nearest-entity queries. Operate on flat coordinate arrays — no per-entity Lua table overhead.
ent_hit_first(ax, ay, na, bx, by, nb, r2, result)
Circle-vs-circle collision: for each A entity (flat arrays ax, ay, length na) finds the first B entity within squared-radius r2 and writes its 1-based index into result[i] (0 if none).
ent_hit_first(ax, ay, na, bx, by, nb, r2, result) → result
ent_hit_all(ax, ay, na, bx, by, nb, r2, result)
Like ent_hit_first but collects every B hit per A entity as flat pairs. Returns the total pair count alongside the result table.
ent_hit_all(ax, ay, na, bx, by, nb, r2, result) → result, npairs
-- which enemies are hit by any bullet
local hits = {}
local result, n = ent_hit_all(bul_x, bul_y, #bul_x,
                               ene_x, ene_y, #ene_x,
                               20*20, hits)
ent_nearest(px, py, ex, ey, n)
Returns the 1-based index of the entity in (ex, ey)[1..n] nearest to point (px, py).
ent_nearest(px, py, ex, ey, n) → index
3D (Maggie)
Hardware-accelerated 3D via the Vampire's Maggie rasteriser. Only available on A6000 hardware; macOS/Windows builds run a software fallback at reduced speed. All geometry is submitted in world space — the engine handles MVP transforms internally.

Limits: 8 mesh slots, 8 texture slots, ≤12,288 total vertices across all meshes.
init3d(fov [, znear, zfar])
Initialises the 3D subsystem. Call once in _init before any other 3D function. FOV 50-90°. Returns false if 3D is unavailable.
init3d(fov [, znear, zfar]) → bool
function _init()
    if not init3d(72) then return end
    mesh_floor = load_mesh("floor.obj")
    tex_floor  = load_tex("floor.png")
end
cam(x, y, z, yaw [, pitch, roll]) / cam_at(ex,ey,ez, tx,ty,tz)
cam is an Euler camera: Y is up, floor is Y=0, eye height ≈0.55, angles in degrees. cam_at is a look-at camera given an eye position and target point.
cam(x, y, z, yaw [, pitch, roll])
cam_at(ex, ey, ez, tx, ty, tz)
cam(px, 0.55, pz, yaw)
push() / pop() / translate(x,y,z) / rotate(yaw,pitch,roll) / tint(r,g,b)
push copies the current transform matrix and resets tint to white; pop restores the parent. translate/rotate (YXZ Euler, degrees) compose onto the current matrix. tint (0-255 per channel) multiplies into all vertices of subsequent mesh_draw calls at this stack level — set it after push, not before. Max stack depth 16.
push()
pop()
translate(x, y, z)
rotate(yaw, pitch, roll)
tint(r, g, b)
mesh_draw(mesh_id, tex_id) / begin_3d() / end_3d()
mesh_draw draws a mesh at the current transform with the given texture (-1 for untextured). All 3D drawing for a frame must be bracketed by begin_3d()/end_3d(); end_3d() composites the 2D framebuffer over the 3D image (palette index 0 is transparent). Always draw at least one mesh between begin_3d/end_3d — zero triangles hangs on Amiga hardware.
begin_3d()
mesh_draw(mesh_id, tex_id)
end_3d()
function _draw()
    cam(px, 0.55, pz, yaw)
    begin_3d()
        push()
            tint(255, 255, 255)
            mesh_draw(mesh_floor, tex_floor)
        pop()
    end_3d()
end
billboard(wx, wy, wz, hw [, hh, tex, lean])
Draws a camera-facing quad at world position (wx, wy, wz). hw/hh are half-width/half-height in world units (hh defaults to hw). tex is a texture slot (default white_tex). lean shears the top of the quad sideways — useful for smoke columns or exhaust trails. Apply tint() beforehand to colour it. Must be inside begin_3d()/end_3d().
billboard(wx, wy, wz, hw [, hh, tex, lean])
tint(255, 80, 20)
billboard(px, py, pz, 0.3, 0.3, white_tex)
set_ambient(intensity) / set_light(x, y, z)
set_ambient sets the global ambient light level, 0.0 (fully dark, unlit faces black) to 1.0 (fully ambient, shading disabled). Default ~0.25. set_light sets the directional light vector (need not be normalised) controlling diffuse shading -- this is the direction light travels (source→scene), not the direction toward the source: for an overhead light that means a negative Y, e.g. (0,-1,0), not positive. A face gets full brightness when its normal points opposite this vector. Every real cart that calls this uses a negative Y -- treat that as the working convention, not the engine's compiled-in default. Set both once per frame before begin_3d().
set_ambient(intensity)
set_light(x, y, z)
set_ambient(0.25)
set_light(0.4, -1.0, 0.3)   -- sun from upper-left, overhead (negative Y)
load_mesh(filename) / load_tex(filename)
load_mesh parses an OBJ file (Y-up, any polygon count per face — faces are fan-triangulated) into a mesh slot (0-7), or -1 on failure. If the OBJ references an MTL with map_Kd, the texture loads automatically (retrieve its slot with mesh_tex). load_tex loads a square PNG, auto-scaled to power-of-two, into a texture slot (0-7), or -1 on failure.
load_mesh(filename) → slot
load_tex(filename) → slot
spr_to_tex(start_slot, w_tiles [, h_tiles, flip_v])
Reads a w_tiles×h_tiles grid of 16×16 sprite bank slots starting at start_slot and copies the pixels into a brand-new texture slot (does not overwrite an existing one). h_tiles defaults to w_tiles. Minimum output is 32×32 (Maggie's DXT1 decoder floor). Use to apply painted 2D sprites as 3D object textures without an external PNG.
spr_to_tex(start_slot, w_tiles [, h_tiles, flip_v]) → tex slot
tex_ship = spr_to_tex(8, 2)   -- 2x2-tile (32x32) sprite grid at slot 8
poly_build(mesh_parts)
Builds one combined mesh from an array of shape tables — extruded 2D outlines and/or hand-sculpted vertex/triangle data — and returns a mesh slot (0-7, same pool as load_mesh), or -1 if the result has zero triangles. This is the same format the in-editor Poly Designer's "Export Lua" action produces.
poly_build(mesh_parts) → mesh slot
FieldDescription
verticesExtruded shape: array of {x, z} outline points (≥3). Winding is corrected automatically.
pos, rot, scaleWorld offset, Y rotation in degrees, uniform scale applied to the outline. Default {0,0,0}, 0, 1.
extrude_heightHeight extruded from pos.y. Default 1.
mesh.verts / mesh.trisSculpted shape: {x,y,z} world-space verts and 1-based {a,b,c} triangle indices. Winding is not auto-corrected — a wrongly-wound triangle silently renders invisible.
color / mesh.colorsPalette index 0-255, per-shape or per-triangle. Default 7.
room_mesh = poly_build({
    { color = 6, extrude_height = 0.2,
      vertices = {{-4,-4},{4,-4},{4,4},{-4,4}} },   -- floor
    { color = 7, pos = {0,0.2,-4}, extrude_height = 3,
      vertices = {{-4,0},{4,0},{4,0.3},{-4,0.3}} },  -- wall
})
mesh_draw(room_mesh, -1)  -- -1: untextured, uses each shape's colour
3D Collision
Static-level collision for 3D scenes: register geometry once as a collider, then use move_and_slide() each frame to walk/slide a sphere (the player) across it, or raycast3d() for line-of-sight/hit checks. Colliders model static geometry only — there's no way to move one after adding it; remove and re-add to reposition.
collider_mesh_add(mesh_id, x, y, z [, yaw, pitch, roll, scale])
Bakes a copy of an already-loaded/built mesh's (load_mesh or poly_build) triangles into world space at the given placement and registers it as a static collider. yaw/pitch/roll are degrees (default 0), same convention as rotate(). scale is uniform (default 1). Independent of the source mesh slot afterward.
collider_mesh_add(mesh_id, x, y, z [, yaw, pitch, roll, scale]) → handle
ParamDescription
mesh_idA mesh slot from load_mesh() or poly_build()
x, y, zWorld placement
yaw, pitch, rollDegrees. Default 0.
scaleUniform scale. Default 1.
Limits: 16 mesh colliders, 4096 triangles shared across all of them. Returns -1 if mesh_id is invalid/empty, the collider pool is full, or the bake would exceed the triangle budget.
collider_mesh_add(mesh_floor, 0, 0, 0)
collider_mesh_remove(handle)
Removes a mesh collider previously returned by collider_mesh_add. Its baked triangles are not reclaimed from the shared pool until collider_clear() — repeated add/remove churn can exhaust the triangle budget even though individual handles are freed.
collider_mesh_remove(handle)
collider_box_add(x, y, z, hx, hy, hz)
Registers a static axis-aligned box collider centred at (x,y,z) with half-extents (hx,hy,hz) — cheaper than a mesh collider for invisible walls/floors that don't need real geometry.
collider_box_add(x, y, z, hx, hy, hz) → handle
Limit: 64 box colliders. Returns -1 if the pool is full.
collider_box_remove(handle)
Removes a box collider previously returned by collider_box_add.
collider_box_remove(handle)
collider_clear()
Removes every mesh and box collider at once, and reclaims the shared triangle pool.
collider_clear()
move_and_slide(x, y, z, dx, dy, dz [, radius])
Moves a sphere of radius (default 0.3) from (x,y,z) by delta (dx,dy,dz), pushing it out of — and sliding along — any registered collider it would end up penetrating. A character-controller helper for walking on floors and sliding along walls. Discrete, substepped correction to resist tunnelling through thin geometry at speed, not a full swept solver — an extremely large single-call delta can still tunnel; call it more than once per frame with smaller deltas if your movement speeds are unusually high.
move_and_slide(x, y, z, dx, dy, dz [, radius]) → x, y, z, grounded
ParamDescription
x, y, zCurrent position
dx, dy, dzDesired movement this call (e.g. velocity × dt)
radiusSphere radius. Default 0.3.
grounded is true if any correction this call had a strongly upward-facing normal — a simple "standing on something" heuristic for gravity/jump logic.
function _update(dt)
    local dx = (btn("right") and 1 or 0) - (btn("left") and 1 or 0)
    vy = vy - 9.8 * dt
    local nx, ny, nz, grounded = move_and_slide(px, py, pz, dx * 3 * dt, vy * dt, 0, 0.4)
    px, py, pz = nx, ny, nz
    if grounded then vy = 0 end
end
raycast3d(ox, oy, oz, dx, dy, dz [, max_dist])
Casts a ray from (ox,oy,oz) in direction (dx,dy,dz) (need not be normalised) up to max_dist (default 1000) against every registered mesh triangle and box collider. The returned normal always faces back toward the ray origin, regardless of the source mesh's triangle winding.
raycast3d(ox, oy, oz, dx, dy, dz [, max_dist]) → hit, x, y, z, nx, ny, nz, dist
Returns just false if nothing was hit within max_dist.
-- ground check, straight down
local hit, hx, hy, hz, nx, ny, nz, dist = raycast3d(px, py, pz, 0, -1, 0, 5)
Hardware (Amiga-only)
Low-level register access. Only meaningful on A6000/Vampire — poke/peek are no-ops on macOS/Windows.
poke16(addr, val) / poke32(addr, val) / peek16(addr)
Writes a 16- or 32-bit value to hardware address addr, or reads a 16-bit value back.
poke16(addr, val)
poke32(addr, val)
peek16(addr) → number
pip_test([fmt])
Fills the SAGA PIP overlay with horizontal colour bars and enables it in format fmt (0-3, cycles PipFormat values). A hardware bring-up / diagnostic tool for confirming PIP register behaviour, not a general-purpose feature check.
pip_test([fmt])
Shell Commands
The SHELL tab (inside FRED-80) provides built-in import and conversion commands — no external tools required. Commands operate on the currently active cart. Type help in the SHELL tab for the full list.
bring img <file.png>
Converts a PNG to FRED-80's .img format (palette-indexed) and saves it into the current cart folder. Pixels are remapped to the nearest palette colour; fully transparent pixels and magenta (#FF00FF) become index 0 (transparent).
bring img <file.png>
function _init()
  local src = image_load(CART_DIR .. "bg.img")
  bg = canvas_new(640, 480)
  canvas_set(bg); canvas_draw(src, 0, 0); canvas_set()
end
glean spr <file.png>
Slices a PNG sprite sheet into 16×16 tiles and writes them into sprites.spr, starting at the first empty slot. Transparent pixels (alpha < 128) become index 0. Reflected immediately in the SPRITE tab.
glean spr <file.png>
bake wav <file.wav>
Converts a WAV file (any sample rate, mono or stereo, 8- or 16-bit PCM) to raw signed 8-bit mono at 22050 Hz, saved as <name>.pcm. Capped at 1MB (~47 seconds) — trim longer audio before importing.
bake wav <file.wav>
bake snd <file.json>
Synthesises a note sequence from a JSON file to raw signed 8-bit PCM at 22050 Hz, ready for snd_load(). Each note is [name, dur_sec, wave, vol] — wave 0=Square, 2=Triangle, 3=Sawtooth, 4=Noise (never 1/Sine in sfx() — freezes A6000, but safe here in the JSON synthesiser).
bake snd <file.json>
{ "notes": [
    ["C4", 0.2, 0, 80],
    ["E4", 0.2, 0, 80],
    ["G4", 0.4, 0, 80],
    ["",   0.1, 0,  0]
] }
bake aud
Bakes the current cart's TOMMY audio data (aud.dat) to music.pcm, following the song arranger order (or pattern 1 if no song is programmed). Load the result with snd_load(0, "music.pcm").
bake aud
Tile World (Particles + Digging)
For particles that need to bounce off real tile geometry (embers, debris, ricochets), and raycasts that destroy destructible terrain (mining, tunnelling, laser/drill weapons). Runs entirely natively — zero per-particle and per-dig Lua cost, only the setup calls below touch Lua.

Setup: copy fred80/lib/tileworld.lua into your cart's own folder, then local TileWorld = require("tileworld"). There's no cross-cart require() path in this engine — every cart carries its own copy, same as fred80/lib/obj.lua.

This wraps lower-level engine calls (part_tile_set_grid, part_tile_set_cell, part_tile_set_ramp, part_tile_spawn, part_tile_update, part_tile_draw, tile_set_destruct, tile_clear_destruct, tile_dig_ray) — use those directly only if you need something the wrapper doesn't expose.
TileWorld.new{ tile_size, cols, rows [, solid] }
Creates a tile-world handle. solid is a plain 2D table solid[y][x] (0-based) — 1/true = solid, 0/false = open. Omit it if your level isn't generated yet and call set_grid() once it is.
TileWorld.new{...} → world
ParamDescription
tile_sizeTile size in pixels
cols, rowsGrid dimensions in tiles
solidOptional 2D solid grid, solid[y][x]
local TileWorld = require("tileworld")
local world = TileWorld.new{ tile_size = 16, cols = 128, rows = 100, solid = my_solid_grid }
world:set_grid(solid)
Re-uploads the whole grid from a 2D solid[y][x] table — e.g. after regenerating a level. For single-tile changes during gameplay use set_cell() instead; re-uploading the whole grid every change is wasteful.
world:set_grid(solid)
world:set_grid(new_solid_grid)
world:set_cell(tx, ty, is_solid)
O(1) update to a single tile. Call this whenever your own gameplay code opens or seals a tile at runtime, so particle collision and dig_ray() both stay in sync without re-uploading the whole grid.
world:set_cell(tx, ty, is_solid)
world:set_cell(cx, cy, false)   -- player broke through this tile
world:set_destructible([grid2d])
Marks some solid tiles as indestructible — dig_ray() stops dead at them instead of clearing through. 2D table, same shape as the solid grid (1/true = destructible, 0/false/nil = permanent). Call with no argument for "everything solid is destructible" — the default if you never call this at all.
world:set_destructible([grid2d])
world:set_destructible(bedrock_grid)   -- some tiles now blast-proof
world:set_destructible()               -- back to "everything diggable"
world:add_ramp(name, colors)
Registers a fade-colour ramp by name — particles cycle through these colours over their lifetime. Up to 4 ramps, 8 colours each. Call once per ramp at init, not per particle.
world:add_ramp(name, colors)
ParamDescription
nameAny string — referenced later by spawn{ramp=...}
colorsArray of up to 8 palette indices, dark→bright or however you want the fade to read
world:add_ramp("spark", { 8, 9, 10, 11 })
world:add_ramp("smoke", { 0, 1, 2, 3, 4 })
world:spawn{ x, y, ramp [, vx, vy | angle, speed] [, life, gravity, size] }
Spawns one tile-colliding particle. Give it either vx/vy directly, or angle/speed — whichever's more convenient at the call site.
world:spawn{...}
ParamDescription
x, ySpawn position
rampName registered via add_ramp()
vx, vyVelocity, OR use angle/speed below. Default 0.
angle, speedAlternative to vx/vy — 0..1 turn convention
lifeLifetime in frames. Default 60.
gravityAdded to vy every frame. Default 0.
sizeSquare side length in pixels. Default 3.
world:spawn{ x = vx, y = vy, angle = 0.25, speed = 60,
    life = 40, gravity = -0.03, size = 6, ramp = "smoke" }
world:update() / world:draw()
Call once each per frame — moves/collides and draws the WHOLE particle pool natively. No per-particle Lua cost either way, regardless of how many are alive.
world:update()
world:draw()
function _update(dt)
    world:update()
end
function _draw()
    world:draw()
end
world:dig_ray{ x, y, angle [, range, step, radius] }
Raymarches from (x, y) at the given angle, clearing a radius-square of solid+destructible tiles around every solid tile it touches, and stopping dead at the first indestructible tile. Returns one table instead of a pile of positional values.
world:dig_ray{...} → result
ParamDescription
x, yRay origin
angle0..1 turn convention
rangeMax ray distance in pixels. Default 300.
stepDistance per raymarch step. Default 6.
radiusClear radius around each hit tile, in tiles. Default 1.
Result fieldDescription
hitDid the ray touch any solid tile at all
x, yFirst solid point touched (world coords) — e.g. for a "struck here" VFX
blockedDid it stop at an indestructible tile
end_x, end_yFinal beam tip (world coords) — draw the beam to here
cellsFlat list of every tile actually cleared this call — pass to each_cell()
local dig = world:dig_ray{ x = ship.x, y = ship.y, angle = ship.angle, range = 300 }
if dig.blocked then spark_vfx(dig.end_x, dig.end_y) end
if dig.hit then melt_vfx(dig.x, dig.y) end
TileWorld.each_cell(cells)
Iterates a flat coordinate-pair list two-at-a-time — works on any such list, not just dig_ray()'s cells result, and doesn't need a world instance. A stateless iterator (no closure allocated per call) so it's cheap to use every frame — the first loop variable is a running index Lua's generic-for requires; discard it with _ if you don't need it, same idiom as for _, v in ipairs(t).
for _, tx, ty in TileWorld.each_cell(cells) do ... end
for _, tx, ty in TileWorld.each_cell(dig.cells) do
    on_tile_cleared(tx, ty)   -- your own bookkeeping: pickups, sprite IDs, sound
end
Movement
Setup: copy fred80/lib/movement.lua into your cart's own folder, then local Movement = require("movement"). There's no cross-cart require() path in this engine — every cart carries its own copy, same as fred80/lib/tileworld.lua/obj.lua.

Solves one specific, well-known bug: tunneling through thin walls/floors/ceilings once gravity or speed builds up enough that a single per-frame position step can skip clean past a solid tile without ever being checked. The usual "does the new position overlap something solid?" pattern looks correct and works at low speed, then silently breaks the moment a cart adds real gravity or fast movement.
Movement.move_and_slide(x, y, vx, vy, dt, is_solid [, max_step])
Moves a point by (vx, vy)*dt against a caller-supplied is_solid(px, py) test, automatically sub-stepping so a single big-velocity frame can never skip over a thin solid feature. Resolves x and y independently, so it slides along a wall instead of stopping dead the instant either axis would clip. Also available as Movement.move_and_collide (same function, both names kept since different engines call this pattern one or the other).
Movement.move_and_slide(x, y, vx, vy, dt, is_solid [, max_step]) → new_x, new_y, blocked_x, blocked_y
ParamDescription
x, yCurrent position
vx, vyCurrent velocity in px/sec — include any gravity/acceleration already added this frame
dtThis frame's delta time
is_solidYour own callback, function(px, py) → bool — works with any world representation (tile grid, height field, whatever); this library only ever asks "is this exact point solid?" and doesn't know or care what "solid" means. If the moving thing has real size, bake that into your own callback the same way you'd sample several offset points around its radius for any hand-rolled collision check
max_stepOptional, default 4px — the largest distance ever moved before re-checking is_solid. Must be smaller than the thinnest solid feature in your world that a fast-moving object could otherwise skip clean over in one step
local Movement = require("movement")
local function is_solid(px, py) return mget(flr(px/8), flr(py/8)) > 0 end

-- every frame, after adding gravity to vy yourself:
x, y, hit_x, hit_y = Movement.move_and_slide(x, y, vx, vy, dt, is_solid)
if hit_y then vy = 0 end   -- landed on the ground / hit a ceiling
if hit_x then vx = 0 end
Persistent Storage
64 number slots shared across all carts, backed by fred80.dat in PROGDIR (the FRED-80 install folder on Amiga, the working directory on macOS). Survives cart restarts.
dget(slot)
Reads a number from persistent storage slot 0–63. Returns 0.0 if never written.
dget(slot) → number
local hi = dget(0)   -- load high score
dset(slot, val)
Writes a number to persistent storage slot 0–63 and saves to disk immediately.
dset(slot, val)
if score > hi_score then
  hi_score = score
  dset(0, hi_score)
end