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
| n | Returns |
|---|---|
| 0 | Current 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
| Param | Description |
|---|---|
| t | Angle 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
| Param | Description |
|---|---|
| t | Angle 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
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
| name | Description |
|---|---|
| "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)
| Param | Description |
|---|---|
| joy | Joystick index 0–3 |
| axis | Axis 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
| Bit | Direction |
|---|---|
| 1 | Up |
| 2 | Right |
| 4 | Down |
| 8 | Left |
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])
| Param | Description |
|---|---|
| c | Palette 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)
| Param | Description |
|---|---|
| x, y | Screen position in pixels |
| c | Palette 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)
| Param | Description |
|---|---|
| i | Palette index 1–255 (0 and 240 are protected) |
| r, g, b | Red, 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)
| Param | Description |
|---|---|
| n | Bank 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)
| Param | Description |
|---|---|
| n | Scene 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
| Param | Description |
|---|---|
| n | Scene slot 0–15 to import into |
| master_idx | Colour 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)
| Param | Description |
|---|---|
| data | Flat 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)
| Param | Description |
|---|---|
| c | Palette index for all pixels |
| data | Flat 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])
| Param | Description |
|---|---|
| x0, y0 | Start point |
| x1, y1 | End point |
| c | Palette 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])
| Param | Description |
|---|---|
| str | String to draw |
| x, y | Top-left position |
| c | Palette 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])
| Param | Description |
|---|---|
| amount | Palette index to subtract per frame (1–255). Default 1. |
| floor | Minimum 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])
| Param | Description |
|---|---|
| n | Sprite slot 0–127 |
| x, y | Screen position (top-left of sprite) |
| flip_h | Mirror horizontally. Default false |
| flip_v | Mirror 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)
| Param | Description |
|---|---|
| n | Sprite slot 0–127 |
| pos | Flat 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)
| Param | Description |
|---|---|
| tiles | Flat table: {slot,dx,dy, slot,dx,dy, ...} up to 16 tiles |
| pos | Flat 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])
| mode | Description |
|---|---|
| "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
| Param | Description |
|---|---|
| w, h | Width and height in pixels |
| pixels | Flat 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])
| Param | Description |
|---|---|
| c | Canvas object |
| x, y | Top-left position on screen |
| scale | Integer ≥ 1. Default 1 |
| flip_h, flip_v | Mirror flags. Default false |
| alpha | Opacity 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.dat–map7.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
| Param | Description |
|---|---|
| tw, th | Tile width and height in pixels |
| cols, rows | Map dimensions in tiles |
| tiles | Array of Sprite objects (1-based) |
| data | Flat 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
| Param | Description |
|---|---|
| tile_w, tile_h, height_unit | Tile size and elevation-lift-per-height-unit, in pixels |
| cols, rows | Map dimensions in tiles |
| tiles | Array of Sprite objects (1-based) |
| data | Flat cols×rows array of tile indices (0 = empty/hole, blocks nothing on its own) |
| heights | Flat cols×rows array, elevation per cell (0 = ground) |
| ramps | Flat 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).
| Tool | Description |
|---|---|
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])
| Param | Description |
|---|---|
| freq | Hz number, or note string e.g. "A4" |
| dur | Duration in seconds. Default 0.5 |
| wave | 0=Square, 1=Sine, 2=Triangle, 3=Sawtooth, 4=Noise |
| vol | Volume 0–100. Default 100 |
| channel | 0–7. Default -1 (first free channel) |
| env | Optional table with ADSR, LFO, filter keys (see below) |
| env key | Range | Description |
|---|---|---|
| pw | 0.0–1.0 | Pulse width / duty cycle (square only) |
| a | ms | Attack time |
| d | ms | Decay time |
| s | 0–100 | Sustain level |
| r | ms | Release time |
| lfo | Hz | LFO rate (0 = off) |
| lfd | 0.0–1.0 | LFO depth |
| lft | 1/2/3 | LFO target: 1=pulse width, 2=volume, 3=both |
| flt | 0/1/2 | Filter: 0=off, 1=low-pass, 2=high-pass |
| fc | Hz | Filter cutoff |
| fq | 0.1–10 | Filter 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)
| Param | Description |
|---|---|
| slot | Sound slot index, 0–15 (16 slots total) |
| path | Path 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)
| Param | Description |
|---|---|
| slot | Sound slot loaded with snd_load() |
| loop | true to loop continuously |
| channel | Voice 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. |
| vol | Volume 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)
| Param | Description |
|---|---|
| slot | Sound slot loaded with snd_load() |
| channel | Voice channel — same range and -1 auto-pick behaviour as snd_play() above |
| target_hz | Frequency to retune the sample to |
| root_hz | The sample's own natural/baked pitch, in Hz — target_hz == root_hz plays it unpitched |
| vol | Volume 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])
| Param | Description |
|---|---|
| channel | Voice 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
| Param | Description |
|---|---|
| dur | Duration in seconds |
| start, end | Start 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
| Param | Description |
|---|---|
| t | Position along curve 0..1 |
| x0,y0 … x2,y2 | Control points for quadratic curve |
| x3,y3 | Optional 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
| Param | Description |
|---|---|
| x, y | Initial position |
| r | Collision radius. Default 8. |
| mass | Mass. 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])
| Param | Description |
|---|---|
| damp | Velocity 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])
| Param | Description |
|---|---|
| ox, oy, or | Static circle centre and radius |
| restitution | Bounce 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
| Param | Description |
|---|---|
| a, b | Body handles to connect |
| rest_len | Natural length of the spring in pixels |
| stiffness | Spring 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])
| Param | Description |
|---|---|
| iters | Constraint 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])
| Param | Description |
|---|---|
| col | Palette 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
| Field | Description |
|---|---|
| cols | Array of palette indices. Colour steps evenly across lifetime. Default {7}. |
| size | 1 = single pixel, 2 = 2×2 block. Default 1. |
| grav | Per-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)
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)
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.
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_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)
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()
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_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
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
| Field | Description |
|---|---|
| vertices | Extruded shape: array of {x, z} outline points (≥3). Winding is corrected automatically. |
| pos, rot, scale | World offset, Y rotation in degrees, uniform scale applied to the outline. Default {0,0,0}, 0, 1. |
| extrude_height | Height extruded from pos.y. Default 1. |
| mesh.verts / mesh.tris | Sculpted 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.colors | Palette 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
| Param | Description |
|---|---|
| mesh_id | A mesh slot from load_mesh() or poly_build() |
| x, y, z | World placement |
| yaw, pitch, roll | Degrees. Default 0. |
| scale | Uniform 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
| Param | Description |
|---|---|
| x, y, z | Current position |
| dx, dy, dz | Desired movement this call (e.g. velocity × dt) |
| radius | Sphere 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
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
This wraps lower-level engine calls (
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
| Param | Description |
|---|---|
| tile_size | Tile size in pixels |
| cols, rows | Grid dimensions in tiles |
| solid | Optional 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)
| Param | Description |
|---|---|
| name | Any string — referenced later by spawn{ramp=...} |
| colors | Array 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{...}
| Param | Description |
|---|---|
| x, y | Spawn position |
| ramp | Name registered via add_ramp() |
| vx, vy | Velocity, OR use angle/speed below. Default 0. |
| angle, speed | Alternative to vx/vy — 0..1 turn convention |
| life | Lifetime in frames. Default 60. |
| gravity | Added to vy every frame. Default 0. |
| size | Square 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()
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
| Param | Description |
|---|---|
| x, y | Ray origin |
| angle | 0..1 turn convention |
| range | Max ray distance in pixels. Default 300. |
| step | Distance per raymarch step. Default 6. |
| radius | Clear radius around each hit tile, in tiles. Default 1. |
| Result field | Description |
|---|---|
| hit | Did the ray touch any solid tile at all |
| x, y | First solid point touched (world coords) — e.g. for a "struck here" VFX |
| blocked | Did it stop at an indestructible tile |
| end_x, end_y | Final beam tip (world coords) — draw the beam to here |
| cells | Flat 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
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.
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
| Param | Description |
|---|---|
| x, y | Current position |
| vx, vy | Current velocity in px/sec — include any gravity/acceleration already added this frame |
| dt | This frame's delta time |
| is_solid | Your 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_step | Optional, 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