Graphics

The O8 screen is 128x128 pixels, one palette index per pixel. There are 16 colors, fixed forever. This page covers the two ways pixels get on screen — drawing calls and the sprite sheet — plus the map, camera, and fill patterns.

The palette is the color

There are no RGB values in a cart. Color 3 is orange, color 10 is sky, always. The full table with hex values is in the API reference; the short version you will memorize in a week:

rangeuse
0-3black, plum, maroon, orange — outlines, darks
4-7sand, lime, green, teal — light, nature
8-11indigo, blue, sky, cyan — deep, water, air
12-15white, silver, steel, slate — text, metal

Working by index instead of by RGB is palette discipline. It is why remapping with pal() can recolor a whole scene in one call, and why carts stay readable: rectfill(x0, y0, x1, y1, 5) says what it draws.

Drawing calls

Every primitive takes an optional last color argument; without it, they draw in the color set by color() (13, silver, at load).

lua
function _draw()
  cls(0)
  color(13)
  pset(64, 8, 12)
  line(8, 16, 120, 16)
  rect(8, 24, 40, 40, 9)
  rectfill(48, 24, 80, 40, 8)
  circ(24, 56, 8, 10)
  circfill(48, 56, 8, 11)
  tri(72, 64, 96, 40, 120, 64, 12)
  trifill(8, 96, 32, 72, 56, 96, 3)
end

cls() clears and resets the cursor and clip. print draws the 3x5 console font — four pixels of advance per glyph, so a string of n characters is 4n pixels wide. Centering hello (5 chars, 20px) on the 128px screen: x = 54.

oval and ovalfill take the bounding box — any two opposite corners — and draw the ellipse inscribed in it, edge pixels included. A zero-width box is a line, a zero-height box is a line, a 1x1 box is a pixel:

lua
function _draw()
  cls(0)
  ovalfill(20, 44, 60, 84, 10)     -- tall: sky
  ovalfill(64, 44, 107, 84, 5)     -- wide lime fill
  oval(64, 44, 107, 84, 12)        -- white ring around it
  oval(112, 44, 112, 84, 3)        -- w=0: a vertical line
  ovalfill(20, 90, 27, 97, 4)      -- small 8x8 blob
  oval(32, 90, 33, 91, 7)          -- 2x2: four pixels
end

ovalfill respects fillp like the other filled shapes; oval's outline does not, like circ's.

Sprites

The sprite sheet is 128 sprites of 8x8, stored as one pixel per byte. Sprite n sits at sheet position x = (n % 16) * 8, y = flr(n / 16) * 8 — eight rows of sixteen.

Paint sprites in the sprites tab. The sheet is also gfx RAM, address 0x0 through 0x1FFF — one row is 128 bytes — so code can paint too. Here sprite 0 is painted pixel by pixel, where . means color 0 (transparent for sprites by default):

lua
-- paint sprite 0, then use it
function _init()
  local rows = {
    "..77....",
    ".7777...",
    "771777..",
    "777777..",
    ".7777...",
    "..77....",
    "........",
    "........",
  }
  for y = 0, 7 do
    for x = 0, 7 do
      local ch = rows[y + 1]:sub(x + 1, x + 1)
      poke(y * 128 + x, ch == "." and 0 or tonumber(ch, 16))
    end
  end
end

function _draw()
  cls(0)
  spr(0, 8, 8)                        -- one cell
  spr(0, 8, 24, 2, 1)                 -- two cells wide
  spr(0, 8, 48, 1, 1, true, false)    -- mirrored
  sspr(0, 0, 8, 8, 64, 24, 32, 32)    -- stretched to 32x32
end

spr(n, x, y, w, h, flipx, flipy) draws the sprite plus w by h cells right and down — a 2x1 blob for a walking character, flipped by flipx for the other direction. Facing is one boolean, not two sprites.

sspr copies any rectangle of the sheet to any rectangle of the screen, stretching as needed. It is how you scale a sprite for a title screen.

Transparency: color 0 is transparent for spr, sspr, and map at load. palt(c, t) marks any other color transparent (or opaque), and palt() resets to the default.

Named sprite animations

In the O8 or O16 sprite editor, use the animation controls to choose a consecutive sprite range, set its playback rate, and save it under a name. The definition is stored in the cartridge's Lua source, so it travels with project saves and PNG/HTML exports. The editor manages this block:

lua
-- @sprite-animations begin
sprite_animations = {
  ["idle"] = { 16, 1, 8 },
  ["run"] = { 16, 2, 8 },
}
-- @sprite-animations end

Each entry is { first_sprite, frame_count, frames_per_second }. Call spr(sprite_animation_frame("run", tick), x, y) to draw it, where tick counts 60 Hz game updates. The O8 platformer starter shows idle/run state selection. Names use letters, numbers and underscores, start with a letter, and have at most 24 characters. Up to 24 animations are supported, with 1–30 frames per second; frames must fit on the sprite sheet. The editor refuses to overwrite a block with unsupported manual edits. Timing is uniform within each animation; per-frame events and pivots are not yet authored here.

