Tutorial 01

Build Your First 3D Microgame

A complete walkthrough from factory-fresh laptop to a fully playable 3D browser game deployed live on the internet. Zero prior experience required. Uses Three.js for rendering and Vite for development.

Beginner Three.js Vite 2 Live Demos ~90 min

๐ŸŽฎ Play It First โ€” Game 1: Cube Dodger

Before we write a single line of code, here is what you will build. A fully playable 3D dodge game running live in the browser โ€” no plugins, no installs, no app store. Just a URL you can share with anyone in the world.

This is a top-down arena game. Your blue cube starts in the center. Red enemy cubes spawn from the edges and close in. Gold orbs appear randomly and reward fast collection. The mechanic is simple to grasp in two seconds and surprisingly hard to master โ€” which is exactly the right balance for an arcade microgame. Notice how the challenge escalates as the timer counts down: that is not a difficulty level system. It is one line of code that increases enemy spawn rate proportionally to elapsed time. Simple input, emergent complexity.

Controls: Arrow keys or WASD to move. On mobile, tap where you want to go. Dodge the red cubes. Collect the gold orbs. Survive 60 seconds.

cube-dodger ยท live demo ยท three.js
๐Ÿ’ก
What You Just Played

This entire game โ€” scene setup, lighting, collision detection, HUD, game loop, and UI โ€” is written in roughly 350 lines of JavaScript and 100 lines of HTML/CSS. No Unity. No Unreal. No game engine subscription. Just a browser and a text editor.

๐Ÿš€ Game 2: Corridor Runner

The same stack โ€” Three.js, no framework, no engine โ€” produces a completely different genre. This side-scrolling endless runner uses identical tools and the same code architecture as the dodge game above. The mechanic shifts: instead of evading in an open arena, you fly through a corridor and must thread gaps in oncoming red walls while collecting gold orbs.

The key insight here is that the same underlying patterns โ€” game loop, scene, player object, obstacle system, score โ€” flex across wildly different game types. Switching genres does not require switching stacks. It requires changing how you use them. Notice the speed increases gradually as you travel further: that is a single variable, speedTier, incrementing every 200 meters. One number controls the entire difficulty arc. No state machine. No level design document. Just arithmetic.

Controls: Arrow Up / W to move up. Arrow Down / S to move down. On mobile, tap the top or bottom half of the screen.

corridor-runner ยท live demo ยท three.js
๐Ÿ”
Two Games, One Stack

Both games above are built with Three.js and vanilla JavaScript. No engine, no framework. The same knowledge that builds one builds the other. What changes is the game design โ€” not the tools.

In This Tutorial

  1. Environment Setup (Start Here)
  2. Accounts You Will Need
  3. The Microgame Philosophy
  4. The Tech Stack
  5. Project Setup
  6. Building the 3D Scene
  7. The Player
  8. Enemies & Spawning
  9. The Game Loop
  10. Feedback & Polish
  11. What's Next

0Environment Setup

This section is for people starting from a factory-fresh Windows 10 or 11 laptop with nothing installed. If you already have Node.js, a code editor, and Git, skip ahead to the accounts section. If you are not sure, open a terminal (search "cmd" in the Start menu) and type node --version. If you get a version number back, Node is installed. If you get an error, follow the steps below.

The goal of this section is to get your machine ready to code in under 15 minutes. We are installing three things and creating four accounts. That is all. Everything here is free.

15'
Target Time
All installs and account creation should take no more than 15 minutes on a normal internet connection. If you do this before the session starts, you will be fully ready to code from minute zero.

Step 1 โ€” Install Node.js

Node.js is the JavaScript runtime that powers your development tools. When you run commands like npm install or npm run dev, you are using Node. Without it, none of the development commands in this tutorial will work โ€” your terminal will simply report "command not found."

Go to nodejs.org and download the LTS version (Long-Term Support โ€” the stable one, not the latest experimental build). Run the installer with all default settings. When it finishes, open a new terminal window and verify the install worked:

bashterminal โ€” verify Node installed correctly
node --version
# Should print something like: v20.11.0

npm --version
# Should print something like: 10.2.4

Both commands should return version numbers. If either says "not recognized," close the terminal completely, reopen it, and try again โ€” the installer updates your system PATH but the change only applies to new terminal windows. If it still fails, restart your machine and try once more.

โš ๏ธ
Always Open a Fresh Terminal After Installing

