Build a HTML5 “Helix Jump” prototype with Three.js and TypeScript – Step 2: adding a bouncing ball with no physics engine
Talking about Helix Jump game, 3D, Game development, HTML5, Javascript and TypeScript.
Time to add a bouncing ball to Helix Jump prototype.
In the first step, we built the basic structure of a Helix Jump prototype using Three.js and TypeScript, creating a vertical column and a series of solid platforms made with cylinder sections.
Today we are moving forward adding a bouncing ball to make the gameplay start feeling alive, and we improve the platforms to properly simulate falling through gaps, without using any physics engine, just a bit of math and trigonometry.
As always, the entire project uses Vite as a bundler for fast development and the source code is fully commented for easy understanding. Don’t know what is a bundler? I’ll explain it to you step by step in this free minibook.
Now, look at the prototype:
Press A and D to rotate the tower counter clockwise and clockwise. Hit the green sector to make ball fall down.
This is what I added in this second step:
A bouncing ball: a sphere mesh that constantly falls due to gravity and bounces off platforms.
Collision detection: calculate when the ball touches the topmost platform and either makes it bounce or fall through it.
New platform structure: instead of building platforms with gaps and walls, I now model them with two separate cylinder sections — the main solid part and the complementary hole part — for a cleaner, more realistic collision.
Camera smooth follow: camera follows the ball smoothly by using lerp interpolation, keeping it in the center of the action.
Improved lighting: replaced the directional light with a point light that moves down together with the camera, keeping the scene properly lit.
Infinite platforms: every time the ball falls through a platform, a new one is spawned below the last one, keeping the game area alive forever. The central column also moves down accordingly, so players get the feeling of an endless tower without interruptions.
Now, look at the completely commented source code, which consists in one HTML file, one CSS file and four 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>Helix Jump</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 = {
columnRadius : 1, // column radius
columnColor : 0x00ff00, // column color
totalPlaftforms : 10, // total platorms in game
platformGap : 3, // vertical gap between two platorms
platformRadius : 3, // platform radius
platformHeight : 1, // platform heignt
minThetaLength : Math.PI * 1.5, // min theta length, minimum radians of the circular sector
maxThetaLength : Math.PI * 1.85, // max theta length, maximum radians of the circular sector
rotationSpeed : 6, // helix rotation speed
gapColor : 0x00ff00, // gap color
ballRadius : 0.4, // ball radius
ballColor : 0x444444, // ball color
gravity : 10, // ball gravity
bounceImpulse : 6, // ball bounce impulse
platformColors : [0xff0000, 0x0000ff, 0xffff00, 0xff00ff]
}
main.ts
This is where the game is created.
import * as THREE from 'three'; // import all THREE.js components
import { GameOptions } from './gameOptions'; // import game options
import { Platform } from './platform'; // import Platform class
import { Ball } from './ball'; // import Ball class
import './style.css'; // import web page style sheet
// create the 3D scene container
const scene : THREE.Scene = new THREE.Scene();
// set up a perspective camera, then manually position and orient it
const camera : THREE.PerspectiveCamera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 4, 12);
// create the WebGL renderer with antialiasing enabled
const renderer : THREE.WebGLRenderer = new THREE.WebGLRenderer({
antialias: true
});
// set the renderer size to match the window
renderer.setSize(window.innerWidth, window.innerHeight);
// enable shadow rendering
renderer.shadowMap.enabled = true;
// use soft shadows for smoother lighting
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// add the renderer canvas to the DOM
document.body.appendChild(renderer.domElement);
// create an ambient light to softly illuminate the scene
const ambientLight : THREE.AmbientLight = new THREE.AmbientLight(0xffffff, 0.3);
// add the ambient light to the scene
scene.add(ambientLight);
// create a directional light to simulate sunlight
const light : THREE.PointLight = new THREE.PointLight(0xffffff, 40);
// manually position the light source
light.position.set(5, 10, 7.5);
// enable shadow casting from this light
light.castShadow = true;
// add the directional light to the scene
scene.add(light);
// create the geometry for the central column
const columnGeometry : THREE.CylinderGeometry = new THREE.CylinderGeometry(GameOptions.columnRadius, GameOptions.columnRadius, 50);
// create a standard material for the column
const columnMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : GameOptions.columnColor
});
// create the column mesh using geometry and material
const column : THREE.Mesh = new THREE.Mesh(columnGeometry, columnMaterial);
// enable shadow reception on the column
column.receiveShadow = true;
// add the column to the scene
scene.add(column);
// create a group to hold all platforms
const platformGroup : THREE.Group = new THREE.Group();
// add the platform group to the scene
scene.add(platformGroup);
// build the platforms
for (let i : number = 0; i < GameOptions.totalPlaftforms; i ++) {
// create a new platform
const platform : Platform = new Platform(GameOptions.platformGap * -i);
// add platform to platformGroup
platformGroup.add(platform);
}
// create and add the ball
const ball : Ball = new Ball();
scene.add(ball);
// store the key press timestamps or false when released
const keys : { [key : string] : number | false } = {};
// handle keydown events and store press time
window.addEventListener('keydown', (e : KeyboardEvent) => {
const key : string = e.key.toLowerCase();
if (!keys[key]) {
keys[key] = Date.now();
}
});
// handle keyup events and reset key state
window.addEventListener('keyup', (e : KeyboardEvent) => {
const key : string = e.key.toLowerCase();
keys[key] = false;
});
// three clock to measure time between frames
const clock : THREE.Clock = new THREE.Clock();
// function to be executed at each frame
function update() : void {
requestAnimationFrame(update);
// get top platform
const topPlatform = platformGroup.children[0];
// get current camera y position
const currentY = camera.position.y;
// y target is always a bit above top platform
const targetY = topPlatform.position.y + 2.5;
// lerp camera position and direction. Some hardcoded values here, will optimize a bit later
camera.position.y = THREE.MathUtils.lerp(currentY, targetY, 0.02);
camera.lookAt(0, THREE.MathUtils.lerp(currentY - 4, topPlatform.position.y - 2, 0.05), 0);
// make light follow the camera
light.position.y = camera.position.y + 6;
// determine rotation direction according to pressed keys
let rotateDirection : number = 0;
// get the time elapsed since the last frame
const delta : number = clock.getDelta();
// do we need to rotate counter clockwise?
if (keys['a'] && !keys['d']) {
rotateDirection = 1;
}
else {
// do we need to rotate clockwise?
if (keys['d'] && !keys['a']) {
rotateDirection = -1;
}
else {
// are we trying to rotate in both directions? Let's see which one was the latest
if (keys['d'] && keys['a']) {
rotateDirection = (keys['a'] > keys['d']) ? 1 : -1;
}
}
}
// apply rotation to the platform group
platformGroup.rotation.y += rotateDirection * GameOptions.rotationSpeed * delta;
// update ball position
ball.update(delta);
// if the velocity is less than zero (the ball is falling)
if (ball.velocity < 0) {
// get the topmost platform
const platform : Platform = platformGroup.children[0] as Platform;
// get the y coordinate of the first platform floor, according to ball rdius, platform y position and height
const impactPoint : number = platform.position.y + GameOptions.platformHeight / 2 + + GameOptions.ballRadius;
// is ball y positon less than impact point (the ball is touching platform floor)
if (ball.position.y < impactPoint) {
// get platform start and end angle. Final + Math.PI * 2 and % (Math.PI * 2) are used to avoid negative values
const startAngle = ((platform.rotation.y + platformGroup.rotation.y) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
const endAngle = ((startAngle + platform.thetaLength) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
// if start angle is less than end angle, it means the interval includes zero, ball's position. Which should now fall
if (startAngle < endAngle) {
// remove the platform from the group and scene
platformGroup.remove(platform);
scene.remove(platform);
// move the column down to pretend it's endless
column.position.y -= GameOptions.platformGap;
// create a new platform below the last one
const lastPlatform : Platform = platformGroup.children[platformGroup.children.length - 1] as Platform;
const newY : number = lastPlatform.position.y - GameOptions.platformGap;
const newPlatform : Platform = new Platform(newY);
platformGroup.add(newPlatform);
}
// if not, make the ball bounce
else {
// place the ball on the impact point, not to intersecate the platform
ball.position.y = impactPoint;
// method to make ball bounce
ball.bounce();
}
}
}
// render the scene from the camera's point of view
renderer.render(scene, camera);
}
// resize window listener
window.addEventListener('resize', () : void => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
update();
platform.ts
Custom class that handles platform group creation.
import * as THREE from 'three'; // import all THREE.js components
import { GameOptions } from './gameOptions'; // import game options
// Platform class extends THREE.Group
export class Platform extends THREE.Group {
thetaLength : number; // theta length, in radians
constructor(posY : number) {
super();
// choose a random rotation angle around the column
const angle : number = Math.random() * Math.PI * 2;
// choose a random color for this platform
const material : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : GameOptions.platformColors[Math.floor(Math.random() * GameOptions.platformColors.length)]
});
// define the angular length of the platform arc
this.thetaLength = GameOptions.minThetaLength + Math.random() * (GameOptions.maxThetaLength - GameOptions.minThetaLength);
// create the curved surface of the platform using a cylinder segment
const cylinderGeometry: THREE.CylinderGeometry = new THREE.CylinderGeometry(GameOptions.platformRadius, GameOptions.platformRadius, GameOptions.platformHeight, 32, 1, false, 0, this.thetaLength);
// create a mesh with the cylinder geometry and material
const cylinder: THREE.Mesh = new THREE.Mesh(cylinderGeometry, material);
// the cylinder casts and receives shadows
cylinder.castShadow = true;
cylinder.receiveShadow = true;
// add the cylinder to the platform group
this.add(cylinder);
// gap material, where te ball should land
const gapMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : GameOptions.gapColor
});
// create the complementary curved surface of the platform using a cylinder segment
const gapGeometry: THREE.CylinderGeometry = new THREE.CylinderGeometry(GameOptions.platformRadius, GameOptions.platformRadius, GameOptions.platformHeight, 32, 1, false, this.thetaLength, Math.PI * 2 - this.thetaLength);
// create a mesh with the cylinder geometry and material
const gap : THREE.Mesh = new THREE.Mesh(gapGeometry, gapMaterial);
// the gap casts and receives shadows
gap.castShadow = true;
gap.receiveShadow = true;
// add the gap to the platform group
this.add(gap);
// place the platform vertically
this.position.y = posY;
// rotate the platform around the column
this.rotation.y = angle;
}
}
ball.ts
Custom class that handles ball creation.
import * as THREE from 'three'; // import all THREE.js components
import { GameOptions } from './gameOptions'; // import game options
// Ball class extends THREE.Mesh
export class Ball extends THREE.Mesh {
velocity : number; // ball velocity
constructor() {
// create ball material
const material : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : GameOptions.ballColor
});
// create ball geometry
const geometry : THREE.SphereGeometry = new THREE.SphereGeometry(GameOptions.ballRadius);
super(geometry, material);
// ball casts shadow
this.castShadow = true;
// place the ball
this.position.set(0, 5, GameOptions.platformRadius - GameOptions.ballRadius);
// set ball velocity at zero
this.velocity = 0;
}
// method to apply gravity and update vertical position
update(delta : number) : void {
// apply gravity
this.velocity -= GameOptions.gravity * delta;
// update y position
this.position.y += this.velocity * delta;
}
// method to make ball bounce
bounce() : void {
// set velocity to bounce impulse
this.velocity = GameOptions.bounceImpulse;
}
}
That’s all at the moment. Next time, I’ll add deadly spikes to avoid. 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.