Lua Libs

Small, optional Lua helper modules that sit on top of FRED-80's built-in functions — the kind of code every hand-written cart in a given genre ends up needing, factored out once instead of re-solved per project. They're plain Lua, not engine internals: nothing here does anything a cart couldn't write itself, they just save you from re-deriving the same math (isometric projection, actor depth-sorting, movement) or re-writing the same file parser (OBJ meshes) every time.

How to use one: FRED-80 has no package manager and no require() across cart folders — dofile() only finds a file that's physically sitting inside your own cart's directory. Copy the full source below into a file (e.g. isometric.lua) next to your cart's main.lua, then:
local iso = dofile(CART_DIR .. "isometric.lua")
That's the entire integration step — no build system, no install.
isometric.lua

A wrapper around the engine's built-in tilemap_new_iso() / tilemap_iso_*() primitives for diamond-projected isometric tilemaps. The raw engine calls give you tile placement and picking; this fills in the handful of things every hand-authored iso cart ends up needing on top of that:

Projection & picking — world-pixel position of any cell, and screen-point → cell lookup for mouse/cursor interaction, so a cart never has to re-derive the diamond projection formula by hand.
Actor depth-sorting — draws a moving sprite at the correct depth relative to the static terrain, and sorts a list of actors back-to-front, so nearer actors always draw on top without a full z-buffer.
Two movement styleshop-tween (smooth eased motion between grid cells, good for turn-based/grid-locked characters) and free/continuous movement (sub-cell analog position, good for real-time action) can coexist on the same map — an NPC can hop while the player moves freely.
Screen-relative input — converts a screen-space direction (what "right" visually means to the player) into the correct grid-space movement, since a raw grid axis reads as diagonal on screen for a non-square diamond tile. Without this conversion, cardinal-direction movement looks noticeably worse than diagonal movement, which is the opposite of what players expect.
Layout centering — computes the on-screen bounding box of an entire hand-built layout, for centering a map before you have a placement offset to build the map handle with in the first place.
Rotated mapstilemap_new_iso()'s own angle_degrees parameter only rotates where tiles get placed, not their pixel art (this engine draws with plain software sprite blits, no hardware transform at draw time). iso.rotate_bank_sprite() bakes a rotated copy of art you've already imported or drawn into the sprite bank — the one most carts want, since real content usually isn't generated by a formula. iso.rotated_shape_sprite() does the same for a procedural shape you define yourself (a plain callback), for carts that build their art in code instead.

Full method-by-method docs (with usage examples) live on the API Reference page; there's also a short runnable isometric example cart.

fred80/lib/isometric.lua — full source, copy-paste ready
-- Isometric tilemap helper: world<->screen projection, mouse/cursor
-- picking, actor depth-compositing, and ramp height interpolation on top
-- of the engine's tilemap_new_iso()/tilemap_iso_*() primitives.
--
-- Usage:
--   local iso = dofile(CART_DIR.."../lib/isometric.lua")
--   local map = iso.new(tm, ox, oy)   -- tm from tilemap_new_iso(); ox,oy =
--                                     -- world position where cell (1,1) at
--                                     -- height 0 should project
--   map:draw()
--   map:draw_actor(spr, col, row, height, flip_h, flip_v)
--   local col, row, height = map:pick(mx, my)

local iso = {}
iso.__index = iso

-- iso.new(tm, ox, oy) -> handle bundling a Tilemap + its world placement.
function iso.new(tm, ox, oy)
    local bx, by = tilemap_iso_origin(tm)
    return setmetatable({ tm = tm, ox = ox, oy = oy, bx = bx, by = by }, iso)
end

-- Draws the baked terrain. tilemap_draw() itself takes the bgbuf's own
-- pixel (0,0) placement, not cell (1,1)'s -- this applies the translation
-- tilemap_iso_origin() gave us once at iso.new() time, so callers never
-- need to know the bake's internal layout.
function iso:draw()
    tilemap_draw(self.tm, self.ox - self.bx, self.oy - self.by)