Windows updates the PATH environment variable when Node installs, but any terminal window that was already open will not pick up the change. Always close and reopen the terminal after installing any tool. This is the most common reason Node commands fail immediately after installation.

Step 2 โ€” Install Git

Git is the version control system that tracks changes to your code and connects your project to GitHub. Netlify (your free hosting platform) watches your GitHub repository and deploys automatically every time you push code โ€” so Git is not optional for the deployment workflow.

Go to git-scm.com/download/win and download Git for Windows. During installation, when asked about the default editor, choose anything you are comfortable with โ€” this setting rarely matters in practice. Accept all other defaults. After installing, verify:

bashterminal โ€” verify Git installed correctly
git --version
# Should print something like: git version 2.44.0.windows.1

Git also installs Git Bash โ€” a Unix-style terminal that is more comfortable for running the commands in this tutorial than the default Windows Command Prompt. You can use either, but if you run into issues with paths or commands, switching to Git Bash usually resolves them.

Step 3 โ€” Install a Code Editor (PyCharm Community)

A code editor is where you write and edit your game files. Any editor works, but for group workshops we recommend everyone uses the same one to avoid environment-specific debugging. PyCharm Community Edition is free, handles JavaScript and HTML well, has good built-in terminal support, and provides useful autocomplete for the code patterns in this tutorial.

Download PyCharm Community Edition from JetBrains. Run the installer with defaults. When you first open it, you can skip any plugin suggestions โ€” the built-in tools are sufficient for everything in this tutorial.

๐Ÿ’ก
Built-in Terminal

PyCharm has a terminal built into the bottom panel (View โ†’ Tool Windows โ†’ Terminal). Using it means you never have to leave the editor to run commands โ€” you write code in the top half of the screen and run it in the bottom half. This is the fastest way to work during a timed session.

0bAccounts You Will Need

Four free accounts are needed to go from code to a live published game with a global leaderboard. Create them now, before you write a single line of code, so you are not interrupted mid-session waiting for verification emails. All four support sign-up with a Google account, which is the fastest option โ€” one click, no password to create, no email to verify.

โœ…
Use Google Sign-In Everywhere

GitHub, Netlify, Supabase, and Vercel all support "Sign in with Google." If you use your Gmail account for all four, you eliminate the need to remember four separate passwords and skip all email verification steps. For a 90-minute session where every minute counts, this is the right call.

๐Ÿ”—
Connect Git to GitHub (One-Time Setup)

After creating your GitHub account and repository, you need to connect your local Git installation to GitHub. The easiest way on Windows is to run git config --global user.email "you@gmail.com" and git config --global user.name "Your Name" in the terminal. When you first run git push, Windows will open a browser window asking you to authorize GitHub โ€” click Authorize and you will never be asked again on that machine.

1The Microgame Philosophy

Most people who want to make games imagine the wrong starting point. They picture something with a map, a story, multiple characters, a skill tree. What they should picture instead is a single, satisfying mechanical loop that a stranger can understand in two seconds.

A microgame has three things and only three things:

01

One Clear Mechanic

Move a character. Avoid obstacles. Collect items. One verb. One noun. The entire game fits in a sentence.

02

A Mission

A score, a timer, lives. Something that tells the player how they're doing and why they should keep going. Numbers that go up feel good.

03

Feedback

The game must react visibly to everything the player does. Hit something? Flash red. Collect something? Pulse gold. Silence is failure.

This is not a simplified version of game development. It is game development. The most successful arcade games in history are microgames: Pac-Man, Space Invaders, Tetris. The complexity is in the depth of the mechanic, not in the number of mechanics. Pac-Man is a game about moving through a maze while being chased. That is the whole thing. The depth comes from the ghost AI, the power-up timing, the score multipliers โ€” all layered on top of one sentence.

The practical benefit for you as a developer: a microgame can be built in a weekend. It can be shipped on a Monday. You will learn more from building and publishing five microgames than from spending two years on an unfinished open-world RPG. The shipped thing teaches you everything the unshipped thing never will.

โš ๏ธ
Scope is the Enemy

Every unfinished game in history was a victim of scope. Commit to a mechanic you can describe in one sentence. Add complexity only after it is working and playable. Shipping a simple thing is worth a hundred unshipped complex things.

2The Tech Stack

