Skip to content

Tutorial 3 — Quest

A Zelda-ish overworld: walk a scrolling map, collect coins, talk to an NPC, fight slimes, and complete a quest. Assumes you’ve done 01-bounce and 02-starship. This is where the engine’s camera and tilemap-as-world come in.

The full source for this tutorial lives on GitHub: tutorials/03-quest.

Run any step:

python3 sim/run.py tutorials/03-quest/stepN_name.py --hold DOWN --shot /tmp/out.png

Tip: the talk/quest steps (6–8) are best seen live with --backend pygame — a headless --shot can catch a dialog box mid-draw (the overlay paints in several passes), so the box may screenshot without its text even though it renders fine on device and in the live window.


step 1 — step1_world.py · a world bigger than the screen

Section titled “step 1 — step1_world.py · a world bigger than the screen”

Quest step 1 An ASCII map becomes a Tilemap (30×20 tiles = 480×320 px, larger than the 320×240 screen). shp.tileset_colors builds the tileset (grass/path/water/tree/wall/door/goal). scene.set_view(ox, oy) chooses the visible window — that’s the camera. The hero lives in world coordinates; the view offset decides where it lands on screen. You see: a patch of world with the hero centred. Try it: edit the MAP strings.

15 collapsed lines
# Quest -- step 1: a world bigger than the screen.
#
# This third tutorial builds a top-down RPG. It teaches what Bounce and Starship
# couldn't: a scrolling world larger than the display, a camera that follows the
# hero, tile-based wall collision, a walk animation, items, an NPC you talk to, and
# light combat. Do 01-bounce and 02-starship first.
#
# What you learn here: a Tilemap can be a whole WORLD (30x20 tiles = 480x320 px,
# bigger than the 320x240 screen). scene.set_view(ox, oy) chooses which part of the
# world is on screen -- that's the camera. We centre it on the hero. The hero lives
# in WORLD coordinates; the view offset decides where that lands on screen.
#
# New: a large Tilemap from an ASCII map, shp.tileset_colors, scene.set_view.
#
# Run: python3 sim/run.py tutorials/03-quest/step1_world.py --shot /tmp/q1.png
import array
import picogame as pg
import picogame_game
import picogame_clock
import picogame_shapes as shp
W, H = 320, 240
TILE = 16
# . grass : path ~ water(solid) # tree(solid) W wall(solid) D door G goal
# P player N npc * coin E enemy
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
# map char -> tile value (entities sit on grass; the tile under them is grass/path)
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
# tile colours for values 1..7 (value 0 is unused)
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE # world pixel position of the hero
for ty in range(MROWS):
row = MAP[ty]
for tx in range(MCOLS):
ch = row[tx] if tx < len(row) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
scene.add(world)
def hero_bitmap():
"""A simple hero: a 16x16 body with a lighter 'face' nub on the facing edge.
frame 0=down, 1=up, 2=left, 3=right."""
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)])
stride = TILE * 4
data = bytearray(stride * TILE)
for f in range(4):
for y in range(TILE):
for x in range(TILE):
face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + f * TILE + x] = 2 if face else 1
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)
scene.add(hero)
def follow():
# centre the camera on the hero, clamped so we never show past the world edges
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
while True:
scene.refresh()
clock.tick()
▶ Try it in the browser

step 2 — step2_walk.py · walk, camera follows

Section titled “step 2 — step2_walk.py · walk, camera follows”

Quest step 2 4-direction movement; after each move follow() re-centres the camera, clamped to the world so you never see past the edges (near an edge the hero walks toward the screen edge instead). The hero faces its movement direction (sprite.frame). No wall collision yet — you can walk over water. Try it: change SPEED.

