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

thingO8O16
screen128x128256x224
colors1632 (indices 0-15 identical)
sprites128 of 8x8256 of 16x16
map1 layer, 128x642 layers, 128x128 each
code budget11,892 bytes159,216 bytes
buttons6 (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:

lua
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:

lua
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)
end

Fifteen 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:

lua
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)
end
  • scale is 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) and cream (29) bands just below

the horizon after the mode7 call — a handful of rectfill rows with the two highlights built for exactly this.

  • The X and Y buttons (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:

lua
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)
end

Distant 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:

lua
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
end

bank 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:

lua
-- 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)
end

The 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.