end

-- Same translation, for the parallax/composite-over-framebuffer draw path.
function iso:draw_parallax()
    tilemap_parallax(self.tm, self.ox - self.bx, self.oy - self.by)
end

-- World-space position of a cell (NOT camera-adjusted) -- composes
-- directly with spr_blit(). col/row are 1-based, matching tilemap_get/set.
function iso:project(col, row, height)
    return tilemap_iso_project(self.tm, self.ox, self.oy, col, row, height or 0)
end

-- Screen point (e.g. from mouse()) -> col, row, height, or nil if no cell
-- is under the cursor.
function iso:pick(mx, my)
    return tilemap_iso_pick(self.tm, self.ox, self.oy, mx, my)
end

-- Draws a moving sprite (something that can't be baked into the static
-- terrain) at the correct depth relative to the terrain. Optional sway
-- params (t, amp, speed, curl) apply spr_blit_sway's wind-sway instead of
-- a plain blit -- see spr_blit_sway; mutually exclusive with flip_h/flip_v.
function iso:draw_actor(spr, col, row, height, flip_h, flip_v, sway_t, sway_amp, sway_speed, sway_curl)
    tilemap_iso_draw_actor(self.tm, self.ox, self.oy, spr, col, row, height or 0,
        flip_h, flip_v, sway_t, sway_amp, sway_speed, sway_curl)
end

-- Draws N actors sharing one sprite in one native call -- data is flat
-- {col,row,height,flip_h,flip_v, ...} per instance (1-based col/row,
-- same convention as draw_actor above). No sway support, and instances
-- draw in the order given (not depth-sorted against each other) -- see
-- tilemap_iso_draw_actor_batch()'s own doc comment in engine.c. Intended
-- for many small moving actors sharing a sprite (e.g. a lane of cars).
function iso:draw_actor_batch(spr, data)
    tilemap_iso_draw_actor_batch(self.tm, self.ox, self.oy, spr, data)
end

-- Gameplay-time terrain edit (raise/lower/re-tile a cell).
function iso:set(col, row, tile_idx, height, ramp)
    tilemap_iso_set(self.tm, col, row, tile_idx, height or 0, ramp or 0)
end

-- Sorts a list of actors ({col=, row=, ...}) ascending by (col+row) -- the
-- back-to-front order to draw them in relative to each other. Returns a
-- new array; doesn't mutate the input.
function iso.sort_actors(list)
    local out = {}
    for i, a in ipairs(list) do out[i] = a end
    table.sort(out, function(a, b) return (a.col + a.row) < (b.col + b.row) end)
    return out
end

-- Smooth elevation while walking a ramp cell: base_height plus a
-- fractional lift (frac in [0,1] -- how far across the cell an actor has
-- walked). dir is currently unused (every ramp orientation interpolates
-- the same straight linear rise) but kept in the signature so callers
-- don't need to change if a future ramp shape needs direction-specific
-- interpolation.
function iso.ramp_height(base_height, dir, frac)
    return base_height + frac
end

-- ─── hop-tween animation ───────────────────────────────────────────────
-- Smooth interpolated movement between grid cells. tilemap_iso_project/
-- tilemap_iso_draw_actor accept fractional col/row/height directly (an
-- engine change made alongside this), so this can compose with either --
-- callers aren't forced to bypass them for smooth movement anymore.

function iso.ease(t) return t * t * (3 - 2 * t) end   -- smoothstep

-- New animation state, settled at (col,row,h). h may be omitted if a
-- cart doesn't track height (iso.tile_pos then returns just col,row).
function iso.new_anim(col, row, h)
    return { from_col = col, from_row = row, from_h = h,
             to_col   = col, to_row   = row, to_h   = h,
             t = 1 }   -- t in [0,1]; 1 = fully settled at to_*
end

