I previously introduced a simple outrun-style racing game and followed up with some ‘how to’ articles describing:

… and now we’re into the final lap! (groan). In this article we will add:

  • Billboards and trees
  • Other cars
  • Collision detection
  • Rudimentary car AI
  • A HUD with lap timer and fastest lap

… which will give us just enough interactivity to justify finally calling this a ‘game’.

A note on code structure

I mentioned early on in this series that this is code for a tech demo with global variables and few classes/structures, it is certainly not an example of javascript best practices.

In this final section, as we add much more code for maintaining sprites and updating other cars, we cross that (subjective) boundary where previously simple code starts to become more complex and could benefit from a little more structure…

… but it wont get that structure because its just a tech demo, so please forgive the slightly messy code examples in this article, and trust that in a real project we would want to aggressively clean this up.

Sprites

In part 1, before our game loop started, we loaded a sprite sheet containing all of the cars, trees, and billboards.

You could create a sprite sheet manually in any image editor, but maintaining the images and calculating the coordinates is best done with an automated tool. In this case, my spritesheet was generated with a small Rake task using the sprite-factory Ruby Gem.

This task generates the unified sprite sheets from individual image files as well as calculating the x,y,w,h coordinates to be stored in a SPRITES constant:

var SPRITES = {
  PALM_TREE:   { x:    5, y:    5, w:  215, h:  540 },
  BILLBOARD08: { x:  230, y:    5, w:  385, h:  265 },

  // ... etc

  CAR04:       { x: 1383, y:  894, w:   80, h:   57 },
  CAR01:       { x: 1205, y: 1018, w:   80, h:   56 },
};

Adding billboards and trees

We add an array to each road segment to contain our roadside sprites.

Each sprite consists of a source from the SPRITES collection along with a horizontal offset which is normalized so that -1 indicates the left edge of the road while +1 indicates the right edge of the road, allowing us to stay independent of the actual roadWidth.

Some sprites are placed very deliberately, while others are randomized.

function addSegment() {
  segments.push({
    ...
    sprites: [],
    ...
  });
}

function addSprite(n, sprite, offset) {
  segments[n].sprites.push({ source: sprite, offset: offset });
}

function resetSprites() {

  addSprite(20,  SPRITES.BILLBOARD07, -1);
  addSprite(40,  SPRITES.BILLBOARD06, -1);
  addSprite(60,  SPRITES.BILLBOARD08, -1);
  addSprite(80,  SPRITES.BILLBOARD09, -1);
  addSprite(100, SPRITES.BILLBOARD01, -1);
  addSprite(120, SPRITES.BILLBOARD02, -1);
  addSprite(140, SPRITES.BILLBOARD03, -1);
  addSprite(160, SPRITES.BILLBOARD04, -1);
  addSprite(180, SPRITES.BILLBOARD05, -1);

  addSprite(240, SPRITES.BILLBOARD07, -1.2);
  addSprite(240, SPRITES.BILLBOARD06,  1.2);

  
  for(n = 250 ; n < 1000 ; n += 5) {
    addSprite(n, SPRITES.COLUMN, 1.1);
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE1, -1 - (Math.random() * 2));
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE2, -1 - (Math.random() * 2));
  }

  ...
}

NOTE: If we were building a real game we would want to build a road editor of some kind for visually creating a map with hills and curves and include a mechanism to place sprites along the road…. but for our purposes we can simply addSprite() programatically.

Adding cars

In addition to our roadside sprites, we add a collection of the cars that occupy each segment. Along with a separate collection of all the cars on the track.

var cars      = [];  // array of cars on the road
var totalCars = 200; // total number of cars on the road

function addSegment() {
  segments.push({
    ...
    cars: [], // array of cars within this segment
    ...
  });
}

Maintaining 2 data structures for cars allows us to easily iterate over all the cars during the update() method, moving them from one segment to another as necessary, while at the same time allowing us to only render() the cars on the visible segments.

Each car is given a random horizontal offset, z position, sprite source and speed:

function resetCars() {
  cars = [];
  var n, car, segment, offset, z, sprite, speed;
  for (var n = 0 ; n < totalCars ; n++) {
    offset = Math.random() * Util.randomChoice([-0.8, 0.8]);
    z      = Math.floor(Math.random() * segments.length) * segmentLength;
    sprite = Util.randomChoice(SPRITES.CARS);
    speed  = maxSpeed/4 + Math.random() * maxSpeed/(sprite == SPRITES.SEMI ? 4 : 2);
    car = { offset: offset, z: z, sprite: sprite, speed: speed };
    segment = findSegment(car.z);
    segment.cars.push(car);
    cars.push(car);
  }
}