52 collapsed lines
# Quest -- step 2: walk around, camera follows.
#
# What you learn: world vs screen coordinates. The hero moves in WORLD space; after
# each move we call follow() to re-aim the camera. Near the world edges the clamp in
# follow() stops the camera and the hero walks toward the screen edge instead -- the
# classic top-down feel. The hero also FACES the way it moves (frame = facing).
# There's no wall collision yet, so you can walk over water and trees (step 3 fixes
# that).
#
# New vs step 1: 4-direction input, facing (sprite.frame), calling follow() on move.
#
# Run: python3 sim/run.py tutorials/03-quest/step2_walk.py --hold DOWN --shot /tmp/q2.png
import array
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3 # facing -> frame
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
scene.add(world)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)])
stride = TILE * 4
data = bytearray(stride * TILE)
for f in range(4):
for y in range(TILE):
for x in range(TILE):
face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + f * TILE + x] = 2 if face else 1
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=DOWN)
scene.add(hero)
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
while True:
btn.poll()
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
hero.frame = RIGHT if dx > 0 else LEFT # face horizontally
elif dy:
hero.frame = DOWN if dy > 0 else UP # face vertically
if dx or dy:
hero.move(hero.x + dx * SPEED, hero.y + dy * SPEED)
follow()
scene.refresh()
clock.tick()
▶ Try it in the browser

step 3 — step3_walls.py · tile collision

Section titled “step 3 — step3_walls.py · tile collision”

Quest step 3 solid_at(px, py) maps a world pixel to a tile and checks a SOLID set; can_walk() probes the hero’s four corners. We test the X and Y moves separately, so you slide along a wall instead of sticking when pushing diagonally into it. You see: water, trees and walls now block you. Try it: add a tile value to SOLID (or remove one).

52 collapsed lines
# Quest -- step 3: walls (tile-based collision).
#
# What you learn: stop the hero walking through water/trees/walls. Convert a world
# pixel to a tile (tx = px // TILE), look the tile up, and treat some values as
# SOLID. can_walk() probes the hero's body (its four corners, inset a little) so it
# can't clip into a wall. We test the X and Y moves SEPARATELY, so the hero slides
# along a wall instead of sticking when you push diagonally into it.
#
# New vs step 2: solid_at()/can_walk() pixel->tile collision, per-axis movement.
#
# Run: python3 sim/run.py tutorials/03-quest/step3_walls.py --hold RIGHT --shot /tmp/q3.png
import array
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6) # water, tree, wall, door block movement
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
scene.add(world)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)])
stride = TILE * 4
data = bytearray(stride * TILE)
for f in range(4):
for y in range(TILE):
for x in range(TILE):
face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + f * TILE + x] = 2 if face else 1
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=DOWN)
scene.add(hero)
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
# probe the hero's body corners (inset 2px) -- all must be free
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
while True:
btn.poll()
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
hero.frame = RIGHT if dx > 0 else LEFT
elif dy:
hero.frame = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y): # test X alone
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED): # then Y alone -> slide
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow()
scene.refresh()
clock.tick()
▶ Try it in the browser

step 4 — step4_anim.py · walk animation

Section titled “step 4 — step4_anim.py · walk animation”

Quest step 4 The hero becomes an 8-frame sheet (2 steps × 4 facings), driven by picogame_anim.AnimatedSprite: play(name) picks the facing’s animation, tick(dt) advances it using the real dt from clock.tick() (so the walk speed is frame-rate independent). Standing still shows the rest frame. Try it: change the animation fps.

