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.

HTML
<!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.

CSS
body {
    margin: 0
}

gameOptions.ts

Configurable game options. Changing these values affects the gameplay.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.