Game Logic and State Management

1

Game Logic and State Management

▶

Every interactive game — no matter how simple or complex — is fundamentally a program that tracks information over time and reacts to changes in that information. A bullet either hits a target or it doesn't. A player either has lives remaining or they don't. The score either meets a winning threshold or it doesn't. Game logic and state management is the discipline of organizing, updating, and responding to all of this information in a structured, reliable way. Without a clear system for managing state, games quickly become tangled webs of variables that fall out of sync, produce invisible bugs, or behave inconsistently. With a clear system, every mechanic — scoring, collision, winning, losing, restarting — becomes a predictable and maintainable piece of a larger whole.

This topic walks through the core pillars of game logic: how to define and track game state, how to implement scoring, how to detect collisions between objects, how to recognize win and loss conditions, and how to give players the ability to restart cleanly. Each concept builds on the one before it, and together they form the backbone of almost every browser-based Canvas game.

Tracking Game State

Game state is the complete picture of what is happening in a game at any given moment. It answers questions like: Is the game currently running or paused? How many lives does the player have left? Where is the player on screen? Where are the enemies? What is the current score? If you can answer all of those questions by looking at a set of variables, then those variables collectively represent your game state.

The most important principle for managing game state well is centralization. Rather than scattering individual variables across different functions or letting them live implicitly inside closures, you should define a dedicated state object or a clearly grouped set of variables at the top of your script, before the game loop starts. This makes the state easy to inspect, easy to reset, and easy to reason about.

Consider a simple approach using a plain object:

const gameState = {
  isRunning: false,
  isPaused: false,
  isGameOver: false,
  score: 0,
  lives: 3,
  level: 1,
  playerX: 200,
  playerY: 400,
  enemyX: 100,
  enemyY: 50,
  enemySpeed: 2
};

Grouping everything into one object has a practical advantage: when you need to reset the game, you only need to restore the properties of one object rather than hunt down a dozen individual variables spread across the file. Each property is a named, readable description of something meaningful in the game world.

State variables must be updated inside the game loop or event handlers whenever a meaningful change occurs. If the player moves right, you update gameState.playerX. If the player presses the spacebar to fire, an event handler sets a flag like gameState.isFiring = true, and the game loop responds to that flag on its next tick. This separation of concerns — input sets flags, the loop reads flags and updates state, rendering reads state and draws — is a clean pattern that prevents confusion.

At the start of each iteration of the game loop, you should check state flags to decide what the loop should do. A common structure looks like this:

function gameLoop() {
  if (gameState.isGameOver) {
    renderGameOver();
    return; // Stop the loop from scheduling itself again
  }

  if (gameState.isPaused) {
    requestAnimationFrame(gameLoop); // Keep the loop alive but do nothing else
    return;
  }

  update();   // Apply movement, physics, AI
  checkCollisions();
  checkWinCondition();
  render();   // Draw the current state to the Canvas

  requestAnimationFrame(gameLoop);
}

This structure ensures the loop always begins with a clear decision about whether it should proceed normally, wait, or stop entirely. The state drives the behavior — the loop never assumes anything.

Implementing a Scoring System

A scoring system gives players feedback and motivation. Implementing one correctly involves three steps: initializing the score, incrementing it in response to events, and displaying it every frame.

Initialize the score before the loop begins. Whether you store it in a state object or as a standalone variable, it must start at zero so there is no leftover value from a previous run:

gameState.score = 0;