This tutorial uses two tools: Three.js and Vite. That is the entire stack. No framework. No game engine. No heavy dependencies. The deliberate simplicity is a feature, not a limitation โ€” every layer you add to a project is a layer that can break, require maintenance, and demand its own learning curve. Two tools means two things to understand, two things to debug, two things to update.

๐ŸงŠ
Three.js
Abstracts WebGL โ€” the low-level 3D graphics API โ€” into readable JavaScript. You describe what you want (a blue cube, a point light, a camera) and Three.js handles the GPU calls.
โšก
Vite
The development server. Instant hot-reload on every save. Handles the production build when you're ready to deploy. One command to start, one command to build.
๐ŸŸจ
Vanilla JS
No React, no Vue, no framework overhead. Game loops work best as imperative loops, not reactive state machines. Plain JavaScript gives you full control and nothing in the way.
๐ŸŒ
The Browser
Your target platform. Every modern browser runs WebGL. Your game works on desktop, tablet, and mobile with zero adaptation. No app store, no install โ€” just a URL.

The reason we chose this stack over alternatives like Unity WebGL, Godot, or Babylon.js comes down to one criterion: how fast can a beginner go from zero to a deployed URL? Unity and Godot require engine downloads and project setup that alone can consume an hour. Babylon.js is excellent but larger in scope than what a single microgame needs. Three.js plus Vite is four terminal commands and you are coding.

โ„น๏ธ
Prerequisites

Node.js (v18+), Git, and a code editor โ€” covered in the Environment Setup section above. Basic familiarity with JavaScript is helpful but not required. If you have written a for loop and a function in any language, you have enough background to follow this tutorial.

3Project Setup

With Node.js installed, setting up a Three.js project takes four terminal commands and about 60 seconds. Vite handles everything: it creates the folder structure, installs the dependencies, and starts a development server with hot-reload. You do not need to configure a bundler, set up a module system, or write a webpack config. That complexity is handled invisibly.

Open a terminal in the folder where you want your project to live (in PyCharm: Terminal tab at the bottom of the window). Run these commands in order:

bashterminal โ€” one-time project initialization
# Create the project โ€” "vanilla" means plain JS, no React/Vue
npm create vite@latest my-first-game -- --template vanilla

# Move into the project folder
cd my-first-game

# Install dependencies (creates the node_modules folder)
npm install

# Install Three.js specifically
npm install three

# Start the dev server โ€” browser should open automatically
npm run dev

After npm run dev, Vite starts a local server and prints a URL like http://localhost:5173. Open that URL and you will see the Vite default page. From this point, every time you save a file, the browser updates instantly โ€” no manual refresh needed. This tight feedback loop is what makes Vite so valuable for game development, where you are constantly tweaking values and needing to see the result immediately.

One common confusion: the node_modules folder that appears after npm install contains thousands of files and should never be committed to Git. Vite creates a .gitignore file for you that already excludes it. If you clone your project on a different machine, run npm install again to reinstall the dependencies locally โ€” they are never stored in the repository.

Your final project folder structure should look like this:

my-first-game/
โ”œโ”€โ”€ index.html โ† entry point: canvas + HUD HTML
โ”œโ”€โ”€ vite.config.js โ† dev server configuration (minimal)
โ”œโ”€โ”€ package.json โ† project metadata + dependency list
โ”œโ”€โ”€ .gitignore โ† tells Git to ignore node_modules
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ main.js โ† bootstrap: game loop lives here
    โ”œโ”€โ”€ scene.js โ† renderer, camera, lights, floor
    โ”œโ”€โ”€ player.js โ† player mesh + movement logic
    โ”œโ”€โ”€ enemies.js โ† enemy spawn + update logic
    โ””โ”€โ”€ state.js โ† score, lives, timer, game events
๐Ÿ’ก
Why Split Into Multiple Files?

You could write everything in one file. Many small games do. But separating scene setup, player logic, enemy logic, and game state means each file has a single clear responsibility. When something breaks โ€” and something always breaks โ€” you know immediately which file to open. This habit costs nothing when your game is small and pays dividends when it grows.

vite.config.js

Vite's configuration file is intentionally minimal. The defaults work for most projects. The only things worth setting explicitly are the base path (important for Netlify deployments) and the development port. Everything else Vite infers from your project structure automatically.

javascriptvite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  base: '/',        // root path โ€” important for Netlify
  server: {
    port: 3000,
    open: true       // auto-open browser on npm run dev
  }
})

