Build a 3D Endless Runner with Babylon.js
Build a playable 3D endless runner game in the browser using Babylon.js.
Introduction
30 Second Summary
You've probably played an endless runner before. A character sprinting forward, obstacles flying at you, your reflexes the only thing keeping the run alive.
In this project, you will build a 3D endless runner game from scratch using Babylon.js. Your glowing player sphere dodges obstacles on a track while the score climbs and difficulty ramps up over time.
What You'll Build
A complete single-file HTML game featuring a 3D scene with keyboard controls, procedural obstacle spawning, collision detection, and a Babylon.js GUI overlay for scoring and game state management.
By the end of this project, you'll have:
- A 3D scene with a ground track, glowing player sphere, and responsive keyboard controls for movement and jumping.
- Procedural obstacle spawning with bounding-box collision detection and automatic mesh cleanup.
- A Babylon.js GUI overlay with live scoring, a game-over screen, and a full restart flow.
- A Secret Mission to add lane-based movement, particle trail effects, and persistent high scores.
Are there any prerequisites?
This project assumes solid JavaScript knowledge and basic familiarity with Babylon.js concepts like scenes, engines, and cameras. If you've never used Babylon.js before, start with an introductory project first.
Before We Start
Before We Start...
Set Up the Project and Render a Scene
Every 3D game starts the same way: you need an engine running, a camera watching, and a light illuminating.
In this step, you will create a single-file project that loads Babylon.js from a CDN, initializes the rendering engine, and displays a blank scene in the browser.
In this step, get ready to:
- Install the Live Server extension and create the project files.
- Initialize the Babylon.js engine, scene, camera, and lighting.
- Verify the scene renders in the browser with no errors.
Install Live Server and create the project
Babylon.js needs to be served over HTTP to load properly. The Live Server extension in VS Code gives you a local web server with automatic browser refresh whenever you save a file.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Search for Live Server in the search bar.
I already have Live Server
Great. You should see Live Server by Ritwick Dey listed with a Disable button, meaning it is already installed.
I need to install it
- Find Live Server by Ritwick Dey in the search results.
- Click Install.
- Wait for the installation to finish. The button changes to Disable when it is ready.
Now create the project folder and file.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir endless-runner
cd endless-runner
- Create the index.html file by running:
touch index.html
- Confirm the file exists by running:
ls
You should see index.html in the output.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the endless-runner folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click index.html to open it in the editor.
- Paste the following HTML into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner - Babylon.js</title>
<!-- Remove default margins and make the canvas fill the viewport -->
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<!-- The canvas Babylon.js renders into -->
<canvas id="renderCanvas"></canvas>
<!-- Babylon.js core and GUI libraries pinned to v9.0.0 -->
<script src="https://cdn.babylonjs.com/v9.0.0/babylon.js"></script>
<script src="https://cdn.babylonjs.com/v9.0.0/gui/babylon.gui.min.js"></script>
<script>
// Game code goes here
</script>
</body>
</html>
What does this code do?
- The <style> block strips default browser margins and stretches the canvas to fill the entire window. touch-action: none prevents mobile browsers from hijacking touch events meant for the game.
- The <canvas> element is the surface Babylon.js draws on. Every frame of your game renders here.
- The two <script> tags load Babylon.js core and its GUI library from the CDN, pinned to version 9.0.0. Pinning the version ensures your game works the same way every time.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
You will not see anything in the browser yet. The next substep adds the JavaScript that brings the scene to life.
Seeing a red squiggly in the editor?
- Make sure the two CDN <script> tags appear before the empty <script> tag where your game code will go. Order matters because the browser loads scripts top to bottom.
- Double check that every opening tag has a matching closing tag. A missing </script> or </style> will break the page.
Still stuck? Ask for help.
Initialize the Babylon.js engine and scene
With the HTML shell in place, you can now write the JavaScript that powers the 3D scene. This code grabs the canvas, starts the rendering engine, places a camera and light, and kicks off the render loop.
- In index.html, find the line that says // Game code goes here.
- Replace that comment with the following code:
// Get the canvas and create the Babylon.js engine
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const createScene = function () {
const scene = new BABYLON.Scene(engine);
// Dark blue background to match the game's aesthetic
scene.clearColor = new BABYLON.Color3(0.05, 0.05, 0.15);
// Camera positioned behind and above the track, looking forward
const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 4, -8), scene);
camera.setTarget(new BABYLON.Vector3(0, 1, 10));
// Hemisphere light for even illumination with a cool blue tint
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0.5), scene);
light.intensity = 1.0;
light.diffuse = new BABYLON.Color3(0.8, 0.8, 1.0);
light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4);
return scene;
};
const scene = createScene();
// Render the scene continuously
engine.runRenderLoop(function () {
scene.render();
});
// Keep the engine responsive to window size changes
window.addEventListener("resize", function () {
engine.resize();
});
What does this code do?
- BABYLON.Engine connects Babylon.js to the canvas element and enables antialiasing (the true parameter) for smoother edges.
- createScene() wraps all scene setup in a function. This keeps things organized as the game grows across future steps.
- scene.clearColor sets the background to a dark navy blue using RGB values between 0 and 1.
- FreeCamera is placed at position (0, 4, -8), which means centered on the track, 4 units high, and 8 units behind the origin. setTarget() points it forward down the track.
- HemisphericLight casts light from above. The groundColor property adds a subtle purple fill from below so objects are not pitch black on their undersides.
- engine.runRenderLoop() tells the engine to call scene.render() every frame. Without this, nothing appears on screen.
- The resize listener keeps the canvas properly sized if the player resizes their browser window.
Why FreeCamera instead of ArcRotateCamera?
In an endless runner, the camera stays locked behind the player looking forward. It never orbits. FreeCamera gives you direct control over position and target without the orbit mechanics that ArcRotateCamera adds. Simpler tool for the job.
- Save index.html.
You will not see a change in the editor. The next substep opens the file in the browser so you can verify the scene renders.
Seeing a syntax error underline?
- Make sure your new JavaScript code is between the opening <script> and closing </script> tags (the third pair, after the two CDN script tags).
- Check that every opening brace { has a matching closing brace }. Missing braces on the createScene function or the runRenderLoop callback are the most common issue.
Help me debug my Babylon.js scene setup code.
Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
I'd like to double check the full code
Here is the complete index.html file as it should look right now.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner - Babylon.js</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/v9.0.0/babylon.js"></script>
<script src="https://cdn.babylonjs.com/v9.0.0/gui/babylon.gui.min.js"></script>
<script>
// Get the canvas and create the Babylon.js engine
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const createScene = function () {
const scene = new BABYLON.Scene(engine);
// Dark blue background to match the game's aesthetic
scene.clearColor = new BABYLON.Color3(0.05, 0.05, 0.15);
// Camera positioned behind and above the track, looking forward
const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 4, -8), scene);
camera.setTarget(new BABYLON.Vector3(0, 1, 10));
// Hemisphere light for even illumination with a cool blue tint
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0.5), scene);
light.intensity = 1.0;
light.diffuse = new BABYLON.Color3(0.8, 0.8, 1.0);
light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4);
return scene;
};
const scene = createScene();
// Render the scene continuously
engine.runRenderLoop(function () {
scene.render();
});
// Keep the engine responsive to window size changes
window.addEventListener("resize", function () {
engine.resize();
});
</script>
</body>
</html>
Open the file with Live Server and verify the scene
Time to see your scene in the browser. Live Server will serve the file over HTTP and automatically refresh whenever you save changes.
- Make sure index.html is open in the editor.
- Click Go Live in the bottom-right corner of the VS Code status bar.
Your default browser opens to a page that is entirely dark navy blue. That is the clearColor you set on the scene. No objects exist yet, so the background is all you see.
- Open the browser console by pressing Cmd+Option+J (macOS Chrome) or Ctrl+Shift+J (Windows Chrome).
- Confirm there are no red error messages in the console.
What should I see?
A full-screen dark navy blue page with nothing else on it. The console should be clean with no errors. If you see yellow warnings from Babylon.js about audio or cameras, those are safe to ignore.
Seeing a blank white page or console errors?
- If the page is white, the Babylon.js CDN scripts may not have loaded. Check the console for a net::ERR message. Verify the script URLs match exactly: https://cdn.babylonjs.com/v9.0.0/babylon.js.
- If you see BABYLON is not defined, your game code script tag may appear before the CDN script tags. The CDN tags must come first.
- If the URL bar shows file:// instead of http://127.0.0.1, you opened the file directly instead of through Live Server. Go back to VS Code and click Go Live.
Help me fix my Babylon.js scene setup.
Your engine is running and the scene is rendering. Next up, you will build the track and player sphere, then wire up keyboard controls so you can start dodging.
Build the Track and Player
Your scene is rendering with a camera and light, but the viewport is empty. Time to build something to play on and something to play as.
In this step, you will create a ground track, a glowing player sphere, and a keyboard input system that lets you move left, right, and jump. By the end, you will have something interactive running in the browser.
In this step, get ready to:
- Create the ground track and player sphere with materials.
- Set up keyboard input tracking for responsive controls.
- Add left/right movement and jump mechanics to the game loop.
Create the ground track and player sphere
Before you build the meshes, the game needs a few variables to control the player's physics and the track boundaries. These live outside the createScene function so they are accessible later when you add restart logic.
- In index.html, find the line const engine = new BABYLON.Engine(canvas, true); and add the following variables below it, before the createScene function:
// Input tracking
const inputMap = {};
// Player physics
let playerYVelocity = 0;
let isJumping = false;
const gravity = -0.006;
const jumpForce = 0.12;
const playerGroundY = 0.5;
const trackHalfWidth = 3;
What do these variables do?
- inputMap tracks which keyboard keys are currently pressed. Each key becomes a property set to true on key down and false on key up.
- playerYVelocity and isJumping manage the jump arc. Velocity starts at jumpForce and decreases by gravity each frame until the player lands.
- trackHalfWidth defines how far left or right the player can move before being clamped to the track edge.
You won't see a change yet. These variables control the player's physics and track boundaries in the chunks ahead.
Now for the track itself. Babylon.js has a MeshBuilder utility that creates 3D primitives like grounds, spheres, and boxes. You will use CreateGround for a long runway and apply a dark material so it reads as a track.
- Inside the createScene function, find the lighting code that ends with light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4); and add the following code below it:
// Ground track
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 8, height: 200 }, scene);
ground.position.z = 80;
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.15, 0.15, 0.25);
groundMat.specularColor = new BABYLON.Color3(0, 0, 0);
ground.material = groundMat;
What does this code do?
- CreateGround builds a flat plane. The width: 8 makes the track narrow enough to feel like a lane. The height: 200 stretches it far into the distance so obstacles have room to spawn.
- position.z = 80 shifts the ground forward so it extends ahead of the camera instead of centering on the origin.
- Setting specularColor to black removes shiny highlights from the ground, giving it a matte finish.
- Save index.html and refresh your browser.
- You should see a long, dark purple-gray strip stretching from the bottom of the screen into the distance.
Don't see the ground?
- Make sure the ground code is inside the createScene function, after the lighting code and before return scene;.
- Check the browser console for red error messages. A missing semicolon or bracket is the most common cause.
- help me debug why my ground is not appearing in the Babylon.js scene
Next, the player. You will create a sphere and give it a glowing cyan material using emissiveColor. Emissive color makes a mesh appear to emit light, so the sphere stands out against the dark track.
- Directly below the ground code you just added, add the player sphere:
// Player
const player = BABYLON.MeshBuilder.CreateSphere("player", { diameter: 1 }, scene);
player.position.y = playerGroundY;
player.position.z = 0;
const playerMat = new BABYLON.StandardMaterial("playerMat", scene);
playerMat.emissiveColor = new BABYLON.Color3(0.0, 0.8, 1.0);
playerMat.diffuseColor = new BABYLON.Color3(0.0, 0.5, 0.8);
player.material = playerMat;
What does this code do?
- CreateSphere creates a sphere mesh with a diameter of 1 unit.
- position.y = playerGroundY places the sphere at 0.5 (half its diameter) so it sits on top of the ground instead of clipping through it.
- emissiveColor makes the sphere appear to glow even without direct light. diffuseColor adds shading depth when light hits it.
- Save index.html and refresh your browser.
- You should see a bright cyan glowing sphere sitting on the dark track near the bottom of the viewport.
Sphere not visible?
- If the sphere is below the ground, check that you defined const playerGroundY = 0.5; in the variables block outside createScene.
- Make sure the player code sits after the ground code, not before it. Order matters for readability but both should be inside createScene.
- help me debug why my player sphere is not showing up
Set up keyboard input and movement
For a game that needs continuous movement (holding a key to keep moving), discrete key events are not enough. You need to know which keys are held down right now, every single frame.
The pattern is simple: on keydown, mark the key as active. On keyup, mark it as inactive. Then each frame, check the map and move accordingly.
- Below the player code inside createScene, add the keyboard event listeners:
// Keyboard input
window.addEventListener("keydown", function (e) {
inputMap[e.key] = true;
});
window.addEventListener("keyup", function (e) {
inputMap[e.key] = false;
});
Why an inputMap instead of moving directly in the event handler?
If you moved the player inside the keydown handler, movement would depend on the OS key repeat rate. That creates inconsistent speed across machines. By recording key state in a map and reading it every frame in the game loop, movement is tied to the frame rate instead. This is the standard approach for game input.
You won't see a change yet. The next chunk reads these key states to move the player.
Now you will create the game loop. In Babylon.js, scene.onBeforeRenderObservable fires a callback before every frame is drawn. This is where you read the input map, move the player, and apply jump physics.
- Below the keyboard input code and before return scene;, add the game loop:
// Game loop
scene.onBeforeRenderObservable.add(function () {
// Player movement
const moveSpeed = 0.1;
if (inputMap["ArrowLeft"] || inputMap["a"]) {
player.position.x -= moveSpeed;
}
if (inputMap["ArrowRight"] || inputMap["d"]) {
player.position.x += moveSpeed;
}
player.position.x = Math.max(-trackHalfWidth, Math.min(trackHalfWidth, player.position.x));
// Jump
if ((inputMap[" "] || inputMap["ArrowUp"]) && !isJumping) {
isJumping = true;
playerYVelocity = jumpForce;
}
if (isJumping) {
playerYVelocity += gravity;
player.position.y += playerYVelocity;
if (player.position.y <= playerGroundY) {
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
}
}
// Player sphere rotation for visual flair
player.rotation.x -= 0.05;
});
What does this code do?
- The movement section checks inputMap for the left/right arrow keys (and a/d as alternates) and shifts the player along the x-axis.
- Math.max(-trackHalfWidth, Math.min(trackHalfWidth, player.position.x)) clamps the position so the player cannot slide off the track edges.
- The jump section applies a one-time upward velocity (jumpForce) when the space bar or up arrow is pressed. Each frame, gravity pulls the velocity downward until the player lands back at playerGroundY.
- player.rotation.x -= 0.05 rotates the sphere slightly each frame, making it look like a ball rolling forward.
- Save index.html.
Player not responding to keys?
- Click inside the browser window first. If the page does not have focus, it will not receive keyboard events.
- Check that const inputMap = {}; is defined outside createScene, not inside it. The game loop and the event listeners both need access to the same object.
- Verify the key names are exact strings: "ArrowLeft", "ArrowRight", " " (a single space for the space bar). Capitalization matters.
- help me debug why my player is not moving when I press arrow keys
Test the track and player controls
- Refresh your browser and click inside the game window to give it focus.
- Press the left and right arrow keys. The sphere should slide smoothly across the track and stop at the edges.
- Press the space bar. The sphere should jump upward and arc back down to the ground.
- Watch the sphere closely. It should appear to be slowly rotating forward, giving the impression of rolling.
Awesome, I've got everything!
Great. Double check you have saved index.html and your sphere is moving and jumping as expected.
I'd like to double check the full code
Here is the complete index.html as it should look at the end of this step.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner - Babylon.js</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/v9.0.0/babylon.js"></script>
<script src="https://cdn.babylonjs.com/v9.0.0/gui/babylon.gui.min.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
// Input tracking
const inputMap = {};
// Player physics
let playerYVelocity = 0;
let isJumping = false;
const gravity = -0.006;
const jumpForce = 0.12;
const playerGroundY = 0.5;
const trackHalfWidth = 3;
const createScene = function () {
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color3(0.05, 0.05, 0.15);
// Camera
const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 4, -8), scene);
camera.setTarget(new BABYLON.Vector3(0, 1, 10));
// Lighting
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0.5), scene);
light.intensity = 1.0;
light.diffuse = new BABYLON.Color3(0.8, 0.8, 1.0);
light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4);
// Ground track
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 8, height: 200 }, scene);
ground.position.z = 80;
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.15, 0.15, 0.25);
groundMat.specularColor = new BABYLON.Color3(0, 0, 0);
ground.material = groundMat;
// Player
const player = BABYLON.MeshBuilder.CreateSphere("player", { diameter: 1 }, scene);
player.position.y = playerGroundY;
player.position.z = 0;
const playerMat = new BABYLON.StandardMaterial("playerMat", scene);
playerMat.emissiveColor = new BABYLON.Color3(0.0, 0.8, 1.0);
playerMat.diffuseColor = new BABYLON.Color3(0.0, 0.5, 0.8);
player.material = playerMat;
// Keyboard input
window.addEventListener("keydown", function (e) {
inputMap[e.key] = true;
});
window.addEventListener("keyup", function (e) {
inputMap[e.key] = false;
});
// Game loop
scene.onBeforeRenderObservable.add(function () {
// Player movement
const moveSpeed = 0.1;
if (inputMap["ArrowLeft"] || inputMap["a"]) {
player.position.x -= moveSpeed;
}
if (inputMap["ArrowRight"] || inputMap["d"]) {
player.position.x += moveSpeed;
}
player.position.x = Math.max(-trackHalfWidth, Math.min(trackHalfWidth, player.position.x));
// Jump
if ((inputMap[" "] || inputMap["ArrowUp"]) && !isJumping) {
isJumping = true;
playerYVelocity = jumpForce;
}
if (isJumping) {
playerYVelocity += gravity;
player.position.y += playerYVelocity;
if (player.position.y <= playerGroundY) {
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
}
}
// Player sphere rotation for visual flair
player.rotation.x -= 0.05;
});
return scene;
};
const scene = createScene();
engine.runRenderLoop(function () {
scene.render();
});
window.addEventListener("resize", function () {
engine.resize();
});
</script>
</body>
</html>
Your player can move and jump on a track. Next up, you will spawn obstacles that rush toward the player and detect collisions when they hit.
Spawn Obstacles and Detect Collisions
Your player sphere moves smoothly across the track and jumps on command. A game needs something to dodge, though.
In this step, you'll build an obstacle spawning system that creates colored boxes far ahead on the track and sends them rushing toward the camera. You'll also wire up collision detection so the game knows the instant the player gets hit.
In this step, get ready to:
- Build the obstacle spawning function with randomized positions and colors.
- Add obstacle movement, cleanup, and collision detection to the game loop.
- Test that obstacles rush toward the player and trigger collisions.
Build the obstacle spawning function
The spawning system has two parts. A function that creates a box mesh at a random position on the track, and a timer in the game loop that calls this function at regular intervals.
- In index.html, find the keyboard input listeners (keydown and keyup). Copy the following code below them, before the game loop comment:
// Obstacle colors
const obstacleColors = [
new BABYLON.Color3(1, 0.2, 0.2),
new BABYLON.Color3(1, 0.6, 0),
new BABYLON.Color3(0.8, 0, 0.8),
new BABYLON.Color3(1, 1, 0),
new BABYLON.Color3(0, 1, 0.5),
];
function spawnObstacle() {
// Create a box obstacle with random size and rotation
const box = BABYLON.MeshBuilder.CreateBox("obstacle", { width: 1.2, height: 1.2, depth: 1.2 }, scene);
const xPos = (Math.random() * trackHalfWidth * 2) - trackHalfWidth;
box.position.x = xPos;
box.position.y = 0.6;
box.position.z = 60;
box.rotation.y = Math.random() * Math.PI;
// Give it a random color with a subtle glow
const mat = new BABYLON.StandardMaterial("obsMat" + obstacles.length, scene);
const color = obstacleColors[Math.floor(Math.random() * obstacleColors.length)];
mat.diffuseColor = color;
mat.emissiveColor = color.scale(0.3);
box.material = mat;
// Track it so we can move and dispose it later
obstacles.push(box);
}
What does this code do?
- obstacleColors defines five bright colors that obstacles can randomly pick from.
- spawnObstacle() creates a box with MeshBuilder.CreateBox, places it at a random x-position across the track width, and pushes it 60 units ahead on the z-axis so it appears in the distance.
- color.scale(0.3) applies a dimmed version of the random color as emissiveColor, giving each obstacle a subtle glow.
- Each new box is pushed into the obstacles array so the game loop can track, move, and dispose it later.
You won't see obstacles yet. The function exists but nothing calls it. The next chunk wires up the spawn timer.
The game loop needs a guard at the top so it stops running when a collision happens. It also needs a timer that calls spawnObstacle() at regular intervals.
- Inside the game loop callback (scene.onBeforeRenderObservable.add), find the very first line of the function body. Add this guard line at the top, before the player movement code:
if (gameOver) return;
- Now find the end of the jump section (the closing brace of if (isJumping)). Copy the following code below it:
// Player sphere rotation for visual flair
player.rotation.x -= 0.05;
// Spawn obstacles on a timer
spawnTimer++;
if (spawnTimer >= spawnInterval) {
spawnObstacle();
spawnTimer = 0;
}
What does this code do?
- if (gameOver) return; stops the entire game loop callback from running once a collision happens. No movement, no spawning, nothing.
- player.rotation.x -= 0.05 spins the sphere forward each frame so it looks like it is rolling.
- spawnTimer increments every frame. When it hits spawnInterval (90 frames), a new obstacle appears and the timer resets to 0.
- Save index.html.
- Refresh your browser. You should see colored boxes start appearing in the distance every couple of seconds. They stay put for now because we haven't added movement yet.
Not seeing any obstacles?
- Make sure the spawnObstacle() function is defined before the game loop. If the function comes after the onBeforeRenderObservable callback, JavaScript can still hoist it, but double-check placement.
- Check that the spawn timer code is inside the game loop callback, not outside it.
- Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for errors.
- Why aren't my obstacles spawning in the Babylon.js scene?
Move obstacles and detect collisions
Obstacles need to slide toward the camera every frame. When one passes behind the camera, it should be disposed to free memory. If any obstacle touches the player, the game stops.
- Still inside the game loop callback, copy the following code below the spawn timer you just added:
// Move obstacles and check collisions
for (let i = obstacles.length - 1; i >= 0; i--) {
const obs = obstacles[i];
obs.position.z -= obstacleSpeed;
obs.rotation.y += 0.02;
// Collision check
if (player.intersectsMesh(obs, false)) {
console.log("Collision detected!");
gameOver = true;
return;
}
// Remove obstacles that passed the camera
if (obs.position.z < -10) {
if (obs.material) {
obs.material.dispose();
}
obs.dispose();
obstacles.splice(i, 1);
}
}
What does this code do?
- The loop iterates backward (i--) because we splice items out of the array mid-loop. Iterating forward would skip elements after a splice.
- obs.position.z -= obstacleSpeed moves each obstacle closer to the camera every frame.
- player.intersectsMesh(obs, false) checks if the player's bounding box overlaps the obstacle's bounding box. The false argument means bounding-box mode, which is fast and good enough for this game.
- When an obstacle's z-position drops below -10, it has passed behind the camera. The code disposes both the material and the mesh, then removes it from the array. This prevents a memory leak from accumulating invisible meshes.
Why intersectsMesh instead of a physics engine?
For a game this simple, polling intersectsMesh() each frame is lighter and easier to debug than integrating a full physics engine like Havok or Cannon.js. Bounding-box checks are a single boolean comparison per obstacle. A physics engine adds gravity solvers, rigid bodies, and constraint systems you don't need here.
- Save index.html.
- Refresh your browser. Obstacles should now rush toward you from the distance. They spin slowly as they approach.
Obstacles not moving?
- Check that the movement loop is inside the game loop callback, not after it. The for loop must live inside the scene.onBeforeRenderObservable.add function.
- Make sure the movement loop comes after the if (gameOver) return; guard but not outside the callback entirely.
- If the game freezes instantly, your gameOver variable might be set to true from a previous test. Refresh the page to reset it.
- My obstacles spawn but don't move in my Babylon.js game. Help me debug.
Test collisions and obstacle cleanup
Time to verify that the whole system works end to end. Obstacles should spawn, rush forward, trigger a collision when they hit you, and silently dispose when they pass the camera.
- Refresh your browser to start a fresh game.
- Open the browser console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Steer your player directly into an approaching obstacle.
You should see Collision detected! in the console. The game freezes because gameOver stops the game loop from running.
- Refresh again and this time dodge every obstacle. Let a few pass behind you.
No errors should appear in the console. Obstacles silently dispose as they leave the screen.
Why dispose the material separately?
Calling mesh.dispose() removes the mesh from the scene, but its StandardMaterial stays in memory. Each obstacle gets its own material, so disposing both prevents a slow memory leak over a long play session.
Collision not triggering?
- Make sure you are passing false as the second argument to intersectsMesh(). Passing true enables per-triangle precision, which is slower and can behave differently.
- Check that the collision check references player (the sphere variable) and not a different mesh name.
- If the player jumps over the obstacle, bounding boxes may not overlap. Try colliding at ground level to confirm detection works.
- My intersectsMesh collision check isn't detecting hits. Help me debug.
Awesome, I've got everything!
Great. Double check you have saved index.html.
I'd like to double check the full code
Here is the full index.html as it should look at the end of this step. The collision currently logs to the console. You will replace that with a proper game-over screen in the next step.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner - Babylon.js</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/v9.0.0/babylon.js"></script>
<script src="https://cdn.babylonjs.com/v9.0.0/gui/babylon.gui.min.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
// Game state
let gameOver = false;
let score = 0;
let obstacleSpeed = 0.15;
let spawnTimer = 0;
let spawnInterval = 90;
const obstacles = [];
const inputMap = {};
// Player physics
let playerYVelocity = 0;
let isJumping = false;
const gravity = -0.006;
const jumpForce = 0.12;
const playerGroundY = 0.5;
const trackHalfWidth = 3;
const createScene = function () {
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color3(0.05, 0.05, 0.15);
// Camera
const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 4, -8), scene);
camera.setTarget(new BABYLON.Vector3(0, 1, 10));
// Lighting
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0.5), scene);
light.intensity = 1.0;
light.diffuse = new BABYLON.Color3(0.8, 0.8, 1.0);
light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4);
// Ground track
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 8, height: 200 }, scene);
ground.position.z = 80;
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.15, 0.15, 0.25);
groundMat.specularColor = new BABYLON.Color3(0, 0, 0);
ground.material = groundMat;
// Player
const player = BABYLON.MeshBuilder.CreateSphere("player", { diameter: 1 }, scene);
player.position.y = playerGroundY;
player.position.z = 0;
const playerMat = new BABYLON.StandardMaterial("playerMat", scene);
playerMat.emissiveColor = new BABYLON.Color3(0.0, 0.8, 1.0);
playerMat.diffuseColor = new BABYLON.Color3(0.0, 0.5, 0.8);
player.material = playerMat;
// Keyboard input
window.addEventListener("keydown", function (e) {
inputMap[e.key] = true;
});
window.addEventListener("keyup", function (e) {
inputMap[e.key] = false;
});
// Obstacle colors
const obstacleColors = [
new BABYLON.Color3(1, 0.2, 0.2),
new BABYLON.Color3(1, 0.6, 0),
new BABYLON.Color3(0.8, 0, 0.8),
new BABYLON.Color3(1, 1, 0),
new BABYLON.Color3(0, 1, 0.5),
];
function spawnObstacle() {
const box = BABYLON.MeshBuilder.CreateBox("obstacle", { width: 1.2, height: 1.2, depth: 1.2 }, scene);
const xPos = (Math.random() * trackHalfWidth * 2) - trackHalfWidth;
box.position.x = xPos;
box.position.y = 0.6;
box.position.z = 60;
box.rotation.y = Math.random() * Math.PI;
const mat = new BABYLON.StandardMaterial("obsMat" + obstacles.length, scene);
const color = obstacleColors[Math.floor(Math.random() * obstacleColors.length)];
mat.diffuseColor = color;
mat.emissiveColor = color.scale(0.3);
box.material = mat;
obstacles.push(box);
}
// Game loop
scene.onBeforeRenderObservable.add(function () {
if (gameOver) return;
// Player movement
const moveSpeed = 0.1;
if (inputMap["ArrowLeft"] || inputMap["a"]) {
player.position.x -= moveSpeed;
}
if (inputMap["ArrowRight"] || inputMap["d"]) {
player.position.x += moveSpeed;
}
player.position.x = Math.max(-trackHalfWidth, Math.min(trackHalfWidth, player.position.x));
// Jump
if ((inputMap[" "] || inputMap["ArrowUp"]) && !isJumping) {
isJumping = true;
playerYVelocity = jumpForce;
}
if (isJumping) {
playerYVelocity += gravity;
player.position.y += playerYVelocity;
if (player.position.y <= playerGroundY) {
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
}
}
// Player sphere rotation for visual flair
player.rotation.x -= 0.05;
// Spawn obstacles on a timer
spawnTimer++;
if (spawnTimer >= spawnInterval) {
spawnObstacle();
spawnTimer = 0;
}
// Move obstacles and check collisions
for (let i = obstacles.length - 1; i >= 0; i--) {
const obs = obstacles[i];
obs.position.z -= obstacleSpeed;
obs.rotation.y += 0.02;
// Collision check
if (player.intersectsMesh(obs, false)) {
console.log("Collision detected!");
gameOver = true;
return;
}
// Remove obstacles that passed the camera
if (obs.position.z < -10) {
if (obs.material) {
obs.material.dispose();
}
obs.dispose();
obstacles.splice(i, 1);
}
}
});
return scene;
};
const scene = createScene();
engine.runRenderLoop(function () {
scene.render();
});
window.addEventListener("resize", function () {
engine.resize();
});
</script>
</body>
</html>
The track is alive with obstacles and your player can collide with them. Next up, you'll add a HUD with live scoring and a game-over screen with a restart button.
Add the HUD, Scoring, and Game Over
Your obstacles are spawning, rushing toward the player, and triggering collisions. The core gameplay loop is in place.
A game without feedback is just shapes moving around. In this step, you will build a live score counter, a game over screen with a restart button, and progressive difficulty scaling. All of it uses Babylon.js GUI, which renders 2D interface elements directly inside the engine instead of relying on HTML/CSS overlays.
In this step, get ready to:
- Create a HUD overlay with a live score display.
- Build a game over panel with a restart button.
- Add progressive difficulty that increases speed over time.
Create the score HUD
The HUD (heads-up display) shows game information on top of the 3D scene. Babylon.js has a built-in GUI system called AdvancedDynamicTexture that handles this. It creates a 2D layer that floats over your 3D world.
Before you build any GUI controls, you need a few new state variables to track the score, game over state, and references to the GUI elements you will update at runtime.
- In index.html, find your player physics variables (playerYVelocity, isJumping, etc.). Add these new variables directly below them:
// GUI references
let scoreText;
let gameOverPanel;
let finalScoreText;
- Next, find your game state variables near the top of the <script> tag. Add gameOver and score to the existing game state block:
// Game state
let gameOver = false;
let score = 0;
- Now, inside your createScene function, find the spawnObstacle() function. Below it, add the GUI setup code:
// GUI setup
const advancedTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("UI");
scoreText = new BABYLON.GUI.TextBlock();
scoreText.text = "Score: 0";
scoreText.color = "white";
scoreText.fontSize = 28;
scoreText.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
scoreText.textVerticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_TOP;
scoreText.paddingLeft = "20px";
scoreText.paddingTop = "20px";
advancedTexture.addControl(scoreText);
What does this code do?
- CreateFullscreenUI() creates an invisible 2D layer on top of your 3D scene. All GUI controls (text, buttons, panels) live on this layer.
- TextBlock is the basic text element in Babylon.js GUI. You set its alignment to the top-left corner with HORIZONTAL_ALIGNMENT_LEFT and VERTICAL_ALIGNMENT_TOP.
- addControl() attaches the text block to the GUI layer so it actually renders on screen.
Why Babylon.js GUI instead of HTML/CSS?
You could build a HUD with plain HTML elements positioned over the canvas. Babylon.js GUI keeps the interface inside the engine instead. This matters for WebXR and VR scenarios where HTML overlays do not render. Learning the native GUI system now means your skills transfer directly to immersive projects.
- Save index.html.
- Refresh your browser. You should see Score: 0 pinned to the top-left corner of the screen, overlaying the 3D scene.
Don't see the score text?
- Make sure your GUI <script> tag (babylon.gui.min.js) is included in your HTML <head>. Without it, BABYLON.GUI is undefined.
- Check the browser console for errors. A common mistake is a typo in AdvancedDynamicTexture or CreateFullscreenUI.
- Confirm the GUI code is placed after the spawnObstacle() function but before the game loop.
Why isn't my Babylon.js GUI TextBlock rendering on screen?
The score text is on screen, but it is stuck at zero. You need a frame counter to track elapsed frames and increment the score at regular intervals.
- Find the line scene.onBeforeRenderObservable.add(function () { in your game loop. Add a frame counter variable just above it:
// Game loop
let frameCount = 0;
- Inside the game loop callback, near the top (right after the opening brace of the function), add:
frameCount++;
- Now scroll to the bottom of the game loop callback, after the obstacle movement loop. Add the score increment logic:
// Score
if (frameCount % 10 === 0) {
score++;
scoreText.text = "Score: " + score;
}
What does this code do?
- frameCount tracks how many render frames have passed since the game started.
- frameCount % 10 === 0 fires every 10th frame. At roughly 60 frames per second, the score goes up about 6 times per second.
- Each time the score increments, the scoreText.text property updates the GUI in real time.
- Save index.html and refresh your browser.
- You should see the score counting up in the top-left corner as obstacles fly past you.
Score stuck at zero?
- Make sure frameCount++ is inside the game loop callback, not outside it. If it is outside, it only runs once.
- Confirm the score block is at the bottom of the game loop callback, not after the callback has closed.
Help me figure out why my score isn't incrementing in the game loop.
Build the game over panel
When the player hits an obstacle, the game needs to freeze and show results. You will build a panel with the final score and a restart button using Babylon.js GUI container controls.
- In your createScene function, find the line advancedTexture.addControl(scoreText). Below it, add the game over panel code:
// Game over panel (hidden initially)
gameOverPanel = new BABYLON.GUI.Rectangle();
gameOverPanel.width = "400px";
gameOverPanel.height = "280px";
gameOverPanel.cornerRadius = 12;
gameOverPanel.color = "white";
gameOverPanel.thickness = 2;
gameOverPanel.background = "rgba(0, 0, 0, 0.85)";
gameOverPanel.isVisible = false;
advancedTexture.addControl(gameOverPanel);
const panelStack = new BABYLON.GUI.StackPanel();
panelStack.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER;
gameOverPanel.addControl(panelStack);
const gameOverTitle = new BABYLON.GUI.TextBlock();
gameOverTitle.text = "GAME OVER";
gameOverTitle.color = "#ff4444";
gameOverTitle.fontSize = 36;
gameOverTitle.height = "60px";
panelStack.addControl(gameOverTitle);
finalScoreText = new BABYLON.GUI.TextBlock();
finalScoreText.text = "Score: 0";
finalScoreText.color = "white";
finalScoreText.fontSize = 24;
finalScoreText.height = "50px";
panelStack.addControl(finalScoreText);
const restartBtn = BABYLON.GUI.Button.CreateSimpleButton("restartBtn", "Play Again");
restartBtn.width = "200px";
restartBtn.height = "50px";
restartBtn.color = "white";
restartBtn.cornerRadius = 8;
restartBtn.background = "#0088cc";
restartBtn.fontSize = 20;
restartBtn.paddingTop = "20px";
panelStack.addControl(restartBtn);
What does this code do?
- Rectangle is a GUI container that acts as the panel background. It starts with isVisible = false so it is hidden during gameplay.
- StackPanel stacks its children vertically inside the rectangle. This gives you an automatic layout for the title, score, and button.
- Button.CreateSimpleButton() creates a clickable text button. You will wire its click handler in the next substep.
- finalScoreText is stored in the outer-scope variable so you can update its text when a collision happens.
The panel is built but hidden. Now you need to connect it to the collision detection so it appears when the player dies.
- In the game loop callback, find the very top of the function (right after scene.onBeforeRenderObservable.add(function () {). Add this guard as the first line:
if (gameOver) return;
What does this line do?
When gameOver is true, the entire game loop exits immediately. No movement, no spawning, no collision checks. The 3D scene stays frozen on the last frame.
- Now find the collision check inside the obstacle loop. It currently looks like this:
// Collision check
if (player.intersectsMesh(obs, false)) {
console.log("Collision detected!");
}
- Replace the entire console.log block with the game over logic so the collision check now looks like this:
// Collision check
if (player.intersectsMesh(obs, false)) {
gameOver = true;
finalScoreText.text = "Score: " + score;
gameOverPanel.isVisible = true;
return;
}
What changed?
- gameOver = true flips the flag. On the next frame, the guard at the top of the loop stops all game logic.
- finalScoreText.text updates the panel's score display with whatever score the player reached.
- gameOverPanel.isVisible = true reveals the panel instantly. The return exits the current frame's loop iteration so no further obstacle processing happens.
- Save index.html and refresh your browser.
- Play the game and intentionally run into an obstacle. You should see a dark panel appear in the center of the screen with a red "GAME OVER" title, your final score, and a "Play Again" button.
Panel not appearing on collision?
- Check that gameOverPanel.isVisible = false is set during panel creation. If you accidentally left it as true, the panel shows immediately and you might miss the collision trigger.
- Make sure the collision block sets gameOverPanel.isVisible = true (not false).
- Confirm that if (gameOver) return; is the first line inside the game loop callback, before frameCount++. If it is placed after the obstacle loop, the guard never freezes the game.
Why isn't my game over panel showing up when I collide with an obstacle?
Wire the restart button and add difficulty scaling
The game over screen shows up, but the "Play Again" button does nothing yet. You need a handler that resets every piece of state and clears the scene so the player can start fresh.
- Find the line panelStack.addControl(restartBtn) in your GUI setup code. Below it, add the restart handler:
restartBtn.onPointerUpObservable.add(function () {
// Dispose all obstacles
for (let i = obstacles.length - 1; i >= 0; i--) {
if (obstacles[i].material) {
obstacles[i].material.dispose();
}
obstacles[i].dispose();
}
obstacles.length = 0;
// Reset player
player.position.x = 0;
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
// Reset game state
score = 0;
obstacleSpeed = 0.15;
spawnTimer = 0;
gameOver = false;
scoreText.text = "Score: 0";
gameOverPanel.isVisible = false;
});
What does this code do?
- onPointerUpObservable fires when the player clicks the button. This is the Babylon.js GUI equivalent of a DOM click handler.
- The obstacle cleanup loop runs backward (i--) so that splicing items does not skip indices. Each obstacle's material and mesh get disposed to free GPU memory.
- obstacles.length = 0 empties the tracking array. The player position, velocity, score, and speed all reset to their starting values.
- Setting gameOver = false lets the game loop guard pass again. The game resumes on the very next frame.
- Save index.html and refresh your browser.
- Play until you hit an obstacle. Click the Play Again button. The game should reset completely: score back to zero, all obstacles gone, player back to center.
Restart button not working?
- Make sure the handler is attached with onPointerUpObservable.add(), not onclick. Babylon.js GUI buttons use observables, not DOM events.
- If old obstacles reappear after restart, confirm that obstacles.length = 0 is inside the handler. Without it, the array still holds references to disposed meshes.
Help me debug why my restart button is not resetting the game.
The game works end to end, but it plays the same way every time. Adding difficulty scaling makes each run feel more intense the longer you survive.
- In the game loop, find the score increment block you added earlier. Below it, add the difficulty scaling logic:
// Difficulty scaling
if (frameCount % 300 === 0) {
obstacleSpeed += 0.01;
if (spawnInterval > 40) {
spawnInterval -= 3;
}
}
What does this code do?
- Every 300 frames (roughly every 5 seconds), the obstacle speed increases by 0.01. Obstacles move toward you faster the longer you play.
- spawnInterval decreases by 3 each time, capped at a minimum of 40. This means new obstacles appear more frequently as the game progresses.
- Both values reset to their starting values in the restart handler, so each new run starts at the base difficulty.
- Save index.html and refresh your browser.
- Play the game for 15 to 20 seconds. You should notice obstacles arriving faster and spawning more frequently as your score climbs.
Difficulty not ramping up?
- Make sure the difficulty block is inside the game loop callback, not outside it. It should be right after the score block.
- Check that frameCount is incrementing every frame. If you placed frameCount++ after the if (gameOver) return guard, it should be counting up correctly.
Why isn't my difficulty scaling working in the game loop?
Test the full game cycle by playing, dying, checking your score, clicking Play Again, and playing again. Each piece of the loop should flow smoothly into the next.
Awesome, I've got everything!
Double check you have saved index.html. Your game is fully playable.
I'd like to double check the full code
Here is the complete index.html with everything from Steps 1 through 4.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner - Babylon.js</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/v9.0.0/babylon.js"></script>
<script src="https://cdn.babylonjs.com/v9.0.0/gui/babylon.gui.min.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
// Game state
let gameOver = false;
let score = 0;
let obstacleSpeed = 0.15;
let spawnTimer = 0;
let spawnInterval = 90;
const obstacles = [];
const inputMap = {};
// Player physics
let playerYVelocity = 0;
let isJumping = false;
const gravity = -0.006;
const jumpForce = 0.12;
const playerGroundY = 0.5;
const trackHalfWidth = 3;
// GUI references
let scoreText;
let gameOverPanel;
let finalScoreText;
const createScene = function () {
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color3(0.05, 0.05, 0.15);
// Camera
const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 4, -8), scene);
camera.setTarget(new BABYLON.Vector3(0, 1, 10));
// Lighting
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0.5), scene);
light.intensity = 1.0;
light.diffuse = new BABYLON.Color3(0.8, 0.8, 1.0);
light.groundColor = new BABYLON.Color3(0.2, 0.2, 0.4);
// Ground track
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 8, height: 200 }, scene);
ground.position.z = 80;
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.15, 0.15, 0.25);
groundMat.specularColor = new BABYLON.Color3(0, 0, 0);
ground.material = groundMat;
// Player
const player = BABYLON.MeshBuilder.CreateSphere("player", { diameter: 1 }, scene);
player.position.y = playerGroundY;
player.position.z = 0;
const playerMat = new BABYLON.StandardMaterial("playerMat", scene);
playerMat.emissiveColor = new BABYLON.Color3(0.0, 0.8, 1.0);
playerMat.diffuseColor = new BABYLON.Color3(0.0, 0.5, 0.8);
player.material = playerMat;
// Keyboard input
window.addEventListener("keydown", function (e) {
inputMap[e.key] = true;
});
window.addEventListener("keyup", function (e) {
inputMap[e.key] = false;
});
// Obstacle colors
const obstacleColors = [
new BABYLON.Color3(1, 0.2, 0.2),
new BABYLON.Color3(1, 0.6, 0),
new BABYLON.Color3(0.8, 0, 0.8),
new BABYLON.Color3(1, 1, 0),
new BABYLON.Color3(0, 1, 0.5),
];
function spawnObstacle() {
const box = BABYLON.MeshBuilder.CreateBox("obstacle", { width: 1.2, height: 1.2, depth: 1.2 }, scene);
const xPos = (Math.random() * trackHalfWidth * 2) - trackHalfWidth;
box.position.x = xPos;
box.position.y = 0.6;
box.position.z = 60;
box.rotation.y = Math.random() * Math.PI;
const mat = new BABYLON.StandardMaterial("obsMat" + obstacles.length, scene);
const color = obstacleColors[Math.floor(Math.random() * obstacleColors.length)];
mat.diffuseColor = color;
mat.emissiveColor = color.scale(0.3);
box.material = mat;
obstacles.push(box);
}
// GUI setup
const advancedTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("UI");
scoreText = new BABYLON.GUI.TextBlock();
scoreText.text = "Score: 0";
scoreText.color = "white";
scoreText.fontSize = 28;
scoreText.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
scoreText.textVerticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_TOP;
scoreText.paddingLeft = "20px";
scoreText.paddingTop = "20px";
advancedTexture.addControl(scoreText);
// Game over panel (hidden initially)
gameOverPanel = new BABYLON.GUI.Rectangle();
gameOverPanel.width = "400px";
gameOverPanel.height = "280px";
gameOverPanel.cornerRadius = 12;
gameOverPanel.color = "white";
gameOverPanel.thickness = 2;
gameOverPanel.background = "rgba(0, 0, 0, 0.85)";
gameOverPanel.isVisible = false;
advancedTexture.addControl(gameOverPanel);
const panelStack = new BABYLON.GUI.StackPanel();
panelStack.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER;
gameOverPanel.addControl(panelStack);
const gameOverTitle = new BABYLON.GUI.TextBlock();
gameOverTitle.text = "GAME OVER";
gameOverTitle.color = "#ff4444";
gameOverTitle.fontSize = 36;
gameOverTitle.height = "60px";
panelStack.addControl(gameOverTitle);
finalScoreText = new BABYLON.GUI.TextBlock();
finalScoreText.text = "Score: 0";
finalScoreText.color = "white";
finalScoreText.fontSize = 24;
finalScoreText.height = "50px";
panelStack.addControl(finalScoreText);
const restartBtn = BABYLON.GUI.Button.CreateSimpleButton("restartBtn", "Play Again");
restartBtn.width = "200px";
restartBtn.height = "50px";
restartBtn.color = "white";
restartBtn.cornerRadius = 8;
restartBtn.background = "#0088cc";
restartBtn.fontSize = 20;
restartBtn.paddingTop = "20px";
panelStack.addControl(restartBtn);
restartBtn.onPointerUpObservable.add(function () {
// Dispose all obstacles
for (let i = obstacles.length - 1; i >= 0; i--) {
if (obstacles[i].material) {
obstacles[i].material.dispose();
}
obstacles[i].dispose();
}
obstacles.length = 0;
// Reset player
player.position.x = 0;
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
// Reset game state
score = 0;
obstacleSpeed = 0.15;
spawnTimer = 0;
gameOver = false;
scoreText.text = "Score: 0";
gameOverPanel.isVisible = false;
});
// Game loop
let frameCount = 0;
scene.onBeforeRenderObservable.add(function () {
if (gameOver) return;
frameCount++;
// Player movement
const moveSpeed = 0.1;
if (inputMap["ArrowLeft"] || inputMap["a"]) {
player.position.x -= moveSpeed;
}
if (inputMap["ArrowRight"] || inputMap["d"]) {
player.position.x += moveSpeed;
}
player.position.x = Math.max(-trackHalfWidth, Math.min(trackHalfWidth, player.position.x));
// Jump
if ((inputMap[" "] || inputMap["ArrowUp"]) && !isJumping) {
isJumping = true;
playerYVelocity = jumpForce;
}
if (isJumping) {
playerYVelocity += gravity;
player.position.y += playerYVelocity;
if (player.position.y <= playerGroundY) {
player.position.y = playerGroundY;
playerYVelocity = 0;
isJumping = false;
}
}
// Player sphere rotation for visual flair
player.rotation.x -= 0.05;
// Spawn obstacles
spawnTimer++;
if (spawnTimer >= spawnInterval) {
spawnObstacle();
spawnTimer = 0;
}
// Move obstacles and check collisions
for (let i = obstacles.length - 1; i >= 0; i--) {
const obs = obstacles[i];
obs.position.z -= obstacleSpeed;
obs.rotation.y += 0.02;
// Collision check
if (player.intersectsMesh(obs, false)) {
gameOver = true;
finalScoreText.text = "Score: " + score;
gameOverPanel.isVisible = true;
return;
}
// Remove obstacles that passed the camera
if (obs.position.z < -10) {
if (obs.material) {
obs.material.dispose();
}
obs.dispose();
obstacles.splice(i, 1);
}
}
// Score
if (frameCount % 10 === 0) {
score++;
scoreText.text = "Score: " + score;
}
// Difficulty scaling
if (frameCount % 300 === 0) {
obstacleSpeed += 0.01;
if (spawnInterval > 40) {
spawnInterval -= 3;
}
}
});
return scene;
};
const scene = createScene();
engine.runRenderLoop(function () {
scene.render();
});
window.addEventListener("resize", function () {
engine.resize();
});
</script>
</body>
</html>
Secret mission
Lane System, Particle Trail, and High Scores
Your game works, but it still feels like a prototype. Real endless runners like Subway Surfers use lane-based movement, flashy particle effects, and persistent high scores. In this secret mission, you will add all three to level up your game from a tech demo to something that feels polished and fun to replay.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder containing index.html.
- Live Server VS Code extension.
- Babylon.js loaded via CDN (no local install).
Keep everything running
No action needed. Choose this if you're still actively building or want to jump into the Secret Mission.
- Your project folder and index.html file stay as they are.
- Live Server keeps running in VS Code until you stop it or close the editor.
Pause - I'll come back to this later
Shut down Live Server to free up the local port. Your project files stay on disk so you can pick up right where you left off.
- Click Go Live in the VS Code status bar to stop the Live Server.
- Close the browser tab running your game.
- Your project folder and all files remain on your machine for next time.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Stop Live Server by clicking Go Live in the VS Code status bar.
- Delete the entire project folder from your file system.
- Optionally uninstall the Live Server extension from VS Code if you no longer need it.
Mission accomplished
That's a Wrap
Nice work! You just built a fully playable 3D endless runner game from scratch using Babylon.js. That is a real game running in your browser, with keyboard controls, obstacle dodging, scoring, and a restart loop.
You've learned how to:
- Build a complete 3D game loop with a Babylon.js scene, keyboard input tracking, jump physics, and frame-by-frame collision detection using intersectsMesh().
- Implement dynamic mesh spawning and disposal to procedurally generate obstacles, clean them up after they pass the camera, and scale difficulty over time.
- Create an in-game HUD and game-over screen with live scoring, a restart button, and full state management using Babylon.js GUI.
- Add lane-based movement, a particle trail effect, and persistent high scores with localStorage to polish the game.
Ready to quiz yourself?