Increment the score whenever the player performs a positive action. This might happen inside a collision detection routine (the player's projectile hit an enemy), inside an item pickup check, or at a time-based interval (surviving for ten seconds earns bonus points). The key is that the increment happens in a logical, meaningful place — not randomly:

function handleEnemyHit() {
  gameState.score += 100;
  // Additional logic: remove enemy, play sound effect, etc.
}

You might also want tiered scoring — worth more points for harder enemies, or bonus points for hitting targets quickly. This is simply a matter of passing the point value as a parameter or using conditional logic:

function awardPoints(baseValue, comboMultiplier) {
  gameState.score += baseValue * comboMultiplier;
}

Finally, the score must be drawn on the Canvas every frame. Because the Canvas is a pixel surface and not a DOM document, drawing text once does not make it persist — the next frame's clearRect call wipes it away. Redrawing the score each frame ensures the display always reflects the current value:

function renderScore(ctx) {
  ctx.fillStyle = 'white';
  ctx.font = '20px Arial';
  ctx.fillText('Score: ' + gameState.score, 10, 30);
}

Call renderScore at the end of your render function, after clearing the canvas, so the score appears on top of all other drawn elements.

Collision Detection

Collision detection is the process of determining whether two game objects occupy overlapping space. It is one of the most computationally active parts of any game loop, because it must run every single frame for every relevant pair of objects. Choosing the right approach depends on the shape of your objects.

For rectangular objects (the most common case in simple games), the algorithm is called Axis-Aligned Bounding Box (AABB) detection. Two rectangles overlap if and only if they overlap on both the X axis and the Y axis at the same time. If they are separated on even one axis, they do not collide. The logic checks whether one rectangle's right edge is past the other's left edge, and vice versa, on both axes:

function rectsOverlap(a, b) {
  // a and b each have: x, y, width, height
  return (
    a.x < b.x + b.width  &&
    a.x + a.width > b.x  &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  );
}

Walk through an example to build intuition. Suppose object A is at position (50, 50) with a width and height of 40, and object B is at position (80, 70) with a width and height of 40:

Check Calculation Result
A's left < B's right 50 < 80 + 40 = 120 true
A's right > B's left 50 + 40 = 90 > 80 true
A's top < B's bottom 50 < 70 + 40 = 110 true
A's bottom > B's top 50 + 40 = 90 > 70 true

All four conditions are true, so the rectangles overlap and a collision is detected.

For circular objects (balls, bubbles, planets), AABB is inaccurate because it treats circles as squares. Instead, measure the distance between the two centers and compare it to the sum of their radii. If the distance is less than or equal to the combined radii, the circles overlap:

function circlesOverlap(a, b) {
  // a and b each have: cx, cy, radius
  const dx = a.cx - b.cx;
  const dy = a.cy - b.cy;
  const distance = Math.sqrt(dx * dx + dy * dy);
  return distance <= a.radius + b.radius;
}

For performance, many developers avoid the square root step (which is expensive) by comparing the squared distance to the squared sum of radii:

function circlesOverlapFast(a, b) {
  const dx = a.cx - b.cx;
  const dy = a.cy - b.cy;
  const distanceSquared = dx * dx + dy * dy;
  const radiusSum = a.radius + b.radius;
  return distanceSquared <= radiusSum * radiusSum;
}

Regardless of which detection method you use, the collision function must be called inside the game loop every frame. Missing a frame means potentially missing a collision — especially for fast-moving objects that could pass completely through another object between frames:

function checkCollisions() {
  enemies.forEach(function(enemy) {
    if (rectsOverlap(gameState.player, enemy)) {
      handlePlayerHit();
    }

    projectiles.forEach(function(projectile) {
      if (rectsOverlap(projectile, enemy)) {
        handleEnemyHit(enemy, projectile);
      }
    });
  });
}

Win Conditions

A win condition is the specific state of the game in which the player has succeeded. It could be reaching a score threshold, defeating all enemies, reaching a goal position, surviving for a set time, or collecting all items. The most important thing about win conditions is that you must define them clearly before building the game, because the check must be woven into the game loop from the start.

Once you have defined the win condition, you evaluate it every frame by comparing current state variables against the target. The check is usually small and fast:

function checkWinCondition() {
  if (gameState.score >= 1000) {
    triggerWin();
  }

  if (enemies.length === 0) {
    triggerWin();
  }
}

When the condition is met, you stop the game loop and display a win screen. Stopping the loop means not calling requestAnimationFrame again. Displaying the win screen means drawing congratulatory text and shapes onto the Canvas using the same drawing API you use for everything else:

function triggerWin() {
  gameState.isRunning = false;

  // Draw a semi-transparent overlay
  ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Draw win message
  ctx.fillStyle = 'gold';
  ctx.font = 'bold 48px Arial';
  ctx.textAlign = 'center';
  ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2);

  ctx.fillStyle = 'white';
  ctx.font = '24px Arial';
  ctx.fillText('Final Score: ' + gameState.score, canvas.width / 2, canvas.height / 2 + 50);
}

Notice that triggerWin does not call requestAnimationFrame, so the loop stops naturally. If you want a "Play Again" button, you can render it on the Canvas and attach a click listener to the canvas element that checks whether the click coordinates land on the button area.

Loss Conditions and Lives