Rendering hills (revisited)

In previous articles I talked about rendering road segments, including curves and hills, but there were a few lines of code I glossed over regarding a maxy variable that started off at the bottom of the screen, but decreased as we rendered each segment to indicate how much of the screen had already been rendered:

for(n = 0 ; n < drawDistance ; n++) {

  ...

  if ((segment.p1.camera.z <= cameraDepth) || // behind us
      (segment.p2.screen.y >= maxy))          // clip by (already rendered) segment
    continue;

  ...

  maxy = segment.p2.screen.y;
}

This allows us to clip segments that would be obscured by already rendered hills.

A traditional painters algorithm would normally render from back to front, having the nearer segments overwrite the further segments. However we can’t afford to waste time rendering polygons that will ultimately get overwritten, so it becomes easier to render front to back and clip far segments that have been obscured by already rendered near segments if their projected coordinates are lower than maxy.

Rendering billboards, trees and cars

However, iterating over the road segments front to back will not work when rendering sprites because they frequently overlap and therefore must be rendered back to front using the painters algorithm.

This complicates our render() method and forces us to loop over the road segments in two phases:

  1. front to back to render the road
  2. back to front to render the sprites

In addition to having to deal with sprites that overlap, we also need to deal with sprites that are ‘just over’ a hilltop horizon. If the sprite is tall enough we should be able to see the top of it even if the road segment it sits in is on the backside of the hill and therefore not rendered.

We can tackle this latter problem by saving the maxy value for each segment as a clip line during phase 1. Then we can clip the sprites on that segment to the clip line during phase 2.

The remainder of the rendering logic figures out how much to scale and position the sprite, based on the road segments scale factor and screen coordinates (calculated in phase 1), leaving us with something like this for the second phase of our render() method:

// back to front painters algorithm
for(n = (drawDistance-1) ; n > 0 ; n--) {
  segment = segments[(baseSegment.index + n) % segments.length];

  // render roadside sprites
  for(i = 0 ; i < segment.sprites.length ; i++) {
    sprite      = segment.sprites[i];
    spriteScale = segment.p1.screen.scale;
    spriteX     = segment.p1.screen.x + (spriteScale * sprite.offset * roadWidth * width/2);
    spriteY     = segment.p1.screen.y;
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, sprite.source, spriteScale, spriteX, spriteY, (sprite.offset < 0 ? -1 : 0), -1, segment.clip);
  }

  // render other cars
  for(i = 0 ; i < segment.cars.length ; i++) {
    car         = segment.cars[i];
    sprite      = car.sprite;
    spriteScale = Util.interpolate(segment.p1.screen.scale, segment.p2.screen.scale, car.percent);
    spriteX     = Util.interpolate(segment.p1.screen.x,     segment.p2.screen.x,     car.percent) + (spriteScale * car.offset * roadWidth * width/2);
    spriteY     = Util.interpolate(segment.p1.screen.y,     segment.p2.screen.y,     car.percent);
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, car.sprite, spriteScale, spriteX, spriteY, -0.5, -1, segment.clip);
  }

}

Colliding with billboards and trees

So now that we can add, and render, roadside sprites, we need to modify our update() method to detect if the player has collided with any of the sprites in the players current segment:

We use a helper method Util.overlap() to provide a generic rectangle overlap detection and if an overlap is detected we stop the car:

if ((playerX < -1) || (playerX > 1)) {
  for(n = 0 ; n < playerSegment.sprites.length ; n++) {
    sprite  = playerSegment.sprites[n];
    spriteW = sprite.source.w * SPRITES.SCALE;
    if (Util.overlap(playerX, playerW, sprite.offset + spriteW/2 * (sprite.offset > 0 ? 1 : -1), spriteW)) {
      // stop the car
      break;
    }
  }
}

NOTE: if you examine the real code you see that we dont actually stop the car because then they can no longer drive sideways around the obstacle, as a hack shortcut we keep their position fixed and allow the car to ‘slide’ sideways around the sprite.

Colliding with Cars

In addition to colliding with road side sprites, we need to detect collision with other cars, and if an overlap is detected we slow the player down and ‘bounce’ back behind the car that we collided with:

