Log In  

Cart #tudanawati-10 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

This tutorial is part 3 of a series. View part 1 here.

And the end of part 2 we had roadside objects drawn using scaled sprites and sections of the map.

Everything so far has been flat, so in this tutorial we will add some hills and valleys.
This will require solving some overlap issues which we will solve using a clip rectangle "trick", which will in turn set us up nicely for implementing tunnels - so we'll do that too.

Defining the pitch

First we'll define the pitch of each corner with a field called "pi" (not to be mistaken for the Greek letter and mathematical constant).


This will define the gradient at the start of each corner. So a "pitch" of 1 means the ground rises 1 unit for every unit it advances forward - a 45 degree incline. Likewise -1 would be a 45 degree decline. And 0 is of course level.

For brevity we've made the pitch optional - it will default to 0 if not supplied.

To make the hills and valleys smooth the pitch will smoothly interpolate between values across each corner. We will calculate the delta to add to the pitch for each segment in the _init() method:

 -- calculate the change in pitch
 -- per segment for each corner.
 for i=1,#road do
  local corner=road[i]
  local pi=corner.pi or 0
  local nextpi=road[i%#road+1].pi or 0

The pitch delta is stored in field "dpi" (not to be confused with "dots per inch" - naming stuff is hard).


The pitch affects the vertical direction of the road as it is drawn. We actually already have a "yd" variable which is added to the y coordinate after each segment is drawn. It's just that until now the yd has always been 0, so the ground has always been flat. Now we can use it to create the hills and valleys.

First we calculate the initial value at the start of _draw()

 -- direction
 local camang=camz*road[camcnr].tu
 local xd=-camang
 local yd=road[camcnr].pi+road[camcnr].dpi*(camseg-1)
 local zd=1

This is the start value "pi" plus the delta "dpi" added for every segment the camera has traversed along the corner.

Then we add "dpi" to the "yd" after each segment is drawn, similar to how we've added the turn "tu" to "xd":

  -- turn and pitch

This is all we need to give the road a smooth rising and falling effect.

Cart #tudanawati-6 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

A note on camera movement

It's worth pointing out that the camera automatically follows the shape of the hills and valleys smoothly, without us having to write any specific code.

This works, because the camera position is skewed in the direction of the segment it is on. Back in tutorial one we implemented the skew function like this:

function skew(x,y,z,xd,yd)
 return x+z*xd,y+z*yd,z

In particular the "y+z*yd" ensures the camera moves up and down with the shape of the road.

Storing positions "unskewed" and skewing them at draw time has some advantages. Regardless of how the road turns or pitches:

  • The ground is always at y=0
  • The center of the road is always at x=0
  • The sides of the road are always at x=+/-3

This approach will also make implementing AI cars easier later on.


The hills and valleys introduce some obvious overlap issues - sprites visible through hills, far away ground being drawn in front instead of behind.

One approach to fixing this would be to change the road drawing order so that it is back-to-front as well.
This would be a perfectly valid approach (although it would require careful management to incorporate the sprites and road/ground at the same time).

However we're going to use a different approach. We're going to use clipping rectangles to prevent far away road/ground being drawn over near objects.

It will work like this:

  • We start with a clipping rectangle covering the whole screen. I.e. nothing is clipped.
  • As we draw forward, we move the bottom of the clipping rectangle up so that it does not include the road and ground we have just drawn.
  • The clipping rectangle prevents anything drawn further down the road from overlapping with what we've already drawn. Anything that would overlap will simply be clipped away.

The advantage of this method is that we can continue drawing the road as we walk forward along it. We don't have to change the algorithm too much.
It also reduces overdraw, which was important back-in-the-day on platforms that were fill-rate limited. Pico-8 drawing is fast enough that it doesn't really matter though.

To start with we define the initial clip region before the main drawing loop:

 -- current clip region
 local clp={0,0,128,128}

The "clp" array defines the left, top, right and bottom values in that order.

We reduce the clip region after drawing each segment, immediately after adding the background sprites (for reasons we'll see later):

  -- reduce clip region

The "min" function ensures that the bottom of the rectangle only ever moves up, which is important for drawing crests of hills correctly.

"setclip" is simply a helper function that sets the Pico-8 clip region for drawing:

function setclip(clp)

Putting this together we get:

Cart #tudanawati-7 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

Clipping sprites

The hills are no longer see through, but now the background sprites are not being drawn correctly. The problem is the sprites are being drawn after the road, when the clipping rectangle has been reduced to cover just the sky. We could reset the clipping rectangle to cover the whole screen before drawing the sprites, which would improve things, but sprites would still be visible through the hills.

What we really need to do is draw each sprite using the clipping rectangle that was active when its segment was drawn, which we can do by storing a copy of the clip rectangle in the sprites array.

We'll add a "clp" parameter to addbgsprite:

function addbgsprite(sp,sumct,bg,side,px,py,scale,clp)


 -- add to sprite array

It's important that we create a new array and copy each of the "clp" fields individually, so that we get a snapshot of the "clp" array at that point. Referencing "clp" directly would not work, because "clp" changes as the road is drawn.

Now we update the calls in the main drawing loop to pass in the clipping rectangle:

 -- add background sprites
 addbgsprite(sp,sumct,road[cnr].bgr, 1,px,py,scale,clp)
 addbgsprite(sp,sumct,road[cnr].bgc, 0,px,py,scale,clp)

Finally we apply the clipping rectangle in drawbgsprite:

function drawbgsprite(s)

With this in place we should have hills and valleys drawn correctly, including the background sprites.

Cart #tudanawati-8 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA


For the last part of this tutorial we will implement basic tunnels. Tunnels give racing games a cool change of environment and they're just fun to drive through.

And with the clipping rectangle logic is in place they're reasonably straightforward to implement.

It's essentially an extension of the clipping logic used for the ground. But now we also reduce the top of the clipping rectangle as we draw the tunnel ceiling, and the left and right sides as we draw the tunnel walls.

We'll start by defining the tunnel in our road:




Tunnel corners are denoted by "tnl=true".

I've placed the tunnel section towards the start of the road so that it's quicker to get to for testing and debugging. (It can be moved later once everything is working correctly).

Tunnel front face

Next we'll draw the tunnel face.
We need to detect the first corner tunnel ("tnl" is set to true) where the previous corner was not a tunnel, which we can do by tracking the tunnel flag for the current corner, and the previous corner.

We set the initial value of the previous tunnel flag before the main drawing loop:

 -- previous tunnel flag
 local ptnl=road[cnr].tnl

Strictly speaking this should be set to the "tnl" property of the previous segment, but it doesn't actually make any noticeable difference for our purposes.

Inside the main loop we compare it with the current segment's tunnel flag, and draw the tunnel face at the start of the tunnel:

 -- draw tunnel face
 local tnl=road[cnr].tnl
 if tnl and not ptnl then

Note that the tunnel face will be drawn at the start of the current segment. This means we must use the previous cursor position (ppx, ppy, pscale), as cursor positions are for the end of their segment.

We also need to copy "tnl" to "ptnl" just before the loop ends, so that "ptnl" is set correctly for the next segment:

 -- track previous projected position

We will draw the tunnel face by drawing rectangles around the mouth of the tunnel.
We'll start by creating a helper function that takes the projected road position and calculates a rectangle describing the tunnel floor, ceiling and walls.

function gettunnelrect(px,py,scale)
 local w,h=6.4*scale,4*scale
 local x1=ceil(px-w/2)
 local y1=ceil(py-h)
 local x2=ceil(px+w/2)
 local y2=ceil(py)
 return x1,y1,x2,y2

The red rectangle is the "tunnel rectangle". I.e. the mouth of the tunnel.

We're allowing 6.4 units of width for the road, rather than 6, because the red and white shoulder things stick out another 0.2 units each way.
The tunnel will be 4 units high, which is low enough to make the tunnel feel claustrophobic but high enough to be above the camera.

Using this we can implement drawtunnelface:

function drawtunnelface(px,py,scale)

 -- tunnel mouth 
 local x1,y1,x2,y2=gettunnelrect(px,py,scale)

 -- tunnel wall top
 local wh=4.5*scale
 local wy=ceil(py-wh)

 -- draw faces

Essentially we're drawing a rectangle that extends from the top of the facing wall down to the top of the tunnel mouth. Then we draw two more rectangles either side of the mouth down to the ground.

The last thing we need to do is restrict the clipping rectangle to the tunnel mouth.
We'll create this function to adjust the clipping rectangle:

function cliptotunnel(px,py,scale,clp)
 local x1,y1,x2,y2=gettunnelrect(px,py,scale)

And update the tunnel face drawing code in the main drawing loop:

 -- draw tunnel face
 local tnl=road[cnr].tnl
 if tnl and not ptnl then

Now we should have a front facing tunnel wall:

Cart #tudanawati-9 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

Tunnel interior

Next we need to draw the tunnel interior. This will consist of the ceiling, walls and road.

First we need to separate the road drawing code from the ground drawing code, so that we can just draw the road when inside the tunnel. We'll move the ground drawing code out into it's own function:

function drawground(y1,y2,sumct)

 -- draw ground
 local gndcol=3

And delete the "draw ground" lines from drawroad.

We'll update the drawing code in the main loop to draw the ground or tunnel interior as appropriate, then draw the road:

  -- draw ground/tunnel walls
  local sumct=getsumct(cnr,seg)
  if tnl then

  -- draw road

We draw the tunnel walls by comparing the tunnel rectangles at the start and end of the current segment.

We draw a ceiling rectangle to connect the top of each tunnel rectangle.
Then we draw wall rectangles on each side to connect the left and right sides.

We'll use an alternating colour for effect.

function drawtunnelwalls(px,py,scale,ppx,ppy,pscale,sumct)

 -- colour
 local wallcol=0

 -- draw walls
 local x1,y1,x2,y2=gettunnelrect(px,py,scale)
 local px1,py1,px2,py2=gettunnelrect(ppx,ppy,pscale)


The final step is to update the reduce-clip-region logic in the main drawing loop to handle tunnels.

  -- reduce clip region
  if tnl then

And this completes our tunnel:

Cart #tudanawati-10 | 2019-11-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

Next steps

This is the end of part 3. In part 4 we'll add some cars to overtake.

P#69744 2019-11-09 23:30 ( Edited 2019-11-10 08:29)

You have really snapped on to this, @Mot. :D
Here is your first star for this incarnation.
Now all you need is an in-code road editor for both players and designers.

P#69768 2019-11-09 23:56 ( Edited 2019-11-09 23:56)

Actually an editor wouldn't be too difficult. The road data structure isn't that complex.

P#69778 2019-11-10 08:29

Let me know if part 4 onwards will be up and running. I am trying to learn Lua, even if my ADHD sees the learning process as too "herculean" to my current intellect.

P#77406 2020-05-29 22:59

You've picked a good platform to learn on.

I have the code changes all ready to go for a part 4. I just haven't done the write up yet - been a bit side-tracked by other projects.
Also I wasn't sure how much of a demand there was to go further.

P#77408 2020-05-29 23:15

Alright, Mot. Keep me posted.

P#77419 2020-05-30 04:18

Hey! I'm new with Lua but this will be one of my first projects when i feel more confident, really nice tutorial!

Do you know if it would be too complex to add SpriteStack models? I want to achieve this look

P#80526 2020-08-08 22:50 ( Edited 2020-08-08 22:50)

@equlsa I think I've seen somebody use spritestack sprites in Pico-8, but I don't know how much work it would be. I assume if you can get the sprites loaded in it's just about drawing the slices on top of each other. Drawing them scaled might complicate things a little, but should be doable.

P#80584 2020-08-10 06:55

@Mot when will there be part 4? I am anxious o/

P#83618 2020-11-02 17:14

Sorry to grave post but is part 4 coming? And if not, how do I add actual player input?

P#132650 2023-08-02 02:40

Yeah, perhaps I should actually finish this.
I had some source code and sprites for the next version, but they got lost/deleted somehow, which threw me off my stride and I never got around to picking it up again.

P#132783 2023-08-06 12:18

[Please log in to post a comment]