-- Current interpolated position of an in-flight or settled animation.
function iso.tile_pos(a)
    local e = iso.ease(a.t)
    local col = a.from_col + (a.to_col - a.from_col) * e
    local row = a.from_row + (a.to_row - a.from_row) * e
    if a.from_h ~= nil then
        return col, row, a.from_h + (a.to_h - a.from_h) * e
    end
    return col, row
end

-- Starts a new hop toward (col,row,h), using the animation's CURRENT
-- visual position (not its old settled cell) as the new start, so a hop
-- that interrupts one still in flight blends instead of popping.
function iso.start_hop(anim, col, row, h)
    anim.from_col, anim.from_row, anim.from_h = iso.tile_pos(anim)
    anim.to_col, anim.to_row, anim.to_h = col, row, h
    anim.t = 0
end

-- Instant relocate, no glide -- e.g. respawn, which should pop back into
-- existence rather than visibly slide in from its old position.
function iso.snap_anim(anim, col, row, h)
    anim.from_col, anim.from_row, anim.from_h = col, row, h
    anim.to_col, anim.to_row, anim.to_h = col, row, h
    anim.t = 1
end

-- ─── free (continuous) movement ────────────────────────────────────────
-- Alternative to the hop-tween system above for carts that want smooth
-- analog movement instead of discrete cell-to-cell stepping. Coexists
-- with hop-tween -- an NPC can still hop while the player moves freely,
-- same map, same actors list.

-- A continuous (non-grid-snapped) col/row/height position, moved directly
-- rather than eased between settled cells.
function iso.new_free_actor(col, row, h)
    return { col = col, row = row, h = h or 0 }
end

-- Advances a free actor's position by (dx,dy)*speed*dt, in grid-cell
-- units per second. dx,dy should already be a normalized direction (see
-- iso.input_dir_8way/iso.input_dir_joy below) -- this does no collision
-- checking of its own; pair it with iso.is_passable for that.
function iso.move_free(actor, dx, dy, speed, dt)
    actor.col = actor.col + dx * speed * dt
    actor.row = actor.row + dy * speed * dt
    return actor.col, actor.row
end

-- Normalized (dx,dy) direction from whichever of the 4 digital
-- left/right/up/down buttons are currently held -- 0,0 if none, unit
-- length for a single direction, diagonal-normalized (1/sqrt2 each axis)
-- for two held together, giving 8-way movement from 4 buttons.
function iso.input_dir_8way()
    local dx, dy = 0, 0
    if btn("left")  then dx = dx - 1 end
    if btn("right") then dx = dx + 1 end
    if btn("up")    then dy = dy - 1 end
    if btn("down")  then dy = dy + 1 end
    if dx ~= 0 and dy ~= 0 then
        local inv = 0.7071067811865476   -- 1/sqrt(2)
        dx, dy = dx * inv, dy * inv
    end
    return dx, dy
end

-- Normalized (dx,dy) direction + magnitude (0..1) from a joystick's
-- analog stick (axis 0=x, 1=y), or nil if within the deadzone (no
-- input) -- true continuous-angle movement, not snapped to 8 directions.
function iso.input_dir_joy(joy, deadzone)
    deadzone = deadzone or 0.2
    local dx = joy_axis(joy, 0) / 32767
    local dy = joy_axis(joy, 1) / 32767
    local mag = math.sqrt(dx * dx + dy * dy)
    if mag < deadzone then return nil end
    if mag > 1 then dx, dy, mag = dx / mag, dy / mag, 1 end
    return dx, dy, mag
end

