Build a game like C64 classic Trailblazer in Three.js using TypeScript, with a lot of room for customization

Talking about Trailblazer game, 3D, Game development, HTML5, Javascript and TypeScript.

If you grew up in the 80s, you probably remember Trailblazer, the classic C64 arcade game where you control a ball rolling over a track made of colored tiles, each affecting gameplay in a different way.

The original C64 version used clever perspective tricks and fast tile-based rendering to simulate a 3D effect.

But now, thanks to modern web technologies, we can bring Trailblazer to life in true 3D using Three.js.

Let’s see what’s hunder the hood:

What’s under the hood
Here’s a breakdown of the main techniques used in this Three.js Trailblazer remake:

Procedural tile generation: the track is made of randomly generated rows of colored tiles. This ensures seamless, endless gameplay and opens the door to procedural difficulty scaling.

Three.js fog: a subtle fog effect gradually fades out distant tiles, hiding the end of the track and adding a sense of depth and motion. It also helps improve performance by reducing overdraw.

Fake shadows: instead of using GPU-heavy dynamic shadows, a soft PNG shadow texture is placed just beneath the ball. This lightweight trick gives the illusion of contact with the ground and improves visual grounding without impacting frame rate. Besides, let’s be honest, using real-time shadows in a game inspired by a C64 classic would be downright illegal.

Frame-rate independent movement: all position and rotation updates are scaled with deltaTime, making movement smooth and consistent across devices with different refresh rates.

Realistic ball rotation: the ball’s forward rotation is calculated using the formula Theta = d / r, where d is the distance traveled and r is the radius. This ensures the ball rolls naturally along the track, without slipping or sliding.

Camera follows only along Z axis: to keep gameplay readable and consistent, the camera tracks the ball’s forward movement but doesn’t follow it laterally. This keeps the track centered at all times, just like the original game.

TypeScript best practices: all variables are explicitly typed, and the project structure is clean and scalable, using modular imports and Vite’s efficient dev tooling. Moreover, the source code is completely commented, for you to learn and improve it.

Just in case you don’t know where to start with TypeScript, web servers and Vite, here is a free guide I wrote for you.

Now, let’s have a look at the prototype:

Control the ball with WASD keys, to move horizontally, accelerate or brake.

Now, look at the completely commented source code, which consists in one HTML file, one CSS file and two TypeScript files.

index.html

The web page which hosts the game.

HTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Trailblazer</title>
  </head>
  <body>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

style.css

The cascading style sheets of the main web page.

CSS
body {
    margin: 0
}

gameOptions.ts

Configurable game options. Changing these values affects the gameplay.

TypeScript
export const GameOptions = {
    ballRadius          : 0.3,      // ball radius
    cameraZDistance     : 3,        // camera z distance from the ball
    tileSize            : 1,        // tile size. Each tile is a square
    trackLenght         : 200,      // track lenght, in tiles
    trackSideLanes      : 2,        // track side lanes, to left and right of the central line
    startSpeed          : 0.03,     // start speed
    acceleration        : 0.03,     // ball acceleration
    sideAcceleration    : 0.05,     // ball side acceleration
    minSpeed            : 0.015,    // min speed
    brake               : 0.04,     // brake deceleration
    maxXVelocity        : 0.05,     // max side velocity
    xDeceleration       : 0.8,      // side deceleration
    tileColors          : [0x00ff00, 0xff0000, 0x0000ff, 0xffff00, 0xff00ff],   // tile colors
    finishColors        : [0xffffff, 0x222222]                                  // finish line colors
}

main.ts

This is where the game is created.

TypeScript
import * as THREE from 'three';
import { GameOptions } from './gameOptions';
import './style.css';

const xLimit : number = GameOptions.trackSideLanes * GameOptions.tileSize + GameOptions.tileSize / 2;                               // x limit, to keep the ball inside the track
const zLimit : number = -(GameOptions.trackLenght - 1) * GameOptions.tileSize;  // final z position to reach to close the track

