Build a 2D Browser Game with JavaScript and HTML Canvas

0

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:

text

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.

html

<!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:

html

<canvas id="game" width="800" height="450"></canvas>

JavaScript obtains a two-dimensional drawing context:

javascript

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:

css

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:

javascript

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:

javascript

const keys = new Set();

A key press adds its code to the set:

javascript

keys.add(event.code);

Releasing it removes the code:

javascript

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)

javascript

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:

javascript

const dt =
  previousTime === null
    ? 0
    : Math.min((timestamp - previousTime) / 1000, 1 / 30);

Movement uses that value:

javascript

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:

javascript

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:

javascript

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:

javascript

state = 'playing';

When the player wins, runs out of time, or hits an obstacle, finish() changes it:

javascript

state = 'finished';

The update function checks that state before doing anything:

javascript

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.

javascript

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:

javascript

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():

javascript

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:

javascript

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.

Loading discussion…Preparing formatted code and rich content.

Join the Discussion

Respond directly to the original thread. To respond to another member, use Reply under that response.

Join the discussion

Log in with your ProfoundSyntax account to participate.

Log In to Reply

0 Replies