Daniel Keast

Pico 8: Mode 7

Pico 8, Programming

I made a Pico 8 cart which produces a SNES Mode 7 style floor. The trick is in the function tline, which draws a line between two points, sampling from the map data along the way. You give it the map position to start from, as well as dx and dy values it should use as an increment after each pixel. The other main thing is setting a depth value per scanline, the closer to the bottom of the screen the nearer to the player’s view. The rest is just trigonometry, applying angles to vectors using sin and cos.

Click here to try it out, and here to download it.

Here is the top level code. This is run when the cart is first booted. I put constants here, and code that should run once and never again. The poke statements are taken from the Pico 8 manual section on tline.

--horizontal map loop size
poke(0x5f38,8)
--vertical map loop size
poke(0x5f39,8)

--constants
--scanline where ground starts
hrzn=63
--forward and backward movement
spd=0.1
trn_spd=0.006

This is the _init function. Similar to the top level code it’s called once when the cart is first booted. The main difference here is that you can call this function to effectively reset the cart.

function _init()
 cam={h=128,x=5,y=5}
 look(0)
end

This is a helper function to point the camera in a certain direction. It also stores some calculations for use when drawing, the sine and cosine of the angle, as well as the vectors of the left and right side of the screen.

function look(a)
 local c=cos(a)
 local s=sin(a)

 --pico 8 uses "turns" rather
 --than radians
 cam.a=a%1
 cam.c=c
 cam.s=s

 --calculate the left and right
 --edges of the screen, 90 fov.
 --adding and subtracting the
 --vector looking right gives 45
 --degrees in either direction
 --
 --(x=s,y=-c) == right vector
 --e.g:
 --(x=0,y=-1) == forwards
 --(x=1,y= 0) == right
 cam.l={x=c-s,y=s+c}
 cam.r={x=c+s,y=s-c}
end

This function is to move the player on the map using the sine and cosine values calculated in look.

function move(spd)
  --cosine of an angle is the
  --x offset of a unit circle
  cam.x+=cam.c*spd
  --sine of an angle is the
  --y offset of a unit circle
  cam.y+=cam.s*spd
end

The _update60 function is called once per frame, before drawing. This is your chance to react to input and update the game state. The 60 in the name represents frames per second. The default function is just called _update and is called 30 times a second.

function _update60()
 if btn(⬆️) then
  move(spd)
 end
 if btn(⬇️) then
  move(-spd)
 end
 if btn(➡️) then
  look(cam.a+trn_spd)
 end
 if btn(⬅️) then
  look(cam.a-trn_spd)
 end
 if btn(❎) then
  cam.h=min(500,cam.h+1)
 end
 if btn(🅾️) then
  cam.h=max(1,cam.h-1)
 end
end

The _draw function is called after update to render the screen. When your game slows down and cannot keep up with the requested fps this is called less frequently, but update is still called regularly.

function _draw()
 cls(12)
 for y=hrzn,128 do
  --start sampling the map from
  --the top, no matter where the
  --horizon is. adding 1 to stop div/0
  --division by zero on the next
  --line
  local ln=y-hrzn+1
  --since line increases down
  --the screen, depth decreases
  --higher line = further away
  local dpth=cam.h/ln

  --map tile coords of the left
  --and right side of the screen
  local lx=cam.x+dpth*cam.l.x
  local ly=cam.y+dpth*cam.l.y
  local rx=cam.x+dpth*cam.r.x
  local ry=cam.y+dpth*cam.r.y

  local ln_wth=rx-lx
  local ln_hgt=ry-ly

  tline(
   --line coords, full scanline
   0,y,128,y,
   --map start coords
   lx,ly,
   --width/pixels=h step
   ln_wth/128,
   --height/pixels=v step
   ln_hgt/128)
 end

 print("h  :"..cam.h)
 print("a  :"..cam.a)
 print("x,y:"..cam.x..","..cam.y)
end