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.
-- 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)
endThree 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.
-- 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)
endTwo things worth stealing for any game:
- The collision samples a thin column of the box (
px+1topx+6), not
the full width. Without that, landing on a tile corner snags on the neighboring wall.
coyis 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.
-- 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)
endsq 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.
-- 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
endWhat 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.
statefreezes 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
spritestab,
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
broin 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.