Build a HTML5 “Helix Jump” prototype with Three.js and TypeScript – Step 3: adding spikes and using GSAP for camera tweening

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

Time to add some deadly spikes to Helix Jump prototype.

In the previous step, I added a bouncing ball using no phyics engine.

Will I be able to add lethal spikes and manage collisions all the time without using physics engines? The challenge increases in level, but not too much.

One of the major improvements was to use GSAP for tweening.

GSAP (GreenSock Animation Platform) is a powerful JavaScript animation library commonly used in web development to create smooth, timeline-based animations.

I used GSAP to animate the camera when the player dies, moving it to frame the point of impact, and to smootly fade ball’s color to red.

Instead of relying on lerp() frame-by-frame, GSAP allows declarative, flexible animation that are easier to read and extend.

To install GSAP, simply type in your terminal:

npm install gsap

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

And about deadly spikes, each platform now includes a number of deadly spikes placed only along the solid arc of the cylinder segment.

These are represented as THREE.ConeGeometry meshes and placed along the arc using simple trigonometry.

Collision detection is handled by checking the distance between the spike tip and the center of the ball.

If that distance falls below ball’s radius, it’s game over.

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.

Don’t land on spikes!

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
    spikeRadius         : 0.2,              // spike radius
    spikeHeight         : 0.6,              // spike height
    spikeColor          : 0x444444,         // spike color
    gravity             : 10,               // ball gravity
    bounceImpulse       : 6,                // ball bounce impulse
    spikeProbability    : 0.25,             // probabilty of a spike to appear, 0..1          
    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 { gsap } from 'gsap';                    // import the GSAP library used for tween-based animations
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);
camera.lookAt(0, -2, 0);

// 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, i > 0);
    
    // 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();