-- Converts a screen-space direction (sx,sy -- need not be pre-normalized,
-- only their ratio matters) into a grid-space (dcol,drow) unit vector, via
-- the exact inverse of the iso projection (sx=(col-row)*(tw/2),
-- sy=(col+row)*(th/2)). Without this, a single cardinal key (e.g. "right")
-- changes col only, which moves BOTH sx and sy on screen for a non-square
-- diamond tile -- two screen axes quantizing to whole pixels every frame
-- reads as noticeably juddery. A true diagonal (two grid axes changing
-- together) happens to cancel one screen axis exactly, which is why
-- diagonal movement already looked smoother before this existed. This
-- makes EVERY screen direction behave like that best case: only the axis
-- actually being moved along picks up per-frame rounding.
function iso.screen_to_grid_dir(sx, sy, tw, th)
    if sx == 0 and sy == 0 then return 0, 0 end
    local slen = math.sqrt(sx * sx + sy * sy)
    sx, sy = sx / slen, sy / slen
    local halfw, halfh = tw / 2, th / 2
    local dcol = (sx / halfw + sy / halfh) / 2
    local drow = (sy / halfh - sx / halfw) / 2
    local glen = math.sqrt(dcol * dcol + drow * drow)
    if glen == 0 then return 0, 0 end
    return dcol / glen, drow / glen
end

-- True if (col,row) is a valid cell to stand on. tilemap_get() already
-- returns 0 (its "empty" value) for out-of-range col/row, so this
-- doubles as a bounds check with no separate cols/rows needed.
function iso.is_passable(tm, col, row)
    local ic = math.floor(col + 0.5)
    local ir = math.floor(row + 0.5)
    return tilemap_get(tm, ic + 1, ir + 1) ~= 0
end

-- ─── screen-space bounding box / centering ─────────────────────────────
-- Every hand-authored iso cart so far has needed to center its whole
-- layout on screen before iso.new() exists yet (ox,oy have to be known
-- BEFORE a map handle can be built), by walking every cell's own
-- projected footprint. tile_h is the tile's full extent (its actual
-- sprite height, used for the sx+tile_w/sy+tile_h bbox-growing terms).
-- spacing_h is the height used for the diagonal (c+r)*(spacing_h/2)
-- placement term -- for a flat tile these are the same value and
-- spacing_h can be omitted (defaults to tile_h); for a cube-style tile
-- with a separate top face and side wall, pass the top face's own height
-- as spacing_h while tile_h stays the full body height (see iso_workout's
-- DH vs TH). height_of(col, row) (0-based) returns that cell's height,
-- or nil/false to exclude it entirely (e.g. a hole) -- callers that don't
-- track height can just return a constant.

function iso.compute_bounds(cols, rows, tile_w, tile_h, height_unit, height_of, spacing_h)
    spacing_h = spacing_h or tile_h
    local sx_min, sx_max = math.huge, -math.huge
    local sy_min, sy_max = math.huge, -math.huge
    for r = 0, rows - 1 do
        for c = 0, cols - 1 do
            local h = height_of(c, r)
            if h then
                local sx = (c - r) * (tile_w / 2)
                local sy = (c + r) * (spacing_h / 2) - h * height_unit
                if sx < sx_min then sx_min = sx end
                if sx + tile_w > sx_max then sx_max = sx + tile_w end
                if sy < sy_min then sy_min = sy end
                if sy + tile_h > sy_max then sy_max = sy + tile_h end
            end
        end
    end
    return sx_min, sx_max, sy_min, sy_max
end

-- Convenience wrapper: ox,oy such that the layout's bounding box centers
-- on screen point (cx,cy) -- e.g. (320,240) at zoom(1), (160,120) at
-- zoom(2) (only the top-left quarter of the logical 640x480 space is
-- actually visible once doubled).
function iso.center_bounds(cols, rows, tile_w, tile_h, height_unit, height_of, cx, cy, spacing_h)
    local sx_min, sx_max, sy_min, sy_max =
        iso.compute_bounds(cols, rows, tile_w, tile_h, height_unit, height_of, spacing_h)
    return cx - (sx_min + sx_max) / 2, cy - (sy_min + sy_max) / 2
end

