Northwood Foundry — Forge, Play, Win
NORTHWOOD FOUNDRY
Wildhaven: Evolve · Alpha playtest

Make your first browser game.

A picture-led, step-by-step workshop: start with an empty file and finish with a game you can play and share.

Build a complete little game, one working step at a time.
Set upLearn the basicsStart the eight stepsOrganize your filesFix problemsMore workshopsTest & share

You can start with no coding experience.

We are a father-and-son team learning by making things. This course gives you the same useful habit: build something small, try it, change one thing, and try again.

You will make Lantern Trail, a small browser game. Move a blue explorer, collect five golden lights, avoid a red hazard, and finish before time runs out. You will add a start screen, pause, touch buttons, and a saved best time. All artwork is simple shapes you draw yourself.

This is a separate teaching project. It contains no game-server setup, player records, passwords, or private project files. You can finish it without downloading Wildhaven.

Work through one lesson at a time. The core project can take several sessions. You do not need to memorize everything; you need to understand what changed and prove that it works.

The finished Lantern Trail play area: a blue square explorer, five golden circles to collect, and a red moving hazard.
The actual game you will build. Every shape and rule comes from the code in this course.

Before you begin: your workbench

  1. Use a computer for editing. You need a modern browser and a plain-text editor. A phone or tablet is useful later for testing. Do not use a word processor that saves formatted documents.
  2. Create a folder called lantern-trail. Put it somewhere you can find again. You do not need a server, paid software, an account, or a terminal for the first eight lessons.
  3. Show file extensions. In your file manager, enable the option to show extensions. An HTML file must end in .html, not .html.txt.
  4. Create index.html. Save as plain text, UTF-8. If your editor offers a file-type choice, choose All files so it does not add .txt.
  5. Keep two windows open. Your editor changes the file; your browser runs it. Save in the editor, then refresh the browser after every change.

Each lesson provides a complete working checkpoint. Open its code, copy all of it into index.html, save, and refresh. At the next lesson, replace the entire previous file; do not paste a second page beneath it. Keep a copy named step-1.html, step-2.html, and so on before moving on.

EditSaveRefreshCheck
Your repeatable workflow: edit the file, save it, refresh the browser, then check the result.

A few words that make code less mysterious

HTML: the parts

A heading, a button, and a drawing surface are page elements. Tags such as <button> start an element; </button> ends it. An id gives an element a name that code can find.

CSS: the appearance

A selector chooses an element. Inside braces, properties describe color, spacing, and size. canvas { border: 3px solid gold; } gives the drawing surface a border.

JavaScript: the behavior

JavaScript remembers values and responds to events. It can move the explorer, count lights, and change the text after a win.

Variable
A named value. let score = 0; starts a score that can change. const means the name cannot be reassigned.
Object
A group of named values, such as { x: 40, y: 180 }. Read a value with player.x.
Array
A list, such as [10, 20, 30]. The first item has index 0. A loop lets you visit each item.
Function
A named job. function add(a, b) { return a + b; } gives back the sum. Call it with add(2, 3).
Condition
A decision: if (score === 5) checks whether the score is exactly five. A single = assigns a value; === compares it.
Event
Something happened: a key went down, a pointer moved, or a button was pressed. An event listener runs your chosen function in response.

Read code punctuation slowly: parentheses hold inputs, braces group instructions, brackets hold a list, and quotes surround text. // starts a comment for the reader.

Make a page that opens

Step 1 of 8 · HTML, CSS, and a drawing surface

Create the heading, instructions, status paragraph, and canvas. The canvas has a 640 by 360 drawing grid. CSS lets that grid shrink to fit a narrower screen without changing the game coordinates.

  1. Open the complete checkpoint below and copy it into index.html.
  2. Save the file and double-click it to open it in your browser.
  3. Change the heading from Lantern Trail to your own game name. Save and refresh.
Expected result: A dark page, your heading, and an empty green rectangle with a gold border. Nothing moves yet; that is correct.

If it fails: If you see code instead of a page, check the extension. If an edit does not appear, save the editor file and check you refreshed the same file.

Make one change: Change the border color and page background. Predict the result before refreshing.

Complete step 1 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Draw your explorer

Step 2 of 8 · Canvas coordinates and objects

JavaScript finds the canvas by its id and asks for a 2D drawing context. The player object stores a position, radius, and speed. The draw function paints the background first, then a blue square. Here the center is (40, 180). Subtracting the radius gives the square’s top-left corner.

  1. Replace index.html with checkpoint 2.
  2. Find const player and change x from 40 to 100.
  3. Save, refresh, and check that the explorer moves right.
(0, 0)x increases →y increases ↓player center
Picture 1. The canvas uses its own coordinate grid, even when CSS makes it smaller.
Expected result: One blue square sits inside the green play area.

If it fails: If there is no square, open the browser Console and look for a misspelled id or missing bracket. JavaScript names are case-sensitive.

Make one change: Put the explorer in the middle. The middle of a 640 by 360 grid is x = 320, y = 180.

Complete step 2 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
}
draw();
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Make time move

Step 3 of 8 · The game loop and seconds

A browser can draw many frames each second. requestAnimationFrame asks for the next frame. We measure elapsed milliseconds, divide by 1000 to get seconds, then move by speed × seconds. The small time cap avoids a huge leap after a slow frame.

  1. Replace index.html with checkpoint 3.
  2. Watch the square cross the screen and wrap around.
  3. Change 80 * dt to 120 * dt inside frame. Save and compare.
