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.

A picture-led, step-by-step workshop: start with an empty file and finish with a game you can play and share.
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.
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.
.html, not .html.txt.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.
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.
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 remembers values and responds to events. It can move the explorer, count lights, and change the text after a win.
let score = 0; starts a score that can change. const means the name cannot be reassigned.{ x: 40, y: 180 }. Read a value with player.x.[10, 20, 30]. The first item has index 0. A loop lets you visit each item.function add(a, b) { return a + b; } gives back the sum. Call it with add(2, 3).if (score === 5) checks whether the score is exactly five. A single = assigns a value; === compares it.Read code punctuation slowly: parentheses hold inputs, braces group instructions, brackets hold a list, and quotes surround text. // starts a comment for the reader.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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.
<!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.
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.
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
<link rel="stylesheet" href="style.css">.<script src="game.js" defer></script>. The defer attribute waits until the page has been read.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.
console.log(player.x, player.y) in your code. Remove repeated frame logs afterward so they do not flood the console.| Symptom | Check first |
|---|---|
| Unexpected token | A quote, brace, parenthesis, or pasted extra tag near the reported line. |
| Cannot read properties of null | An element id differs between HTML and JavaScript, or code ran before the element existed. |
| Page unchanged | Unsaved file, wrong tab, or a second copy in another folder. |
| Movement too fast | Multiplying by seconds once, not adding speed every frame or starting two animation loops. |
| Works on keyboard, fails on touch | Pointer release/cancel handling, touch target size, and whether the page scrolls during movement. |
| Restart acts strangely | All changing values must be reset, including collected items and invulnerability. |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.