-- ─── rotated sprites (for a rotated tilemap_new_iso angle_degrees) ─────
-- tilemap_new_iso's angle_degrees parameter only rotates where tiles
-- get PLACED -- it can't rotate their pixel content, since this engine
-- draws via plain software sprite blits with no hardware transform
-- pipeline (unlike e.g. LOVE2D, where love.graphics.rotate() rotates
-- anything at draw time for free). A rotated map's tile/actor sprites
-- have to be pre-baked already rotated, by the SAME angle, or they
-- won't line up with the rotated placement -- these two helpers are
-- the reusable version of that baking step, so a cart wanting rotation
-- doesn't have to re-derive the inverse-rotation-sampling math itself.

-- Core: renders a procedural shape, already rotated by `angle` radians,
-- via inverse-rotation sampling. `shape_fn(lx, ly)` is YOUR shape test,
-- called for each output pixel with (lx,ly) already converted back into
-- the shape's own UNROTATED, CENTERED local space ((0,0) = the shape's
-- middle, x right, y down) -- return a palette index, or nil/0 for
-- transparent. w,h bound that unrotated local space (only used to size
-- the rotated output canvas correctly; shape_fn decides the actual
-- silhouette within it). Returns the sprite plus its actual (larger,
-- to fit the rotated silhouette with no clipping) pixel dimensions --
-- pass these, not w/h, as tile_w/tile_h to tilemap_new_iso and to any
-- centering math (e.g. a col/row offset solved the way road_hop's own
-- register_offset() does).
function iso.rotated_shape_sprite(w, h, angle, shape_fn)
    local hw, hh = w / 2, h / 2
    local cos_a, sin_a = math.cos(angle), math.sin(angle)
    local ext_x = math.abs(hw * cos_a) + math.abs(hh * sin_a)
    local ext_y = math.abs(hw * sin_a) + math.abs(hh * cos_a)
    local W = math.ceil(ext_x * 2) + 1
    local H = math.ceil(ext_y * 2) + 1
    local pix = {}
    for oy = 0, H - 1 do
        for ox = 0, W - 1 do
            local lx, ly = ox - W / 2 + 0.5, oy - H / 2 + 0.5
            local sx =  lx * cos_a + ly * sin_a
            local sy = -lx * sin_a + ly * cos_a
            pix[oy * W + ox + 1] = shape_fn(sx, sy) or 0
        end
    end
    return spr_new(W, H, pix), W, H
end

