Integrating and Assembling the Complete Game

1

Integrating and Assembling the Complete Game

▶

Building a complete, playable browser game means taking every individual skill you have developed — drawing shapes and sprites on a canvas, animating them smoothly, reading keyboard and mouse input, and enforcing game rules — and weaving them into a single, coherent, self-contained project. This final integration step is where everything clicks into place. It is also where subtle problems emerge: a draw call that happens before the canvas is ready, an event listener registered twice after a restart, or a collision check that runs in the wrong order relative to the render pass. Understanding how to structure the whole project from the ground up, and why each structural decision matters, is what separates a working game from a fragile collection of loosely connected pieces.

The sections below walk through every layer of that integration in depth, from the very first lines of your HTML file all the way to publishing and testing the finished product.

Establishing the Project Structure

Every browser game built with the Canvas API needs at least one HTML file to serve as the entry point. Even if you later split JavaScript across multiple files, the HTML document is the shell that the browser loads first, and its structure sets the tone for everything else. The most important element is the <canvas> tag, which becomes the single rendering surface for the entire game.

A minimal but well-structured starting point looks like this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Complete Game</title>
  <style>
    body { margin: 0; background: #111; display: flex; justify-content: center; align-items: center; height: 100vh; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="gameCanvas" width="800" height="600"></canvas>

  <!-- Scripts go here, at the bottom of body -->
  <script src="input.js"></script>
  <script src="entities.js"></script>
  <script src="renderer.js"></script>
  <script src="game.js"></script>
</body>
</html>

Placing <script> tags at the very bottom of the <body> — not in the <head> — is critical. When the browser parses HTML top to bottom, a script in the <head> executes before the <canvas> element exists in the DOM. Any attempt to call document.getElementById('gameCanvas') at that point returns null, and every subsequent canvas operation throws an error. Placing scripts at the bottom guarantees the entire DOM has been constructed before a single line of game code runs.

Separating concerns across files (or clearly labeled sections in a single file) makes the project dramatically easier to debug and extend. A sensible division is:

  • input.js — tracks keyboard/mouse state, exposes a shared keys or input object
  • entities.js — defines player, enemies, bullets, and other game objects with their properties and update methods
  • renderer.js — contains the render() function and all drawing helpers
  • game.js — owns the game loop, game state, win/lose logic, and calls into the other modules

Even in a single-file project you can achieve the same clarity with comments like // === INPUT ===, // === ENTITIES ===, and so on. The goal is that any reader — including your future self — can find the relevant code in under ten seconds.

Combining Drawing and Animation Systems

A canvas game does not maintain a scene graph the way a game engine might. Every frame you erase the entire canvas and redraw every visible object from scratch. This might sound wasteful, but it is the standard, performant approach for 2D canvas games and it keeps the rendering model simple: what you draw last appears on top.

The render() function is responsible for exactly one thing — painting the current game state onto the canvas. It should never modify game state; it only reads it. A well-organized render function looks like this:

function render() {
  // 1. Clear the entire canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 2. Draw the background (lowest layer)
  drawBackground();

  // 3. Draw all game entities (middle layer)
  drawEnemies();
  drawPlayer();
  drawBullets();
  drawParticles();

  // 4. Draw the UI on top (highest layer)
  drawScore();
  drawLives();
  drawGameStateOverlay(); // shows "Game Over" or "You Win" if needed
}

The order of draw calls is your z-order. Anything drawn earlier is painted over by anything drawn later, so backgrounds must come first and HUD elements must come last. A common mistake is drawing the score before the background, causing the score to be hidden on every frame.

requestAnimationFrame() is the browser's built-in mechanism for scheduling animation. Unlike setInterval(), it synchronizes with the display's refresh rate (typically 60 Hz), pauses automatically when the tab is in the background to save CPU, and passes a high-resolution timestamp into your callback, which you can use to compute delta time. The pattern is:

function gameLoop(timestamp) {
  update(timestamp);
  render();
  requestAnimationFrame(gameLoop); // schedule the next frame
}

requestAnimationFrame(gameLoop); // kick off the loop

Each call to requestAnimationFrame(gameLoop) schedules exactly one future callback. The loop continues only because each invocation of gameLoop schedules the next one. This means stopping the loop is as simple as not calling requestAnimationFrame again — or storing the return ID and calling cancelAnimationFrame(id).

Wiring Up Event Handling and Player Input

Keyboard and mouse event listeners should be registered exactly once, during game initialization, before the loop starts. The cleanest approach stores the current input state in a plain object that any part of the game can read:

const keys = {};

function initInput() {
  window.addEventListener('keydown', (e) => {
    keys[e.code] = true;
  });
  window.addEventListener('keyup', (e) => {
    keys[e.code] = false;
  });
}

By writing to keys['ArrowLeft'], keys['Space'], and so on, the update() function can simply check if (keys['ArrowRight']) every frame without needing to track events itself. This polling model is more predictable than reacting to individual events inside the game loop.

A particularly nasty bug appears when a game allows restarting. If your restart logic calls initInput() again without removing the old listeners, you now have two (or more) keydown handlers. Every key press fires all of them. This can cause the player to move twice as fast, bullets to spawn in doubles, or scores to increment multiple times per keypress. The fix is to use named function references so you can call removeEventListener, or — simpler — to call initInput() only once at page load and never again:

// WRONG: called every time the player hits "restart"
function restartGame() {
  initInput(); // registers a second set of listeners!
  resetGameState();
  requestAnimationFrame(gameLoop);
}

// RIGHT: separate initialization from reset
function initGame() {
  initInput();       // called once
  resetGameState();  // can be called any time
  requestAnimationFrame(gameLoop);
}

Mouse input follows the same principle. Register mousemove and click (or mousedown/mouseup) listeners once on the canvas element, store their state, and read that state inside update().

Implementing the Unified Game Loop

The game loop is the heartbeat of the entire project. Its four-step rhythm — read input → update state → render → schedule next frame — must be preserved every frame without exception. Mixing these responsibilities (for example, modifying a sprite's position inside the render function) creates hard-to-trace bugs where behavior depends on draw order rather than logic order.

Delta time is the key to frame-rate-independent movement. Because requestAnimationFrame does not guarantee a fixed interval between frames (a slow device might deliver only 30 frames per second while a fast one delivers 120), basing movement on raw frame count produces wildly different speeds across hardware. Delta time measures how many milliseconds (or seconds) elapsed since the last frame and scales movement accordingly:

let lastTimestamp = 0;

function gameLoop(timestamp) {
  const deltaTime = (timestamp - lastTimestamp) / 1000; // convert ms → seconds
  lastTimestamp = timestamp;

  update(deltaTime);
  render();

  requestAnimationFrame(gameLoop);
}

Inside update(), movement uses delta time as a multiplier:

function update(dt) {
  if (keys['ArrowRight']) {
    player.x += player.speed * dt; // speed is in pixels per second
  }
  if (keys['ArrowLeft']) {
    player.x -= player.speed * dt;
  }
  // ... rest of update logic
}

If player.speed is 300 (pixels per second) and dt is 0.016 (one frame at 60 fps), the player moves 4.8 pixels. On a 30 fps device dt is 0.033 and the player moves 9.9 pixels — the same distance per second, just in larger steps. The game feels the same on all devices.

One caution: on the very first frame, lastTimestamp is 0, so deltaTime will be enormous (the current Unix-ish timestamp in milliseconds divided by 1000). Guard against this with a clamp:

const deltaTime = Math.min((timestamp - lastTimestamp) / 1000, 0.05);

Clamping to 0.05 seconds (20 fps minimum) prevents objects from teleporting across the screen if the browser tab is switched and then restored, causing a single huge delta spike.

Integrating Game Logic and Win/Lose Conditions

All game logic — collision detection, score increments, spawning new enemies, timer countdowns — belongs inside update(). The render function is kept deliberately ignorant of logic. This separation makes both functions easier to reason about and test independently.

A practical update() for a simple shooter might look like:

function update(dt) {
  if (gameState !== 'playing') return; // don't update during "game over" or "win"

  movePlayer(dt);
  moveBullets(dt);
  moveEnemies(dt);
  spawnEnemies(dt);

  checkBulletEnemyCollisions();
  checkPlayerEnemyCollisions();

  updateScore();
  checkWinCondition();
  checkLoseCondition();
}

Game state is typically represented by a string variable or an enum-like constant set:

let gameState = 'playing'; // possible values: 'playing', 'won', 'lost'

Win and lose checks read the current conditions and transition state:

function checkWinCondition() {
  if (enemies.length === 0 && score >= targetScore) {
    gameState = 'won';
  }
}

function checkLoseCondition() {
  if (player.lives <= 0) {
    gameState = 'lost';
  }
}

The render function reads gameState and draws the appropriate overlay:

function drawGameStateOverlay() {
  if (gameState === 'lost') {
    ctx.fillStyle = 'rgba(0,0,0,0.6)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = 'red';
    ctx.font = 'bold 64px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2);
    ctx.font = '24px sans-serif';
    ctx.fillStyle = 'white';
    ctx.fillText('Press R to restart', canvas.width / 2, canvas.height / 2 + 50);
  } else if (gameState === 'won') {
    ctx.fillStyle = 'rgba(0,0,0,0.6)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = 'gold';
    ctx.font = 'bold 64px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2);
  }
}

Because the loop keeps running even after the game ends (the update function exits early but the render still fires), the overlay appears immediately and stays visible until the player restarts.

Testing and Debugging the Complete Project

Integration testing means playing the game as a user would — from launch through every possible end state — not just verifying individual functions in isolation. A useful checklist for a complete playthrough includes:

  • Does the game launch and immediately show the correct opening state?
  • Does player input respond correctly from the first frame?
  • Does the lose condition trigger reliably when the losing event occurs?
  • Does the win condition trigger and display correctly?
  • Does restarting fully reset all game state (score, lives, enemy positions, bullet arrays)?
  • Does restarting work multiple times in a row without degrading performance?

console.log() placed strategically is your most accessible debugging tool. Rather than logging every frame (which floods the console and slows the game), log at transition points:

function checkLoseCondition() {
  if (player.lives <= 0) {
    console.log('Lose condition triggered. Score:', score, 'State:', gameState);
    gameState = 'lost';
  }
}

For per-frame inspection, use a temporary display on the canvas itself rather than the console:

function drawDebugInfo() {
  ctx.fillStyle = 'lime';
  ctx.font = '14px monospace';
  ctx.fillText(`State: ${gameState} | Enemies: ${enemies.length} | FPS: ${Math.round(1/dt)}`, 10, 20);
}

Common integration bugs and their causes are worth memorizing:

Bug Likely Cause Fix
Canvas is null on load Script runs before DOM is parsed (script in <head>) Move <script> tags to bottom of <body>
Player moves twice as fast after restart Event listeners registered multiple times Register listeners once; never re-register on restart
Objects teleport on tab switch Massive delta time spike after tab was backgrounded Clamp delta time to a maximum (e.g., 0.05 s)
Score keeps counting after game over update() does not check gameState before running logic Add early-return guard at top of update()
Old bullets/enemies persist after restart Arrays not cleared during resetGameState() Set arrays to [] and reinitialize all objects in reset function
Drawing a null or undefined object crashes the game Entity removed from array but still referenced elsewhere Filter arrays after removal; check for existence before drawing

Embedding and Presenting the Finished Game

A canvas game that works only in one browser or renders blurry on high-DPI screens is not truly finished. Before presenting the game, a few final steps ensure it looks and behaves professionally.

Canvas dimensions must be set in two places. The HTML width and height attributes define the resolution — the number of actual pixels in the drawing buffer. CSS width and height control the displayed size on screen. If you set only the CSS size, the browser stretches a default 300×150 canvas buffer to fill that space, resulting in blurry, pixelated rendering. Setting both keeps the drawing sharp:

<!-- HTML attribute: sets the drawing buffer resolution -->
<canvas id="gameCanvas" width="800" height="600"></canvas>
/* CSS: controls how large it appears on screen */
canvas {
  width: 800px;
  height: 600px;
}

For high-DPI (Retina) displays, you can go further by scaling the canvas buffer by devicePixelRatio and then scaling the context back down:

const dpr = window.devicePixelRatio || 1;
canvas.width = 800 * dpr;
canvas.height = 600 * dpr;
canvas.style.width = '800px';
canvas.style.height = '600px';
ctx.scale(dpr, dpr);

This makes every line and glyph appear crisp on Retina screens while the rest of your game code continues to work in 800×600 logical coordinates.

Cross-browser testing should cover at least Chrome, Firefox, and Safari (or Edge). Open the HTML file directly with File → Open to simulate how users will experience it without a server. Check that sounds play (some browsers require a user gesture before audio starts), that animations are smooth, and that the game over and win screens display correctly in each browser.

A restart mechanism is essential for playability. Do not force the player to refresh the page. A keypress handler is the simplest approach:

window.addEventListener('keydown', (e) => {
  if (e.code === 'KeyR' && gameState !== 'playing') {
    resetGameState();
    gameState = 'playing';
    // The loop is already running; no need to call requestAnimationFrame again
  }
});

Notice that the game loop never stopped — it kept running during the game over screen, faithfully rendering the overlay every frame. Restarting just resets the data and changes gameState back to 'playing', so the next frame's update() call no longer returns early and the game resumes. This is simpler and more reliable than stopping and restarting the loop.

For audiences who may not know keyboard shortcuts, a visible on-screen button can complement the keypress:

function drawGameStateOverlay() {
  if (gameState === 'lost' || gameState === 'won') {
    // ... overlay drawing ...
    ctx.fillStyle = 'white';
    ctx.font = '20px sans-serif';
    ctx.fillText('Press R to play again', canvas.width / 2, canvas.height / 2 + 60);
  }
}

Alternatively, a real HTML <button> element positioned over the canvas via CSS absolute positioning gives mobile users a tap target. The button's click listener calls the same reset logic as the key handler.

The complete integration flow, from initialization to a restartable game, can be summarized in the order operations should occur:

  • Page loads → HTML is parsed, canvas and all scripts are available
  • initInput() → event listeners registered exactly once
  • loadAssets() → images and sounds loaded (if any); game starts after load completes
  • resetGameState() → all game variables set to their initial values
  • requestAnimationFrame(gameLoop) → loop begins
  • Each frame: update(dt) → render() → requestAnimationFrame(gameLoop)
  • Win/lose triggered: gameState changes, update() exits early, render() shows overlay
  • Player restarts: resetGameState() called, gameState = 'playing', loop continues seamlessly

Following this structure from the start — rather than bolting it together at the end — means that adding new features, fixing bugs, and handing the project to another developer all become straightforward tasks. The game is not just functional; it is organized, testable, and ready to grow.

NotesStudents should be encouraged to build the project incrementally: get the loop running first with a blank canvas, then add a moving player, then enemies, then collision, then win/lose screens, rather than writing everything at once and debugging a fully assembled project. Emphasize that the separation between update() and render() is a professional convention with real practical benefits, not just style preference.