Back to portfolio
Case study · 2025

Turret Defence

A 3D tower defense game where you are a turret defending against waves of enemies approaching from all directions.

Overview

Turret Defence is a 3D tower defence game. You control one turret in the centre of the map, defending against waves of enemies that approach from every direction.

Traditional tower defence is about placing towers. Here there is no placement at all: it comes down to aiming, timing and how you upgrade a single turret.

How it plays

Each run follows the same loop. Survive a wave, earn coins from kills, then spend them on upgrades to damage, reload speed and bullet velocity, or on items. Waves mix normal enemies with tanks, which are slower and take more hits, and every fifth wave brings a boss with regenerating shields.

Four difficulty levels, from Easy to Impossible, change enemy health and speed. A day-night cycle changes the lighting and fog as the waves progress.

You pick one of three turrets:

  • Standard: balanced.
  • Sniper: high damage and range, but a slow reload. Hold to charge a shot.
  • Machine Gun: rapid fire and lower damage. It overheats with sustained fire.
  • Items: Absolute Zero slows all enemies for 10s, Time Warp sends them back to random spawn points, Shield gives 5s of invulnerability, and EMP Blast stuns everything for 3s.
  • Super Charge: a meter that fills as you defeat enemies. Activating it pushes every turret stat one level beyond its maximum for a short time.

Controls: A/D rotate · S fire · Q Super Charge · 1–4 items

How it's built

The game is React with Three.js, rendered through React Three Fiber. All game state (the wave, enemies, coins, upgrades, items and Super Charge) lives in a single Zustand store that components subscribe to.

That keeps data flowing one way. Input updates the store, and the 3D scene and the UI react to it.

The game loop runs in React Three Fiber's useFrame hook. Each frame it moves enemies and projectiles and runs custom collision detection.

The leaderboard is a separate Express.js server. After a game over, players submit a name, the server stores the score in SQLite, and the frontend fetches the rankings through a REST API.

The model viewer

A separate page lets you inspect the game's 3D models. It has three main features, plus day and night lighting modes.

  • Hologram shader: a custom GLSL shader with a grid overlay, a pulsing rim light and moving scan lines. Its colour, intensity, grid size and pulse speed are shader uniforms, so they can be changed live.
  • Post-processing: bloom, chromatic aberration, film grain and vignette, each with live controls.
  • Exploded view: pulls a model's parts away from its centre, then reassembles them with GSAP and elastic easing.

The core of the hologram fragment shader:

glsl
uniform float time;
uniform vec3 color;
uniform float intensity;
uniform float gridSize;
uniform float pulseSpeed;

varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  float rim = 1.0 - max(dot(vNormal, vec3(0.0, 0.0, 1.0)), 0.0);
  rim = pow(rim, 2.0) * sin(time * pulseSpeed) * 0.5 + 0.5;

  float gridX = step(0.98, fract(vUv.x * gridSize));
  float gridY = step(0.98, fract(vUv.y * gridSize));
  float grid = 0.15 * (gridX + gridY);

  float scanLine = 0.15 * step(0.98, fract(vPosition.y * 10.0 - time));

  vec3 baseColor = color * intensity;
  vec3 finalColor = mix(baseColor, vec3(1.0), grid + scanLine + rim);
  float alpha = 0.7 + 0.3 * (rim + grid);

  gl_FragColor = vec4(finalColor, alpha);
}

The hard part

The first challenge was bringing low-level GLSL into a React component tree. The shader works at a very different level from the rest of the code, but its colour, intensity, grid size and pulse speed had to follow React state, so the uniforms update live as the controls change.

The second was testing a 3D game. I kept the game logic in the Zustand store, where Vitest unit tests cover enemy tracking, upgrades and max levels, item purchases and cooldowns, and Super Charge. Component tests with React Testing Library cover the menu, UI, game and turret, with Three.js-heavy components mocked so the tests check logic rather than WebGL rendering. Balance was tuned through playtesting.

Stack

React
Three.js
Blender
Express.js
Zustand
SQLite
React Three Fiber
React Three Drei
@react-three/postprocessing
GSAP
React Router
Vitest
React Testing Library
  • React Three Fiber: builds the 3D scene from React components, and its useFrame hook runs the game loop.
  • Zustand: one store that the scene and UI both subscribe to, which keeps data flowing one way and the game logic easy to unit test.
  • GSAP: animates the model viewer's exploded view, with elastic easing on reassembly.
  • Vitest: runs the store unit tests and, with React Testing Library, the component tests.
  • Express + SQLite: a REST API that stores leaderboard scores and serves them to the frontend.

Credits: sound effects and music from freesound.org, mixkit.co, uppbeat.io and zapsplat.com.