๐ฎ 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.
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.
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
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.
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:
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.
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:
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.
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.
-
Account
GitHub โ github.com
Where your code lives. Netlify watches this repository and deploys automatically on every push. Create a free account, then create your first repository: click the green "New" button, give it a name likemy-first-game, check "Add a README file," and click Create. You will connect this to your local machine withgit clonein the setup step. -
Account
Netlify โ netlify.com
Free hosting for static sites. Sign up with GitHub (this also grants Netlify permission to watch your repos). You will connect your game repository to Netlify later โ when you push code to GitHub, Netlify builds and deploys it automatically in about 30 seconds. No manual upload, no FTP, no server to manage. -
Account
Supabase โ supabase.com
Free hosted database for the global leaderboard. Sign up with GitHub. Once in the dashboard, create a new project โ choose a region close to you (e.g., South America for Argentina, US East for the Americas). Note your project URL and anon API key from Settings โ API. You will need these when adding the leaderboard to your game. -
Account
Vercel (optional backup) โ vercel.com
An alternative to Netlify with the same free-tier hosting model. Worth creating an account as a fallback โ if Netlify has an issue during a timed session, Vercel deploys identically. Sign up with GitHub.
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.
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:
One Clear Mechanic
Move a character. Avoid obstacles. Collect items. One verb. One noun. The entire game fits in a sentence.
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.
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.
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.
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.
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:
# 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:
โโโ 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
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.
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.
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.
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.
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.
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.
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.
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."
9What's Next
You now have a working, playable 3D browser game and the conceptual framework to build another one. The code patterns here โ scene setup, player module, enemy system, game loop โ are the same patterns used in every Three.js game regardless of genre. The next steps depend on where you want to go.
Project Structure
How to organize your files as the game grows. The module dependency map and the principle that keeps codebases readable.
Add a Leaderboard
Connect to a real global database using Supabase. Players from anywhere in the world can compete for the top score. Free tier, no backend.
Publish It Live
Deploy with a custom domain. Netlify + GitHub means every git push updates the live site. Your game gets a real URL you can share anywhere.