for(n = 0 ; n < playerSegment.cars.length ; n++) {
  car  = playerSegment.cars[n];
  carW = car.sprite.w * SPRITES.SCALE;
  if (speed > car.speed) {
    if (Util.overlap(playerX, playerW, car.offset, carW, 0.8)) {
      // slow the car
      break;
    }
  }
}

Updating Cars

To make the other cars drive along the road, we give them the simplest possible AI:

  • drive at a constant speed
  • automatically steer around the player when overtaking
  • automatically steer around other cars when overtaking

NOTE: we don’t actually have to worry about steering other cars around curves in the road because the curves are fake. If we just keep the cars moving forward through our road segments they will automatically make it through the curves.

This all happens during the game update() loop with a call to updateCars() where we move each car forward at constant speed and switch from one segment to the next if they have driven far enough during this frame.

function updateCars(dt, playerSegment, playerW) {
  var n, car, oldSegment, newSegment;
  for(n = 0 ; n < cars.length ; n++) {
    car         = cars[n];
    oldSegment  = findSegment(car.z);
    car.offset  = car.offset + updateCarOffset(car, oldSegment, playerSegment, playerW);
    car.z       = Util.increase(car.z, dt * car.speed, trackLength);
    car.percent = Util.percentRemaining(car.z, segmentLength); // useful for interpolation during rendering phase
    newSegment  = findSegment(car.z);
    if (oldSegment != newSegment) {
      index = oldSegment.cars.indexOf(car);
      oldSegment.cars.splice(index, 1);
      newSegment.cars.push(car);
    }
  }
}

The updateCarOffset() method provides the ‘AI’ that allows a car to steer around the player or another car. It is one of the more complex methods in the entire code base, and for a real game would need to become a lot more complex to make the cars appear more realistic than they do in this simple demo.

For our purposes we will stick to a naive, brute-force AI and have each car

  • lookahead 20 segments
  • if it detects a slower car ahead that overlaps, then steer around it
  • steer to the right of obstacles that are on the left side of the road
  • steer to the left of obstacles that are on the right side of the road
  • steer enough to avoid the obstacle ahead within the distance remaining

We can also cheat with cars that are not visible to the player and simply skip any steering, letting those cars overlap and drive through each other. They only need to ‘be smart’ when in view of the player.

function updateCarOffset(car, carSegment, playerSegment, playerW) {

  var i, j, dir, segment, otherCar, otherCarW, lookahead = 20, carW = car.sprite.w * SPRITES.SCALE;

  // optimization, dont bother steering around other cars when 'out of sight' of the player
  if ((carSegment.index - playerSegment.index) > drawDistance)
    return 0;

  for(i = 1 ; i < lookahead ; i++) {
    segment = segments[(carSegment.index+i)%segments.length];

    if ((segment === playerSegment) && (car.speed > speed) && (Util.overlap(playerX, playerW, car.offset, carW, 1.2))) {
      if (playerX > 0.5)
        dir = -1;
      else if (playerX < -0.5)
        dir = 1;
      else
        dir = (car.offset > playerX) ? 1 : -1;
      return dir * 1/i * (car.speed-speed)/maxSpeed; // the closer the cars (smaller i) and the greater the speed ratio, the larger the offset
    }

    for(j = 0 ; j < segment.cars.length ; j++) {
      otherCar  = segment.cars[j];
      otherCarW = otherCar.sprite.w * SPRITES.SCALE;
      if ((car.speed > otherCar.speed) && Util.overlap(car.offset, carW, otherCar.offset, otherCarW, 1.2)) {
        if (otherCar.offset > 0.5)
          dir = -1;
        else if (otherCar.offset < -0.5)
          dir = 1;
        else
          dir = (car.offset > otherCar.offset) ? 1 : -1;
        return dir * 1/i * (car.speed-otherCar.speed)/maxSpeed;
      }
    }
  }
}

This algorithm works well enough in most cases, but if there is heavy traffic ahead we might find our cars rubberbanding left and then right as they try to squeeze through a gap between 2 other vehicles. There are many ways we can make this AI more robust, one of which might be to allow the cars to slow down when there is not enough room to steer around obstacles.

Heads up display

And finally, we add a rudimentary HTML heads up display (HUD):

