The Lisp Game Jam is a semiannual tradition I enjoy participating in. This time around I created Spilljackers, a 3D cyberspace heist game with my friend Emma Bukacek. Rather than use Fennel with the LÖVE framework (my usual choice) we went back to TIC-80 which we had used previously on our last game collaboration.
There's a lot to say about the style and feel of the game; Emma's punchy writing and catchy tunes brought so much to the table, but for this post I want to focus on the technical aspects. This was the first time I tried writing a 3D game. Instead of doing "proper 3D" which requires a lot of matrix math. I took a much simpler approach and built it using raycasting which applies some limitations but results in much simpler and faster1 code.
Raycasting (not to be confused with ray-tracing) works by making each column of pixels on the screen cast a "ray" out to see what walls it encounters, and we use the information about walls to draw a column of pixels representing the wall. Some raycasting engines (like that of the famous Wolfenstein 3D) force all walls to be the same height, which means you can stop tracing when you hit the first wall, but we want to allow walls of various heights, so it traces the ray out to the distance limit, then tracks back and draws all the lines back-to-front so that the nearer lines cover the further ones.
The final jam version of Spilljackers was 1093 lines, but I had the basic rendering of the map nailed down after the first evening of coding, and in fact the core of the algorithm can be demoed in 43 lines of code. There is some trig used, but if you've ever built a 2D game that used trig to do movement or collision detection, the math here is no more complex than that. Let's take a look!
We start by defining some constants and variables, including screen size, player characteristics (speed, turn speed, width, and height), and position/rotation:
(local (W MW H MH tau) (values 240 136 140 68 (* math.pi 2))) (local (spd tspd pw ph) (values 0.7 0.06 0.7 0.5)) (var (x y rotation) (values 12 12 0))
Next we have the movement code. This looks a lot like it would in a 2D TIC game—when we move, we check all four corners of the player's bounding box to see if the new position is valid based on whether the map coordinates for that position show an empty tile (zero) or not. In a real game we would have some momentum and sliding across walls, but that's omitted for clarity.
(fn ok? [x y] (= 0 (mget (// x 8) (// y 8)))) (fn move [spd] (let [nx (+ x (* spd (math.cos rotation))) ny (+ y (* spd (math.sin rotation)))] (when (and (ok? (- nx pw) (- ny pw)) (ok? (- nx pw) (+ ny pw)) (ok? (+ nx pw) (- ny pw)) (ok? (+ nx pw) (+ ny pw))) (set (x y) (values nx ny))))) (fn input-update  (when (btn 0) (move spd)) (when (btn 1) (move (- spd))) (when (btn 2) (set rotation (% (- rotation tspd) tau))) (when (btn 3) (set rotation (% (+ rotation tspd) tau))))
Now before we get to the actual raycasting, let's take a look at the map data. In TIC-80 each map position has a tile number in it which corresponds to a location on the sprite sheet. Rather than encoding complex tables of tile numbers to the visual properties of the map cells they describe, we encode properties about the tile in its sprite sheet position.
The color of the cell is determined by the sprite's column, and the height of the cell is determined by the its row. In this image tile #34 is selected. Since the sprites are arranged in a 16x16 grid, we calculate its column (and therefore its color) by taking the tile number and calculating modulo 16, getting 3. Likewise 34 divided by 16 (integer division) is 3, which gives us our height multiplier.
Back to the code—let's jump to the outermost function and work our
way inwards. The
TIC global function is called sixty times
per second and ties everything together: reading input, updating state, and
for loop here steps thru every column to
draw-ray after precalculating a few things.
(fn _G.TIC  (input-update) (cls) (for [col 0 W] ; draw one column of the screen at a time (let [lens-r (math.atan (/ (- col MW) 100))] (draw-ray (math.sin (+ rotation lens-r)) (math.cos (+ rotation lens-r)) (math.cos lens-r) col x y x y 16))))
If we calculate all distances as being from the
x,y point representing the player's position,
(as is the case in the video here) then columns at the player's
peripheral vision will look further away than columns near the
center of the screen, resulting in a fisheye lens
lens-r value above is used below to
counteract that by calculating how far away the current column is
from the midpoint of the screen. We also
sin once as an
optimization to avoid having to do it repeatedly
draw-ray function below starts by calling
cast helper function to see where the ray will
intersect with the next tile, and what tile number that is. The
height of the wall is calculated based on the distance of that
intersection point from the player, with
lens-factor applied as mentioned above to
counteract the fisheye effect. Once we have the height factor, it's
used to calculate the
the "wall slice" line by offsetting it from
vertical midpoint of the screen), the
ph height of the
(// tile 16), which tells us which row in
the sprite sheet we're looking at.
Because we have to draw some walls behind other walls,
draw-ray function must be
limit argument tells us how far to recurse;
if we haven't hit our limit, keep casting the
ray before calling
line to actually
render the wall we've just calculated. This ensures that more
distant walls are drawn behind closer walls. Finally we only
line if the tile is nonzero, because the zeroth
tile indicates empty space. The color of the line is
tile 16) since as per above, the column in the
16-tile-wide sprite sheet determines wall color.
(fn draw-ray [sin cos lens-factor col rx ry x y limit] (let [(hit-x hit-y tile) (cast rx ry cos sin) ; where and what tile is hit? dist (math.sqrt (+ (math.pow (- hit-x x) 2) (math.pow (- hit-y y) 2))) height-factor (/ 800 (* dist lens-factor)) top (- MH (* height-factor (+ (// tile 16) (- 1 ph)))) bottom (+ MH (* height-factor ph))] (when (< 0 limit) ; draw behind the current wall first (draw-ray sin cos lens-factor col hit-x hit-y x y (- limit 1))) (when (not= tile 0) ; only draw nonzero tiles (line col top col bottom (% tile 16)))))
In order to determine which tile a ray hits next,
cast function must check whether the ray will
hit a horizontal edge of the next map cell or a vertical edge. Once
it determines this it can use the precalculated
sin values to pinpoint the coordinates at which the
next cell is hit, and call
mget to identify the tile of
(fn cast-n [n d] (- (* 8 (if (< 0 d) (+ 1 (// n 8)) (- (math.ceil (/ n 8)) 1) )) n)) (fn ray-hits-x? [nx ny nxy nyx] (< (+ (* nx nx) (* nxy nxy)) (+ (* ny ny) (* nyx nyx)))) (fn cast [x y cos sin] (let [nx (cast-n x cos) nxy (/ (* nx sin) cos) ny (cast-n y sin) nyx (/ (* ny cos) sin)] (if (ray-hits-x? nx ny nxy nyx) (let [cx (+ x nx) cy (+ y nxy)] (values cx cy (mget (// (+ cx cos) 8) (// cy 8)))) (let [cx (+ x nyx) cy (+ y ny)] (values cx cy (mget (// cx 8) (// (+ cy sin) 8)))))))
And that's it! That's all you need2
for a minimal raycasting game in TIC-80. Below is an embedded HTML
export of the game so you can try it out for yourself!
ESC and clicking "close game" will bring you
to the TIC console where you can press ESC again to see the code and
map editors. Making changes to the code or map and
RUN in the console will show you the effects
of your changes!
- CLICK TO PLAY -
I learned about raycasting from reading and modifying the source to the game Portal Caster which creates some neat puzzles using portals. I also found this write-up of FPS-80, a somewhat more elaborate TIC-80 raycaster that includes some impressive lighting effects. In the final version of Spilljackers, I used the interlaced rendering strategy from FPS-80. Every even tick, you render the even columns, and every odd tick you render the odd columns. This results in some "fuzzy" visuals, but it improves performance to the point where the game runs smoothly even with a long render distance even on an old Thinkpad from 2008.
 In this context, the reason raycasting is faster is that the platform I'm using (TIC-80) does not have any access to the GPU and does all its rendering on the CPU. If you have a GPU then things are different!
 You can
see the full code
on its own in a text file here. If you have TIC-80 downloaded, you
tic80 mini.fnl to load up the game locally; the
data for the map and palette are encoded as comments in the bottom
of the text file.