You do not need a game engine to start building games. In this tutorial, we will create Star Collector, a complete browser game using HTML, CSS, and JavaScript.
The objective is simple: collect 10 yellow stars before the timer runs out, and avoid the red obstacles. Along the way, you will implement keyboard controls, character movement, collision detection, scoring, and restarting.
The project uses no external libraries, images, or audio files. Everything is contained in one HTML file.
What You Will Learn
– Create a game area with HTML Canvas.
– Track keyboard input.
– Move a player using elapsed time.
– Detect collisions between game objects.
– Generate collectibles outside obstacles.
– Implement a score, countdown, and win or loss conditions.
– Pause gameplay when the game loses focus.
– Restart without creating duplicate animation loops.
You should be comfortable with basic HTML and JavaScript concepts, including variables, functions, objects, and event listeners.
This version uses a keyboard and is intended for desktop browsers. Touch controls are not included.
1. Create the Project
Create a folder named star-collector and add a file named index.html inside it:
star-collector/
└── index.html
The entire game will live in this file. There are no packages to install and no build commands to run.
2. Add the Complete Game
Copy the following code into index.html, save the file, and open it in your browser.
Click the game area to begin, then move using WASD or the arrow keys.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Star Collector</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
background: #101827;
color: #edf2ff;
font: 16px/1.5 system-ui, sans-serif;
text-align: center;
}
main {
max-width: 800px;
margin: auto;
}
canvas {
display: block;
width: 100%;
height: auto;
background: #18243a;
border: 2px solid #627698;
border-radius: 8px;
}
canvas:focus-visible {
outline: 3px solid #67e8f9;
outline-offset: 4px;
}
button {
margin: 16px 0;
padding: 10px 20px;
cursor: pointer;
font: inherit;
border: 0;
border-radius: 6px;
}
</style>
</head>
<body>
<main>
<h1>Star Collector</h1>
<p id="instructions">
Click the game, then use WASD or the arrow keys.
Collect 10 yellow stars in 30 seconds.
Avoid the red blocks.
</p>
<canvas
id="game"
width="800"
height="450"
tabindex="0"
aria-label="Star Collector game area"
aria-describedby="instructions"
>
This game requires a browser with HTML Canvas support.
</canvas>
<button id="restart" type="button">Restart game</button>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
'use strict';
const canvas = document.querySelector('#game');
const ctx = canvas.getContext('2d');
const status = document.querySelector('#status');
const keys = new Set();
const controls = new Set([
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'KeyW',
'KeyA',
'KeyS',
'KeyD'
]);
const GOAL = 10;
const ROUND_SECONDS = 30;
const obstacles = [
{ x: 210, y: 100, w: 90, h: 70 },
{ x: 490, y: 280, w: 110, h: 70 },
{ x: 560, y: 80, w: 65, h: 95 }
];
let player;
let star;
let score;
let remaining;
let state;
let previousTime = null;
function overlaps(a, b) {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
function spawnStar() {
const candidate = {
x: 0,
y: 0,
w: 20,
h: 20
};
for (let attempt = 0; attempt < 1000; attempt++) {
candidate.x =
10 + Math.random() * (canvas.width - candidate.w - 20);
candidate.y =
50 + Math.random() * (canvas.height - candidate.h - 60);
const touchesPlayer = overlaps(candidate, player);
const touchesObstacle = obstacles.some(
block => overlaps(candidate, block)
);
if (!touchesPlayer && !touchesObstacle) {
return candidate;
}
}
throw new Error(
'No free star position found. Check the obstacle layout.'
);
}
function reset() {
player = {
x: 40,
y: 220,
w: 28,
h: 28,
speed: 240
};
score = 0;
remaining = ROUND_SECONDS;
state = 'playing';
previousTime = null;
keys.clear();
star = spawnStar();
status.textContent =
'New game. Collect 10 stars. ' +
'The timer pauses when the game loses focus.';
}
function finish(message) {
state = 'finished';
keys.clear();
status.textContent =
message + ' Use Restart game to play again.';
}
canvas.addEventListener('keydown', event => {
if (!controls.has(event.code)) {
return;
}
event.preventDefault();
keys.add(event.code);
});
window.addEventListener('keyup', event => {
keys.delete(event.code);
});
canvas.addEventListener('blur', () => {
keys.clear();
});
window.addEventListener('blur', () => {
keys.clear();
});
document.addEventListener('visibilitychange', () => {
keys.clear();
previousTime = null;
});
canvas.addEventListener('pointerdown', () => {
canvas.focus();
});
document.querySelector('#restart').addEventListener('click', () => {
reset();
canvas.focus();
});
function update(dt) {
if (state !== 'playing') {
return;
}
remaining = Math.max(0, remaining - dt);
if (remaining === 0) {
finish('Time is up!');
return;
}
let dx =
Number(keys.has('ArrowRight') || keys.has('KeyD')) -
Number(keys.has('ArrowLeft') || keys.has('KeyA'));
let dy =
Number(keys.has('ArrowDown') || keys.has('KeyS')) -
Number(keys.has('ArrowUp') || keys.has('KeyW'));
const length = Math.hypot(dx, dy);
if (length > 0) {
dx /= length;
dy /= length;
}
player.x = Math.max(
0,
Math.min(
canvas.width - player.w,
player.x + dx * player.speed * dt
)
);
player.y = Math.max(
40,
Math.min(
canvas.height - player.h,
player.y + dy * player.speed * dt
)
);
const hitObstacle = obstacles.some(
block => overlaps(player, block)
);
if (hitObstacle) {
finish('You hit a red block!');
return;
}
if (overlaps(player, star)) {
score++;
if (score >= GOAL) {
finish('You win! All 10 stars collected.');
return;
}
star = spawnStar();
status.textContent = `Star collected: ${score} of ${GOAL}.`;
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw obstacles.
ctx.fillStyle = '#fb7185';
for (const block of obstacles) {
ctx.fillRect(block.x, block.y, block.w, block.h);
}
// Draw a five-pointed star.
ctx.fillStyle = '#facc15';
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const angle = -Math.PI / 2 + i * Math.PI / 5;
const radius = i % 2 === 0 ? 10 : 4.5;
const x = star.x + 10 + Math.cos(angle) * radius;
const y = star.y + 10 + Math.sin(angle) * radius;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
// Draw the player.
ctx.fillStyle = '#67e8f9';
ctx.fillRect(player.x, player.y, player.w, player.h);
// Draw the score and timer.
ctx.fillStyle = '#ffffff';
ctx.font = '20px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`Stars: ${score}/${GOAL}`, 16, 28);
ctx.fillText(`Time: ${Math.ceil(remaining)}`, 650, 28);
const paused =
document.activeElement !== canvas ||
!document.hasFocus();
if (state === 'finished' || paused) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(0, 175, canvas.width, 100);
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
const message =
state === 'finished'
? 'Round over — use Restart game'
: 'Paused — click here to play';
ctx.fillText(message, canvas.width / 2, 230);
ctx.textAlign = 'left';
}
}
function frame(timestamp) {
const dt =
previousTime === null
? 0
: Math.min((timestamp - previousTime) / 1000, 1 / 30);
previousTime = timestamp;
const canUpdate =
!document.hidden &&
document.hasFocus() &&
document.activeElement === canvas;
if (canUpdate) {
update(dt);
}
draw();
requestAnimationFrame(frame);
}
reset();
requestAnimationFrame(frame);
</script>
</body>
</html>
3. Understand the Canvas
The <canvas> element defines the game’s drawing area. Its HTML attributes establish an internal resolution of 800 by 450 pixels:
<canvas id="game" width="800" height="450"></canvas>
JavaScript obtains a two-dimensional drawing context:
const canvas = document.querySelector('#game');
const ctx = canvas.getContext('2d');
We use that context to draw rectangles, text, and the star’s outline. Canvas does not manage these objects for us; our code stores their positions and redraws them as the game runs. MDN’s Canvas tutorial covers these drawing operations in more detail. [developer.mozilla](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial)
The CSS makes the canvas fit its container while preserving its proportions:
canvas {
width: 100%;
height: auto;
}
Resizing its display does not change our game’s internal coordinate system. The player still moves within an 800-by-450 game world.
4. Represent Game Objects
The player is a JavaScript object:
player = {
x: 40,
y: 220,
w: 28,
h: 28,
speed: 240
};
Each property has a specific purpose:
– x and y identify the upper-left corner.
– w and h define the collision rectangle.
– speed defines movement in canvas pixels per second.
Obstacles use the same position and size properties. Sharing this structure allows one collision function to work with the player, obstacles, and collectibles.
5. Track Keyboard Input
Instead of moving the player directly inside a keyboard event, we record which keys are held:
const keys = new Set();
A key press adds its code to the set:
keys.add(event.code);
Releasing it removes the code:
keys.delete(event.code);
The update function then reads the set every frame.
This separates input from movement. It also allows multiple keys to work together, such as holding up and right to move diagonally.
The controls use event.code, so the WASD bindings refer to physical keyboard positions. Arrow keys provide an alternative.
The canvas receives keyboard focus through tabindex="0" and the click handler. Movement keys prevent page scrolling only when handled by the focused game area.
6. Build the Game Loop
The game loop performs two jobs:
1. Update the game’s state.
2. Draw the current result.
We schedule each frame with requestAnimationFrame(). The browser supplies a timestamp to the callback, which lets us calculate elapsed time instead of assuming every display runs at the same refresh rate. [developer.mozilla](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
function frame(timestamp) {
// Calculate elapsed time.
// Update the game when focused.
// Draw the current state.
requestAnimationFrame(frame);
}
The complete example calculates delta time, or dt, in seconds:
const dt =
previousTime === null
? 0
: Math.min((timestamp - previousTime) / 1000, 1 / 30);
Movement uses that value:
player.x += dx * player.speed * dt;
At ordinary frame rates, a speed of 240 means approximately 240 canvas pixels per second—not 240 pixels per frame.
Why Cap Delta Time?
A delayed frame could otherwise produce a large movement jump. The example limits each update to one thirtieth of a second.
This is a deliberate simplification with a tradeoff: if the game consistently runs below 30 frames per second, movement and the countdown slow relative to real-world time.
For a more advanced version, use a fixed-step simulation and a separate wall-clock timer.
7. Prevent Faster Diagonal Movement
Moving horizontally and vertically at full speed simultaneously would make diagonal movement faster.
The example normalizes the direction vector:
const length = Math.hypot(dx, dy);
if (length > 0) {
dx /= length;
dy /= length;
}
This preserves the direction while keeping its total length at one.
The player then moves at the same overall speed regardless of direction.
We also clamp the position to the arena boundaries. The top 40 pixels are reserved for the score and timer, so the player cannot enter that strip.
8. Detect Collisions
The game uses axis-aligned bounding boxes: rectangles that do not rotate.
Two objects overlap when their horizontal ranges overlap and their vertical ranges overlap:
function overlaps(a, b) {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
The same function handles two interactions:
– Overlapping a red obstacle ends the round.
– Overlapping a star increases the score.
In this game, obstacles are hazards rather than solid walls. The character does not slide along them; touching one causes a loss.
The star’s collision area is a rectangle surrounding its drawn shape. That makes collection slightly more forgiving than checking the exact star outline.
Keep Collectibles Out of Obstacles
The spawnStar() function generates a candidate position, then rejects it if it overlaps the player or an obstacle.
It tries up to 1,000 positions. If none works, it throws an error instead of getting stuck in an infinite loop.
This works for the included open layout. If you create rooms, enclosed spaces, or maze-like levels, you should also check whether each collectible is reachable.
9. Manage Game States
The state variable distinguishes active play from a completed round:
state = 'playing';
When the player wins, runs out of time, or hits an obstacle, finish() changes it:
state = 'finished';
The update function checks that state before doing anything:
if (state !== 'playing') {
return;
}
Drawing continues after the round ends so the game can display the final arena and restart message.
Restart Without Duplicating the Loop
The restart button calls reset(), which restores:
– The starting position.
– The score.
– The countdown.
– The active game state.
– The collectible.
– The stored keyboard input.
It does not create a new animation loop.
That distinction matters. Starting another loop on every restart can cause multiple updates to run simultaneously and make the game appear to accelerate.
10. Pause When Focus Changes
A player should not lose a round simply because they click outside the game.
The simulation updates only when:
– The page is visible.
– The browser window has focus.
– The canvas is the active element.
const canUpdate =
!document.hidden &&
document.hasFocus() &&
document.activeElement === canvas;
We also clear held keys when focus changes. Otherwise, a missed key-release event could leave the character moving after the player returns.
The visible status message reports collected stars and the final result. Its live-region attributes provide an additional announcement channel, but this tutorial is not a complete nonvisual game implementation.
11. Test the Game
Before modifying the project, check the original behavior:
1. Open index.html. A paused message should appear.
2. Click the canvas. The countdown should begin.
3. Test the arrow keys and WASD.
4. Compare diagonal and horizontal movement speed.
5. Move toward each edge. The player should remain inside the arena.
6. Collect a star. The score should increase and another star should appear.
7. Hit a red obstacle. The round should end.
8. Restart and let the timer expire.
9. Collect 10 stars to verify the win condition.
10. Click outside the game or switch tabs. Gameplay should pause.
11. Restart several times. Movement speed should remain unchanged.
If the page displays incorrectly, confirm that the filename is index.html, not index.html.txt.
If the game does not respond to movement keys, click the canvas first.
If the game fails to start, open your browser’s developer console and check for JavaScript errors, especially missing brackets or quotation marks introduced while copying.
12. Customize the Game
Start with small changes and test after each one.
### Adjust the Difficulty
Change the target score or round duration:
const GOAL = 10;
const ROUND_SECONDS = 30;
If you change these values, update the introductory instructions and any messages that currently say “10 stars” or “30 seconds.”
Change Player Speed
Adjust speed inside reset():
speed: 240
A higher number makes movement faster, but extremely high speeds may let the player skip over a narrow obstacle between collision checks. Faster games need more robust collision handling.
Design Another Arena
Edit the obstacle array:
const obstacles = [
{ x: 210, y: 100, w: 90, h: 70 },
{ x: 490, y: 280, w: 110, h: 70 },
{ x: 560, y: 80, w: 65, h: 95 }
];
Keep the starting position clear and leave enough space for the player to navigate.
Add More Features
Possible extensions include:
– Multiple levels with different layouts.
– A title screen and difficulty selection.
– Sound effects with a mute control.
– Animated sprites.
– Touch controls for mobile devices.
– A saved best score.
– Moving hazards.
– A fixed-step simulation.
Build one feature at a time. Keeping input, updating, drawing, and resetting separate makes those additions easier to reason about.
Resources
No external assets are required to complete this project. All graphics are drawn directly in the game.
For further reading:
MDN: Canvas tutorial — drawing shapes, paths, text, and images.
MDN: requestAnimationFrame() — scheduling animation frames and using timestamps.
You now have a small but complete game built around a reusable structure: collect input, update state, detect interactions, and draw the result.

0 Replies