Read timeUpdateDrawRepeat
Picture 2. Each frame reads time, changes the world, and draws the result.
Expected result: The explorer moves on its own. It returns from the left after leaving the right edge.

If it fails: If it leaves a blue trail, make sure draw still fills the whole background every frame. If it never moves, check that requestAnimationFrame(frame) runs again at the end.

Make one change: Explain why 80 * dt is steadier than adding 80 every frame.

Complete step 3 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  player.x += 80 * dt;
  if (player.x > canvas.width + player.r) player.x = -player.r;
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Give the player control

Step 4 of 8 · Events, held keys, and boundaries

A Set remembers keys without duplicates. Keydown adds a key; keyup removes it. Each frame reads that Set. Diagonal movement is divided by the direction’s length so moving diagonally is not faster. clamp keeps the explorer inside the play area.

  1. Replace index.html with checkpoint 4.
  2. Click the canvas so it receives keyboard input. Hold an arrow key or WASD.
  3. Push against every edge, then hold two directions together.
Expected result: The explorer moves while a key is held and stops when released. It stays inside the border.

If it fails: If keys scroll the page, click the canvas first. If movement gets stuck after switching windows, check the blur listeners that clear the Set.

Make one change: Change player.speed. Find a value that feels comfortable on the small screen too.

Complete step 4 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 2. Remember which movement keys are held down.
const keys = new Set();
const allowed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'KeyW', 'KeyA', 'KeyS', 'KeyD'];
canvas.addEventListener('keydown', event => {
  if (allowed.includes(event.code)) {
    event.preventDefault();
    keys.add(event.code);
  }
});
window.addEventListener('keyup', event => keys.delete(event.code));
canvas.addEventListener('blur', () => keys.clear());
window.addEventListener('blur', () => keys.clear());
function move(dt) {
  const dx = Number(keys.has('ArrowRight') || keys.has('KeyD'))
           - Number(keys.has('ArrowLeft') || keys.has('KeyA'));
  const dy = Number(keys.has('ArrowDown') || keys.has('KeyS'))
           - Number(keys.has('ArrowUp') || keys.has('KeyW'));
  const length = Math.hypot(dx, dy) || 1;
  player.x = clamp(player.x + dx / length * player.speed * dt,
    player.r, canvas.width - player.r);
  player.y = clamp(player.y + dy / length * player.speed * dt,
    player.r, canvas.height - player.r);
}
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  move(dt);
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Collect something

Step 5 of 8 · Arrays, collision, and score

Each light is an object with a position and a taken flag. Math.hypot measures distance between centers. If the distance is smaller than the two radii added together, they touch. Mark a light taken before adding its point, so the next frame cannot award it again.

  1. Replace index.html with checkpoint 5.
  2. Move onto a golden light. Read the score.
  3. Stay on the same spot: the score must not keep increasing.
Touch lightMark takenAdd point
Picture 3. Award a collectible once by changing its state before the next frame.
Expected result: Five lights appear. Each disappears on contact, and the counter advances once.

If it fails: If one light gives endless points, check !light.taken and the assignment light.taken = true. Drawing should not award points.

Make one change: Move the lights by changing their coordinate pairs. Keep every light away from the outer border.

Complete step 5 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 2. Remember which movement keys are held down.
const keys = new Set();
const allowed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'KeyW', 'KeyA', 'KeyS', 'KeyD'];
canvas.addEventListener('keydown', event => {
  if (allowed.includes(event.code)) {
    event.preventDefault();
    keys.add(event.code);
  }
});
window.addEventListener('keyup', event => keys.delete(event.code));
canvas.addEventListener('blur', () => keys.clear());
window.addEventListener('blur', () => keys.clear());
function move(dt) {
  const dx = Number(keys.has('ArrowRight') || keys.has('KeyD'))
           - Number(keys.has('ArrowLeft') || keys.has('KeyA'));
  const dy = Number(keys.has('ArrowDown') || keys.has('KeyS'))
           - Number(keys.has('ArrowUp') || keys.has('KeyW'));
  const length = Math.hypot(dx, dy) || 1;
  player.x = clamp(player.x + dx / length * player.speed * dt,
    player.r, canvas.width - player.r);
  player.y = clamp(player.y + dy / length * player.speed * dt,
    player.r, canvas.height - player.r);
}
// 3. Data describes our lights; contact changes that data.
const lights = [[120, 60], [240, 280], [360, 80], [480, 260], [560, 100]]
  .map(([x, y]) => ({ x, y, r: 10, taken: false }));
