The O32 guide
The examples below teach the legacy affine ttri renderer, which remains supported. New editor projects use the extended O32 contract, which also provides opt-in depth/perspective tri3d; see the extended API reference. In the O32 3D collection starter, use the mesh tab to import an OBJ, place a named range, select it in the scene viewport, and edit its parent, transform, material and camera fields. Scene records survive source archives and playable exports; the saved viewport camera and the game's Lua-controlled camera are distinct.
Legacy O32 is the third machine in the omibit family: the 3D rung. Screen 320x240, 256 colors, a mesh bank instead of map layers, and exactly one new drawing call — ttri, the textured affine triangle. The identity is PS1 wobble: vertices snap to whole pixels, texture sampling is affine (no perspective divide between vertices, ever), and depth is the painter's algorithm — you sort, the machine rasterizes. Every 2D call you know carries over except the map family: O32 has no map sections, and mget/mset/map error deterministically if called (a documented divergence, not silence).
This page is one sitting: build a cube in the mesh editor, paint its texture, paste a twenty-line draw loop, run, export. Press new, pick O32, and press run first — the demo cart already contains everything this page teaches, poked into existence by its own _init.
A complete starter and reusable helpers
Download the O32 Crystal Walk starter, create an O32 cart and paste the file into its code tab. It needs no imported assets. Collect six crystals with the arrows; Z starts another run after winning. Your best completion time is stored with the game-save API.
The first part is a reusable o3 helper table: model yaw/pitch transforms, world-to-camera transforms, near-plane triangle clipping with interpolated UVs/shades, projection, stable painter ordering, AABB overlap and interpolation. o3.mesh(scene,start,count,pose,material) adds a mesh-bank face range to a scene for o3.draw(scene,camera). For example, o3.mesh(faces,0,12,{x=2,z=8,scale=0.01},{tex=1,shade=1}) places twelve imported faces at world position (2,0,8), shrinks their model coordinates to game units, and draws them from texture page 1. material.color applies to solid faces. Mesh-bank Y-down positions become the helpers' Y-up world positions; UVs are preserved. Keep the face count modest enough for your frame budget, especially if you instance the same range many times.
The MESH tab's scene placements section uses those helpers directly. Place a selected face or named range, then edit its model range, world position, yaw/pitch, scale, texture page (or solid color), and shade. The controls write a marked Lua block and draw call into the cart's code, so placements survive project saves, PNG export, and HTML export. The range names above are still local editor notes; placement names and values are inside the cart. Start from the O32 3D collection template to get the scene helpers and draw call. If you edit the marked block by hand, keep its JSON data comment and Lua table in sync before using the placement controls again. Angles are radians; +Z is forward and +Y is up. Clip before dividing by depth. Keep the helper section when replacing the example game. The helpers are Lua source counted against your cart budget, not new machine APIs. Painter ordering still cannot resolve arbitrary intersecting geometry; split faces or design around those cases. Change the demo's save ID before publishing your own game.
What changes on O32
| thing | O16 | O32 |
|---|---|---|
| screen | 256x224 | 320x240 |
| colors | 32 | 256 = 32 families x 8 shade steps |
| sprites | 256 of 16x16 | same — page 0 of the sheet is the sprite trinity |
| texture pages | - | 4 (a 256x1024 sheet; ttri's tex arg 0-3) |
| map | 2 layers | none — meshes + code are the world |
| mesh bank | - | 524,288 bytes = 21,845 face records |
| code budget | 159,216 bytes | 257,520 bytes |
| buttons | 8 (arrows, z/x, c/v) | same 8 |
| 3D | - | ttri() |
The palette is a ladder
Index = family + 32 * step. The 32 families are the O16 colors you already know, byte-identical at indices 0-31. Each family has 8 steps toward black: step 0 is the O16 color itself, step 7 is nearly gone. Light only darkens — that is the whole lighting model.
In the editor's texture tab the picker is the ladder: 32 family columns, 8 step rows. Paint by family (pick a column), light by step (pick a row). In code, ttri's per-vertex shade argument deepens whatever it draws: shade 3 on family 4 (sand, index 4) draws index 4 + 32*3 = 100. Art-time rule: pick the family for the hue you want at full bright, let the ladder do the dark.
ttri
ttri(x0,y0,u0,v0, x1,y1,u1,v1, x2,y2,u2,v2, [tex], [s0], [s1], [s2]) — three screen-space vertices, each with texture coordinates in texel units; tex picks the texture page 0-3 (floored and wrapped mod 4 — tex 4 is page 0); nil = solid fill with the current draw color; the shades default to 0. The machine rounds vertices to whole pixels after the camera subtract — that snap, plus affine sampling between snapped vertices, IS the wobble. Each page tiles mod 256 on both axes forever (an infinite texture plane per page — UV magnitude never bleeds into the next band), the raw texel passes palt then the draw palette then the shade ladder, and camera + clip apply like every other draw call. There is no z-buffer and no machine sorting: you draw far faces first.
Texture pages — banking, the console way
The sheet is four 256x256 pages stacked into 256x1024. Page p starts at sheet byte p * 0x10000 (page 0 at 0x00000, page 1 at 0x10000, page 2 at 0x20000, page 3 at 0x30000; in the editor's texture tab the page selector jumps to the right 256-row band). The banking idiom is one page per job:
- page 0 — sprites + track tile: the 16x16 trinity lives here (spr
numbers 0-255 address page 0 only), so HUD/menus and world tiling share it, exactly like an O16 cart's sheet.
- pages 1-3 — scenery banks: wall textures, sky gradients, prop
atlases. A face is drawn from the page its ttri call names, so one mesh can mix pages per face — canyon walls from page 1 over a page-0 road, a page-2 sky behind both.
spr and tline see page 0 only (tline has no page argument — a documented divergence); sspr can address the whole 256x1024 sheet directly when you want to blit a band. The current O32 profile defines four texture pages (N.18); compose scenes within that limit.
The mesh bank
Cart section at payload 0x7F000, RAM home 0x40000, poke/peek-addressable. Flat array of 24-byte face records — no headers, no slots, no names. A "mesh" is a range of face records that you (and your editor notes) agree on:
| field | bytes | encoding |
|---|---|---|
| x0, y0, z0 | 6 | i16le — model-space vertex 0 |
| x1, y1, z1 | 6 | i16le — vertex 1 |
| x2, y2, z2 | 6 | i16le — vertex 2 |
| u0..v2 | 6 | u8 each — sheet texels, tiled at draw |
Face n lives at 0x40000 + n * 24; peek2 reads the i16s (unwrap with v - 0x10000 when >= 0x8000). The bank fits 21,845 faces. Winding is your convention — the machine draws both.
The sitting
1. The cube in the MESH tab
A cube is 6 quads = 12 triangles = 12 face records. Corners (i16, model space, y down like the screen):
| corner | x | y | z |
|---|---|---|---|
| c1 | -32 | -32 | -32 |
| c2 | 32 | -32 | -32 |
| c3 | 32 | 32 | -32 |
| c4 | -32 | 32 | -32 |
| c5 | -32 | -32 | 32 |
| c6 | 32 | -32 | 32 |
| c7 | 32 | 32 | 32 |
| c8 | -32 | 32 | 32 |
Twelve faces, two per quad (the second reuses the diagonal):
| face | tri (corners) | uv (v0, v1, v2) |
|---|---|---|
| 0 | c5, c6, c7 | (0,0) (31,0) (31,31) |
| 1 | c5, c7, c8 | (0,0) (31,31) (0,31) |
| 2 | c2, c1, c4 | (0,0) (31,0) (31,31) |
| 3 | c2, c4, c3 | (0,0) (31,31) (0,31) |
| 4 | c1, c5, c8 | (0,0) (31,0) (31,31) |
| 5 | c1, c8, c4 | (0,0) (31,31) (0,31) |
| 6 | c6, c2, c3 | (0,0) (31,0) (31,31) |
| 7 | c6, c3, c7 | (0,0) (31,31) (0,31) |
| 8 | c1, c2, c6 | (0,0) (31,0) (31,31) |
| 9 | c1, c6, c5 | (0,0) (31,31) (0,31) |
| 10 | c4, c8, c7 | (0,0) (31,0) (31,31) |
| 11 | c4, c7, c3 | (0,0) (31,31) (0,31) |
In the MESH tab: add face twelve times, then type each face's fifteen numbers into the vertex table (x, y, z as i16; u, v as u8). The preview orbits — drag it or use the arrow keys; solid shades one family per face, textured shows the sheet, wire is the skeleton. Face 0 shows its uv triangle over the sheet next to the table, so seams are visible before you run anything. Name ranges (like cube 0-11) in the range box — the machine stores no names, so the names live in the editor only.
2. The texture in the TEXTURE tab
The uv range 0..31 samples the top-left 32x32 texels of page 0 — the 2x2 block of 16x16 sprites at 0, 1, 16, 17 in the picker. Paint them like any sprite: pick a family column and a step row, draw. Strong two- or four-color patterns read best under affine crawl — the demo cart's checker is peach (21) against sand step 1 (36). The page selector above the canvas switches the editing band (0-3): page 0 is sprites and this cube; later, paint walls on page 1, a sky on page 2, and pass the page number as ttri's tex argument at draw time.
3. The draw loop
New cart, MESH tab cube, painted sheet. Delete the demo code, paste this, run:
function o16v(v)
if v >= 0x8000 then return v - 0x10000 end
return v
end
function drawmesh(n, yaw, pitch)
local cy, sy = cos(yaw), sin(yaw)
local cp, sp = cos(pitch), sin(pitch)
local list = {}
for f = 0, n - 1 do
local b = 0x40000 + f * 24
local p = {}
local depth = 0
for v = 0, 2 do
local x = o16v(peek2(b + v * 6))
local y = o16v(peek2(b + v * 6 + 2))
local z = o16v(peek2(b + v * 6 + 4))
local rx = x * cy - z * sy
local rz = x * sy + z * cy
local ry = y * cp - rz * sp
local zz = y * sp + rz * cp + 300
depth = depth + zz
p[v + 1] = {
160 + flr(rx * 420 / zz),
120 + flr(ry * 420 / zz),
peek(b + 18 + v * 2),
peek(b + 18 + v * 2 + 1),
mid(0, flr((zz - 250) / 12), 7),
}
end
list[f + 1] = { depth, p }
end
table.sort(list, function(a, b) return a[1] > b[1] end)
for i = 1, n do
local p = list[i][2]
ttri(
p[1][1], p[1][2], p[1][3], p[1][4],
p[2][1], p[2][2], p[2][3], p[2][4],
p[3][1], p[3][2], p[3][3], p[3][4],
0, p[1][5], p[2][5], p[3][5]
)
end
end
yaw = 0
function _update()
yaw = yaw + 0.004
end
function _draw()
cls(25)
drawmesh(12, yaw, 0.12)
print("o32 wobble", 8, 8, 12)
endA wobbling, fog-shaded, textured cube. Export html from the toolbar and it runs anywhere, offline, from one file. The rest of this page is what those lines are doing.
Model in Blender, import to O32
Typing fifteen numbers per face stops being fun around face twelve. The MESH tab's import obj button reads a Wavefront .obj straight into the bank: positions become i16 model coords, texture coords become u8 sheet texels, and every o/g group becomes a named range. Quads and polygons are fan-triangulated (corner 0 to corner i to corner i+1); normals and materials are ignored with a summary note — the machine has no use for either. If Blender is your blocker before an import, the box and prism buttons drop a 12-tri box or a 6/8-sided prism at a typed size, and mirror x / dup +1 / delete work on the click-selection (shift-click adds faces to it).
Export from Blender (File - Export - Wavefront (.obj)):
| setting | value | why |
|---|---|---|
| Triangulate Faces | optional | the importer fans quads/ngons itself; check it if you care which diagonal a quad splits on |
| Write Normals | off | ignored anyway |
| Include Materials / UVs | materials off, UVs on | materials are ignored; uvs are the point |
| Forward / Up | default -Z / Y | the OBJ comes out Y-up, which the importer expects |
| Apply Modifiers | your call | exported geometry is what lands in the bank |
Two flips happen on import, both verified against the machine. OBJ is Y-up and stores v=0 at the bottom-left of the texture; the machine is Y-down like the screen (the sitting's cube table) and its sheet v=0 is the top row (the uv overlay and the texture page render both draw v straight down, and ttri samples floor(v) the same way). So the importer negates every Y and flips every V: y_o32 = -round(y_obj * scale) and v_o32 = 255 - round(v_obj * 255) (u is round(u_obj * 255)). A texture painted right side up in Blender reads right side up on the sheet, and a model built standing up in Blender stands up in the preview.
The import flow. Pick the file; a preview modal shows the group list with face counts, a scale control, the projected bounding box against the i16 limits, and an offset. fit (default) scales the largest scene dimension to 192 model units. center at origin optionally subtracts the source bounds center before scaling, useful when a small model sits far from its source origin. manual multiplies every OBJ unit by your factor (switching to manual pre-fills the fit scale so you can nudge it). Apply writes the faces at the offset, registers one named range per group, and lands as a single undo — one cmd+z takes the whole import out.
| limit | value |
|---|---|
| bank capacity | 21,845 faces total; import past the limit is dropped and reported per group |
| position coords | i16, -32768..32767 after scaling; oversized scales clamp and report the count |
| uv coords | u8, 0..255 = the 256-wide page; uv values outside 0..1 clamp to the sheet edge and report |
| degenerate faces | skipped with counts (repeated corners, or corners that collapse under i16 rounding) |
| offset | 0..first free face; the bank is prefix-packed (the first blank face ends it), so an offset past a blank face would hide data and is refused |
A worked example
Paste this cube.obj out of any text editor (it is Blender's default cube with planar uvs — six quads, twelve triangles after the fan):
o Cube
v -1 -1 -1
v 1 -1 -1
v -1 1 -1
v 1 1 -1
v -1 -1 1
v 1 -1 1
v -1 1 1
v 1 1 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
f 1/1 2/2 6/3 5/4
f 3/4 7/1 8/2 4/3
f 1/1 3/4 4/3 2/2
f 5/4 6/1 8/2 7/3
f 1/1 5/4 7/3 3/2
f 2/2 4/3 8/4 6/1Import it (fit scale 96 — the cube spans 2 units, 192/2), then paste this loop over the demo code and run. The full-page checker makes the whole-sheet uv mapping visible:
function o16v(v)
if v >= 0x8000 then return v - 0x10000 end
return v
end
function _init()
-- big checker across page 0: peach (21) vs sand step 1 (36)
for y = 0, 255 do
for x = 0, 255 do
poke(y * 256 + x, (flr(x / 32) + flr(y / 32)) % 2 < 1 and 21 or 36)
end
end
end
function drawmodel(n, yaw, pitch)
local cy, sy = cos(yaw), sin(yaw)
local cp, sp = cos(pitch), sin(pitch)
local list = {}
for f = 0, n - 1 do
local b = 0x40000 + f * 24
local p = {}
local depth = 0
for v = 0, 2 do
local x = o16v(peek2(b + v * 6))
local y = o16v(peek2(b + v * 6 + 2))
local z = o16v(peek2(b + v * 6 + 4))
local rx = x * cy - z * sy
local rz = x * sy + z * cy
local ry = y * cp - rz * sp
local zz = y * sp + rz * cp + 300
depth = depth + zz
p[v + 1] = {
160 + flr(rx * 420 / zz),
120 + flr(ry * 420 / zz),
peek(b + 18 + v * 2),
peek(b + 18 + v * 2 + 1),
mid(0, flr((zz - 250) / 12), 7),
}
end
list[f + 1] = { depth, p }
end
table.sort(list, function(a, b) return a[1] > b[1] end)
for i = 1, n do
local p = list[i][2]
ttri(
p[1][1], p[1][2], p[1][3], p[1][4],
p[2][1], p[2][2], p[2][3], p[2][4],
p[3][1], p[3][2], p[3][3], p[3][4],
0, p[1][5], p[2][5], p[3][5]
)
end
end
yaw = 0
function _update()
yaw = yaw + 0.004
end
function _draw()
cls(25)
drawmodel(12, yaw, 0.12)
print("blender cube", 8, 8, 12)
endThis listing is run-verified the same way the demo cart is: the imported bank bytes (the importer output for the .obj above, fit scale 96, offset 0) built into a cart with this code, 90 frames clean, final state hash e774b105 (omibit run <cart.png> --frames 90 prints it; the importer's unit tests pin the same byte mapping).
The recipe, piece by piece
Project — integer, mat-free. No matrix library, no console transform state: rotate each vertex by yaw around the y axis, then by pitch around the x axis, add the camera distance, divide once, and snap with flr. That final flr is where the wobble is born — the machine re-snaps after the camera too, so sub-pixel positions never survive. Bigger 420 is a wider lens; bigger + 300 pulls the camera back.
Sort — the painter's algorithm. Average the three rotated depths into depth, then table.sort descending and draw in that order: far faces first, near faces last. There is no z-buffer to catch a mistake, so for convex things (cubes, rooms seen from inside) the sort is usually useful, but not guaranteed correct for intersecting or overlapping geometry, and for everything else it is the classic PS1 discipline. Cull back faces yourself when a mesh is closed — skip a face when (x1-x0)*(y2-y0) - (x2-x0)*(y1-y0) <= 0 in screen space; winding is the only convention the machine leaves you.
Fog — the ladder doing distance. The shade slot is mid(0, flr((zz - 250) / 12), 7): near vertices get 0 (full bright), far vertices slide toward 7 (deep step). Because shade rides the same affine interpolation as u and v, fog bands wobble with the texture — authentic by construction. For sky fog to match, clear with a family you also use on far geometry (the demo clears with dusk, 25) or paint mist/cream bands just past your far plane.
UV seams. A quad is two ttri calls sharing a diagonal: give both triangles the same uv on the shared vertices (the table's even/odd (0,0)(31,0)(31,31) / (0,0)(31,31)(0,31) pattern) and the texture is continuous across the quad — the crease is invisible unless you want it visible, which is also a technique. The sheet tiles mod 256 forever, and an edge's uv delta always takes the path under 32769 texels, so whole-sheet spans never wrap the wrong way.
The demo cart, for reference
A fresh O32 cart ships with the same scene assembled in code — the checker poked into the sheet, the cube poked into the bank, the same draw loop. It is the fastest way to see every piece at once:
-- cube: 8 corners, 12 triangles
vt = {}
for k, c in ipairs({
-32,-32,-32, 32,-32,-32, 32,32,-32, -32,32,-32,
-32,-32, 32, 32,-32, 32, 32,32, 32, -32,32, 32,
}) do
vt[k] = c
end
faces = {
5,6,7, 5,7,8,
2,1,4, 2,4,3,
1,5,8, 1,8,4,
6,2,3, 6,3,7,
1,2,6, 1,6,5,
4,8,7, 4,7,3,
}
function o16v(v)
if v >= 0x8000 then return v - 0x10000 end
return v
end
function _init()
-- checker texture into the top-left 32x32 of the sheet
for y = 0, 31 do
for x = 0, 31 do
poke(y * 256 + x, (flr(x / 4) + flr(y / 4)) % 2 < 1 and 21 or 36)
end
end
-- poke the cube into mesh bank faces 0-11 (24 bytes per face)
for f = 0, 11 do
local b = 0x40000 + f * 24
local uvs = f % 2 == 0 and {0, 0, 31, 0, 31, 31} or {0, 0, 31, 31, 0, 31}
for v = 0, 2 do
local k = faces[f * 3 + v + 1]
poke2(b + v * 6, vt[k * 3 - 2])
poke2(b + v * 6 + 2, vt[k * 3 - 1])
poke2(b + v * 6 + 4, vt[k * 3])
poke(b + 18 + v * 2, uvs[v * 2 + 1])
poke(b + 18 + v * 2 + 1, uvs[v * 2 + 2])
end
end
end
function drawmesh(n, yaw, pitch)
local cy, sy = cos(yaw), sin(yaw)
local cp, sp = cos(pitch), sin(pitch)
local list = {}
for f = 0, n - 1 do
local b = 0x40000 + f * 24
local p = {}
local depth = 0
for v = 0, 2 do
local x = o16v(peek2(b + v * 6))
local y = o16v(peek2(b + v * 6 + 2))
local z = o16v(peek2(b + v * 6 + 4))
local rx = x * cy - z * sy
local rz = x * sy + z * cy
local ry = y * cp - rz * sp
local zz = y * sp + rz * cp + 300
depth = depth + zz
p[v + 1] = {
160 + flr(rx * 420 / zz),
120 + flr(ry * 420 / zz),
peek(b + 18 + v * 2),
peek(b + 18 + v * 2 + 1),
mid(0, flr((zz - 250) / 12), 7),
}
end
list[f + 1] = { depth, p }
end
table.sort(list, function(a, b) return a[1] > b[1] end)
for i = 1, n do
local p = list[i][2]
ttri(
p[1][1], p[1][2], p[1][3], p[1][4],
p[2][1], p[2][2], p[2][3], p[2][4],
p[3][1], p[3][2], p[3][3], p[3][4],
0, p[1][5], p[2][5], p[3][5]
)
end
end
yaw = 0
function _update()
yaw = yaw + 0.004
end
function _draw()
cls(25)
drawmesh(12, yaw, 0.12)
print("o32 wobble", 8, 8, 12)
endBoth listings in this guide are run-verified against the O32 machine: 90 frames of the demo cart and 90 frames of the from-scratch loop run clean, error-free, with deterministic final-frame state hashes (40a29333 and 40a29333 — the same value on purpose: the from-scratch cart rebuilds the demo's machine state exactly, and the machine hashes state, not source; omibit hash <cart.png> --frames N prints them; the guard test in packages/cli re-runs both listings and asserts these values on every test run, so the page cannot drift from the machine again).
The editor’s new → O32 3D collection option starts from the runnable 3D collection source. It provides camera, projection, near-plane clipping, painter ordering and AABB collision helpers you can edit. The editor replaces the source example’s fixed save ID with a unique one for each new project, so separate games do not share saved progress after export or code edits.