The map

The map is a 128x64 grid of bytes, one per cell. A cell value of 1-127 is a sprite number; 0 is empty. Paint it in the map tab, or from code with mset. The listing below paints a checkered ground tile into sprite 1, lays a level with it, and scrolls the level with the camera:

lua
-- a tile, a level, and a camera
cam = 0

function _init()
  for y = 0, 7 do
    for x = 0, 7 do
      poke(y * 128 + 8 + x, (x + y) % 2 == 0 and 6 or 5)
    end
  end
  for x = 0, 127 do mset(x, 15, 1) end
  for x = 20, 26 do mset(x, 12, 1) end
  for x = 34, 40 do mset(x, 10, 1) end
end

function _update()
  if btn(1) then cam = cam + 1 end
  if btn(0) then cam = cam - 1 end
  cam = mid(0, cam, 896)
end

function _draw()
  cls(0)
  camera(cam, 0)
  map(0, 0, 0, 0, 128, 16)
  camera()
  print("cam " .. cam, 2, 2, 13)
end

Sprite 1 lives at sheet x = 8, y = 0, so its first byte is y * 128 + 8 + x — the 8 is the sprite's column offset.

map(mx, my, dx, dy, w, h) draws the region starting at map cell (mx, my) to screen pixel (dx, dy) — one call for a whole level. Two idioms for scrolling:

  • Camera scrolling (above): draw the map at 0,0 and shift everything

with camera(). The map draw only touches visible cells, so a long level costs the same as a short one.

  • Region scrolling: map(flr(cam / 8), 0, -(cam % 8), 0, 17, 16) moves

the map window itself, cell by cell. Use it when you want the map draw explicit.

For collision, read cells back with mget(cx, cy) — cell coordinates, not pixels. The first game guide builds a full tile-collision scheme on it.

Camera and clip

camera(x, y) offsets all subsequent draw calls by -x, -y; camera() resets. Draw your world under a camera, then reset it before drawing the HUD so overlays stay pinned to the screen.

clip(x, y, w, h) restricts drawing to a rectangle; clip() resets. A clipped draw is how you do health bars that fill from the left without overdrawing their frame.

Fill patterns

fillp(p) sets a 4x4, 16-bit pattern applied to rectfill, circfill, and trifill (and tri). A 1 bit draws the call's color; a 0 bit draws the next color, (c + 1) % 16 — or nothing when bit 0x8000 is set:

lua
function _draw()
  cls(0)
  fillp(0x33cc)
  rectfill(8, 8, 56, 56, 10)
  fillp(0xb3cc)
  rectfill(64, 8, 112, 56, 10)
  fillp(0)
  circfill(64, 88, 24, 12)
end

The left block is a 2x2 checker of sky and cyan. The right block is the same checker with bit 0x8000 set — the off cells are transparent, so the black background shows through. fillp(0) returns to solid.

Two properties worth knowing: the pattern is anchored to screen pixels (it does not scroll with the camera), and line, pset, print, and sprites ignore it. Dithered shading and cheap transparency, one number.

Palette swaps

pal(c0, c1) remaps the draw palette: color c0 draws as c1 — for primitives and sprites alike. pal(c0, c1, 1) remaps the screen palette: pixels already drawn as c0 display as c1. Screen-palette swaps are instant and free — one call re-tints the whole screen, which is the standard trick for hit flashes, level moods, and fade-outs:

lua
t = 0

function _update()
  t = t + 1
end

function _draw()
  cls(0)
  circfill(64, 64, 30, 3)
  circfill(64, 64, 20, 4)
  if t % 60 < 30 then
    pal(4, 10, 1)
  else
    pal()
  end
  print("screen palette swap", 16, 100, 13)
end

The sun's inner disc flips between sand and sky at display time — the framebuffer never changed. pal() with no arguments resets both palettes.

Budget notes

The sheet holds 128 sprites; a 2x1 character costs 2, a whole 8-direction ship with flips costs 1. Many finished carts use fewer than 40 sprites. When you run low, sspr sub-rectangles of larger art, and pal swaps on the same sprite, stretch the budget a long way.

The screen is also RAM — pget(x, y) reads it and pset writes it — so effects that read the frame back are possible when the instruction budget allows.

Editing game maps

The Map tab has a fill tool for changing a connected area of matching cells. Pick a tile, choose fill, then click an area or focus the map canvas, move its cursor and press Enter. The change is one undo step. O16 applies the fill to the selected layer.

Games can label the meaning of their tiles with code comments such as:

lua
-- @map-tile 1 solid wall
-- @map-tile 2 goal finish
-- @map-tile 3 hazard spikes
-- @map-tile 4 spawn player start

The Map tab shows a legend with counts for labeled tile IDs; roles on adds colored overlays while editing. Supported roles are solid, hazard, goal, collectible, door, exit and spawn. These comments help editing only: your Lua code still decides collision and gameplay. The O8 platformer and O16 adventure starters use map tiles for their player starts, so moving a spawn marker changes where play begins.