<div id="hud">
  <span id="speed"            class="hud"><span id="speed_value" class="value">0</span> mph</span>
  <span id="current_lap_time" class="hud">Time: <span id="current_lap_time_value" class="value">0.0</span></span> 
  <span id="last_lap_time"    class="hud">Last Lap: <span id="last_lap_time_value" class="value">0.0</span></span>
  <span id="fast_lap_time"    class="hud">Fastest Lap: <span id="fast_lap_time_value" class="value">0.0</span></span>
</div>

… style it with CSS:

#hud                   { position: absolute; z-index: 1; width: 640px; padding: 5px 0; font-family: Verdana, Geneva, sans-serif; font-size: 0.8em; background-color: rgba(255,0,0,0.4); color: black; border-bottom: 2px solid black; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; }
#hud .hud              { background-color: rgba(255,255,255,0.6); padding: 5px; border: 1px solid black; margin: 0 5px; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud #speed            { float: right; }
#hud #current_lap_time { float: left;  }
#hud #last_lap_time    { float: left; display: none;  }
#hud #fast_lap_time    { display: block; width: 12em;  margin: 0 auto; text-align: center; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud .value            { color: black; font-weight: bold; }
#hud .fastest          { background-color: rgba(255,215,0,0.5); }

… and update() it during our game loop:

if (position > playerZ) {
  if (currentLapTime && (startPosition < playerZ)) {
    lastLapTime    = currentLapTime;
    currentLapTime = 0;
    if (lastLapTime <= Util.toFloat(Dom.storage.fast_lap_time)) {
      Dom.storage.fast_lap_time = lastLapTime;
      updateHud('fast_lap_time', formatTime(lastLapTime));
      Dom.addClassName('fast_lap_time', 'fastest');
      Dom.addClassName('last_lap_time', 'fastest');
    }
    else {
      Dom.removeClassName('fast_lap_time', 'fastest');
      Dom.removeClassName('last_lap_time', 'fastest');
    }
    updateHud('last_lap_time', formatTime(lastLapTime));
    Dom.show('last_lap_time');
  }
  else {
    currentLapTime += dt;
  }
}

updateHud('speed',            5 * Math.round(speed/500));
updateHud('current_lap_time', formatTime(currentLapTime));

Our updateHud() helper method allows us to only update DOM elements only if the value has changed because updating DOM elements can be slow and we shouldn’t do it 60fps unless the actual values have changed.

function updateHud(key, value) { // accessing DOM can be slow, so only do it if value has changed
  if (hud[key].value !== value) {
    hud[key].value = value;
    Dom.set(hud[key].dom, value);
  }
}



Conclusion

Phew! That was a long last lap, but there you have it, our final version has entered that stage where it can legitimately be called a game. It’s still very far from a finished game, but it’s a game nonetheless.

It’s quite astounding what it takes to actually finish a game, even a simple one. And this is not a project that I plan on polishing into a finished state. It should really just be considered how to get started with a pseudo-3d racing game.

The code is available on github for anyone who wants to try to turn this into a more polished racing game. You might want to consider:

  • car sound fx
  • better synchronized music
  • full screen mode
  • HUD fx (flash on fastest lap, confetti, color coded speedometer, etc)
  • more accurate sprite collision
  • better car AI (steering, braking etc)
  • an actual crash when colliding at high speed
  • more bounce when car is off road
  • screen shake when off-road or collision
  • throw up dirt particles when off road
  • more dynamic camera (lower at faster speed, swoop over hills etc)
  • automatic resolution & drawDistance detection
  • more realistic projection based curves with x,y rotation
  • fix the sub-pixel aliasing artifacts on curves
  • smarter fog to cover sprites (blue against sky, cover sprites)
  • multiple stages
  • different maps
  • a lap map, with current position indicator
  • road splits and joins
  • day/night cycle
  • weather effects
  • tunnels, bridges, clouds, walls, buildings
  • city, desert, ocean
  • add city of seattle and space needle to background layers
  • ‘bad guys’ - add some competitor drivers to race against
  • game modes - fastest lap, 1-on-1 racing, collect coins ? shoot bad guys ?
  • a whole lot of gameplay tuning
  • etc
  • etc

So there you have it. Another ‘weekend’ project that took a lot longer than expected but, I think, turned out pretty well in the end. These written up articles maybe had a little too much low level detail, but hopefully they still made some sense.

Comments and feedback are always welcome (below)

Enjoy!

Related Links