The O16 guide
O16 is the second machine in the omibit family. Same idea, more console: a 256x224 screen, 32 fixed colors, 16x16 sprites, two map layers, one new drawing call, and two more buttons. Everything you know from O8 carries over — the frame loop, the palette discipline, the determinism, the cart-is-a-PNG culture. O8 carts still run and still edit exactly as before; the tier byte on every cart says which machine it belongs to.
This page covers what changes and then walks the flagship task: a mode-7 kart floor in about fifteen lines.
What changes on O16
| thing | O8 | O16 |
|---|---|---|
| screen | 128x128 | 256x224 |
| colors | 16 | 32 (indices 0-15 identical) |
| sprites | 128 of 8x8 | 256 of 16x16 |
| map | 1 layer, 128x64 | 2 layers, 128x128 each |
| code budget | 11,892 bytes | 159,216 bytes |
| buttons | 6 (arrows, z/x) | 8 (arrows, z/x, c/v) |
| affine | - | tline() |
The new colors 16-31 fill what sixteen could not do: an earth ramp (umber, rust, amber), skin (peach), true reds (scarlet, blood), shade partners for the darks, and two highlights (cream, mist) that earn their keep the first time you draw fog.
In the editor, press new and pick the tier when you start a cart. A saved cart keeps its tier — the badge in the header always says which machine you are holding. The sprite tab shows the 256x256 sheet with the 32-swatch palette; the map tab gains a 0 / 1 layer switch. The sfx and music tabs are identical on both machines — same slots, same note format, byte-for-byte.
The map has two layers
map, mget, and mset take a leading layer argument on O16:
map(0, 0, 0, 0, 0, 16, 14) -- layer 0, cells (0,0), at (0,0), 16x14 cells
map(1, 0, 0, 16, 0, 16, 14) -- layer 1, same cells, shifted right 16px
v = mget(0, 4, 7) -- read layer 0, cell (4,7)
mset(1, 4, 7, 12) -- write layer 1, cell (4,7)There are no per-tile priority bits. Call order is priority, and it is all the priority you need: draw layer 0, draw your sprites, draw layer 1 as the foreground. Trees behind the kart, branches in front of it, one draw call each. In the map tab the layer switch edits the same two layers your code reads — paint the far layer on 0, the near layer on 1.
tline: one call, every affine trick
tline(x0, y0, x1, y1, sx, sy, sxstep, systep) draws a screen line and samples the sprite sheet along it. The first inked pixel samples sheet pixel (sx, sy); each next pixel advances by (sxstep, systep). The sheet wraps forever in both axes, samples pass through the draw palette, and palt transparency skips. Camera and clip apply.
That is the whole affine story. A scaled sprite is four tlines. A perspective floor is one tline per row. And a mode-7 plane — the signature SNES kart look — is 112 of them, which sounds like a lot until you see it is the same line with different numbers.
The mode-7 floor
The math: the camera sits at world position (cx, cy) facing angle (in turns — cos/sin take turns here, like everywhere on omibit). For each screen row below the horizon, the scanline's distance is scale / (y - horizon): rows near the horizon are far away, rows at the bottom are close. The scanline's left edge starts half the row width to the side of the camera's forward point, and each screen pixel steps d / 256 world pixels to the right, rotated by the camera angle. tline does the sampling, wrapping, and transparency.
Paste this over a fresh O16 cart and press run — the floor spins:
horizon = 112
function mode7(cx, cy, scale, angle)
local fx, fy = cos(angle), sin(angle)
local rx, ry = -fy, fx
for y = 113, 223 do
local d = scale / (y - horizon)
local sx = cx + fx * d - rx * d / 2
local sy = cy + fy * d - ry * d / 2
local st = d / 256
tline(0, y, 255, y, sx, sy, rx * st, ry * st)
end
end
angle = 0
function _update()
angle = angle + 0.0025
end
function _draw()
cls(25)
rectfill(0, 0, 255, horizon, 29)
mode7(128, 128, 96, angle)
print("mode 7", 8, 8, 12)
endFifteen lines inside mode7 and the machine does the rest. Now make it a kart:
- Paint a texture in the top-left of the sprite sheet — a 128x128
block is plenty; the plane tiles it forever. Road, grass, dirt: strong edges read best at distance.
- Drive the camera from the buttons. The kart is a sprite drawn at a
fixed screen point; the world moves under it:
cx, cy, angle = 128, 128, 0
function _update()
if btn(0) then angle = angle - 0.01 end
if btn(1) then angle = angle + 0.01 end
if btn(4) or btn(7) then
cx = (cx + cos(angle) * 2) % 256
cy = (cy + sin(angle) * 2) % 256
end
if btn(5) or btn(6) then
cx = (cx - cos(angle) * 1) % 256
cy = (cy - sin(angle) * 1) % 256
end
end
function _draw()
cls(29)
mode7(cx, cy, 96, angle)
spr(0, 120, 168, 1, 1)
endscaleis the camera height. 96 is a kart; 40 skims the road like a
plane; 200 looks down like a strategy map.
- Fog: draw a gradient of
mist(30) andcream(29) bands just below
the horizon after the mode7 call — a handful of rectfill rows with the two highlights built for exactly this.
- The
XandYbuttons (c and v on the keyboard, bits 6 and 7) exist
because karts want a third button: drift, hop, item — your call.
Layers and parallax
The two-layer map plus camera() gives you parallax without any new concepts: draw the far layer scrolled at half speed, then the near layer at full speed. A one-line camera change per layer:
camx = 0
function _update()
if btn(1) then camx = camx + 1 end
if btn(0) then camx = camx - 1 end
end
function _draw()
cls(0)
camera(-camx * 0.5, 0)
map(0, 0, 0, 0, 0, 16, 14)
camera(-camx, 0)
map(1, 0, 16, 16, 0, 16, 14)
print("parallax", 8, 8, 12)
endDistant mountains on layer 0, the drivable ground on layer 1 — or layer 0 as ground and layer 1 as the tree canopy the kart drives under, which is the classic arrangement: background, action, foreground. Remember the order rule: L0 first, sprites in the middle, L1 last. The map tab's layer switch paints both.
Banked textures: many tracks from one sheet
One 256x256 sheet is a lot of art — until the game you are making has more than one track. The authentic 16-bit answer was banking: only one track's tiles are resident at a time, the rest wait compressed in the program, and the sheet is rewritten the moment a new track loads. O16 has no second gfx bank to swap — and it never needed one. The sheet is RAM starting at 0x0000, poke writes it, and code is the ROM. A bank is a table of packed rows plus one decode loop.
The packing that earns its keep is run-length pairs in base 32: two characters per run, the first the run length minus one, the second the color. The row "0ot605e6" is 1 pixel of pine, 30 of grass, 1 of lime, 15 of grass — a whole grass row for a dozen characters. Decode is a sub and two tonumbers per run. This is the loader from circuits, the library's three-track kart cart:
function bank(k)
local t=T[k].tex
for y=0,255 do
local r=t[y+1]
local i,x=1,0
while x<256 do
local n=tonumber(sub(r,i,i),32)+1
local c=tonumber(sub(r,i+1,i+1),32)
for j=0,n-1 do poke(y*256+x+j,c) end
x=x+n
i=i+2
end
end
rdc={}
local q=T[k].rc
for i=1,#q do rdc[q[i]+1]=1 end
endbank also rebuilds rdc, the set of colors that count as road — the sheet doubles as the collision map, so the surface test under the kart is a peek. Each circuits track (seaside, dunes, midnight) is a full 256x256 sheet packed to roughly 16KB of code — three sheets in less than a third of the 159,216-byte code budget, zero bytes of the gfx section. The menu's confirm button calls rst(sel), which calls bank(sel), which rewrites all 65,536 sheet bytes; the countdown covers the load, and picking track two after a race on track one re-banks the same sheet mid-session — no reset, no reload, one frame of pokes well inside the 4M instruction budget.
Choose banking when art sets never appear together — tracks, levels, title screens — or when full-sheet textures leave no room for sprites. Skip it when something must swap every frame: the decode is a load-time move, not a draw-time one.
The whole pattern distilled runs on a fresh O16 cart. Two 64x64 banks, each decoded once and then replicated to tile the whole sheet, swapped on a timer so you can watch the floor change under a spinning camera:
-- two banks, one sheet: 64x64 textures packed as base-32 run pairs
hz=112
meadow={
"m605l605g6",
"5605l605l605a6",
"b605l605k60g0546",
"h605h60g2605l6",
"0605e60g5605l605f6",
"6605l605l60596",
"c605l605l60536",
"i605l605a60g86",
"1605l605760gc605e6",
"7605460gf605l60586",
"d605l605l60526",
"j605l605j6",
"2605l605l605060gb6",
"8605k60g05l60576",
"a60g2605l605l60516",
"k605l605i6",
"3605l605l605c6",
"9605l605d60g660566",
"f605a60g9605l60506",
"760gc605l605h6",
"4605l605l605b6",
"a605l605l60556",
"g605l605360gh6",
"m605060gj605g6",
"460g05l605l605a6",
"b605l605l60546",
"h605l605j60g06",
"0605l605g60g3605f6",
"6605d60g6605l60596",
"160g9605l605l60536",
"vjvj",
"vjvj",
"vjvj",
"vjvj",
"j605l605j6",
"2605l605l605d6",
"8605l605l6050g66",
"e605j60g0605l60516",
"f60g3605l605i6",
"3605l605l605c6",
"9605l605l60566",
"f605l605c60g760506",
"l605960ga605h6",
"4605660gd605l605b6",
"a605l605l60556",
"g605l605m6",
"m605l605260gc6",
"5605l6050gk605a6",
"960g0605l605l60546",
"h605l605l6",
"0605l605l605f6",
"6605l605f60g460596",
"c605c60g7605l60536",
"660ga605l605k6",
"1605l605l605e6",
"7605l605l60586",
"d605l605560ge60526",
"j605260gh605j6",
"26050gk605l605d6",
"8605l605l60576",
"e605l605l60g16",
"k605i60g1605i6",
"3605f60g4605l605c6",
"060g7605l605l60566",
}
dusk={
"i30mh30mh30m53",
"a30mh30m330tc30md3",
"230mh30mh30mh30m23",
"d30m030tf30mh30ma3",
"530mh30mh30m530tb3",
"g30mh30mh30m73",
"830mh30m230td30mf3",
"030mh30mh30mh30m43",
"b30m0tg30mh30mc3",
"330mh30mh30m430tb30m13",
"e30mh30mh30m93",
"630mh30m130te30mh3",
"h30mh30mh30m63",
"930th30mh30me3",
"130mh30mh30m330tc30m33",
"c30mh30mh30mb3",
"430mh30m030tf30mh30m03",
"f30mh30mh30m530t13",
"630t0mh30mh30mg3",
"i30mh30m230td30m53",
"a30mh30mh30md3",
"230mh30m0tg30mh30m23",
"d30mh30mh30m430t43",
"330t030mh30mh30mi3",
"g30mh30m130te30m73",
"830mh30mh30mf3",
"030mh30th30mh30m43",
"b30mh30mh30m330t73",
"030t130mh30mh30mh30m13",
"e30mh30m030tf30m93",
"630mh30mh30mh3",
"g30t0mh30mh30m63",
"930mh30mh30m230ta3",
"130mh30mh30mh30m33",
"c30mh30m0tg30mb3",
"430mh30mh30mh30m03",
"d30t030mh30mh30m83",
"730mh30mh30m130td3",
"i30mh30mh30m53",
"a30mh30th30md3",
"vpvp",
"vpvp",
"vpvp",
"g30mh30mh30m73",
"830mg30t0mh30mf3",
"030mh30mh30mh30m230t03",
"730t230mh30mh30mc3",
"330mh30mh30m0tg30m13",
"e30mh30mh30m93",
"630mf30t030mh30mh3",
"h30mh30mh30m130t33",
"430t330mh30mh30me3",
"130mh30mh30th30m33",
"c30mh30mh30mb3",
"430me30t130mh30mh30m03",
"f30mh30mh30m030t63",
"130t430mh30mh30mg3",
"i30mg30t0mh30m53",
"a30mh30mh30md3",
"230md30t230mh30mh30m23",
"d30mh30mh30m0t93",
"530mh30mh30mi3",
"g30mf30t030mh30m73",
"830mh30mh30mf3",
}
function bank(t)
for y=0,63 do
local r=t[y+1]
local i,x=1,0
while x<64 do
local n=tonumber(sub(r,i,i),32)+1
local c=tonumber(sub(r,i+1,i+1),32)
for j=0,n-1 do poke(y*256+x+j,c) end
x=x+n
i=i+2
end
for q=1,3 do
for x=0,63 do poke(y*256+q*64+x,peek(y*256+x)) end
end
end
for q=1,3 do
for y=0,63 do
for x=0,255 do poke(q*16384+y*256+x,peek(y*256+x)) end
end
end
end
function mode7(cx,cy,scale,angle)
local fx,fy=cos(angle),sin(angle)
local rx,ry=-fy,fx
for y=113,223 do
local d=scale/(y-hz)
local sx=cx+fx*d-rx*d/2
local sy=cy+fy*d-ry*d/2
local st=d/256
tline(0,y,255,y,sx,sy,rx*st,ry*st)
end
end
angle=0 live=2
function _draw()
local k=flr(time()/90)%2
if k~=live then
live=k
bank(k==0 and meadow or dusk)
end
cls(k==0 and 10 or 3)
rectfill(0,0,255,hz,29)
mode7(128,128,96,angle)
angle=angle+0.0025
print(k==0 and "bank a: meadow" or "bank b: dusk",8,8,12)
endThe floor turns to dusk at frame 90 and back at 180 — the same sheet, two lives. It is run-verified the same way: 240 frames with both swaps land clean, final-frame state hash 42b5dc75.
The mode-7 and parallax listings are run-verified against the O16 machine: 90 frames of the mode-7 floor and 30 frames of the parallax draw clean, error-free, with deterministic final-frame state hashes (57341675 and e5a1a6ac — omibit hash <cart.png> --frames N prints them; the guard test in packages/cli re-runs them and asserts these values on every test run, so the page cannot drift from the machine again).
The editor’s new → O16 adventure option starts with two rooms in the Map tab: layer 0 is room one and layer 1 is room two. Tile 1 is a wall, 2 is the key, 3 is the locked door, 4 is the exit and 5 marks the spawn in each room. You can change either room in the map editor; the standalone Lua example builds the same map when pasted into an empty cart.