The base: '/' setting tells Vite to generate asset paths relative to the root domain. Without it, your deployed game on Netlify might fail to load its JavaScript files because the paths are wrong for the production environment. This is a small setting that silently prevents a confusing deployment bug.

4Building the 3D Scene

The scene file is the stage before any actors arrive. It answers three questions that every 3D render requires: what is the viewport (the renderer and camera), what is the lighting, and what is the ground. These answers never change during gameplay, so they live in their own file, created once at startup, and exported to any module that needs to add objects to the world.

A common mistake here is putting scene setup directly in main.js alongside the game loop. This works until your scene grows complex โ€” at which point scrolling past 80 lines of camera and lighting setup every time you want to edit the game loop becomes friction. Keep the stage separate from the performance.

javascriptsrc/scene.js
import * as THREE from 'three'

// Renderer โ€” this is what draws pixels to the canvas
export const renderer = new THREE.WebGLRenderer({
  canvas: document.getElementById('c'),
  antialias: true
})
renderer.shadowMap.enabled = true
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

// Camera โ€” positioned above and behind the origin for an isometric view
export const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100)
camera.position.set(0, 14, 14)
camera.lookAt(0, 0, 0)

// Scene โ€” the container for all 3D objects, lights, and fog
export const scene = new THREE.Scene()
scene.background = new THREE.Color(0x060c1a)
scene.fog = new THREE.Fog(0x060c1a, 18, 40)

// Lights โ€” ambient fills shadows, directional provides shape
scene.add(new THREE.AmbientLight(0x1a2a4a, 1.2))
const sun = new THREE.DirectionalLight(0xffffff, 1.2)
sun.position.set(8, 14, 8)
sun.castShadow = true
scene.add(sun)

// Floor โ€” a flat plane that receives shadows, giving depth to the scene
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new THREE.MeshStandardMaterial({ color: 0x0f172a })
)
floor.rotation.x = -Math.PI / 2
floor.receiveShadow = true
scene.add(floor)

// Resize handler โ€” keeps proportions correct when window is resized
export function handleResize() {
  const w = window.innerWidth, h = window.innerHeight
  renderer.setSize(w, h)
  camera.aspect = w / h
  camera.updateProjectionMatrix()
}
window.addEventListener('resize', handleResize)
handleResize()

Two things here are easy to get wrong and worth calling out. First: the camera's aspect ratio starts at 1 and gets corrected immediately by handleResize(). Setting it to 1 initially avoids a single misshapen frame on startup. Second: renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) caps the pixel density at 2x. Retina displays report a ratio of 3 or even 4, which would quadruple or more the number of pixels drawn per frame โ€” enough to tank performance. Capping at 2 gives a sharp image without the cost.

Three.js Scene Hierarchy
Scene Camera AmbientLight DirectionalLight Floor Mesh Fog โ€” added at runtime โ€” player enemies[] orbs[]

5The Player

The player is a blue cube. The choice of a cube is not laziness โ€” it is intentional. A cube is instantly readable from any camera angle, easy to orient spatially, and its rotation is visually satisfying. The player module is responsible for three things: creating the mesh, tracking which keyboard keys are currently held, and updating the player's position each frame.

The most important design decision in this module is how keyboard input is handled. A naive implementation moves the player inside the keydown event listener โ€” one movement step per key press. This feels horrible to play: movement is tied to the operating system's key-repeat rate, diagonal movement requires careful timing, and the response feels laggy. The correct approach tracks key state (is this key currently held down?) and applies movement continuously in the game loop. The difference in feel is dramatic.

javascriptsrc/player.js
import * as THREE from 'three'
import { scene } from './scene.js'

const SPEED = 0.12
const ARENA = 9   // half-width of the play area in world units

export const player = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshStandardMaterial({
    color: 0x60a5fa,
    emissive: 0x1d4ed8,
    emissiveIntensity: 0.4
  })
)
player.position.set(0, 0.5, 0)
player.castShadow = true
scene.add(player)

// Track which keys are held โ€” not what was just pressed
const keys = {}
window.addEventListener('keydown', e => { keys[e.code] = true })
window.addEventListener('keyup',   e => { keys[e.code] = false })