56 collapsed lines
# Quest -- step 4: a walking animation.
#
# What you learn: time-based animation. We upgrade the hero to an 8-frame sheet --
# two frames per facing (a small step "bob") -- and drive it with
# picogame_anim.AnimatedSprite: named animations, advanced by tick(dt). While the
# hero moves we play() the animation for the current facing and tick it with the
# frame's real dt (from clock.tick()); when standing still we show the still frame.
# Using dt (not a frame counter) keeps the walk speed the same at any frame rate.
#
# New vs step 3: an 8-frame hero, picogame_anim.AnimatedSprite, play()/tick(dt),
# the dt returned by clock.tick().
#
# Run: python3 sim/run.py tutorials/03-quest/step4_anim.py --hold RIGHT --shot /tmp/q4.png
import array
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_anim
import picogame_shapes as shp
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6)
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
FACE_NAME = ("down", "up", "left", "right")
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
scene.add(world)
def hero_bitmap():
"""8 frames = 4 facings x 2 walk steps. Step 1 bobs the body down 1px so the
walk reads as motion. frame index = facing*2 + step."""
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60),
pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)])
stride = TILE * 8
data = bytearray(stride * TILE)
for f in range(4):
for s in range(2):
fr = f * 2 + s
bob = s # bob down 1px on the second step
for y in range(bob, TILE):
yy = y - bob
for x in range(TILE):
face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + fr * TILE + x] = 2 if face else 1
# two feet at the bottom, swapping sides per step (extra motion cue)
lx = 4 if s == 0 else 6
for x in (lx, TILE - 1 - lx):
data[(TILE - 1) * stride + fr * TILE + x] = 3
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8,
stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)
walk = picogame_anim.AnimatedSprite(hero, {
"down": ([0, 1], 8, True), "up": ([2, 3], 8, True),
"left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})
scene.add(hero)
facing = DOWN
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
dt = 1 / 30
while True:
btn.poll()
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
facing = RIGHT if dx > 0 else LEFT
elif dy:
facing = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y):
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED):
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow()
walk.play(FACE_NAME[facing]) # animate the walk while moving
walk.tick(dt)
else:
hero.frame = facing * 2 # still frame for the current facing
scene.refresh()
dt = clock.tick()
▶ Try it in the browser

step 5 — step5_items.py · items + a fixed HUD

Section titled “step 5 — step5_items.py · items + a fixed HUD”

Quest step 5 Coins are sprites placed from the map; being normal scene items, they scroll with the world. We collect one when the hero is close, hide it, and count it. picogame_ui.SceneLabel is a fixed layer — it does NOT scroll, so the counter stays pinned to the corner. Try it: add more * coins to the map.

59 collapsed lines
# Quest -- step 5: collectible items + a HUD over the scrolling world.
#
# What you learn: world-space pickups and a camera-fixed HUD. Coins are sprites
# placed at map positions; because they're normal scene items they scroll with the
# world. We collect one when the hero is close enough (a simple distance test), hide
# it, and bump a counter. picogame_ui.SceneLabel is a FIXED scene layer -- it does NOT
# scroll, so the coin counter stays pinned to the corner while the world moves under
# it.
#
# New vs step 4: item sprites placed from the map, distance-based pickup, a fixed
# SceneLabel counter.
#
# Run: python3 sim/run.py tutorials/03-quest/step5_items.py --hold RIGHT --shot /tmp/q5.png
import array
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_anim
import picogame_shapes as shp
import picogame_ui as ui
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6)
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
FACE_NAME = ("down", "up", "left", "right")
BG = pg.rgb565(0, 0, 0)
scene, bufA, bufB = picogame_game.setup(background=BG)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE
coin_spots = []
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
elif ch == "*":
coin_spots.append((tx * TILE, ty * TILE))
scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))
coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots] # +4 to centre in tile
for c in coins:
scene.add(c)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60),
pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)])
stride = TILE * 8
data = bytearray(stride * TILE)
for f in range(4):
for s in range(2):
fr = f * 2 + s
for y in range(s, TILE):
yy = y - s
for x in range(TILE):
face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + fr * TILE + x] = 2 if face else 1
lx = 4 if s == 0 else 6
for x in (lx, TILE - 1 - lx):
data[(TILE - 1) * stride + fr * TILE + x] = 3
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8,
stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)
walk = picogame_anim.AnimatedSprite(hero, {
"down": ([0, 1], 8, True), "up": ([2, 3], 8, True),
"left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})
scene.add(hero)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BG)
facing = DOWN
got = 0
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
dt = 1 / 30
while True:
btn.poll()
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
facing = RIGHT if dx > 0 else LEFT
elif dy:
facing = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y):
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED):
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow()
walk.play(FACE_NAME[facing]); walk.tick(dt)
else:
hero.frame = facing * 2
# pick up any coin we're standing on
for c in coins:
if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12:
c.visible = False
got += 1
hud.set("COINS %d/%d" % (got, len(coins)))
scene.refresh()
dt = clock.tick()
▶ Try it in the browser