Loss conditions are the mirror image of win conditions. Where a win condition checks for achievement, a loss condition checks for failure. The most common failure mechanic is a lives system, where the player has a limited number of chances before the game ends.

Initialize the lives variable as part of the game state before the loop starts:

gameState.lives = 3;

Each time the player makes a critical mistake — gets hit by an enemy, falls off a platform, runs out of time — decrement the lives count:

function handlePlayerHit() {
  gameState.lives -= 1;

  if (gameState.lives <= 0) {
    gameState.isGameOver = true;
    triggerGameOver();
  } else {
    resetPlayerPosition(); // Give the player another chance
  }
}

Note the conditional logic: if lives remain, the player is repositioned and the game continues. Only when lives reach zero is the game over flag set and the game-over sequence triggered. This separation makes the lives system feel fair — the player experiences the consequence of a mistake without immediately losing the game.

Rendering the remaining lives on screen every frame keeps the player constantly informed. Lives can be displayed as a number, as repeated icons (hearts, ships, etc.), or as both:

function renderLives(ctx) {
  ctx.fillStyle = 'white';
  ctx.font = '20px Arial';
  ctx.fillText('Lives: ' + gameState.lives, canvas.width - 100, 30);

  // Optionally draw heart icons
  for (let i = 0; i < gameState.lives; i++) {
    ctx.fillStyle = 'red';
    ctx.beginPath();
    // Simplified: draw a red circle as a "life icon"
    ctx.arc(canvas.width - 90 + (i * 25), 55, 8, 0, Math.PI * 2);
    ctx.fill();
  }
}

The renderLives function is called every frame inside the main render function, just like the score display. Because the Canvas redraws from scratch every frame, the life icons always reflect the exact current value of gameState.lives.

Resetting and Restarting the Game

A well-implemented restart is the sign of a mature game architecture. When a player wants to play again after winning or losing, the game must restore every piece of state to its original value and restart the loop cleanly. Doing this poorly leads to scores that don't reset, enemies that don't reappear, or loops that run multiple times simultaneously (a common and confusing bug).

The solution is a dedicated resetGame() function that takes responsibility for the entire reset process:

function resetGame() {
  // Reset all state variables
  gameState.score = 0;
  gameState.lives = 3;
  gameState.isRunning = true;
  gameState.isGameOver = false;
  gameState.isPaused = false;
  gameState.level = 1;

  // Reposition the player
  gameState.playerX = 200;
  gameState.playerY = 400;

  // Rebuild enemy and item arrays
  enemies = createInitialEnemies();
  projectiles = [];
  collectibles = createInitialCollectibles();

  // Restart the loop
  requestAnimationFrame(gameLoop);
}

Every state variable goes back to its initial value. Every game object — player, enemies, projectiles, items — goes back to its starting position or is recreated from scratch. Only after all of that does the function call requestAnimationFrame(gameLoop) to start the loop again.

One subtle but important detail: you must ensure only one instance of the game loop is running at any time. If the player clicks "Restart" and requestAnimationFrame(gameLoop) is called while the previous loop is still running, two loops will execute concurrently, causing doubled movement speeds, doubled score increments, and chaotic behavior. Guard against this by checking or canceling any existing animation frame before starting a new one:

let animationFrameId = null;

function startLoop() {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  animationFrameId = requestAnimationFrame(gameLoop);
}

function gameLoop() {
  // ... all game logic ...
  animationFrameId = requestAnimationFrame(gameLoop);
}

By storing the ID returned by requestAnimationFrame and canceling it before starting a new loop, you guarantee that only one loop ever runs at once. This is a small but critical piece of defensive programming that prevents some of the hardest-to-debug game bugs.

Putting all of these systems together — a centralized state object, a scoring system that updates and renders every frame, frame-accurate collision detection, clearly defined win and loss conditions, a lives system with visual feedback, and a clean reset function — creates a game that is not just playable but maintainable and extensible. Adding a new enemy type, a new scoring rule, or a new win condition becomes straightforward because the architecture already has a defined place for each kind of information and each kind of change.

NotesStudents should pay close attention to the double-loop bug when restarting — it is one of the most common mistakes in Canvas game development. Encourage them to test the restart behavior explicitly by logging a counter inside the game loop to confirm only one loop is running. The AABB vs. circle collision distinction is also worth reinforcing with a visual diagram if the learning environment supports supplementary materials.