let score = 0;
function touching(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y) < a.r + b.r;
}
function collect() {
  for (const light of lights) {
    if (!light.taken && touching(player, light)) {
      light.taken = true;
      score += 1;
    }
  }
}
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#f1c65b';
  for (const light of lights) {
    if (light.taken) continue;
    ctx.beginPath();
    ctx.arc(light.x, light.y, light.r, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
  status.textContent = 'Lights: ' + score + '/5';
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  move(dt);
  collect();
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Add a fair challenge

Step 6 of 8 · Health, a timer, and win or loss

The red hazard moves and reverses at the edges. Touching it removes one heart and sends you to the start. A short shield prevents the same collision from draining all health in a few frames. Time, hearts, and score decide whether the round ends.

  1. Replace index.html with checkpoint 6.
  2. Collect every light once, then refresh and deliberately touch the hazard.
  3. Wait out the timer in another run.
Expected result: You can win by collecting all five lights or lose by running out of time or hearts. Refresh starts this checkpoint again.

If it fails: If hearts drop instantly, inspect shield. If the game keeps moving after a result, check phase in frame. This lesson checks loss first when loss and collection occur together.

Make one change: Change danger.speed to make an easier and a harder version. Test both before deciding which is fun.

Complete step 6 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 2. Remember which movement keys are held down.
const keys = new Set();
const allowed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'KeyW', 'KeyA', 'KeyS', 'KeyD'];
canvas.addEventListener('keydown', event => {
  if (allowed.includes(event.code)) {
    event.preventDefault();
    keys.add(event.code);
  }
});
window.addEventListener('keyup', event => keys.delete(event.code));
canvas.addEventListener('blur', () => keys.clear());
window.addEventListener('blur', () => keys.clear());
function move(dt) {
  const dx = Number(keys.has('ArrowRight') || keys.has('KeyD'))
           - Number(keys.has('ArrowLeft') || keys.has('KeyA'));
  const dy = Number(keys.has('ArrowDown') || keys.has('KeyS'))
           - Number(keys.has('ArrowUp') || keys.has('KeyW'));
  const length = Math.hypot(dx, dy) || 1;
  player.x = clamp(player.x + dx / length * player.speed * dt,
    player.r, canvas.width - player.r);
  player.y = clamp(player.y + dy / length * player.speed * dt,
    player.r, canvas.height - player.r);
}
// 3. Data describes our lights; contact changes that data.
const lights = [[120, 60], [240, 280], [360, 80], [480, 260], [560, 100]]
  .map(([x, y]) => ({ x, y, r: 10, taken: false }));
let score = 0;
function touching(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y) < a.r + b.r;
}
function collect() {
  for (const light of lights) {
    if (!light.taken && touching(player, light)) {
      light.taken = true;
      score += 1;
    }
  }
}
// 4. A round has a time limit, health, and a clear ending.
const danger = { x: 320, y: 180, r: 16, speed: 100 };
let time = 60;
let hearts = 3;
let shield = 0;
let phase = "playing";
function finish(result) {
  phase = result;
}
function dangerStep(dt) {
  time = Math.max(0, time - dt);
  shield = Math.max(0, shield - dt);
  danger.x += danger.speed * dt;
  if (danger.x < danger.r || danger.x > canvas.width - danger.r) {
    danger.x = clamp(danger.x, danger.r, canvas.width - danger.r);
    danger.speed *= -1;
  }
  if (shield === 0 && touching(player, danger)) {
    hearts -= 1;
    shield = 1.5;
    player.x = 40;
    player.y = 180;
  }
  if (hearts <= 0 || time <= 0) finish('lost');
  else if (score === lights.length) finish('won');
}
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#f1c65b';
  for (const light of lights) {
    if (light.taken) continue;
    ctx.beginPath();
    ctx.arc(light.x, light.y, light.r, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = '#ec806b';
  ctx.beginPath();
  ctx.arc(danger.x, danger.y, danger.r, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
  status.textContent = 'Lights: ' + score + '/5 · Hearts: ' + hearts
    + ' · Time: ' + Math.ceil(time) + 's';
  if (phase !== 'playing') {
    const messages = { ready: 'Press Start', paused: 'Paused',
      won: 'You found all five lights!', lost: 'Try another trail!' };
    ctx.fillStyle = '#15251ed9';
    ctx.fillRect(0, 130, canvas.width, 90);
    ctx.fillStyle = '#fff1d3';
    ctx.font = '24px system-ui';
    ctx.textAlign = 'center';
    ctx.fillText(messages[phase], canvas.width / 2, 185);
  }
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  if (phase === 'playing') {
    move(dt);
    collect();
    dangerStep(dt);
  }
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Start, pause, and try again

Step 7 of 8 · Game states and complete reset

The phase variable has five possible values: ready, playing, paused, won, and lost. Only playing updates the world. Restart restores every round value, including taken lights and the hazard’s direction. Leaving the browser pauses the round.

  1. Replace index.html with checkpoint 7.
  2. Press Start, move, pause, and resume.
  3. Lose a heart, collect a light, then press Restart. Check every value.
ReadyPlayingWon / lost
Picture 4. Starting and finishing are explicit states; pause temporarily stops updates.
Expected result: The page waits for Start. Pause freezes time and movement. Restart restores three hearts, sixty seconds, and five lights.

If it fails: If the next round inherits the previous score or missing lights, compare start against the list of variables that change during play.

Make one change: Write down every allowed state transition before adding a new screen.

Complete step 7 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<button id="start">Start / Restart</button>
<button id="pause">Pause / Resume</button>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 2. Remember which movement keys are held down.
const keys = new Set();
const allowed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'KeyW', 'KeyA', 'KeyS', 'KeyD'];
canvas.addEventListener('keydown', event => {
  if (allowed.includes(event.code)) {
    event.preventDefault();
    keys.add(event.code);
  }
});
window.addEventListener('keyup', event => keys.delete(event.code));
canvas.addEventListener('blur', () => keys.clear());
window.addEventListener('blur', () => keys.clear());
function move(dt) {
  const dx = Number(keys.has('ArrowRight') || keys.has('KeyD'))
           - Number(keys.has('ArrowLeft') || keys.has('KeyA'));
  const dy = Number(keys.has('ArrowDown') || keys.has('KeyS'))
           - Number(keys.has('ArrowUp') || keys.has('KeyW'));
  const length = Math.hypot(dx, dy) || 1;
  player.x = clamp(player.x + dx / length * player.speed * dt,
    player.r, canvas.width - player.r);
  player.y = clamp(player.y + dy / length * player.speed * dt,
    player.r, canvas.height - player.r);
}
// 3. Data describes our lights; contact changes that data.
const lights = [[120, 60], [240, 280], [360, 80], [480, 260], [560, 100]]
  .map(([x, y]) => ({ x, y, r: 10, taken: false }));
let score = 0;
function touching(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y) < a.r + b.r;
}
function collect() {
  for (const light of lights) {
    if (!light.taken && touching(player, light)) {
      light.taken = true;
      score += 1;
    }
  }
}
// 4. A round has a time limit, health, and a clear ending.
const danger = { x: 320, y: 180, r: 16, speed: 100 };
let time = 60;
let hearts = 3;
let shield = 0;
let phase = "ready";
function finish(result) {
  phase = result;
}
function dangerStep(dt) {
  time = Math.max(0, time - dt);
  shield = Math.max(0, shield - dt);
  danger.x += danger.speed * dt;
  if (danger.x < danger.r || danger.x > canvas.width - danger.r) {
    danger.x = clamp(danger.x, danger.r, canvas.width - danger.r);
    danger.speed *= -1;
  }
  if (shield === 0 && touching(player, danger)) {
    hearts -= 1;
    shield = 1.5;
    player.x = 40;
    player.y = 180;
  }
  if (hearts <= 0 || time <= 0) finish('lost');
  else if (score === lights.length) finish('won');
}
// 5. Reset every round value, not just the score.
function start() {
  player.x = 40; player.y = 180;
  danger.x = 320; danger.speed = 100;
  score = 0; hearts = 3; time = 60; shield = 0;
  lights.forEach(light => { light.taken = false; });
  keys.clear();
  phase = 'playing';
  canvas.focus();
}
function pause() {
  if (phase === 'playing') phase = 'paused';
  else if (phase === 'paused') phase = 'playing';
  keys.clear();
  canvas.focus();
}
document.getElementById('start').addEventListener('click', start);
document.getElementById('pause').addEventListener('click', pause);
function away() {
  keys.clear();
  if (phase === 'playing') phase = 'paused';
}
document.addEventListener('visibilitychange', () => {
  if (document.hidden) away();
});
window.addEventListener('blur', away);
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#f1c65b';
  for (const light of lights) {
    if (light.taken) continue;
    ctx.beginPath();
    ctx.arc(light.x, light.y, light.r, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = '#ec806b';
  ctx.beginPath();
  ctx.arc(danger.x, danger.y, danger.r, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
  status.textContent = 'Lights: ' + score + '/5 · Hearts: ' + hearts
    + ' · Time: ' + Math.ceil(time) + 's';
  if (phase !== 'playing') {
    const messages = { ready: 'Press Start', paused: 'Paused',
      won: 'You found all five lights!', lost: 'Try another trail!' };
    ctx.fillStyle = '#15251ed9';
    ctx.fillRect(0, 130, canvas.width, 90);
    ctx.fillStyle = '#fff1d3';
    ctx.font = '24px system-ui';
    ctx.textAlign = 'center';
    ctx.fillText(messages[phase], canvas.width / 2, 185);
  }
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  if (phase === 'playing') {
    move(dt);
    collect();
    dangerStep(dt);
  }
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Add touch and a best time

Step 8 of 8 · Pointer events and small local saves

Touch buttons update the same keys Set as the keyboard. Pointer capture keeps receiving the release even when a finger leaves the button. The best winning time is stored locally, with validation and a fallback if storage is blocked.

  1. Replace index.html with checkpoint 8.
  2. Try the four on-screen movement buttons. Release outside a button and check movement stops.
  3. Win, refresh, and look at Best time. Test with a normal browser page as well as a downloaded file.
Expected result: A complete game with keyboard and touch movement, start, pause, restart, win/loss, and a best-time display.

If it fails: The embedded course preview intentionally blocks storage, and some browsers handle local-file storage differently. A missing best time must never stop play. Storage belongs to this browser and site; it is not an online account.

Make one change: Change one thing to make the game yours: a new route, a new palette, or a different time limit. Retest winning and losing.

Complete step 8 code — replace the whole index.html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lantern Trail</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: #15251e; color: #f7edd4;
  font: 18px/1.5 system-ui, sans-serif; text-align: center; }
main { max-width: 700px; margin: auto; }
h1 { margin: 0; }
canvas { display: block; width: 100%; height: auto; background: #253f32;
  border: 3px solid #d8a750; margin: 16px auto; }
canvas:focus { outline: 3px solid #83cdea; outline-offset: 3px; }
button { font: inherit; padding: 10px 16px; margin: 5px; border-radius: 8px;
  border: 0; background: #f0be62; color: #15251e; cursor: pointer; }
button[data-key] { touch-action: none; min-width: 54px; min-height: 54px; }
#status { min-height: 2em; }
</style>
</head>
<body>
<main>
<h1>Lantern Trail</h1>
<p>Collect five golden lights. Avoid the red circles.</p>
<p id="status" role="status">Your trail begins here.</p>
<canvas id="game" width="640" height="360" tabindex="0" aria-label="Lantern Trail game. Move with arrow keys or WASD.">This game needs a browser with canvas support.</canvas>
<button id="start">Start / Restart</button>
<button id="pause">Pause / Resume</button>
<div aria-label="Movement controls">
<button data-key="ArrowUp" aria-label="Move up">↑</button>
<button data-key="ArrowLeft" aria-label="Move left">←</button>
<button data-key="ArrowDown" aria-label="Move down">↓</button>
<button data-key="ArrowRight" aria-label="Move right">→</button>
</div>
<p id="record">Best time: none yet</p>
<p>Click the play area, then use arrow keys or WASD.</p>
</main>
<script>
// 1. Find the drawing surface and the text we will update.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const player = { x: 40, y: 180, r: 12, speed: 180 };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// 2. Remember which movement keys are held down.
const keys = new Set();
const allowed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'KeyW', 'KeyA', 'KeyS', 'KeyD'];
canvas.addEventListener('keydown', event => {
  if (allowed.includes(event.code)) {
    event.preventDefault();
    keys.add(event.code);
  }
});
window.addEventListener('keyup', event => keys.delete(event.code));
canvas.addEventListener('blur', () => keys.clear());
window.addEventListener('blur', () => keys.clear());
function move(dt) {
  const dx = Number(keys.has('ArrowRight') || keys.has('KeyD'))
           - Number(keys.has('ArrowLeft') || keys.has('KeyA'));
  const dy = Number(keys.has('ArrowDown') || keys.has('KeyS'))
           - Number(keys.has('ArrowUp') || keys.has('KeyW'));
  const length = Math.hypot(dx, dy) || 1;
  player.x = clamp(player.x + dx / length * player.speed * dt,
    player.r, canvas.width - player.r);
  player.y = clamp(player.y + dy / length * player.speed * dt,
    player.r, canvas.height - player.r);
}
// 3. Data describes our lights; contact changes that data.
const lights = [[120, 60], [240, 280], [360, 80], [480, 260], [560, 100]]
  .map(([x, y]) => ({ x, y, r: 10, taken: false }));
let score = 0;
function touching(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y) < a.r + b.r;
}
function collect() {
  for (const light of lights) {
    if (!light.taken && touching(player, light)) {
      light.taken = true;
      score += 1;
    }
  }
}
// 4. A round has a time limit, health, and a clear ending.
const danger = { x: 320, y: 180, r: 16, speed: 100 };
let time = 60;
let hearts = 3;
let shield = 0;
let phase = "ready";
function finish(result) {
  phase = result;
  if (result === 'won') {
    const elapsed = Math.round((60 - time) * 100) / 100;
    if (best === null || elapsed < best) {
      best = elapsed;
      try { localStorage.setItem('lantern-trail-best-v1', String(best)); }
      catch { /* The game still works when storage is unavailable. */ }
      showRecord();
    }
  }
}
function dangerStep(dt) {
  time = Math.max(0, time - dt);
  shield = Math.max(0, shield - dt);
  danger.x += danger.speed * dt;
  if (danger.x < danger.r || danger.x > canvas.width - danger.r) {
    danger.x = clamp(danger.x, danger.r, canvas.width - danger.r);
    danger.speed *= -1;
  }
  if (shield === 0 && touching(player, danger)) {
    hearts -= 1;
    shield = 1.5;
    player.x = 40;
    player.y = 180;
  }
  if (hearts <= 0 || time <= 0) finish('lost');
  else if (score === lights.length) finish('won');
}
// 5. Reset every round value, not just the score.
function start() {
  player.x = 40; player.y = 180;
  danger.x = 320; danger.speed = 100;
  score = 0; hearts = 3; time = 60; shield = 0;
  lights.forEach(light => { light.taken = false; });
  keys.clear();
  phase = 'playing';
  canvas.focus();
}
function pause() {
  if (phase === 'playing') phase = 'paused';
  else if (phase === 'paused') phase = 'playing';
  keys.clear();
  canvas.focus();
}
document.getElementById('start').addEventListener('click', start);
document.getElementById('pause').addEventListener('click', pause);
function away() {
  keys.clear();
  if (phase === 'playing') phase = 'paused';
}
document.addEventListener('visibilitychange', () => {
  if (document.hidden) away();
});
window.addEventListener('blur', away);
// 6. Touch buttons share the same input set as the keyboard.
for (const button of document.querySelectorAll('[data-key]')) {
  button.addEventListener('pointerdown', event => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    keys.add(button.dataset.key);
  });
  for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) {
    button.addEventListener(type, () => keys.delete(button.dataset.key));
  }
}
let best = null;
try {
  const raw = localStorage.getItem('lantern-trail-best-v1');
  const value = Number(raw);
  if (raw !== null && Number.isFinite(value) && value >= 0 && value <= 60) best = value;
} catch { /* Some browser modes block storage. */ }
function showRecord() {
  document.getElementById('record').textContent = best === null
    ? 'Best time: none yet' : 'Best time: ' + best.toFixed(2) + ' seconds';
}
showRecord();
// 7. Drawing reads the current state. It does not award points.
function draw() {
  ctx.fillStyle = '#253f32';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#f1c65b';
  for (const light of lights) {
    if (light.taken) continue;
    ctx.beginPath();
    ctx.arc(light.x, light.y, light.r, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = '#ec806b';
  ctx.beginPath();
  ctx.arc(danger.x, danger.y, danger.r, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#83cdea';
  ctx.fillRect(player.x - player.r, player.y - player.r, player.r * 2, player.r * 2);
  status.textContent = 'Lights: ' + score + '/5 · Hearts: ' + hearts
    + ' · Time: ' + Math.ceil(time) + 's';
  if (phase !== 'playing') {
    const messages = { ready: 'Press Start', paused: 'Paused',
      won: 'You found all five lights!', lost: 'Try another trail!' };
    ctx.fillStyle = '#15251ed9';
    ctx.fillRect(0, 130, canvas.width, 90);
    ctx.fillStyle = '#fff1d3';
    ctx.font = '24px system-ui';
    ctx.textAlign = 'center';
    ctx.fillText(messages[phase], canvas.width / 2, 185);
  }
}
// 8. Time is measured in seconds so movement is not tied to frame rate.
let previous = performance.now();
function frame(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  if (phase === 'playing') {
    move(dt);
    collect();
    dangerStep(dt);
  }
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Checkpoint: explain the change in your own words, then save a working copy before continuing.

Try a checkpoint without leaving the course

Choose a Try button above, then click inside the game. This preview is isolated from the guide; local score storage is unavailable here. Download a checkpoint to practice editing and saving on your own computer.

Select a checkpoint to load it.

Back to lesson one

Organize the finished game into three files

Now separate the three jobs you learned. Download these files into the same folder, keeping their exact names. Open index.html. You should get the same game as step 8.

lantern-trail/
  index.html   ← page elements
  style.css    ← colors and layout
  game.js      ← rules and behavior
  1. index.html loads the stylesheet with <link rel="stylesheet" href="style.css">.
  2. It loads JavaScript with <script src="game.js" defer></script>. The defer attribute waits until the page has been read.
  3. Change a color in style.css. Save and refresh.
  4. Change player.speed in game.js. Save and refresh.
  5. Rename the heading in index.html. Save and refresh.

Keep the single-file step 8 as your fallback. If the three-file version loses its colors or movement, check filenames, extensions, and whether the files are beside each other.

A repeatable way to find a bug

  1. Describe one failure. “After releasing Right, the explorer keeps moving” is better than “It is broken.”
  2. Reproduce it. Write the shortest sequence that causes it every time.
  3. Open developer tools. On many desktop browsers, F12 or the browser menu opens them. Choose Console. Read the first error and its line number.
  4. Inspect one value. Try console.log(player.x, player.y) in your code. Remove repeated frame logs afterward so they do not flood the console.
  5. Change one thing. Save, refresh, repeat the exact steps, then test the neighboring behavior.
  6. Keep a known-good copy. If the experiment fails, restore that copy and try a smaller change.
Common beginner problems
SymptomCheck first
Unexpected tokenA quote, brace, parenthesis, or pasted extra tag near the reported line.
Cannot read properties of nullAn element id differs between HTML and JavaScript, or code ran before the element existed.
Page unchangedUnsaved file, wrong tab, or a second copy in another folder.
Movement too fastMultiplying by seconds once, not adding speed every frame or starting two animation loops.
Works on keyboard, fails on touchPointer release/cancel handling, touch target size, and whether the page scrolls during movement.
Restart acts strangelyAll changing values must be reset, including collected items and invulnerability.

24 small workshops for your next game

Finish the core game first. These optional recipes revisit useful game-building ideas: survival, growth, crafting, nesting-style timers, creature behavior, quests, rewards, save upgrades, controls, sound, art, performance, and shared worlds. Each uses invented data.

Unless a recipe explicitly says to edit game.js, run it as a separate Console experiment. The braces around each example keep its variables local, so you can run it again without conflicting names. Do not paste every recipe into the main game at once.

Workshop 1: Food, energy, and resting

Give your explorer a resource that changes over time.

Do this: Paste this standalone experiment in the Console. Change dt from 1 to 5 and compare.

{
let energy = 60;
const dt = 1;
const resting = true;
energy = Math.max(0, Math.min(100, energy + (resting ? 8 : -3) * dt));
console.log(energy);
}

Check: Resting prints 68. Switching resting to false prints 57. In a game, call this from the update step, not draw.

Workshop 2: Growth without a giant jump

Represent progress as a number between zero and one.

Do this: Run this small calculation. Then double elapsedSeconds.

{
const elapsedSeconds = 30;
const secondsToGrow = 120;
const growth = Math.min(1, elapsedSeconds / secondsToGrow);
console.log(growth, 10 + growth * 20);
}

Check: It prints 0.25 and 15. The second value could be a drawing radius. Keep elapsed time and display size separate.

Workshop 3: A safe zone

Make a small place where a hazard cannot hurt the player.

Do this: Run the calculation, then move x outside the square.

{
const actor = {x: 30, y: 40};
const safe = actor.x >= 10 && actor.x <= 80 && actor.y >= 10 && actor.y <= 80;
console.log(safe);
}

Check: It prints true. In Lantern Trail, check a safe-zone condition before subtracting a heart. Test all four edges.

Workshop 4: A simple inventory

Represent collected materials as counts.

Do this: Run this recipe and explain why crafting can happen only once.

{
const bag = {wood: 3, stone: 1};
function craft() {
  if (bag.wood < 2 || bag.stone < 1) return false;
  bag.wood -= 2; bag.stone -= 1;
  return true;
}
console.log(craft(), craft(), bag);
}

Check: The first craft succeeds and the second fails. Do not subtract materials until every requirement passes.

Workshop 5: A timer with conditions

Build an egg, plant, or crafting timer that only advances under the right conditions.

Do this: Run with warm set to true, then false.

{
let remaining = 10;
const warm = true;
const resting = true;
const dt = 2;
if (warm && resting) remaining = Math.max(0, remaining - dt);
console.log(remaining === 0 ? 'Ready!' : remaining);
}

Check: It prints 8 when both conditions hold and 10 otherwise. Test zero and a very large dt; remaining must never become negative.

Workshop 6: A tiny creature brain

Choose one action by priority.

Do this: Change food and danger to test each branch.

{
function decide({danger, food}) {
  if (danger) return 'flee';
  if (food < 30) return 'find food';
  return 'wander';
}
console.log(decide({danger: true, food: 10}));
}

Check: It chooses flee even while hungry. Put urgent rules before routine behavior. Add rest only after deciding its priority.

Workshop 7: An ordered quest

Require players to do three things in sequence.

Do this: Call observe with the wrong first event, then the correct events.

{
const route = ['visit', 'collect', 'return'];
let step = 0;
function observe(event) {
  if (event === route[step]) step += 1;
  return step === route.length ? 'Complete' : route[step];
}
console.log(observe('collect'), observe('visit'), observe('collect'), observe('return'));
}

Check: The first collect does not skip the visit step. When complete, another event must not award the reward again.

Workshop 8: Award an achievement once

Protect rewards from repeated events.

Do this: Call award twice with the same name.

{
const earned = new Set();
function award(name) {
  if (earned.has(name)) return false;
  earned.add(name);
  return true;
}
console.log(award('Explorer'), award('Explorer'));
}

Check: It prints true then false. If you later save it, convert the Set to an array and rebuild it when loading.

Workshop 9: A bounded notice list

Keep recent messages without growing forever.

Do this: Add a fourth message, then inspect the result.

{
const notices = ['First', 'Second', 'Third'];
notices.push('Fourth');
const recent = notices.slice(-3);
console.log(recent);
}

Check: The result contains only the newest three. Use the same idea for a small local activity log.

Workshop 10: Load an older save safely

Give old saves defaults without discarding known values.

Do this: This experiment uses invented data only. Run both calls.

{
function upgrade(raw) {
  return {version: 2, score: Number.isFinite(raw.score) ? raw.score : 0,
    color: typeof raw.color === 'string' ? raw.color : 'blue'};
}
console.log(upgrade({score: 4}), upgrade({score: 'bad'}));
}

Check: The first keeps score 4; the second uses 0. Real saves need stronger validation and a backup before replacement.

Workshop 11: Export a local save

Make a readable copy before experimenting.

Do this: Run this in the Console. A small JSON file downloads; open it in your editor.

{
const save = {version: 1, score: 5};
const url = URL.createObjectURL(new Blob([JSON.stringify(save, null, 2)], {type:'application/json'}));
const a = document.createElement('a');
a.href = url; a.download = 'practice-save.json'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

Check: The downloaded file contains only your invented score. Loading requires parsing and checking fields; never execute imported text as code.

Workshop 12: Test a rule before adding graphics

A small assertion catches a regression quickly.

Do this: Run these checks in the Console, then deliberately break the clamp.

{
const clamp = (n, low, high) => Math.max(low, Math.min(high, n));
console.assert(clamp(-5, 0, 10) === 0, 'lower edge');
console.assert(clamp(15, 0, 10) === 10, 'upper edge');
console.assert(clamp(4, 0, 10) === 4, 'middle');
}

Check: Correct assertions produce no errors. A broken rule should produce a clear failing message.

Workshop 13: Turn points into a map

Convert world coordinates to a smaller display.

Do this: Run the formula with a few positions between 0 and 640.

{
const worldX = 320;
const worldWidth = 640;
const mapWidth = 160;
const mapX = worldX / worldWidth * mapWidth;
console.log(mapX);
}

Check: The center maps to 80. If you later add zoom, apply the same conversion to markers and hit testing.

Workshop 14: Show only what is nearby

Use distance to limit hints, sounds, or interactions.

Do this: Run this pair of distance checks.

{
function nearby(a, b, range) { return Math.hypot(a.x-b.x, a.y-b.y) <= range; }
console.log(nearby({x:0,y:0},{x:3,y:4},5));
console.log(nearby({x:0,y:0},{x:6,y:8},5));
}

Check: It prints true then false. Use this to reveal a collectible hint only when the explorer is close.

Workshop 15: Animate a small pulse

Use time to change appearance without changing collision.

Do this: Run the calculations for t = 0, 0.25, and 0.5.

{
const t = 0.25;
const visualRadius = 10 + Math.sin(t * Math.PI * 2) * 2;
console.log(visualRadius);
}

Check: The drawing radius changes between 8 and 12. Keep light.r unchanged so the collection rule remains predictable.

Workshop 16: Make a short sound

Generate a tone after a real button press.

Do this: Add this to game.js after the setup code. Press Start to hear a quiet tone.

{
document.getElementById('start').addEventListener('click', () => {
  const audio = new AudioContext();
  const tone = audio.createOscillator();
  const volume = audio.createGain();
  volume.gain.value = 0.03; tone.frequency.value = 440;
  tone.connect(volume).connect(audio.destination);
  tone.start(); tone.stop(audio.currentTime + 0.12);
  tone.onended = () => audio.close();
});
}

Check: A short tone plays after pressing Start. Add a mute setting before sharing; do not autoplay loud sounds.

Workshop 17: Design art that fits the hitbox

Use your own small drawing before importing complex art.

Do this: In draw, replace only the blue square fillRect with this drawing. Keep the collision radius.

{
ctx.beginPath();
ctx.moveTo(player.x, player.y - player.r);
ctx.lineTo(player.x + player.r, player.y + player.r);
ctx.lineTo(player.x - player.r, player.y + player.r);
ctx.closePath();
ctx.fill();
}

Check: The explorer becomes a triangle. Check that touching a light still feels fair at every edge.

Workshop 18: Keep collision and ground aligned

One height rule should serve both drawing and movement.

Do this: Try the same function at several x positions.

{
function groundY(x) { return 260 + Math.sin(x / 80) * 20; }
const x = 120;
const footY = groundY(x);
console.log(footY);
}

Check: Use groundY for both the visible surface and the character’s feet in a future platformer. Two different rules create floating or sinking.

Workshop 19: Measure before optimizing

Find expensive work instead of guessing.

Do this: Run a short measured loop. Compare results cautiously; browsers and machines differ.

{
console.time('practice-loop');
let total = 0;
for (let i = 0; i < 10000; i++) total += Math.sqrt(i);
console.timeEnd('practice-loop');
console.log(total);
}

Check: The Console shows elapsed time. Do not log every frame in a finished game; repeated logging can itself slow testing.

Workshop 20: Keep a name separate from a character

One profile can have several independent adventures.

Do this: Inspect this invented object. Change one island’s score only.

{
const profile = {displayName: 'River', adventures: {meadow: {score: 2}, cave: {score: 0}}};
profile.adventures.meadow.score += 1;
console.log(profile.adventures);
}

Check: Meadow becomes 3 while cave remains 0. This is a data-model exercise, not secure account authentication.

Workshop 21: Validate a form

Reject empty or oversized display names before using them.

Do this: Run with blanks, a short name, and a long name.

{
function validName(value) {
  const text = value.trim();
  return text.length >= 2 && text.length <= 20;
}
console.log(validName(' '), validName('River'));
}

Check: It prints false then true. Render user text with textContent, not innerHTML. Client checks improve usability; an online service must check again.

Workshop 22: A scheduled in-game event

Use game time for an event that should happen once per interval.

Do this: Run the loop and inspect when the event fires.

{
let elapsed = 0;
let next = 5;
for (let second = 1; second <= 12; second++) {
  elapsed += 1;
  if (elapsed >= next) { console.log('Event at', elapsed); next += 5; }
}
}

Check: It fires at 5 and 10. Decide whether missed intervals should catch up or be skipped before using this in a real game.

Workshop 23: Prepare for multiplayer

Understand the boundary before connecting players.

Do this: This is a validation exercise only. Feed different commands to the function.

{
function validMove(message) {
  return message && message.type === 'move' &&
    Number.isFinite(message.x) && Math.abs(message.x) <= 1 &&
    Number.isFinite(message.y) && Math.abs(message.y) <= 1;
}
console.log(validMove({type:'move',x:1,y:0}));
}

Check: A server should accept movement intent and decide the outcome. Do not trust a browser message saying “award me 100 points.” Multiplayer needs separate server-side design and tests.

Workshop 24: Plan a 3D or VR follow-up

Keep your first finished game small; change one dimension later.

Do this: Sketch three axes on paper and write how each control changes them.

{
const position = {x: 0, y: 0, z: 0};
position.x += 1; // left/right
position.y += 1; // up/down
position.z += 1; // forward/back
console.log(position);
}

Check: You can describe movement before choosing a 3D library. A headset adds comfort, input, and performance tests; a flat browser game is not automatically a VR game.

Your finish-and-share checklist

Use this procedure every time you think you are done. A game is ready to share when someone else can start it, understand the goal, finish a round, and try again.

  1. Start from a fresh page. Read the instructions as if you had never seen the game.
  2. Test every input. Keyboard, touch buttons, diagonal movement, leaving the window, and releasing outside a button.
  3. Test both endings. Win, lose to time, lose to damage, pause, resume, and restart after each.
  4. Test unusual data. Missing or blocked storage must not crash the game. A new round must reset everything except the intended best time.
  5. Check a small screen. Keep buttons reachable and text readable. Try a real touch device when possible, not only a resized window.
  6. Inspect the Console. Fix errors and remove noisy debugging logs.
  7. Ask a friend to play. Watch where they get stuck without explaining every button. Improve the instructions based on that observation.
  8. Make a dated backup. Keep the previous working folder before a big change.
  9. Share the single-file checkpoint or all three finished files together. A static website host can serve index.html, style.css, and game.js. Follow that provider’s current upload instructions, then test the resulting HTTPS link in a fresh browser.
This starter has no online accounts or shared world. Do not add real passwords to localStorage or put private keys in browser code. Build those features only after learning the separate server-side work they require.

Your next project: change the theme, add one mechanic, and repeat the same checklist. Finishing a small game teaches more than starting ten enormous ones.

Keep learning

These official references explain the browser features used here. Our examples are original and deliberately small.

When using outside artwork or sound later, check the license, keep attribution, and use small test assets first. Start with your own shapes until the game itself works.