Quest step 6 Stand next to the NPC and press A to enter a dialog state: the world keeps drawing underneath while picogame_ui.TextBox overlays a message; movement is frozen until you press a button. You see: a “PRESS A” prompt near the NPC, then a dialog box. Try it: change the dialog LINES.

62 collapsed lines
# Quest -- step 6: an NPC you can talk to.
#
# What you learn: interaction + a simple state machine for dialog. An NPC is a
# sprite; when the hero stands next to it and presses A we switch to a "dialog"
# state: the world keeps drawing underneath, and picogame_ui.TextBox draws a
# multi-line message box on top (a screen-space overlay, drawn after scene.refresh).
# While in dialog we DON'T process movement -- the game is paused on the box until
# you press a button to dismiss it.
#
# New vs step 5: an NPC sprite, an over/dialog state, picogame_ui.TextBox, freezing
# the world during dialog, an adjacency "PRESS A" prompt.
#
# Run: python3 sim/run.py tutorials/03-quest/step6_npc.py --shot /tmp/q6.png
import array
import board
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_anim
import picogame_shapes as shp
import picogame_ui as ui
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6)
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
FACE_NAME = ("down", "up", "left", "right")
BG = pg.rgb565(0, 0, 0)
WHITE = pg.rgb565(255, 255, 255)
NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
hero_x, hero_y = TILE, TILE
npc_x, npc_y = TILE, TILE
coin_spots = []
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
hero_x, hero_y = tx * TILE, ty * TILE
elif ch == "N":
npc_x, npc_y = tx * TILE, ty * TILE
elif ch == "*":
coin_spots.append((tx * TILE, ty * TILE))
scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))
coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]
for c in coins:
scene.add(c)
npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)
scene.add(npc)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60),
pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)])
stride = TILE * 8
data = bytearray(stride * TILE)
for f in range(4):
for s in range(2):
fr = f * 2 + s
for y in range(s, TILE):
yy = y - s
for x in range(TILE):
face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + fr * TILE + x] = 2 if face else 1
lx = 4 if s == 0 else 6
for x in (lx, TILE - 1 - lx):
data[(TILE - 1) * stride + fr * TILE + x] = 3
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8,
stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)
walk = picogame_anim.AnimatedSprite(hero, {
"down": ([0, 1], 8, True), "up": ([2, 3], 8, True),
"left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})
scene.add(hero)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)
dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 60, W - 16, 54, WHITE, NAVY, maxlines=4)
LINES = ["Villager:", "Beware the slimes in the", "tall grass, traveller.", "(press A)"]
facing = DOWN
got = 0
state = "over"
dlg_shown = False # draw the modal once, not every frame
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near_npc():
return abs(hero.x - npc.x) <= TILE and abs(hero.y - npc.y) <= TILE
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
dt = 1 / 30
while True:
btn.poll()
if state == "dialog":
if not dlg_shown: # draw ONCE -> no per-frame flicker
scene.refresh() # world frozen under the box
dlg.draw(board.DISPLAY, bufA, LINES)
dlg_shown = True
if btn.just_pressed(btn.A) or btn.just_pressed(btn.B):
state = "over"
scene.invalidate() # repaint over the box next frame
clock.tick()
continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
facing = RIGHT if dx > 0 else LEFT
elif dy:
facing = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y):
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED):
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow()
walk.play(FACE_NAME[facing]); walk.tick(dt)
else:
hero.frame = facing * 2
for c in coins:
if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12:
c.visible = False
got += 1
if near_npc():
hud.set("COINS %d/%d A: TALK" % (got, len(coins)))
if btn.just_pressed(btn.A):
state = "dialog"; dlg_shown = False
else:
hud.set("COINS %d/%d" % (got, len(coins)))
scene.refresh()
dt = clock.tick()
▶ Try it in the browser

