Your first game

In the editor, choose new → O8 platformer for a playable starter with scrolling, collision, hazards and a goal. The whole game is in the Code tab. Its level is ready in the Map tab: tile 1 is a wall, tile 2 is the goal, tile 3 is a hazard, and tile 4 is the player start. Move tiles to change the stage. The Map tab can show these roles and fill connected areas. O16 adventure and O32 3D collection starters are also in the New menu. Each can be played, edited and exported.

A small platformer, built the same way the seed cart bro is built: controls first, then gravity, then tile collision, then enemies, then coins and a goal. Every listing below is a complete cart — paste it into the code tab and press run.

The player is an 8x8 box in these listings so the code stays readable. Swap in a sprite with spr() once the movement feels right — the numbers do not change.

Step 1: run and jump

Movement from an acceleration model, not constant speed: hold a direction to speed up, release to slide. Jump cuts short when you let go of A. This is the control feel from bro, minus everything else.

lua
-- platformer 1: run and jump
px, py = 24, 112
vx, vy = 0, 0
ground = 112

function _update()
  local acc = 0.09
  if btn(1) then
    vx = min(vx + acc, 1.1)
  elseif btn(0) then
    vx = max(vx - acc, -1.1)
  else
    vx = vx * 0.82
    if abs(vx) < 0.05 then vx = 0 end
  end

  if btnp(4) and py >= ground then vy = -3.2 end
  if not btn(4) and vy < -1.3 then vy = -1.3 end
  vy = min(vy + 0.22, 3.2)

  px = mid(0, px + vx, 120)
  py = min(py + vy, ground)
  if py >= ground then vy = 0 end
end

function _draw()
  cls(0)
  rectfill(0, 120, 127, 127, 5)
  circfill(px + 4, py + 4, 3, 3)
end

Three knobs to feel: acc (how fast you gain speed), 0.82 (friction), and 0.22 (gravity). The -1.3 clamp is the jump cut — release A early and you jump low. That single line is what makes a platformer feel deliberate instead of floaty.

Step 2: tiles and collision

Flat ground does not get you far. The map is a 128x64 grid; a cell value of 1-8 means solid here. The level below is built with mset() at load so the cart needs no map editor work to run — in a real cart you would paint these cells in the map tab instead.

Collision is axis-separated: move on X, push out of walls, then move on Y, land or bump your head. This is the exact scheme bro uses.

lua
-- platformer 2: tile collision
px, py, vx, vy = 16, 96, 0, 0
gr = false coy = 0

function solid(c) return c >= 1 and c <= 8 end

function _init()
  for x = 0, 15 do mset(x, 15, 1) end
  for x = 10, 13 do mset(x, 13, 1) end
  for x = 4, 7 do mset(x, 11, 1) end
end