// boolean variable to check if the game is over
let gameOver : boolean = false;

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

    // ask the browser to call this function again on the next animation frame
    requestAnimationFrame(update);

    // if the game is over, just render the scene and exit the function
    if (gameOver) { 
        renderer.render(scene, camera);
        return;
    }

    // get the time elapsed since the last frame
    const delta : number = clock.getDelta();

    // get top platform
    const topPlatform = platformGroup.children[0];

    // get current camera y position
    const currentCameraY = camera.position.y;

    // y target is always a bit above top platform
    const targetY = topPlatform.position.y + 4;

    // lerp camera position and direction. Some hardcoded values here, will optimize a bit later
    camera.position.y = THREE.MathUtils.lerp(currentCameraY, targetY, 0.01);
    camera.lookAt(0, THREE.MathUtils.lerp(currentCameraY - 6, topPlatform.position.y - 2, 0.01), 0);
    
    // make light follow the camera
    light.position.y = camera.position.y + 6;
    
    // determine rotation direction according to pressed keys  
    let rotateDirection : number = 0;

    // 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);

    // get the topmost platform
    const topmostPlatform : Platform = platformGroup.children[0] as Platform;

    // loop through all spikes
    for (const spike of topmostPlatform.spikes) {

        // get spike tip position
        const spikeTip = new THREE.Vector3(0, GameOptions.spikeHeight / 2, 0); 

        // get spike tip local world coordinate
        spike.localToWorld(spikeTip);

        // determine the distance from spike tip to ball center
        const distanceToTip = spikeTip.distanceTo(ball.position);
            
        // is the spike lower than ball radius? So we have a collision
        // I reduced by 10% ball radius to make the game easier
        if (distanceToTip < GameOptions.ballRadius * 0.9) {
            
            // now it's game over
            gameOver = true;
           
            // tween camera position using GSAP
            gsap.to(camera.position, {
                z           : ball.position.z + 4,                                          // z position
                x           : spikeTip.x > 0 ? ball.position.x + 4 : ball.position.x - 4,   // x position
                y           : ball.position.y,                                              // y position
                duration    : 2,                                                            // duration, in seconds
                ease        : 'power2.out',                                                 // easing
                onUpdate : () => {                                                          // function to be executed at each update
                    camera.lookAt(ball.position.x, ball.position.y, ball.position.z);
                }
            });

            // tween ball material color using GSAP
            gsap.to((ball.material as THREE.MeshStandardMaterial).color, {
                r: 1,           // red
                g: 0,           // green
                b: 0,           // blue
                duration: 2     // duration, in seconds
            });

            setTimeout(() => {

                // reset gameOver flag
                gameOver = false;

                // reset ball position and velocity
                ball.position.set(0, 2, GameOptions.platformRadius - 0.4);
                ball.velocity = 0;

                // reset camera
                camera.position.set(0, 4, 12);
                camera.lookAt(0, -2, 0);

                // reset column
                column.position.y = 0;

                // clear and recreate platforms
                platformGroup.clear();
                for (let i : number = 0; i < GameOptions.totalPlaftforms; i ++) {
                    const newPlatform = new Platform(GameOptions.platformGap * -i, i > 0);
                    platformGroup.add(newPlatform);
                }

                // reset platform group rotation
                platformGroup.rotation.y = 0;

                // reset ball color
                const mat = ball.material as THREE.MeshStandardMaterial;
                mat.color.set(GameOptions.ballColor);
            
            }, 3000); // wait 3 seconds

            // render the scene and exit the function
            renderer.render(scene, camera);
            return;
        }
    }

    // if the velocity is less than zero (the ball is falling)
    if (ball.velocity < 0) {

        // get the y coordinate of the first platform floor, according to ball rdius, platform y position and height
        const impactPoint : number = topmostPlatform.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 = ((topmostPlatform.rotation.y + platformGroup.rotation.y) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2); 
            const endAngle = ((startAngle + topmostPlatform.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(topmostPlatform);
                scene.remove(topmostPlatform);

                // move the column down to pretend it's endless
                column.position.y -= GameOptions.platformGap;

                // create a new platform below the last one ad add it to platform group
                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, true);
                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
    spikes      : THREE.Mesh[];     // store spikes for collision detection
    
    constructor(posY : number, hasSpikes : boolean) {
        
        super();

        // start with no spikes
        this.spikes = [];

        // choose a random rotation angle around the column
        const angle : number = hasSpikes ? Math.random() * Math.PI * 2 : - 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 = hasSpikes ? GameOptions.minThetaLength + Math.random() * (GameOptions.maxThetaLength - GameOptions.minThetaLength) : Math.PI; 
          
        // 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);

        // does the platform have spikes?
        if (hasSpikes) {

            // create deadly spikes on the solid section
            const spikeStep: number = Math.PI / 16; 
            for (let angleSpike : number =  Math.PI / 60; angleSpike < this.thetaLength - Math.PI / 60; angleSpike += spikeStep) {

                // should we place a spike?
                if (Math.random() < GameOptions.spikeProbability) {
                    
                    // define spike geometry
                    const spikeGeometry : THREE.ConeGeometry = new THREE.ConeGeometry(GameOptions.spikeRadius, GameOptions.spikeHeight);

                    // define spike color
                    const spikeMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
                        color: GameOptions.spikeColor
                    });

                    // create the spike mesh
                    const spike : THREE.Mesh = new THREE.Mesh(spikeGeometry, spikeMaterial);

                    // random between -2 and +2 degrees in radians
                    const jitter : number = (Math.random() * 4 - 2) * (Math.PI / 180); 

                    // final spike angle
                    const finalAngle : number = angleSpike + jitter;

                    // place the spike, using trigonometry
                    spike.position.x = Math.cos(-finalAngle + Math.PI / 2) * (GameOptions.platformRadius - GameOptions.ballRadius);
                    spike.position.z = Math.sin(-finalAngle + Math.PI / 2) * (GameOptions.platformRadius - GameOptions.ballRadius);
                    spike.position.y = GameOptions.platformHeight / 2 + GameOptions.spikeHeight / 2;

                    // spikes cast and receive shadows
                    spike.castShadow = true;
                    spike.receiveShadow = true;

                    // add the spike and push it in spikes array
                    this.add(spike);
                    this.spikes.push(spike);
                }
            }
        }
        
        // 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, 2, 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 a score system. 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.