step 7 — step7_combat.py · enemies + bump combat

Section titled “step 7 — step7_combat.py · enemies + bump combat”

Quest step 7 Slimes chase the hero (slower than you, respecting walls). Touching one costs HP and grants brief i-frames; press B to swing at the tile you’re facing and defeat a slime. HP shows in the HUD; reaching 0 sends you back to start. (A still talks.) Try it: change the slime speed or your starting HP.

62 collapsed lines
# Quest -- step 7: enemies and bump combat.
#
# What you learn: simple chasing AI, taking damage, and attacking. Slimes step
# toward the hero (slower than you, and they respect walls via can_walk). Touching
# one costs HP and grants brief invulnerability (i-frames) so one touch doesn't
# drain you instantly. Press B to swing: we defeat any slime in the tile just ahead
# of the way you're facing. HP shows in the HUD; reaching 0 sends you back to start.
#
# New vs step 6: enemy sprites with chase AI, player HP + i-frames + knock-back,
# a B attack in the facing direction. (A still talks to the NPC.)
#
# Run: python3 sim/run.py tutorials/03-quest/step7_combat.py --hold B --shot /tmp/q7.png
import array
import board
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_anim
import picogame_shapes as shp
import picogame_ui as ui
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6)
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
DIR = {DOWN: (0, 1), UP: (0, -1), LEFT: (-1, 0), RIGHT: (1, 0)}
FACE_NAME = ("down", "up", "left", "right")
BG = pg.rgb565(0, 0, 0)
WHITE = pg.rgb565(255, 255, 255)
NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
START = (TILE, TILE)
npc_x, npc_y = TILE, TILE
coin_spots, enemy_spots = [], []
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
START = (tx * TILE, ty * TILE)
elif ch == "N":
npc_x, npc_y = tx * TILE, ty * TILE
elif ch == "*":
coin_spots.append((tx * TILE, ty * TILE))
elif ch == "E":
enemy_spots.append((tx * TILE, ty * TILE))
scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))
coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]
for c in coins:
scene.add(c)
slime_bm = shp.circle(14, pg.rgb565(120, 200, 80))
enemies = [pg.Sprite(slime_bm, x + 1, y + 1) for (x, y) in enemy_spots]
for e in enemies:
scene.add(e)
npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)
scene.add(npc)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60),
pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)])
stride = TILE * 8
data = bytearray(stride * TILE)
for f in range(4):
for s in range(2):
fr = f * 2 + s
for y in range(s, TILE):
yy = y - s
for x in range(TILE):
face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + fr * TILE + x] = 2 if face else 1
lx = 4 if s == 0 else 6
for x in (lx, TILE - 1 - lx):
data[(TILE - 1) * stride + fr * TILE + x] = 3
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8,
stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), START[0], START[1], frame=0)
walk = picogame_anim.AnimatedSprite(hero, {
"down": ([0, 1], 8, True), "up": ([2, 3], 8, True),
"left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})
scene.add(hero)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)
dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 60, W - 16, 54, WHITE, NAVY, maxlines=4)
LINES = ["Villager:", "Slimes ahead! Press B to", "swing at them.", "(press A)"]
facing = DOWN
got = 0
hp = 6
inv = 0
state = "over"
frame = 0
dlg_shown = False # draw the modal once, not every frame
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near(a, bx, by, d=TILE):
return abs(a.x - bx) < d and abs(a.y - by) < d
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
follow()
dt = 1 / 30
while True:
btn.poll()
frame += 1
if state == "dialog":
if not dlg_shown: # draw ONCE -> no per-frame flicker
scene.refresh()
dlg.draw(board.DISPLAY, bufA, LINES)
dlg_shown = True
if btn.just_pressed(btn.A) or btn.just_pressed(btn.B):
state = "over"; scene.invalidate()
clock.tick()
continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
facing = RIGHT if dx > 0 else LEFT
elif dy:
facing = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y):
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED):
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow(); walk.play(FACE_NAME[facing]); walk.tick(dt)
else:
hero.frame = facing * 2
# attack: defeat a slime in the tile ahead of the facing
if btn.just_pressed(btn.B):
ddx, ddy = DIR[facing]
ax, ay = hero.x + ddx * TILE, hero.y + ddy * TILE
for e in enemies:
if e.visible and abs(e.x - ax) < TILE and abs(e.y - ay) < TILE:
e.visible = False
# slimes chase (slower: move every other frame) and respect walls
if frame % 2 == 0:
for e in enemies:
if not e.visible:
continue
sx = (hero.x > e.x) - (hero.x < e.x)
sy = (hero.y > e.y) - (hero.y < e.y)
if sx and can_walk(e.x + sx, e.y):
e.move(e.x + sx, e.y)
if sy and can_walk(e.x, e.y + sy):
e.move(e.x, e.y + sy)
# take damage on contact (unless in i-frames)
if inv > 0:
inv -= 1
else:
for e in enemies:
if e.visible and near(e, hero.x, hero.y, 13):
hp -= 1
inv = 40
if hp <= 0: # down -> back to start, full HP
hp = 6
hero.move(START[0], START[1])
follow()
break
for c in coins:
if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12:
c.visible = False; got += 1
if near(hero, npc.x, npc.y):
hud.set("HP %d COINS %d/%d A:TALK B:SWING" % (hp, got, len(coins)))
if btn.just_pressed(btn.A):
state = "dialog"; dlg_shown = False
else:
hud.set("HP %d COINS %d/%d" % (hp, got, len(coins)))
scene.refresh()
dt = clock.tick()
▶ Try it in the browser