// Called once per frame from the game loop
export function updatePlayer() {
  let dx = 0, dz = 0
  if (keys['ArrowLeft']  || keys['KeyA']) dx -= SPEED
  if (keys['ArrowRight'] || keys['KeyD']) dx += SPEED
  if (keys['ArrowUp']    || keys['KeyW']) dz -= SPEED
  if (keys['ArrowDown']  || keys['KeyS']) dz += SPEED

  // Math.max/min clamps position inside arena bounds
  player.position.x = Math.max(-ARENA+0.5, Math.min(ARENA-0.5, player.position.x + dx))
  player.position.z = Math.max(-ARENA+0.5, Math.min(ARENA-0.5, player.position.z + dz))

  player.rotation.y += 0.03   // constant spin โ€” gives the cube personality
}

The clamping at the end โ€” Math.max(-ARENA+0.5, Math.min(ARENA-0.5, ...)) โ€” constrains the player within the arena boundaries. Without it, the player can walk off the edge of the visible floor and disappear, which breaks the implicit contract with the player that the visible space is the playable space. The 0.5 offset accounts for the half-width of the player cube itself, so the edge of the cube rather than its center stops at the boundary.

6Enemies & Spawning

Enemies spawn at the edge of the arena and move inward. Their speed increases slightly as the game progresses, which produces the difficulty curve without any explicit difficulty-level logic. This is a principle worth internalizing: emergent difficulty from a single scaling variable is almost always better than hand-crafted difficulty levels. It is more predictable, easier to tune, and requires no state machine.

Each enemy is assigned a random rotation velocity on spawn. This tumbling motion is pure visual information โ€” it communicates "unstable, dangerous, moving" without any instruction text. Enemies that sit perfectly still while moving would feel inert. Enemies that tumble feel alive.

javascriptsrc/enemies.js
import * as THREE from 'three'
import { scene } from './scene.js'

const ARENA = 9
export let enemies = []

export function spawnEnemy(timeLeft) {
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(0.9, 0.9, 0.9),
    new THREE.MeshStandardMaterial({
      color: 0xef4444, emissive: 0x7f1d1d, emissiveIntensity: 0.5
    })
  )

  // Speed scales with elapsed time โ€” the difficulty curve in one line
  const s = 0.04 + Math.random() * 0.05 + (60 - timeLeft) * 0.0006

  // Spawn on a random edge, move inward with slight angle variance
  const side = Math.floor(Math.random() * 4)
  let px, pz, vx, vz
  if      (side===0) { px=-ARENA; pz=(Math.random()-.5)*ARENA*2; vx= s; vz=(Math.random()-.5)*s }
  else if (side===1) { px= ARENA; pz=(Math.random()-.5)*ARENA*2; vx=-s; vz=(Math.random()-.5)*s }
  else if (side===2) { px=(Math.random()-.5)*ARENA*2; pz=-ARENA; vx=(Math.random()-.5)*s; vz= s }
  else               { px=(Math.random()-.5)*ARENA*2; pz= ARENA; vx=(Math.random()-.5)*s; vz=-s }

  mesh.position.set(px, 0.45, pz)
  mesh.castShadow = true
  scene.add(mesh)
  enemies.push({ mesh, vx, vz, rot: (Math.random()-0.5)*0.08 })
}

export function updateEnemies() {
  for (const e of enemies) {
    e.mesh.position.x += e.vx
    e.mesh.position.z += e.vz
    e.mesh.rotation.x += e.rot     // tumbling makes them feel dangerous
    // Bounce off arena walls rather than exiting the play area
    if (Math.abs(e.mesh.position.x) > ARENA) e.vx *= -1
    if (Math.abs(e.mesh.position.z) > ARENA) e.vz *= -1
  }
}

The wall bounce โ€” multiplying velocity by -1 when an enemy reaches the arena edge โ€” is worth noting. Enemies that disappear off-screen feel like wasted computation and reduce the sense of danger. Enemies that bounce stay in the play area and contribute to increasing density over time. As more enemies accumulate and bounce around, the available safe space shrinks naturally without any explicit "increase enemy count" logic.

7The Game Loop

The game loop is the heartbeat of every real-time game. It runs once per frame โ€” typically 60 times per second โ€” and its job is exactly the same every frame: read input, update the world, check the rules, draw the result. Everything in a running game is ultimately a consequence of this loop executing correctly, in the right order, fast enough.

The order of operations inside the loop matters. You must update positions before checking collisions โ€” otherwise you are checking against last frame's positions, which produces phantom hits and missed collisions. You must render last โ€” otherwise you draw an intermediate state and the frame looks wrong. These sequencing mistakes are responsible for a significant proportion of game bugs, and they are entirely preventable by being deliberate about the loop structure.