-- Rotates sprite art that ALREADY EXISTS in the sprite bank -- imported
-- art (tools/spr_import.py), hand-drawn pixel art, live-MCP-authored
-- sprites -- anything painted into bank slots, not just a procedural
-- shape. This is the one most carts actually want: most real content
-- isn't generated by a math formula. base/w_tiles/h_tiles use the same
-- composite-sprite addressing convention carts already use by hand to
-- assemble a sprite bigger than one 16x16 slot (slot = base +
-- row*w_tiles + col, e.g. fred80/roguecraft_tiles's hero_from_slots) --
-- pass w_tiles=1, h_tiles=1 for a plain single-slot sprite. Returns the
-- same (sprite, W, H) as rotated_shape_sprite above.
function iso.rotate_bank_sprite(base, w_tiles, h_tiles, angle)
    local SZ = 16 -- SPR_SIZE, src/engine.h -- not exposed to Lua as a named constant, so hardcoded same as every cart's own bank-composite code already does
    local w, h = w_tiles * SZ, h_tiles * SZ
    return iso.rotated_shape_sprite(w, h, angle, function(sx, sy)
        local x, y = math.floor(sx + w / 2), math.floor(sy + h / 2)
        if x < 0 or x >= w or y < 0 or y >= h then return nil end
        local slot = base + (y // SZ) * w_tiles + (x // SZ)
        local v = spr_get(slot, x % SZ, y % SZ)
        if v ~= 0 then return v end
    end)
end

return iso
obj.lua

A minimal OBJ mesh loader and drawer for Maggie3D carts. Wavefront .obj is the easiest export format to get out of Blender or any other 3D tool, but the engine's own load_mesh()/mag3d_* calls work in flat vertex/UV/normal arrays, not files — this bridges the two: it parses a (pre-triangulated) .obj file's v/vt/vn/f lines into exactly the flat arrays obj.draw() needs, then issues the mag3d_begin()/vertex()/end() calls to draw it with a solid modulation colour.

Deliberately simple: no quads/n-gons (triangulate before exporting), no multi-material groups, only the first UV set and first normal per vertex. For anything beyond a single-material static prop, treat this as a starting point to extend rather than a general-purpose importer.

fred80/lib/obj.lua — full source, copy-paste ready
-- OBJ mesh loader (triangulated, v/vt/vn/f)
-- Returns: { verts={x,y,z,...}, uvs={u,v,...}, normals={nx,ny,nz,...}, count=N }
-- count = number of triangles; each triangle is 3 consecutive entries.
-- Only the first texture set (vt) and first normal are used per vertex.
-- Requires a pre-triangulated OBJ (no quads or n-gons).

local obj = {}

function obj.load(path)
    local pos  = {}   -- {x,y,z} indexed from 1
    local uv   = {}   -- {u,v}
    local nor  = {}   -- {nx,ny,nz}

    local out_v = {}
    local out_u = {}
    local out_n = {}

    local f = io.open(path, "r")
    if not f then return nil, "cannot open " .. path end

    for line in f:lines() do
        local tag = line:match("^(%S+)")
        if tag == "v" then
            local x, y, z = line:match("^v%s+(%S+)%s+(%S+)%s+(%S+)")
            pos[#pos+1] = {tonumber(x), tonumber(y), tonumber(z)}
        elseif tag == "vt" then
            local u, v = line:match("^vt%s+(%S+)%s+(%S+)")
            uv[#uv+1] = {tonumber(u), tonumber(v)}
        elseif tag == "vn" then
            local nx, ny, nz = line:match("^vn%s+(%S+)%s+(%S+)%s+(%S+)")
            nor[#nor+1] = {tonumber(nx), tonumber(ny), tonumber(nz)}
        elseif tag == "f" then
            -- parse up to 3 vertex specs (triangulated OBJ only)
            local specs = {}
            for spec in line:gmatch("%d+/?%d*/?%d*") do
                specs[#specs+1] = spec
            end
            if #specs >= 3 then
                for i = 1, 3 do
                    local vi, ti, ni = specs[i]:match("^(%d+)/?(%d*)/?(%d*)$")
                    vi = tonumber(vi)
                    ti = tonumber(ti)
                    ni = tonumber(ni)
                    local p = pos[vi] or {0,0,0}
                    out_v[#out_v+1] = p[1]
                    out_v[#out_v+1] = p[2]
                    out_v[#out_v+1] = p[3]
                    local t = (ti and uv[ti]) or {0,0}
                    out_u[#out_u+1] = t[1]
                    out_u[#out_u+1] = t[2]
                    local n = (ni and nor[ni]) or {0,1,0}
                    out_n[#out_n+1] = n[1]
                    out_n[#out_n+1] = n[2]
                    out_n[#out_n+1] = n[3]
                end
            end
        end
    end
    f:close()

    local count = #out_v / 3   -- vertex count; triangle count = count/3
    return { verts=out_v, uvs=out_u, normals=out_n, count=count }
end

-- Draw a loaded mesh using the current Maggie state (tex already bound).
-- r,g,b = modulation colour (use 255,255,255 for unmodulated texture).
function obj.draw(mesh, r, g, b)
    local v, u, n = mesh.verts, mesh.uvs, mesh.normals
    mag3d_rgb(r, g, b)
    local i = 1
    while i <= mesh.count do
        mag3d_begin()
        for k = 0, 2 do
            local base = (i + k - 1) * 3
            mag3d_normal(n[base+1], n[base+2], n[base+3])
            mag3d_texcoord(u[(i+k-1)*2+1], u[(i+k-1)*2+2])
            mag3d_vertex(v[base+1], v[base+2], v[base+3])
        end
        mag3d_end()
        i = i + 3
    end
end

return obj