let tiles       : THREE.Mesh[]  = [];
let speed       : number        = GameOptions.startSpeed;   // player speed
let velocityX   : number        = 0;                        // x velocity (to move left and right)                        
let moveLeft    : boolean       = false;                    // are we moving left?
let moveRight   : boolean       = false;                    // are we moving right?
let speedUp     : boolean       = false;                    // are we accelerating?
let slowDown    : boolean       = false;                    // are we braking?
let gameStopped : boolean       = false;                    // is the game stopped?
let lastTime    : number        = performance.now();        // current timestamp

// create a new Three scene
const scene : THREE.Scene = new THREE.Scene();

// add a black fog to the scene
scene.fog = new THREE.Fog(0x000000, 10, 40);

// add a camera
const camera : THREE.PerspectiveCamera = new THREE.PerspectiveCamera(
    75,                                     // field of view
    window.innerWidth / window.innerHeight, // aspect ratio
    0.1,                                    // near rendering
    1000                                    // far rendering
);
camera.position.set(0, GameOptions.cameraZDistance / 3 * 2, GameOptions.cameraZDistance);

// add a renderer
const renderer : THREE.WebGLRenderer = new THREE.WebGLRenderer({
    antialias : true
});

// set renderer size
renderer.setSize(window.innerWidth, window.innerHeight);

// add renderer to the page
document.body.appendChild(renderer.domElement);

// add a default ambient light
const ambientLight : THREE.AmbientLight = new THREE.AmbientLight();
scene.add(ambientLight);

// add ball and shadow
const ball : THREE.Mesh = addBall();
const shadow : THREE.Mesh = addShadow();

// generate the track
generateTrack();

// update the game
update();

// function to add the ball
function addBall() : THREE.Mesh {

    // load texture for the ball
    const textureLoader : THREE.TextureLoader = new THREE.TextureLoader();
    const ballTexture : THREE.Texture = textureLoader.load('assets/sprites/ball.png');

    // create ball material
    const ballMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({ 
        map : ballTexture
    });

    // create ball geometry
    const ballGeometry : THREE.SphereGeometry = new THREE.SphereGeometry(GameOptions.ballRadius);

    // create the ball itself
    const ball : THREE.Mesh = new THREE.Mesh(ballGeometry, ballMaterial);

    // set ball position
    ball.position.set(0, GameOptions.ballRadius, 0);

    // add the ball to the scene
    scene.add(ball);

    return ball;
}

// function to add the shadow
function addShadow() : THREE.Mesh {

    // add thexture for the shadow
    const textureLoader : THREE.TextureLoader = new THREE.TextureLoader();
    const shadowTexture : THREE.Texture = textureLoader.load('assets/sprites/shadow.png');
    
    // create shadow material
    const shadowMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
        map         : shadowTexture,
        transparent : true, 
    });

    // create shadow geometry
    const shadowGeometry = new THREE.PlaneGeometry(GameOptions.ballRadius * 2, GameOptions.ballRadius * 2);

    // create the shadow itself
    const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial);

    // place the shadow horizontally
    shadow.rotation.x = -Math.PI / 2; // piano orizzontale

    // place it a bit over tiles
    shadow.position.y = 0.06; 

    // add the shadow to the scene
    scene.add(shadow);

    return shadow;
}

// function to generate the track
function generateTrack() : void {
    
    // remove all existing tiles
    for (const tile of tiles) {
        scene.remove(tile);
    }

    // rest tiles array
    tiles = [];

    // build the track, tile by tile
    for (let i : number = 0; i < GameOptions.trackLenght; i ++) {
        for (let j : number = - GameOptions.trackSideLanes; j <= GameOptions.trackSideLanes; j ++) {
            
            // pick a random color
            let color : number = GameOptions.tileColors[Math.floor(Math.random() * GameOptions.tileColors.length)];

            // if it's the finish line, add finish line colors
            if (i == GameOptions.trackLenght - 1) {
                console.log(j % 2)
                color = GameOptions.finishColors[Math.abs(j) % 2];
            }
            
            // define tile geometry
            const tileGeometry : THREE.BoxGeometry = new THREE.BoxGeometry(GameOptions.tileSize, 0.1, GameOptions.tileSize);

            // define tile material
            const tileMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
                color : color
            });

            // create tile mesh
            const tile: THREE.Mesh = new THREE.Mesh(tileGeometry, tileMaterial);
           
            // set tile position
            tile.position.set(j * GameOptions.tileSize, 0, -i * GameOptions.tileSize);

            // add the tile to the scene
            scene.add(tile);

            // add the tile to tiles array
            tiles.push(tile);
        }
    }
}