function _update()
  local acc = 0.09
  if btn(1) then
    vx = min(vx + acc, 1.1)
  elseif btn(0) then
    vx = max(vx - acc, -1.1)
  else
    vx = vx * 0.82
    if abs(vx) < 0.05 then vx = 0 end
  end

  coy = gr and 5 or coy - 1
  if btnp(4) and coy > 0 then
    vy = -(3.2 + abs(vx) * 0.22) coy = 0 gr = false
  end
  if not btn(4) and vy < -1.3 then vy = -1.3 end
  vy = min(vy + 0.22, 3.2)

  px = px + vx
  if vx > 0 then
    local cx = flr((px + 7) / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 - 8 vx = 0 end
    end
  elseif vx < 0 then
    local cx = flr(px / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 + 8 vx = 0 end
    end
  end
  px = mid(0, px, 120)

  gr = false
  py = py + vy
  if vy >= 0 then
    local cy = flr((py + 8) / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) and py + 8 > cy * 8 then
        py = cy * 8 - 8 vy = 0 gr = true
      end
    end
  else
    local cy = flr(py / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) then py = cy * 8 + 8 vy = 0 end
    end
  end
end

function _draw()
  cls(0)
  for my = 0, 15 do
    for mx = 0, 15 do
      if solid(mget(mx, my)) then
        rectfill(mx * 8, my * 8, mx * 8 + 7, my * 8 + 7, 5)
      end
    end
  end
  circfill(px + 4, py + 4, 3, 3)
end

Two things worth stealing for any game:

  • The collision samples a thin column of the box (px+1 to px+6), not

the full width. Without that, landing on a tile corner snags on the neighboring wall.

  • coy is a coyote timer: five frames after leaving the ground you can

still jump. gr alone would already have punished every late press.

To test collision, jump into a wall at an angle, then land exactly on a tile edge. When you start painting real maps, keep sprite cells 1-8 as your solids or change the solid() range to match your tile numbers.

Step 3: an enemy

A walker that patrols, turns at walls, and can be stomped. The stomp check is the overlap test from bro: boxes intersect, and the player is falling with their feet above the enemy's midline.

lua
-- platformer 3: an enemy
px, py, vx, vy = 16, 104, 0, 0
gr = false coy = 0
foe = {x = 88, y = 104, vx = -0.35, sq = 0}
score = 0

function solid(c) return c >= 1 and c <= 8 end

function _init()
  for x = 0, 15 do mset(x, 15, 1) end
  for x = 8, 11 do mset(x, 12, 1) end
end

function hurt()
  px, py, vx, vy = 16, 104, 0, 0
end

function _update()
  local acc = 0.09
  if btn(1) then
    vx = min(vx + acc, 1.1)
  elseif btn(0) then
    vx = max(vx - acc, -1.1)
  else
    vx = vx * 0.82
    if abs(vx) < 0.05 then vx = 0 end
  end

  coy = gr and 5 or coy - 1
  if btnp(4) and coy > 0 then
    vy = -(3.2 + abs(vx) * 0.22) coy = 0 gr = false
  end
  if not btn(4) and vy < -1.3 then vy = -1.3 end
  vy = min(vy + 0.22, 3.2)

  px = px + vx
  if vx > 0 then
    local cx = flr((px + 7) / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 - 8 vx = 0 end
    end
  elseif vx < 0 then
    local cx = flr(px / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 + 8 vx = 0 end
    end
  end
  px = mid(0, px, 120)

  gr = false
  py = py + vy
  if vy >= 0 then
    local cy = flr((py + 8) / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) and py + 8 > cy * 8 then
        py = cy * 8 - 8 vy = 0 gr = true
      end
    end
  else
    local cy = flr(py / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) then py = cy * 8 + 8 vy = 0 end
    end
  end

  local f = foe
  if f.sq > 0 then
    f.sq = f.sq - 1
    if f.sq == 0 then f.dead = true end
  elseif not f.dead then
    f.x = f.x + f.vx
    local cx = flr((f.x + (f.vx > 0 and 7 or 0)) / 8)
    if solid(mget(cx, flr((f.y + 4) / 8))) then
      f.vx = -(f.vx) f.x = f.x + f.vx * 2
    end
    f.y = f.y + 0.22
    local cy = flr((f.y + 8) / 8)
    for sx = flr((f.x + 1) / 8), flr((f.x + 6) / 8) do
      if solid(mget(sx, cy)) and f.y + 8 > cy * 8 then
        f.y = cy * 8 - 8
      end
    end
    if px + 7 > f.x + 1 and px + 1 < f.x + 7
      and py + 7 > f.y + 1 and py + 1 < f.y + 7 then
      if vy > 0.5 and py + 8 < f.y + 6 then
        f.sq = 16 vy = -2.3 score = score + 100
      else
        hurt()
      end
    end
  end
end

function _draw()
  cls(0)
  for my = 0, 15 do
    for mx = 0, 15 do
      if solid(mget(mx, my)) then
        rectfill(mx * 8, my * 8, mx * 8 + 7, my * 8 + 7, 5)
      end
    end
  end
  local f = foe
  if f.sq > 0 then
    rectfill(f.x, f.y + 6, f.x + 7, f.y + 7, 2)
  elseif not f.dead then
    rectfill(f.x, f.y, f.x + 7, f.y + 7, 2)
    pset(f.x + 2, f.y + 2, 12) pset(f.x + 5, f.y + 2, 12)
  end
  circfill(px + 4, py + 4, 3, 3)
  print(score, 2, 2, 13)
end

sq is the squashed timer — the enemy flattens for 16 frames, then is gone. Respawning instead of dying keeps the listing short; bro tracks lives the same way you would: a counter and a hurt() call.

Step 4: coins, camera, and a goal

The finished game: a wider level, a camera that follows, coins as map cells you collect by touching, sounds defined with poke(), and a flagpole that ends the run. This is bro reduced to one screenful of code.

lua
-- platformer 4: the whole thing
px, py, vx, vy = 16, 104, 0, 0
gr = false coy = 0 cam = 0
foes = {{x = 160, y = 96, vx = -0.35, act = false, sq = 0},
        {x = 344, y = 96, vx = -0.35, act = false, sq = 0}}
score = 0 state = 0 pole = 59 * 8

function solid(c) return c >= 1 and c <= 8 end

function reset()
  px, py, vx, vy = 16, 104, 0, 0
  cam = 0 state = 0 score = 0
  foes = {{x = 160, y = 96, vx = -0.35, act = false, sq = 0},
          {x = 344, y = 96, vx = -0.35, act = false, sq = 0}}
  for x = 0, 63 do
    if x < 38 or x > 40 then mset(x, 15, 1) end
  end
  for _, c in ipairs({{12, 12}, {20, 9}, {30, 12}, {45, 9}, {54, 12}}) do
    mset(c[1], c[2], 24)
  end
end

function _init()
  reset()
  for x = 10, 13 do mset(x, 13, 1) end
  for x = 18, 23 do mset(x, 11, 1) end
  for x = 28, 33 do mset(x, 13, 1) end
  for x = 44, 47 do mset(x, 11, 1) end
  for x = 52, 56 do mset(x, 13, 1) end
  mset(59, 14, 10)
  -- sfx 0 jump, 1 coin, 2 stomp, 3 hurt: two notes each, then end
  poke(0x8000, 40) poke(0x8001, 0x38)
  poke(0x8002, 45) poke(0x8003, 0x38)
  poke(0x8040, 47) poke(0x8041, 0x38)
  poke(0x8042, 54) poke(0x8043, 0x38)
  poke(0x8080, 0xde) poke(0x8081, 0x38)
  poke(0x8082, 0xd8) poke(0x8083, 0x38)
  poke(0x80c0, 0xad) poke(0x80c1, 0x38)
  poke(0x80c2, 0x9c) poke(0x80c3, 0x38)
end

function hurt()
  sfx(3)
  px, py, vx, vy = 16, 104, 0, 0
  cam = 0
end

function _update()
  if state == 1 then
    if btnp(4) then reset() end
    return
  end

  local acc = 0.09
  if btn(1) then
    vx = min(vx + acc, 1.1)
  elseif btn(0) then
    vx = max(vx - acc, -1.1)
  else
    vx = vx * 0.82
    if abs(vx) < 0.05 then vx = 0 end
  end

  coy = gr and 5 or coy - 1
  if btnp(4) and coy > 0 then
    vy = -(3.2 + abs(vx) * 0.22) coy = 0 gr = false sfx(0)
  end
  if not btn(4) and vy < -1.3 then vy = -1.3 end
  vy = min(vy + 0.22, 3.2)

  px = px + vx
  if vx > 0 then
    local cx = flr((px + 7) / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 - 8 vx = 0 end
    end
  elseif vx < 0 then
    local cx = flr(px / 8)
    for sy = flr(py / 8), flr((py + 7) / 8) do
      if solid(mget(cx, sy)) then px = cx * 8 + 8 vx = 0 end
    end
  end

  gr = false
  py = py + vy
  if vy >= 0 then
    local cy = flr((py + 8) / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) and py + 8 > cy * 8 then
        py = cy * 8 - 8 vy = 0 gr = true
      end
    end
  else
    local cy = flr(py / 8)
    for sx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if solid(mget(sx, cy)) then py = cy * 8 + 8 vy = 0 end
    end
  end

  for cy = flr(py / 8), flr((py + 7) / 8) do
    for cx = flr((px + 1) / 8), flr((px + 6) / 8) do
      if mget(cx, cy) == 24 then
        mset(cx, cy, 0) score = score + 10 sfx(1)
      end
    end
  end

  for i, f in ipairs(foes) do
    if not f.act then
      if f.x < cam + 136 then f.act = true end
    elseif f.sq > 0 then
      f.sq = f.sq - 1
      if f.sq == 0 then f.dead = true end
    elseif not f.dead then
      f.x = f.x + f.vx
      local cx = flr((f.x + (f.vx > 0 and 7 or 0)) / 8)
      if solid(mget(cx, flr((f.y + 4) / 8))) then
        f.vx = -(f.vx) f.x = f.x + f.vx * 2
      end
      f.y = f.y + 0.22
      local cy = flr((f.y + 8) / 8)
      for sx = flr((f.x + 1) / 8), flr((f.x + 6) / 8) do
        if solid(mget(sx, cy)) and f.y + 8 > cy * 8 then
          f.y = cy * 8 - 8
        end
      end
      if px + 7 > f.x + 1 and px + 1 < f.x + 7
        and py + 7 > f.y + 1 and py + 1 < f.y + 7 then
        if vy > 0.5 and py + 8 < f.y + 6 then
          f.sq = 16 vy = -2.3 score = score + 100 sfx(2)
        else
          hurt() return
        end
      end
    end
  end

  cam = min(max(cam, px - 48), 64 * 8 - 128)
  if px + 4 >= pole then state = 1 score = score + 100 end
  if py > 140 then hurt() end
end

function _draw()
  cls(10)
  camera(cam, 0)
  local x0 = flr(cam / 8)
  for my = 0, 15 do
    for mx = x0, x0 + 16 do
      local c = mget(mx, my)
      if solid(c) then
        rectfill(mx * 8, my * 8, mx * 8 + 7, my * 8 + 7, 5)
      elseif c == 24 then
        circfill(mx * 8 + 4, my * 8 + 4, 2, 4)
      elseif c == 10 then
        rectfill(mx * 8 + 3, my * 8 - 40, mx * 8 + 4, my * 8 + 7, 13)
        rectfill(mx * 8 + 5, my * 8 - 40, mx * 8 + 12, my * 8 - 33, 3)
      end
    end
  end
  for i, f in ipairs(foes) do
    if f.act and not f.dead and f.sq == 0 then
      rectfill(f.x, f.y, f.x + 7, f.y + 7, 2)
      pset(f.x + 2, f.y + 2, 12) pset(f.x + 5, f.y + 2, 12)
    end
  end
  circfill(px + 4, py + 4, 3, 3)
  camera()
  print(score, 2, 2, 13)
  if state == 1 then
    print("clear!", 46, 56, 12)
    print("score " .. score, 38, 66, 13)
    if time() % 30 < 20 then print("a: again", 42, 78, 13) end
  end
end

What changed from step 3:

  • Coins are map cells with value 24. Touching one clears the cell,

adds score, plays a blip. No coin entity list needed — the map is the state.

  • The camera is one line: cam = min(max(cam, px - 48), 64 * 8 - 128).

Follow the player, never scroll backwards past the furthest point, never scroll past the level edge. Everything drawn between camera(cam, 0) and camera() shifts with it — including the HUD's reason for being drawn after camera() resets.

  • Sounds are two-note squares poked into sfx RAM in _init(). The audio

guide explains the byte format; the pattern here is enough to make noise: two (pitch, flags) pairs, then a zero pair to end.

  • The goal is a map cell too — the pole at cell 59. state freezes the

game on touch and waits for A.

Play it to the flag. If something feels wrong, change one number and run again — that loop is the whole craft.

Next steps

  • Replace the rectangles with sprites: paint a few in the sprites tab,

then swap circfill(px + 4, py + 4, 3, 3) for spr(0, px, py) and the tile rectfill for one map() call.

  • The seed cart bro in the lobby is this game with 200 more lines:

art, music, a timer, lives, coin blocks. Open it in the editor and read it — every technique in it has now appeared on this page.

To keep a best score between sessions, follow saving progress. For runtime errors, use go to line in the run pane; for game logic, pause, step a frame and inspect values in the console. Export a PNG before moving to another browser.