Build a HTML5 “Helix Jump” prototype with Three.js and TypeScript – Step 4: scoring, animated CSS background and platforms fading away

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

Here we are with the fourth step of our HTML5 Helix Jump prototype built with Three.js and TypeScript.

In this update, I am focusing on polishing the presentation and game feel.

The new features I added in this iteration include a CSS-powered score display, an animated background using pure CSS, and a fade to white and fly away effect to platforms.

To show the score, instead of placing a 3D object in the scene to display the player’s score, I opted for a CSS-based HUD element.

It’s just a plain <div> element anchored to the top-right corner of the screen, properly styled.

This keeps the interface lightweight and flexible, and allows for fast updates like:

scoreElement.textContent = score.toString();

To make the scene feel more alive, I added an animated checkerboard background using a simple CSS animation.

Last but not least, until now platforms just disappeared when the ball fell through the gap.

It worked, but looked abrupt and a bit cheap.

Now, when a platform is destroyed, it no longer gets removed immediately.

Instead, it is moved to a secondary graveyard group, its materials are turned to pure white, and the whole platform is moved upward quickly using GSAP.

Once the animation completes, the platform is finally removed from the scene.

Look at the result:

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. Notice the div to display the score.

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>
        <div id="score">0</div>
        <script type="module" src="/src/main.ts"></script>
    </body>
</html>

style.css

The cascading style sheets of the main web page. This is where I set the background animation.

CSS
body {
    margin: 0;
    overflow: hidden;
    background-color: #111;
    background-image: repeating-conic-gradient(#222 0deg 90deg, #333 90deg 180deg);
    background-size: 128px 128px;
    animation: scrollDiagonal 10s linear infinite;
}

@keyframes scrollDiagonal {
    from {
        background-position: 0 0;
    }
    to {
        background-position: 256px 256px;
    }
}

#score {
    position: fixed;
    top: 2vw;
    right: 2vw;
    font-size: clamp(64px, 8vw, 144px);
    font-family: 'Arial', sans-serif;
    font-weight: bold;
    color: white;
    z-index: 9999;
    pointer-events: none;
}

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 and transparency enabed
const renderer : THREE.WebGLRenderer = new THREE.WebGLRenderer({
    antialias   : true,
    alpha       : true  
});
renderer.setClearColor(0x000000, 0);

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

// create and add to the scene a group to be used as graveyard
const platformGraveyard: THREE.Group = new THREE.Group();
scene.add(platformGraveyard);

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

// HTML element with the score. The id must macth the id in index.html file
const scoreElement : HTMLElement = document.getElementById('score') as HTMLElement;

// variable to save the score
let score : number = 0;

// 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.03);
    camera.lookAt(0, THREE.MathUtils.lerp(currentCameraY - 6, topPlatform.position.y - 2, 0.03), 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;

    // apply rotation to the graveyard group
    platformGraveyard.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);       // update camera to look at the ball
                }
            });

            // 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(() => {

                // set score to zero
                score = 0;
                scoreElement.textContent = '0';

                // 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
                platformGroup.remove(topmostPlatform);

                // add the platform to platform graveyard
                platformGraveyard.add(topmostPlatform);

                topmostPlatform.fadeAndRemove(platformGraveyard);

                // 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);   
                
                // increase the score
                score ++;
                scoreElement.textContent = score.toString();
            }

            // 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 { gsap } from 'gsap';                    // import the GSAP library used for tween-based animations
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;
    }

    // method to remove a platform
    // parentGroup: platform's parent group
    fadeAndRemove(parentGroup : THREE.Group) : void {

        // loop through all platform children
        this.children.forEach((child : THREE.Object3D) => {
            
            // in this case, we know all children are meshes, so let's force Mesh type
            const childMesh : THREE.Mesh = child as THREE.Mesh;
           
            // get the standard material
            const material : THREE.MeshStandardMaterial = childMesh.material as THREE.MeshStandardMaterial;
            
            // change material color to white
            material.color.set(0xffffff);
           
        });

        // use GSAP to move the platform up
        gsap.to(this.position, {
            y           : this.position.y + 5,  // new y position
            duration    : 1,                    // duration, in seconds
            ease        : 'power2.out',         // easing
            onComplete  : () => {               // callback function to be executed once the tween has been completed
                parentGroup.remove(this);       // remove the platform
            }
        });
    }
}

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;
    }
} 

Now you can build your HTML5 Helix Jump using Three.js and TypeScript, 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.