// function to reset the game
function resetGame() : void {

    // generate a new track
    generateTrack();

    // place the ball in its position
    ball.position.set(0, GameOptions.ballRadius, 0);
    ball.rotation.set(0, 0, 0);

    // reset the speed
    speed = GameOptions.startSpeed;
    velocityX = 0;

    // unstop the game
    gameStopped = false;
}

// function to be called at each frame
function update() : void {

    // request animation frame
    requestAnimationFrame(update);

    // get the amount of seconds since previous frame
    const currentTime : number = performance.now();
    const deltaTime : number = (currentTime - lastTime) / 1000; 
    lastTime = currentTime;

    // if the game is stopped, that's all
    if (gameStopped) {
        return;
    }

    // handle acceleration
    if (speedUp) {
        speed += GameOptions.acceleration * deltaTime;
    }

    // handle brakes, with a minimum speed
    if (slowDown) {
        speed = Math.max(GameOptions.minSpeed, speed - GameOptions.brake * deltaTime);
    }

    // handle left steering
    if (moveLeft && !moveRight) {
        velocityX = Math.max(velocityX - GameOptions.sideAcceleration * deltaTime, -GameOptions.maxXVelocity);
    }
    else {

        // handle right steering
        if (moveRight && !moveLeft) {
            velocityX = Math.min(velocityX + GameOptions.sideAcceleration * deltaTime, GameOptions.maxXVelocity);
        }   
        else {

            // no steering
            velocityX *= GameOptions.xDeceleration;    
        } 
    }
   
    // update ball position
    ball.position.z -= speed;
    ball.position.x += velocityX;

    // keep the ball inside the track
    if (ball.position.x > xLimit) {
        ball.position.x = xLimit;
        velocityX = 0;
    }
    if (ball.position.x < -xLimit) {
        ball.position.x = -xLimit;
        velocityX = 0;
    }

    // rotate ball according to speed
    ball.rotation.x -= speed / GameOptions.ballRadius;

    // update shadow position
    shadow.position.x = ball.position.x;
    shadow.position.z = ball.position.z;

    // update camera to follow the ball
    camera.position.z = ball.position.z + GameOptions.cameraZDistance;
    camera.lookAt(new THREE.Vector3(0, ball.position.y, ball.position.z));

    // if we reached the finish line, stop and restart
    if (ball.position.z <= zLimit) {
        speed = 0;
        velocityX = 0;
        gameStopped = true;
        setTimeout(resetGame, 2000);
    }

    // render the scene
    renderer.render(scene, camera);
}

// key down listener, to trigger WASD movements
window.addEventListener('keydown', (e: KeyboardEvent) : void => {
    switch (e.key.toLowerCase()) {
      case 'a':
        moveLeft = true;
        break;
      case 'd':
        moveRight = true;
        break;
      case 'w':
        speedUp = true;
        break;
      case 's':
        slowDown = true;
        break;
    }
});

// key up listener
window.addEventListener('keyup', (e: KeyboardEvent) : void => {
    switch (e.key.toLowerCase()) {
      case 'a':
        moveLeft = false;
        break;
      case 'd':
        moveRight = false;
        break;
      case 'w':
        speedUp = false;
        break;
      case 's':
        slowDown = false;
        break;
    }
});

// resize window listener
window.addEventListener('resize', () : void => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

With Three.js you can build a Trailblazer prototype in a matter of minutes. Next time I’ll show you how to use object pooling to create endless tracks, meanwhile download the commented source code along with the entire Vite project.

Don’t know where to start developing with Three.js and TypeScript? I’ll explain it to you step by step in this free minibook.