Skip to content

Pseudo-3D (Mode-7 & raycasting)

Two ways to get a 3D-looking world out of a 2D engine, both rendered into a buffer-less StripDraw view so they cost zero retained RAM. picogame_mode7 gives you a receding ground plane (a racer track, a flying-carpet floor); picogame_ray gives you walls and corridors (a dungeon, a maze). See /reference/ for the signatures.

You wantReach forHow it draws
A ground / floor that recedes to the horizonpicogame_mode7.Camera (drives the C Canvas.mode7)C primitive, per-scanline - fast
Walls / a first-person corridorpicogame_ray.Raycasterpure Python DDA - slower, tune it

Both draw into the below-horizon rows and leave the sky to you; you fill the rows above the horizon yourself (a flat colour, a gradient, or fx.Sky).

A Mode-7 racer - the road recedes, rumble stripes rush toward you

A Mode-7 perspective floor: each screen row below the horizon samples one distance into a texture, so a flat top-down image reads as a ground plane stretching to the horizon. All the maths lives in this Python helper; the per-scanline fill is the C engine primitive Canvas.mode7, so it is cheap.

import picogame_mode7 as m7

  • Camera(fov=0.66) - holds the field of view (higher = wider, more fish-eye). Reuse one camera; pass the pose to draw() each frame.
  • .draw(canvas, texture, x, y, angle, horizon, height, y_off=0) - fill canvas (a Canvas or a StripDraw view) below horizon with a receding view of texture. x/y are the camera position in world (tile) units, angle is the heading in radians, horizon is the screen row of the horizon line, and height is how high the camera sits (bigger = the ground recedes slower, so you see further). In a StripDraw callback pass y_off=vy so the perspective divide uses the absolute screen row.

texture must have power-of-2 width and height, and one world unit = one full texture tile. A texture that tiles seamlessly (grass, a road with rumble stripes) can use a large height and wrap forever; a single non-tiling image (one closed circuit) needs a small height or the far distance repeats it.

import picogame as pg
import picogame_mode7 as m7
cam = m7.Camera(fov=0.9)
def ground(view, vx, vy, vw, vh):
for r in range(vh): # sky: fill rows above the horizon yourself
if vy + r < HORIZON:
view.fill_rect(0, r, vw, 1, SKY)
cam.draw(view, TRACK, car.x, car.y, car.heading, HORIZON, 5.0, y_off=vy)
scene.add(pg.StripDraw(ground, 0, 0, W, H)) # 0 retained bytes
# ...each frame: move car.x/y/heading, then scene.refresh()

A first-person raycaster - a dungeon corridor with depth-shaded walls

A Wolfenstein-style raycaster: one DDA ray per screen column finds the nearest wall, and each column is drawn as a distance-shaded vertical bar. Pure Python, no firmware change - the walls are solid fill_rect columns into a StripDraw view.

import picogame_ray

picogame_ray.Raycaster - first-person wall caster

Section titled “picogame_ray.Raycaster - first-person wall caster”
  • Raycaster(world, wall_colors, sky, floor, fov=0.66, stride=2) - world is a list of equal-length strings ('0' = empty, '1'..'9' = wall types); wall_colors maps each wall type to a (face_colour, side_colour) pair (the side colour a touch darker = a free depth cue); sky/floor are wire-RGB565 backgrounds. stride casts one ray per N columns - the perf/quality knob (see below).
  • .cast(px, py, ang, sw, sh) - cast the rays for camera position (px, py) and heading ang, caching each column’s wall span. Call once per frame before scene.refresh().
  • .draw(view, vx, vy, vw, vh) - the StripDraw callback: paints the sky/floor bands and the wall columns. Adjacent identical columns are merged into one fill_rect.
  • .solid(x, y) - is the map cell at integer (x, y) a wall? Use it for movement collision (out of bounds counts as a wall).
  • .zbuf - after cast(), a list of each column’s wall distance (the depth buffer project_sprite tests against).
  • .project_sprite(sx, sy, margin=0.2) - project a world-space point (sx, sy) onto the screen for the last cast(). Returns (screen_x, size, depth), or None if the point is behind the camera or hidden by a nearer wall. size is the on-screen height in px at the wall scale - set sprite.scale = size / bitmap_height; sort your sprites by depth far-to-near so nearer ones draw on top. margin is z-test slack so a sprite flush against a wall is not culled.
import picogame as pg
import picogame_ray
MAP = ["1111111111", "1000000001", "1011100201", "1000000001", "1111111111"]
WALLS = {1: (pg.rgb565(150, 150, 160), pg.rgb565(95, 95, 110)),
2: (pg.rgb565(170, 90, 60), pg.rgb565(110, 55, 35))}
rc = picogame_ray.Raycaster(MAP, WALLS, pg.rgb565(30, 30, 48), pg.rgb565(40, 34, 30), stride=2)
scene.add(pg.StripDraw(rc.draw, 0, 0, W, H))
# ...each frame:
rc.cast(px, py, ang, W, H) # cast, then paint
scene.refresh()
# ...to move without walking through walls:
if not rc.solid(int(nx), int(py)):
px = nx

The raycaster draws only walls; enemies and pickups are ordinary Sprites scaled and placed each frame from project_sprite, depth-tested against the walls so they hide behind corners. Add the sprites to the Scene after the StripDraw so they layer on top, and reuse a picogame_pool.Pool rather than creating them per frame.

GUYS = [Enemy(3.5, 2.5), Enemy(6.5, 3.5)] # world (tile) positions
for g in GUYS:
g.spr = pg.Sprite(DEMON_BMP, -40, -40) # DEMON_BMP is 8 px tall
g.spr.anchor = (0.5, 0.5)
scene.add(g.spr) # AFTER the StripDraw -> on top of walls
def frame():
rc.cast(px, py, ang, W, H) # cast walls (fills rc.zbuf)
GUYS.sort(key=lambda g: -((g.x - px) ** 2 + (g.y - py) ** 2)) # far-to-near
for g in GUYS:
p = rc.project_sprite(g.x, g.y)
if p:
sx, size, _ = p
g.spr.move(sx, HORIZON) # centre on the horizon row
g.spr.scale = size / 8.0 # bitmap is 8 px tall
g.spr.visible = True
else:
g.spr.visible = False # off-screen or behind a wall
scene.refresh()