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.
<!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
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.
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.
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.
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.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.