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.
<!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.
body {
margin: 0
}
gameOptions.ts
Configurable game options. Changing these values affects the gameplay.
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.
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.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.