step 8 — step8_quest.py · the quest (capstone)

Section titled “step 8 — step8_quest.py · the quest (capstone)”

Quest step 8 Everything becomes a goal: the NPC asks for all the coins; once you have them, talking again opens the door (we rewrite those tiles from “door” to “path” so collision lets you through); stepping on the shrine tile wins. A small stage variable drives the dialog and the door. You see: talk → collect → unlock → reach the shrine → “QUEST COMPLETE”. Try it: require defeating all slimes too before the door opens.

69 collapsed lines
# Quest -- step 8: a quest, a goal, and a win state (capstone).
#
# What you learn: tying the systems into an actual game. The NPC gives an objective
# (collect every coin); once you have them all, talking again OPENS the door (we
# rewrite those tiles from "door" to "path", so collision lets you through); stepping
# on the shrine tile wins. A small quest-stage variable drives the dialog text and
# the door. This is the whole loop: talk -> collect -> unlock -> reach the goal.
#
# New vs step 7: a quest stage, objective-driven dialog, opening the door by editing
# tiles, a goal tile + win state.
#
# BIG PICTURE: you've now hand-built an RPG -- map, camera, collision, animation,
# items, NPC, combat, quest. You DON'T have to keep hand-coding maps like this: the
# editor (editor/) lets you paint the map, place the hero/NPC/coins, and FLAG tiles
# (solid/coin/goal) visually, then the picogame_scene loader builds the scene for
# you -- the exact things this file does by hand become data. See tutorials/README.md
# and examples/picogame_platformer_scene.py for a game whose level is loaded that way.
#
# Run: python3 sim/run.py tutorials/03-quest/step8_quest.py --shot /tmp/q8.png
import array
import board
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_anim
import picogame_shapes as shp
import picogame_ui as ui
W, H = 320, 240
TILE = 16
SPEED = 2
MAP = [
"##############################",
"#.....:......................#",
"#.....:........*.............#",
"#..##.:.............~~~~~~...#",
"#..##.:.............~~~~~~...#",
"#.....N.............~~~~~~...#",
"#.....:.......E.....~~~~~~...#",
"#.....:......................#",
"#.....:.....*.........*......#",
"#.....:......................#",
"#:::::::P:::::::::*::::::::::#",
"#.....:...................*..#",
"#.....:.............E........#",
"#.....:...WWWWWWW............#",
"#.....:...W.....W......##....#",
"#.....:.*.W..G..W......##....#",
"#.....:...W.....W........E...#",
"#.....:...WWWDWWW............#",
"#.....:......................#",
"##############################",
]
MCOLS, MROWS = 30, 20
CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4,
"W": 5, "D": 6, "G": 7}
TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30),
(120, 120, 130), (150, 90, 40), (240, 210, 60)]
SOLID = (3, 4, 5, 6)
DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
DIR = {DOWN: (0, 1), UP: (0, -1), LEFT: (-1, 0), RIGHT: (1, 0)}
FACE_NAME = ("down", "up", "left", "right")
BG = pg.rgb565(0, 0, 0)
WHITE = pg.rgb565(255, 255, 255)
NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])
world = pg.Tilemap(tileset, MCOLS, MROWS)
START = (TILE, TILE)
npc_x, npc_y = TILE, TILE
coin_spots, enemy_spots, door_tiles = [], [], []
for ty in range(MROWS):
for tx in range(MCOLS):
ch = MAP[ty][tx] if tx < len(MAP[ty]) else "."
world.tile(tx, ty, CH2TILE.get(ch, 1))
if ch == "P":
START = (tx * TILE, ty * TILE)
elif ch == "N":
npc_x, npc_y = tx * TILE, ty * TILE
elif ch == "*":
coin_spots.append((tx * TILE, ty * TILE))
elif ch == "E":
enemy_spots.append((tx * TILE, ty * TILE))
elif ch == "D":
door_tiles.append((tx, ty))
scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))
coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]
for c in coins:
scene.add(c)
slime_bm = shp.circle(14, pg.rgb565(120, 200, 80))
enemies = [pg.Sprite(slime_bm, x + 1, y + 1) for (x, y) in enemy_spots]
for e in enemies:
scene.add(e)
npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)
scene.add(npc)
def hero_bitmap():
pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60),
pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)])
stride = TILE * 8
data = bytearray(stride * TILE)
for f in range(4):
for s in range(2):
fr = f * 2 + s
for y in range(s, TILE):
yy = y - s
for x in range(TILE):
face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or
(f == 2 and x < 4) or (f == 3 and x >= TILE - 4))
data[y * stride + fr * TILE + x] = 2 if face else 1
lx = 4 if s == 0 else 6
for x in (lx, TILE - 1 - lx):
data[(TILE - 1) * stride + fr * TILE + x] = 3
return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8,
stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), START[0], START[1], frame=0)
walk = picogame_anim.AnimatedSprite(hero, {
"down": ([0, 1], 8, True), "up": ([2, 3], 8, True),
"left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})
scene.add(hero)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)
dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 64, W - 16, 58, WHITE, NAVY, maxlines=4)
facing = DOWN
got = 0
hp = 6
inv = 0
stage = 0 # 0 not started, 1 collecting, 2 door open
state = "over"
frame = 0
NCOINS = len(coins)
overlay_shown = False # draw dialog/win modal once, not every frame
def solid_at(px, py):
tx, ty = px // TILE, py // TILE
if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS:
return True
return world.tile(tx, ty) in SOLID
def can_walk(px, py):
return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or
solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near(a, bx, by, d=TILE):
return abs(a.x - bx) < d and abs(a.y - by) < d
def follow():
ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2)))
oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2)))
scene.set_view(int(ox), int(oy))
def dialog_lines():
if stage == 0:
return ["Villager:", "Bring me all %d coins and" % NCOINS,
"I'll open the shrine door.", "(press A)"]
if stage == 1 and got < NCOINS:
return ["Villager:", "You have %d of %d coins." % (got, NCOINS),
"Keep looking!", "(press A)"]
return ["Villager:", "The door is open.",
"Seek the shrine within.", "(press A)"]
def open_door():
for (tx, ty) in door_tiles:
world.tile(tx, ty, 2) # door -> path (no longer SOLID)
scene.invalidate()
follow()
dt = 1 / 30
while True:
btn.poll()
frame += 1
if state == "win":
if not overlay_shown: # draw ONCE -> no per-frame flicker
scene.refresh()
dlg.draw(board.DISPLAY, bufA, ["You reached the shrine!", "", "QUEST COMPLETE", "(press A)"])
overlay_shown = True
if btn.just_pressed(btn.A):
state = "over"; scene.invalidate()
clock.tick()
continue
if state == "dialog":
if not overlay_shown: # draw ONCE -> no per-frame flicker
scene.refresh()
dlg.draw(board.DISPLAY, bufA, dialog_lines())
overlay_shown = True
if btn.just_pressed(btn.A) or btn.just_pressed(btn.B):
if stage == 0:
stage = 1
elif stage == 1 and got >= NCOINS:
stage = 2
open_door()
state = "over"; scene.invalidate()
clock.tick()
continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT)
dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP)
if dx:
facing = RIGHT if dx > 0 else LEFT
elif dy:
facing = DOWN if dy > 0 else UP
moved = False
if dx and can_walk(hero.x + dx * SPEED, hero.y):
hero.move(hero.x + dx * SPEED, hero.y); moved = True
if dy and can_walk(hero.x, hero.y + dy * SPEED):
hero.move(hero.x, hero.y + dy * SPEED); moved = True
if moved:
follow(); walk.play(FACE_NAME[facing]); walk.tick(dt)
else:
hero.frame = facing * 2
if btn.just_pressed(btn.B):
ddx, ddy = DIR[facing]
ax, ay = hero.x + ddx * TILE, hero.y + ddy * TILE
for e in enemies:
if e.visible and abs(e.x - ax) < TILE and abs(e.y - ay) < TILE:
e.visible = False
if frame % 2 == 0:
for e in enemies:
if not e.visible:
continue
sx = (hero.x > e.x) - (hero.x < e.x)
sy = (hero.y > e.y) - (hero.y < e.y)
if sx and can_walk(e.x + sx, e.y):
e.move(e.x + sx, e.y)
if sy and can_walk(e.x, e.y + sy):
e.move(e.x, e.y + sy)
if inv > 0:
inv -= 1
else:
for e in enemies:
if e.visible and near(e, hero.x, hero.y, 13):
hp -= 1
inv = 40
if hp <= 0:
hp = 6
hero.move(START[0], START[1]); follow()
break
for c in coins:
if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12:
c.visible = False; got += 1
# reach the shrine (goal tile) once the door is open
if stage >= 2:
ctx = (hero.x + TILE // 2) // TILE
cty = (hero.y + TILE // 2) // TILE
if world.tile(ctx, cty) == 7:
state = "win"; overlay_shown = False
if near(hero, npc.x, npc.y):
hud.set("HP %d COINS %d/%d A:TALK" % (hp, got, NCOINS))
if btn.just_pressed(btn.A):
state = "dialog"; overlay_shown = False
else:
obj = "FIND THE COINS" if stage < 1 or got < NCOINS else ("DOOR OPEN!" if stage >= 2 else "RETURN TO NPC")
hud.set("HP %d COINS %d/%d %s" % (hp, got, NCOINS, obj))
scene.refresh()
dt = clock.tick()
▶ Try it in the browser

The payoff: you hand-built a whole RPG. You don’t have to keep hand-coding maps — the web editor lets you paint this map, place the hero/NPC/coins, and flag tiles (solid/coin/goal) visually, and picogame_scene loads it. Everything step8 does by hand becomes data. See the scene format reference, and the bundled examples/picogame_platformer_scene.py example for a complete scene loaded from data.