The Game Loop โ€” Frame by Frame
requestAnimationFrame Read Input Update positions Collisions score ยท lives Render loops ~60ร— per second
javascriptsrc/main.js
import { renderer, camera, scene } from './scene.js'
import { player, updatePlayer } from './player.js'
import { enemies, spawnEnemy, updateEnemies } from './enemies.js'
import { gameState, checkCollisions, updateHUD } from './state.js'
import * as THREE from 'three'

const clock = new THREE.Clock()
let lastSpawn = 0

function animate() {
  requestAnimationFrame(animate)   // schedule the next frame
  const delta = clock.getDelta()  // seconds since last frame

  if (!gameState.running) return renderer.render(scene, camera)

  // 1. Move player based on held keys
  updatePlayer()

  // 2. Spawn enemies on an interval that shrinks as time passes
  lastSpawn += delta
  const spawnInterval = Math.max(0.4, 1.8 - (60 - gameState.timeLeft) * 0.02)
  if (lastSpawn > spawnInterval) { spawnEnemy(gameState.timeLeft); lastSpawn = 0 }

  // 3. Move all active enemies
  updateEnemies()

  // 4. Check collisions โ€” AFTER positions are updated, BEFORE render
  checkCollisions(player, enemies)

  // 5. Refresh score/lives/timer display
  updateHUD()

  // 6. Draw โ€” always last
  renderer.render(scene, camera)
}

animate()  // start the loop

Notice that main.js contains no logic of its own โ€” it only orchestrates calls to other modules in the correct order. If your main.js grows beyond about 80 lines, that is a signal that logic which belongs in another module has leaked in. The conductor should not be playing any instruments.

The spawn interval formula โ€” Math.max(0.4, 1.8 - (60 - timeLeft) * 0.02) โ€” deserves a moment. At the start of the game, timeLeft is 60, so the interval is 1.8 - 0 = 1.8 seconds: one enemy every 1.8 seconds. At the 30-second mark, it is 1.8 - 0.6 = 1.2 seconds. At 10 seconds remaining, 1.8 - 1.0 = 0.8 seconds. The Math.max(0.4, ...) prevents it from going below 0.4 seconds โ€” two and a half enemies per second โ€” which is about the maximum a player can reasonably handle. The entire difficulty curve lives in this one expression.

8Feedback & Polish ("Juice")

Game developers use the term juice to describe the layer of feedback and polish that makes a game feel alive rather than mechanical. It is the difference between a box that disappears when you hit it and one that flashes red, triggers a sound, and rewards you with an invincibility window. Same mechanic. Completely different feel. The word comes from game designers Martin Jonasson and Petri Purho, who demonstrated in a 2012 talk that a simple game with excellent juice is more satisfying than a complex game with none.

These additions typically represent less than 10% of your total code but account for more than 50% of how satisfying the game feels to play. They are not cosmetic. They are communication. Every piece of juice is the game telling the player something that words cannot.

โ†’

Hit Flash

When the player is hit, the material emissive color jumps to red for 300ms. One setHex() call, one setTimeout(). Communicates damage instantly without any UI.

โ†’

Invincibility Flicker

After taking damage, the player flickers for 2 seconds and cannot be hit again. This window is critical for fairness โ€” without it, one hit can cascade into three before the player can react.

โ†’

Orb Bobbing

Gold orbs float on a Math.sin() wave. Costs zero performance. Immediately draws the eye toward collectibles and distinguishes them from threats.

โ†’

Combo Text

Collect orbs in quick succession and "COMBO ร—3" flashes on screen. Rewards skilled play and teaches the scoring system through demonstration rather than a tooltip.

โ†’

Timer Urgency

The timer turns red at 10 seconds. One conditional CSS change. Creates tension without any additional mechanics.

โ†’

Enemy Tumble

Each enemy rotates at a random rate per frame. The chaos communicates "unstable" and "dangerous" without any instruction text.

๐ŸŽฏ
The Rule of Juice

Before you add a new mechanic, add feedback to the mechanic you already have. A game with one mechanic and excellent feedback is more fun than a game with five mechanics and no feedback. Polish is not a finishing step. It is the work. If your game feels flat, the answer is almost never "add a feature." It is "add a response."