ScuffedSixSiege

Scuffed Six Siege is a top-down tactical shooter inspired by Rainbow Six Siege where players compete in an attack-vs-defend bomb-planting game mode. Teams spawn on opposite sides of a procedurally generated map with destructible walls, hidden rooms, and pathfinding AI opponents that scale difficulty by rank.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make bots super accurate — Set bot aim jitter to nearly 0 across all ranks, making them nearly impossible to escape from.
  2. Speed up bomb capture — Reduce plantTime to 1.5 seconds so attackers can capture in half the time, making the game easier for them.
  3. Fill the map with more rooms — Increase desiredRooms to 25, creating a maze-like level with many hiding spots and obstacles.
  4. Remove wood wall obstacles — Set desiredWoodWalls to 0 so no standalone cover exists—only rooms and doors.
  5. Make player start as defender — Change the random role selection to always assign the player to the defend team.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a complete, playable tactical shooter game with two teams—attackers trying to capture a bomb zone and defenders trying to stop them. The visual experience combines a top-down perspective, destructible wooden obstacles that reveal hidden areas when shot, a dynamic rank/difficulty system that scales bot intelligence, and special effects like camera shake and blood splatters on death. It is powered by pathfinding with BFS (breadth-first search), collision detection between bullets and obstacles, and a sophisticated difficulty system that adjusts bot speed, aim accuracy, and behavior based on rank.

The code is organized into distinct systems: a game state machine ('menu', 'weaponSelect', 'playing', 'roundOver'), a Soldier class for both player and AI with separate update logic, a navigation grid for smart bot movement, an obstacle generation system that creates non-overlapping rooms and doors with hidden fog regions, and UI elements that display rank, weapon info, and HUD overlays. By reading it you will learn how to architect a multi-state game, implement grid-based pathfinding for believable enemy movement, scale difficulty across many parameters, and create procedural level layouts that never overlap.

⚙️ How It Works

  1. When the sketch loads, setup() initializes the p5 canvas, calls setupBotDifficultyParams() to create 8 difficulty profiles (one per rank from Copper to Champion), and displays the home screen menu with the current rank and buttons to START GAME or CHOOSE WEAPON.
  2. If the player presses START, resetGame() is called: it generates a random map with non-overlapping room obstacles, wooden walls for cover, and wooden doors that hide foggy areas until destroyed. It spawns the player and 7 bot teammates on one side, spawns 4 bot opponents on the other, creates a bomb object near the map center, and builds a navigation grid for pathfinding.
  3. Every frame in the 'playing' state, the draw loop calls update(dt) on all soldiers: the player moves with WASD and aims/shoots with the mouse, while bots use findPath() to navigate to goals (bomb, enemies, flanking positions), spot targets within their engagement distance, and shoot with difficulty-scaled accuracy and fire rate.
  4. Bullets are checked for collisions with obstacles (which can be destroyed, opening new paths) and enemy soldiers every frame. When a wooden wall or door is hit, its hp decreases, and when hp reaches 0, it is destroyed, blocksMovement and blocksVision are set to false, and the navigation grid is rebuilt so bots can use the new opening.
  5. The bomb progresses toward capture when attackers are inside its radius and no defenders are present. When the bomb is 100% captured or all soldiers on one team die, the round ends, the rank is updated based on win/loss streaks, and the player can press R to play again or H to return to the menu.
  6. Throughout, the camera smoothly follows the player (or spectates a living teammate if the player dies), displays a HUD with team counts and role info, shows a compass arrow pointing to the bomb, renders blood splatters that fade over time, and shakes the camera when deaths occur.

🎓 Concepts You'll Learn

Game state machineProcedural obstacle generationGrid-based pathfinding (BFS)Collision detection (circle vs rect, bullet vs soldier)Difficulty scaling and rank progressionAI decision-making (target priority, engagement distance)Camera follow and smooth interpolationDestructible obstacles and dynamic navigation grid

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas and all global state needed before the game can run. No game objects (soldiers, obstacles, bullets) exist yet—those are created by resetGame().

function setup() {
  createCanvas(windowWidth, windowHeight);
  setupBotDifficultyParams();
  setupButtons();
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Canvas initialization createCanvas(windowWidth, windowHeight);

Creates a canvas that fills the entire browser window—all game visuals are drawn to this canvas

function-call Difficulty parameter initialization setupBotDifficultyParams();

Populates the BOT_DIFFICULTY_PARAMS array with 8 difficulty profiles, one for each rank, interpolating between Copper (easiest) and Champion (hardest)

function-call UI button creation setupButtons();

Creates the START GAME and CHOOSE WEAPON buttons that appear on the home screen

createCanvas(windowWidth, windowHeight);
Creates a p5 canvas matching the browser window size. All game visuals (soldiers, bullets, obstacles, HUD) are drawn to this canvas.
setupBotDifficultyParams();
Initializes the BOT_DIFFICULTY_PARAMS array with 8 entries (one per rank). Each entry stores speed, fire cooldown, aim jitter, repath timer, and engagement distance, all interpolated from Copper to Champion values.
setupButtons();
Creates the HTML buttons 'START GAME' and 'CHOOSE WEAPON' that the player clicks on the home screen menu.

draw()

draw() runs 60 times per second (or your monitor's refresh rate). It is the main event loop that handles state transitions (menu → weaponSelect → playing → roundOver), updates game logic with frame-time-independent physics (using dt), and renders everything to the canvas. The structure is a classic state machine: check which state we are in, take different actions, and only update game logic if we are actively playing.

🔬 This block is the heartbeat of the game—it updates soldiers, bullets, the bomb, and blood every frame. What happens if you comment out the entire 'if' block? (Hint: game logic stops, but what still renders?)

  if (gameState === "playing") {
    for (let s of soldiers) {
      s.update(dt);
    }
    updateBullets(dt);
    updateBomb(dt);
    updateBlood(dt);
  }
function draw() {
  if (gameState === "menu") {
    drawMenu();
    showHomeScreenButtons();
    hideWeaponSelectionButtons();
    return;
  } else if (gameState === "weaponSelect") {
    drawWeaponSelect();
    hideHomeScreenButtons();
    return;
  } else if (gameState === "roundOver") {
    hideHomeScreenButtons();
    hideWeaponSelectionButtons();
  } else {
    hideHomeScreenButtons();
    hideWeaponSelectionButtons();
  }

  let dt = deltaTime / 1000;
  dt = min(dt, 0.04);

  if (gameState === "playing") {
    for (let s of soldiers) {
      s.update(dt);
    }
    updateBullets(dt);
    updateBomb(dt);
    updateBlood(dt);
  } else {
    updateBlood(dt);
  }

  updateCamera(dt);
  drawWorld();
}
Line-by-line explanation (21 lines)

🔧 Subcomponents:

conditional Menu state handler if (gameState === "menu") {

Shows the home screen, displays buttons, and returns early—nothing else runs

conditional Weapon selection state handler } else if (gameState === "weaponSelect") {

Shows the weapon selection screen and hides home screen buttons

calculation Frame time clamping dt = min(dt, 0.04);

Prevents physics from breaking if a frame takes too long (e.g., lag spike)—caps dt at 40 milliseconds

conditional Active game update loop if (gameState === "playing") {

Updates all soldiers, bullets, bomb state, and blood effects each frame while the round is active

if (gameState === "menu") {
The game state machine starts here. If the player is on the home screen, we draw the menu and show buttons.
drawMenu();
Renders the title 'SCUFFED SIX SIEGE', rank info, rank list, and a decorative background grid.
showHomeScreenButtons();
Makes the START GAME and CHOOSE WEAPON buttons visible (they are hidden during gameplay).
hideWeaponSelectionButtons();
Hides any weapon selection buttons that may have been created (ensures they are not showing on the menu).
return;
Exits draw() early—no game update or world drawing happens while on the menu.
} else if (gameState === "weaponSelect") {
If the player is choosing a weapon, show the weapon selection screen instead.
drawWeaponSelect();
Renders the weapon selection menu with all available weapons for the current rank, showing their stats.
hideHomeScreenButtons();
Hides the START and CHOOSE WEAPON buttons since the player is in weapon selection.
return;
Exits draw() early—no game logic runs while selecting a weapon.
let dt = deltaTime / 1000;
Convert p5's deltaTime (in milliseconds) to seconds so physics calculations are frame-rate independent.
dt = min(dt, 0.04);
Clamp dt to a maximum of 40ms (0.04s). If the frame rate drops, this prevents large jumps that could break collision detection or move soldiers outside the map.
if (gameState === "playing") {
Only update game logic when actively playing a round (not on menu, weapon select, or round over screen).
for (let s of soldiers) {
Loop through every soldier (player and all bots). Each one updates its position, aims, and shoots independently.
s.update(dt);
Call update(dt) on each soldier, which runs updatePlayer() if it is the player or updateAI(dt) if it is a bot.
updateBullets(dt);
Move all bullets, check them against obstacles and soldiers for hits, and remove bullets that leave the map or hit something.
updateBomb(dt);
Check if attackers are in the bomb zone unopposed and increment the capture progress. When progress reaches 100%, the round ends.
updateBlood(dt);
Fade all blood splatters by reducing their alpha value. Remove splatters that become too transparent.
} else {
If the round is over or on a menu screen, still fade blood effects but do not update soldiers, bullets, or the bomb.
updateBlood(dt);
Blood continues to fade even when the round ends, creating a nice transition effect.
updateCamera(dt);
Smoothly move the camera to follow the player (or a living teammate if the player is dead). Apply camera shake if triggered by a death.
drawWorld();
Render the entire game world: background, obstacles, soldiers, bullets, blood, HUD, and bomb status.

resetGame()

resetGame() is called when the player clicks START on the home screen or presses R during/after a round. It wipes all game state and creates a fresh round: new map, new soldiers, new bomb position. The player's rank is NOT reset—it persists between rounds (stored in currentRankIndex), so winning rounds increases rank and losing rounds decreases it.

🔬 This places the bomb in the center 30%×30% area of the map. What happens if you change 0.35 and 0.65 to 0.1 and 0.9? (The bomb will spawn more randomly across the entire map—does strategy change?)

  const bombPos = findFreeSpot(
    MAP_W * 0.35, MAP_W * 0.65,
    MAP_H * 0.35, MAP_H * 0.65,
    60
  );
function resetGame() {
  soldiers = [];
  attackers = [];
  defenders = [];
  bullets = [];
  bloodSplatters = [];

  winnerTeam = null;
  gameState = "playing";
  cameraShakeTimer = 0;
  spectateIndex = 0;
  player = null;

  generateObstacles();

  const bombPos = findFreeSpot(
    MAP_W * 0.35, MAP_W * 0.65,
    MAP_H * 0.35, MAP_H * 0.65,
    60
  );
  bomb = {
    pos: bombPos.copy(),
    radius: 70,
    progress: 0,
    captured: false,
    plantTime: 3.5
  };

  playerTeam = random([TEAM_ATTACK, TEAM_DEFEND]);
  enemyTeam = (playerTeam === TEAM_ATTACK) ? TEAM_DEFEND : TEAM_ATTACK;

  if (playerTeam === TEAM_ATTACK) {
    spawnTeam(TEAM_ATTACK, ATTACKER_COUNT, true);
    spawnTeam(TEAM_DEFEND, DEFENDER_COUNT, false);
  } else {
    spawnTeam(TEAM_DEFEND, DEFENDER_COUNT, true);
    spawnTeam(TEAM_ATTACK, ATTACKER_COUNT, false);
  }

  cameraPos = player.pos.copy();
}
Line-by-line explanation (20 lines)

🔧 Subcomponents:

calculation Clear game object arrays soldiers = [];

Empty all arrays (soldiers, attackers, defenders, bullets, bloodSplatters) so the new round starts fresh

calculation Reset game state variables gameState = "playing";

Set the game state to 'playing' so the draw loop begins updating soldiers, bullets, and the bomb

function-call Generate the map generateObstacles();

Create all rooms, wooden walls, doors, and the navigation grid for this round

calculation Place the bomb bomb = {

Create a bomb object with position (found in a free area), radius, progress, and plant time

calculation Randomly assign player team playerTeam = random([TEAM_ATTACK, TEAM_DEFEND]);

Randomly decide if the player will be an attacker or defender this round

conditional Spawn both teams on opposite sides if (playerTeam === TEAM_ATTACK) {

Spawn the player's team and the enemy team in different areas of the map

soldiers = [];
Clear the soldiers array so no soldiers from the previous round exist. Both attackers and defenders will be re-added when teams are spawned.
attackers = [];
Clear the attackers array (a subset of soldiers used for faster targeting).
defenders = [];
Clear the defenders array.
bullets = [];
Clear the bullets array so no bullets from the previous round persist.
bloodSplatters = [];
Clear blood splatters so the canvas is visually clean at the start of a new round.
winnerTeam = null;
Reset the winner to null—no team has won yet this round.
gameState = "playing";
Set the game state to 'playing' so the draw loop will update soldiers, bullets, and the bomb each frame.
cameraShakeTimer = 0;
Stop any lingering camera shake from the previous round.
spectateIndex = 0;
Reset the spectate index so when the player dies, they will spectate the first living teammate.
player = null;
Clear the player reference—it will be set when spawnTeam() creates the player Soldier object.
generateObstacles();
Procedurally generate all rooms, wooden walls, and doors, ensuring they do not overlap. Also builds the navigation grid.
const bombPos = findFreeSpot(
Find a free position for the bomb in the center area of the map (between 35% and 65% of the map's width and height).
bomb = {
Create a new bomb object with the found position, a radius of 70 pixels, 0% capture progress, and a plant time of 3.5 seconds.
playerTeam = random([TEAM_ATTACK, TEAM_DEFEND]);
Randomly assign the player to either the attack or defend team. This keeps gameplay varied.
enemyTeam = (playerTeam === TEAM_ATTACK) ? TEAM_DEFEND : TEAM_ATTACK;
Set the enemy team to be the opposite of the player's team.
if (playerTeam === TEAM_ATTACK) {
If the player is attacking, spawn the attack team (with the player) first on the left side, then the defend team on the right.
spawnTeam(TEAM_ATTACK, ATTACKER_COUNT, true);
Spawn 4 attackers (including the player) on the left side of the map. The 'true' flag indicates that the player is in this team.
spawnTeam(TEAM_DEFEND, DEFENDER_COUNT, false);
Spawn 4 defenders on the right side of the map. The 'false' flag means no player is in this team—they are all bots.
} else {
If the player is defending, do the opposite: spawn defenders (with the player) on the right, then attackers on the left.
cameraPos = player.pos.copy();
Initialize the camera position to the player's starting position so the camera follows them from the start.

class Soldier()

The Soldier class is the core of the game. It separates player and bot behavior into updatePlayer() and updateAI(), allowing completely different logic for each. Bots use the navigation grid and difficulty parameters to feel intelligent without cheating. The movement system (move() + resolveCircleVsObstacles()) ensures soldiers stay within obstacles, and the shooting system (tryShootTowards() + fireBullet()) creates all bullets in the game. Understanding this class is key to understanding how the game works.

class Soldier {
  constructor(team, x, y, isPlayer) {
    this.team = team;
    this.pos = createVector(x, y);
    this.radius = SOLDIER_RADIUS;
    this.isPlayer = isPlayer;
    this.alive = true;

    if (isPlayer) {
      this.speed = PLAYER_SPEED;
      this.selectedWeapon = WEAPONS.find(w => w.name === 'Pistol');
      this.shootCooldown = random(0.1, 0.3);
      this.engagementDist = Infinity;
      this.aimJitterStdDev = 0;
      this.repathTimerMin = 0;
      this.repathTimerMax = 0;
    } else {
      const difficulty = BOT_DIFFICULTY_PARAMS[currentRankIndex];
      this.speed = difficulty.speed;
      this.shootCooldown = random(difficulty.fireCooldownMin, difficulty.fireCooldownMax);
      this.engagementDist = difficulty.engagementDist;
      this.aimJitterStdDev = difficulty.aimJitterStdDev;
      this.repathTimerMin = difficulty.repathTimerMin;
      this.repathTimerMax = difficulty.repathTimerMax;
      this.botBulletSpeed = 750;
      this.botBulletCount = 1;
      this.botSpread = 0.03;
    }

    this.angle = (team === TEAM_ATTACK) ? 0 : PI;

    this.path = null;
    this.pathIndex = 0;
    this.repathTimer = random(this.repathTimerMin, this.repathTimerMax);
  }

  update(dt) {
    if (!this.alive) return;
    if (this.isPlayer) {
      this.updatePlayer(dt);
    } else {
      this.updateAI(dt);
    }
  }

  updatePlayer(dt) {
    let dx = 0;
    let dy = 0;

    if (keyIsDown(87) || keyIsDown(UP_ARROW)) dy -= 1;
    if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) dy += 1;
    if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) dx -= 1;
    if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) dx += 1;

    const len = Math.hypot(dx, dy);
    if (len > 0) {
      dx /= len;
      dy /= len;
      this.move(dx * this.speed * dt, dy * this.speed * dt);
    }

    const worldMouse = screenToWorld(mouseX, mouseY);
    this.angle = Math.atan2(worldMouse.y - this.pos.y, worldMouse.x - this.pos.x);

    if (this.shootCooldown > 0) {
      this.shootCooldown -= dt;
    }

    if (mouseIsPressed && mouseButton === LEFT) {
      this.tryShootTowards(
        worldMouse.x, worldMouse.y,
        this.selectedWeapon.fireCooldown,
        this.selectedWeapon.bulletSpeed,
        this.selectedWeapon.bulletCount,
        this.selectedWeapon.spread
      );
    }
  }

  updateAI(dt) {
    if (this.shootCooldown > 0) {
      this.shootCooldown -= dt;
    }

    const enemies = (this.team === TEAM_ATTACK) ? defenders : attackers;
    let target = null;
    let minDist = this.engagementDist;
    for (let e of enemies) {
      if (!e.alive) continue;
      const d = p5.Vector.dist(this.pos, e.pos);
      if (d < minDist) {
        minDist = d;
        target = e;
      }
    }

    const distToBomb = bomb ? p5.Vector.dist(this.pos, bomb.pos) : Infinity;

    let moveTarget = null;

    if (this.team === TEAM_DEFEND) {
      if (bomb) {
        const guardRadius = bomb.radius * 1.4;
        if (distToBomb > guardRadius) {
          moveTarget = bomb.pos;
        } else if (target && minDist < this.engagementDist * 0.6 && hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
          moveTarget = target.pos;
        } else {
          moveTarget = bomb.pos;
        }
      }
    } else {
      if (!bomb) {
        if (target) moveTarget = target.pos;
      } else if (bomb.captured) {
        if (target) moveTarget = target.pos;
      } else {
        const enemiesInBomb = areEnemiesInBombZone(TEAM_ATTACK);

        if (!enemiesInBomb) {
          moveTarget = bomb.pos;
        } else {
          if (target && minDist < this.engagementDist && hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
            moveTarget = target.pos;
          } else {
            if (random() < 0.05 * dt) {
              const randomOffset = p5.Vector.random2D().mult(random(bomb.radius * 0.8, bomb.radius * 1.8));
              let candidatePos = p5.Vector.add(bomb.pos, randomOffset);
              candidatePos = findFreeSpot(
                candidatePos.x - SOLDIER_RADIUS, candidatePos.x + SOLDIER_RADIUS,
                candidatePos.y - SOLDIER_RADIUS, candidatePos.y + SOLDIER_RADIUS,
                SOLDIER_RADIUS
              );
              moveTarget = candidatePos;
            } else {
              moveTarget = bomb.pos;
            }
          }
        }
      }
    }

    if (moveTarget) {
      this.followPathTowards(moveTarget, dt);
    }

    if (target) {
      const tx = target.pos.x - this.pos.x;
      const ty = target.pos.y - this.pos.y;
      this.angle = Math.atan2(ty, tx);

      if (minDist < this.engagementDist && this.shootCooldown <= 0) {
        if (hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
          const baseAngle = this.angle;
          const aimJitter = randomGaussian(0, this.aimJitterStdDev);
          const shotAngle = baseAngle + aimJitter;
          const nx = Math.cos(shotAngle);
          const ny = Math.sin(shotAngle);
          this.fireBullet(nx, ny, this.botBulletSpeed, this.botBulletCount, this.botSpread);
          this.shootCooldown = random(this.fireCooldownMin, this.fireCooldownMax);
        }
      }
    }
  }

  followPathTowards(targetPos, dt) {
    if (!targetPos) return;

    if (navGrid.length === 0) {
      const vx = targetPos.x - this.pos.x;
      const vy = targetPos.y - this.pos.y;
      const len = Math.hypot(vx, vy);
      if (len > 5) {
        this.move((vx / len) * this.speed * dt, (vy / len) * this.speed * dt);
      }
      return;
    }

    this.repathTimer -= dt;

    const goalDist = p5.Vector.dist(this.pos, targetPos);
    const needNewPath =
      !this.path ||
      this.pathIndex >= this.path.length ||
      this.repathTimer <= 0 ||
      p5.Vector.dist(this.path[this.path.length - 1], targetPos) > NAV_CELL_SIZE * 1.5;

    if (needNewPath) {
      const newPath = findPath(this.pos, targetPos);
      if (newPath && newPath.length > 0) {
        this.path = newPath;
        this.pathIndex = 0;
      } else {
        this.path = null;
        this.pathIndex = 0;
      }
      this.repathTimer = random(this.repathTimerMin, this.repathTimerMax);
    }

    if (this.path && this.path.length > 0) {
      while (this.pathIndex < this.path.length) {
        const wp = this.path[this.pathIndex];
        const dx = wp.x - this.pos.x;
        const dy = wp.y - this.pos.y;
        const d = Math.hypot(dx, dy);

        if (d < 10 && this.pathIndex < this.path.length - 1) {
          this.pathIndex++;
          continue;
        }

        if (d > 2) {
          this.move((dx / d) * this.speed * dt, (dy / d) * this.speed * dt);
        }
        break;
      }
    } else {
      const vx = targetPos.x - this.pos.x;
      const vy = targetPos.y - this.pos.y;
      const len = Math.hypot(vx, vy);
      if (len > 5) {
        this.move((vx / len) * this.speed * dt, (vy / len) * this.speed * dt);
      }
    }
  }

  move(dx, dy) {
    this.pos.x += dx;
    resolveCircleVsObstacles(this);
    this.pos.x = constrain(this.pos.x, this.radius, MAP_W - this.radius);

    this.pos.y += dy;
    resolveCircleVsObstacles(this);
    this.pos.y = constrain(this.pos.y, this.radius, MAP_H - this.radius);
  }

  tryShootTowards(tx, ty, cooldown, bulletSpeed, bulletCount, spread) {
    if (this.shootCooldown > 0) return;
    const dirX = tx - this.pos.x;
    const dirY = ty - this.pos.y;
    const len = Math.hypot(dirX, dirY);
    if (len < 1) return;
    const baseNx = dirX / len;
    const baseNy = dirY / len;

    for (let i = 0; i < bulletCount; i++) {
      const bulletAngle = Math.atan2(baseNy, baseNx) + random(-spread, spread);
      const nx = Math.cos(bulletAngle);
      const ny = Math.sin(bulletAngle);
      this.fireBullet(nx, ny, bulletSpeed);
    }
    this.shootCooldown = cooldown;
  }

  fireBullet(nx, ny, bulletSpeed) {
    const startX = this.pos.x + nx * (this.radius + 6);
    const startY = this.pos.y + ny * (this.radius + 6);
    bullets.push(new Bullet(startX, startY, nx, ny, this.team, bulletSpeed));
  }

  draw() {
    push();
    translate(this.pos.x, this.pos.y);
    rotate(this.angle);

    noStroke();
    if (this.team === TEAM_ATTACK) {
      fill(220, 60, 60);
    } else {
      fill(60, 130, 255);
    }
    ellipse(0, 0, this.radius * 2, this.radius * 2);

    fill(40);
    arc(0, 0, this.radius * 2, this.radius * 2, -HALF_PI, HALF_PI);

    fill(15);
    rectMode(CENTER);
    rect(this.radius + 6, 0, 20, 4, 2);

    pop();
  }
}
Line-by-line explanation (62 lines)

🔧 Subcomponents:

function-call Soldier constructor constructor(team, x, y, isPlayer) {

Creates a new soldier with team, position, and difficulty parameters. If the soldier is a bot, it loads difficulty from the current rank.

conditional Update method dispatch if (this.isPlayer) {

Calls updatePlayer() for the player or updateAI(dt) for bots—two completely different movement and aiming behaviors

for-loop Player keyboard input handling if (keyIsDown(87) || keyIsDown(UP_ARROW)) dy -= 1;

Check four keys (W, A, S, D and arrow keys) and build a direction vector for movement

conditional Defender movement decision if (this.team === TEAM_DEFEND) {

Defenders strongly anchor to the bomb zone, only chasing enemies that get too close

conditional Attacker movement decision } else {

Attackers push toward the bomb if it is uncaptured, engage enemies blocking the zone, or hold/defend if they have captured

conditional Path-following and repath logic const needNewPath =

Determine if a new path is needed (old target, path completed, or repath timer expired) and find a fresh path if so

constructor(team, x, y, isPlayer) {
Constructor receives team ('attack' or 'defend'), starting position (x, y), and a boolean flag indicating if this is the player or a bot.
this.team = team;
Store which team this soldier belongs to—used to determine who can shoot them and who they target.
this.pos = createVector(x, y);
Create a p5 vector storing the soldier's world position. This is updated every frame during movement.
this.radius = SOLDIER_RADIUS;
Set the collision radius (14 pixels). Used for collision detection with obstacles and other soldiers.
if (isPlayer) {
If this is the human player, use player-specific settings (infinite engagement distance, no aim jitter, PLAYER_SPEED).
this.speed = PLAYER_SPEED;
Player moves at 180 pixels per second (defined in the constants).
this.selectedWeapon = WEAPONS.find(w => w.name === 'Pistol');
Player defaults to the Pistol. They can change this in the weapon selection screen (updated in gameState === 'weaponSelect').
this.aimJitterStdDev = 0;
Player has perfect aim by default (jitter is 0). The mouse direction is used directly without randomness.
} else {
If this is a bot, load all difficulty parameters from the current rank.
const difficulty = BOT_DIFFICULTY_PARAMS[currentRankIndex];
Fetch the difficulty profile for the current rank (Copper → Champion). This is a pre-calculated array of 8 profiles.
this.speed = difficulty.speed;
Bot speed scales from 120 (Copper) to 190 (Champion). Higher ranks move faster.
this.aimJitterStdDev = difficulty.aimJitterStdDev;
Bot aim accuracy scales inversely: Copper has high jitter (0.12 radians ≈ 6.9°), Champion has low jitter (0.03 ≈ 1.7°).
this.engagementDist = difficulty.engagementDist;
How far a bot can 'see' and shoot. Copper: 400px, Champion: 900px. This creates a meaningful skill difference.
this.botBulletSpeed = 750;
Bots use a fixed bullet speed (all bots shoot at the same speed regardless of weapon choice). Different from weapon-based player bullets.
this.repathTimer = random(this.repathTimerMin, this.repathTimerMax);
Initialize the repath timer so bots don't all recalculate paths on the same frame (spreads CPU load).
this.angle = (team === TEAM_ATTACK) ? 0 : PI;
Attackers start aiming right (angle 0), defenders start aiming left (angle π). This is just a visual default.
update(dt) {
Called once per frame. If the soldier is alive, dispatch to either updatePlayer() or updateAI().
if (!this.alive) return;
Dead soldiers do not update—they stay frozen in place until the round ends.
updatePlayer(dt) {
The player's update method. Reads keyboard input, aims at the mouse, and fires when left-click is held.
let dx = 0; let dy = 0;
Initialize direction components. They are set to -1, 0, or 1 based on which keys are held.
if (keyIsDown(87) || keyIsDown(UP_ARROW)) dy -= 1;
Check if W or the up arrow is pressed. If so, subtract 1 from dy (move up).
const len = Math.hypot(dx, dy);
Calculate the magnitude of the direction vector. If the player is moving diagonally (dx=1, dy=1), len will be ~1.41.
if (len > 0) {
Only move if a direction key is pressed (len > 0). Prevents moving when no keys are down.
dx /= len; dy /= len;
Normalize the direction vector so the player moves at the same speed in all directions (including diagonally).
this.move(dx * this.speed * dt, dy * this.speed * dt);
Call move() with a delta position (direction × speed × frame time). This moves the soldier and resolves collisions.
const worldMouse = screenToWorld(mouseX, mouseY);
Convert the screen mouse position to world coordinates (accounting for camera offset). Used to aim and determine shoot direction.
this.angle = Math.atan2(worldMouse.y - this.pos.y, worldMouse.x - this.pos.x);
Calculate the angle from the soldier to the mouse. Used to rotate the soldier's visual and aim direction.
if (mouseIsPressed && mouseButton === LEFT) {
If the left mouse button is held down, attempt to shoot toward the mouse position.
this.tryShootTowards(
Call tryShootTowards() with the weapon's fire cooldown, bullet speed, bullet count, and spread. If cooldown is 0, a bullet is created.
updateAI(dt) {
The bot's update method. Finds targets, decides where to move, and shoots autonomously based on difficulty settings.
const enemies = (this.team === TEAM_ATTACK) ? defenders : attackers;
If this bot is attacking, enemies are defenders. If this bot is defending, enemies are attackers.
let minDist = this.engagementDist;
Initialize minDist to the bot's engagement distance. This limits how far away a target can be to be considered 'in range'.
for (let e of enemies) {
Loop through all enemy soldiers and find the closest one that is alive.
if (d < minDist) {
If this enemy is closer than the previous closest, update the target and minDist.
if (this.team === TEAM_DEFEND) {
Defenders have a different strategy than attackers. They camp the bomb and only chase enemies that come close.
const guardRadius = bomb.radius * 1.4;
Defenders try to stay within a 'guard radius' around the bomb (1.4× the bomb's radius). If outside, move to the bomb.
if (distToBomb > guardRadius) {
If the defender is too far from the bomb, move toward it.
} else if (target && minDist < this.engagementDist * 0.6 && hasLineOfSight(...)) {
If an enemy is close enough AND visible, chase them. Otherwise, stay guarding the bomb.
} else {
Attacker logic. Attackers push toward the bomb unless enemies block them.
const enemiesInBomb = areEnemiesInBombZone(TEAM_ATTACK);
Check if any defenders are currently in the bomb zone. If so, attackers must fight them before capturing.
if (!enemiesInBomb) {
If no defenders are in the bomb zone, all attackers can safely move to the bomb to start capturing.
} else {
If defenders ARE in the bomb zone, attackers must engage them or reposition.
if (random() < 0.05 * dt) {
Occasionally (5% chance per frame), pick a flanking position near the bomb instead of rushing straight at it.
if (moveTarget) {
If a movement target was set (by defender or attacker logic), call followPathTowards() to navigate to it using pathfinding.
if (target) {
If a target enemy was found, aim at them and attempt to shoot (if cooldown is 0 and line-of-sight exists).
followPathTowards(targetPos, dt) {
Navigate toward a target position using precomputed paths from the BFS navigation grid. Falls back to direct movement if nav grid is unavailable.
this.repathTimer -= dt;
Countdown the repath timer. When it reaches 0, the bot will recalculate its path to account for changed obstacles or positions.
const needNewPath =
Determine if a new path is needed. Conditions: no path exists, reached the end of the current path, repath timer expired, or target moved too far.
if (needNewPath) {
If a new path is needed, call findPath() from current position to target position. This can be expensive, so do it only when necessary.
if (this.path && this.path.length > 0) {
If a valid path exists, follow it by moving toward waypoints. Skip waypoints that are close enough.
move(dx, dy) {
Update position by (dx, dy), resolve collisions with obstacles, and clamp to map boundaries. Called by updatePlayer() and followPathTowards().
this.pos.x += dx;
Update x position.
resolveCircleVsObstacles(this);
Push the soldier out of any obstacles they intersected with (based on their radius and the obstacle's rectangle).
this.pos.x = constrain(this.pos.x, this.radius, MAP_W - this.radius);
Clamp x to stay within map boundaries (leaving margin equal to the soldier's radius so they cannot go off-screen).
tryShootTowards(tx, ty, cooldown, bulletSpeed, bulletCount, spread) {
Attempt to shoot toward a target. If cooldown is > 0, return without shooting. Otherwise, create bullet(s) and set cooldown.
if (this.shootCooldown > 0) return;
If the soldier is on cooldown, do not shoot.
for (let i = 0; i < bulletCount; i++) {
Create bulletCount bullets. For a pistol, this is 1. For a shotgun, this is 5. Each bullet has a slightly different angle (spread).
const bulletAngle = Math.atan2(baseNy, baseNx) + random(-spread, spread);
Add a random offset (spread) to the base angle. Higher spread = wider scatter (shotgun spread vs sniper precision).
fireBullet(nx, ny, bulletSpeed) {
Create a new Bullet object starting from the tip of the soldier's gun and moving in direction (nx, ny).
draw() {
Render the soldier as a circle (body), arc (helmet), and small rectangle (gun barrel). Rotated to match this.angle.
if (this.team === TEAM_ATTACK) {
Attackers are drawn in red (220, 60, 60). Defenders are drawn in blue (60, 130, 255).
fill(40); arc(0, 0, this.radius * 2, this.radius * 2, -HALF_PI, HALF_PI);
Draw a dark arc to represent a helmet on top of the body.

generateObstacles()

generateObstacles() is the procedural level generator. It creates a unique map every round by iteratively placing non-overlapping rectangles. Each type of obstacle (rooms, walls, doors) is generated in sequence and checked for collisions with all previously-placed obstacles. The result is a tactical map with natural corridors and cover positions. This is a classic "recursive backtracking" or "constraint satisfaction" approach: propose, check constraints, accept or reject. The final step (buildNavGrid()) converts these visual obstacles into a navigation grid for AI pathfinding.

🔬 These lines generate a random room size and position. What happens if you change the size range from (260, 420) to (100, 300)? (The rooms will be smaller, creating a more cramped, busy map.)

    const w = random(260, 420);
    const h = random(260, 420);
    const x = random(margin, MAP_W - margin - w);
    const y = random(margin, MAP_H - margin - h);
function generateObstacles() {
  obstacles = [];
  rooms = [];
  woodWalls = [];
  doors = [];

  const margin = 160;
  const desiredRooms = 14;
  const maxAttempts = desiredRooms * 20;
  const padding = 30;

  let attempts = 0;
  while (rooms.length < desiredRooms && attempts < maxAttempts) {
    attempts++;

    const w = random(260, 420);
    const h = random(260, 420);
    const x = random(margin, MAP_W - margin - w);
    const y = random(margin, MAP_H - margin - h);
    const candidate = { x, y, w, h };

    let overlaps = false;
    for (let other of rooms) {
      if (rectsOverlap(candidate, other, padding)) {
        overlaps = true;
        break;
      }
    }

    if (!overlaps) {
      const room = {
        x,
        y,
        w,
        h,
        kind: "room",
        blocksMovement: true,
        blocksVision: true,
        destroyed: false
      };
      rooms.push(room);
      obstacles.push(room);
    }
  }

  const desiredWoodWalls = 12;
  let wallAttempts = 0;
  while (woodWalls.length < desiredWoodWalls && wallAttempts < desiredWoodWalls * 25) {
    wallAttempts++;

    const thickness = 18;
    const horizontal = random() < 0.5;
    let w, h;
    if (horizontal) {
      w = random(120, 260);
      h = thickness;
    } else {
      w = thickness;
      h = random(120, 260);
    }

    const x = random(80, MAP_W - 80 - w);
    const y = random(80, MAP_H - 80 - h);
    const candidate = { x, y, w, h };

    let overlapsAny = false;
    for (let obs of obstacles) {
      if (rectsOverlap(candidate, obs, 24)) {
        overlapsAny = true;
        break;
      }
    }
    if (overlapsAny) continue;

    const wall = {
      x,
      y,
      w,
      h,
      kind: "woodWall",
      blocksMovement: true,
      blocksVision: true,
      destroyed: false,
      hp: 3
    };
    woodWalls.push(wall);
    obstacles.push(wall);
  }

  for (let room of rooms) {
    if (random() > 0.55) continue;

    const doorLen = 60;
    const doorThickness = 18;
    const horizontal = random() < 0.5;

    let dx, dy, dw, dh, side;
    if (horizontal) {
      dw = doorLen;
      dh = doorThickness;
      side = random() < 0.5 ? "top" : "bottom";
      dx = room.x + (room.w - dw) / 2;
      dy = (side === "top") ? (room.y - dh - 6) : (room.y + room.h + 6);
    } else {
      dw = doorThickness;
      dh = doorLen;
      side = random() < 0.5 ? "left" : "right";
      dx = (side === "left") ? (room.x - dw - 6) : (room.x + room.w + 6);
      dy = room.y + (room.h - dh) / 2;
    }

    const candidate = { x: dx, y: dy, w: dw, h: dh };

    let overlapsAny = false;
    for (let obs of obstacles) {
      if (rectsOverlap(candidate, obs, 16)) {
        overlapsAny = true;
        break;
      }
    }
    if (overlapsAny) continue;

    let fogRect;
    const fogThickness = 260;

    if (horizontal) {
      if (side === "top") {
        fogRect = {
          x: dx - 140,
          y: dy - fogThickness,
          w: dw + 280,
          h: fogThickness
        };
      } else {
        fogRect = {
          x: dx - 140,
          y: dy + dh,
          w: dw + 280,
          h: fogThickness
        };
      }
    } else {
      if (side === "left") {
        fogRect = {
          x: dx - fogThickness,
          y: dy - 140,
          w: fogThickness,
          h: dh + 280
        };
      } else {
        fogRect = {
          x: dx + dw,
          y: dy - 140,
          w: fogThickness,
          h: dh + 280
        };
      }
    }

    fogRect.x = max(0, fogRect.x);
    fogRect.y = max(0, fogRect.y);
    if (fogRect.x + fogRect.w > MAP_W) {
      fogRect.w = MAP_W - fogRect.x;
    }
    if (fogRect.y + fogRect.h > MAP_H) {
      fogRect.h = MAP_H - fogRect.y;
    }

    const door = {
      x: dx,
      y: dy,
      w: dw,
      h: dh,
      kind: "door",
      blocksMovement: true,
      blocksVision: true,
      destroyed: false,
      hp: 3,
      fogRect
    };

    doors.push(door);
    obstacles.push(door);
  }

  buildNavGrid();
}
Line-by-line explanation (38 lines)

🔧 Subcomponents:

while-loop Non-overlapping room generation while (rooms.length < desiredRooms && attempts < maxAttempts) {

Create 14 large rectangular rooms, each at least 30 pixels apart (padding), until the limit is reached or max attempts exceeded

for-loop Collision check with existing rooms for (let other of rooms) {

Check if the candidate room overlaps with any already-placed room (with padding). If so, reject and try again.

while-loop Standalone wooden wall generation while (woodWalls.length < desiredWoodWalls && wallAttempts < desiredWoodWalls * 25) {

Create 12 destructible wooden walls as cover in open corridors, each oriented horizontally or vertically

for-loop Door placement near rooms for (let room of rooms) {

For each room, randomly place a door on one of its four sides (top, bottom, left, right) with an attached fog region

conditional Fog region behind door if (horizontal) {

Calculate a 260-pixel-deep fogRect behind the door that hides the area until the door is destroyed

function generateObstacles() {
Called by resetGame() to create the random map layout for the round. Generates rooms, wooden walls, and doors.
obstacles = [];
Clear the obstacles array (all colliders). Will be repopulated with rooms, walls, and doors.
rooms = [];
Clear the rooms array (subset of obstacles). These are the large rectangular room obstacles.
const desiredRooms = 14;
Target number of rooms to generate. The algorithm will attempt up to 14×20=280 random placements to reach this number.
const padding = 30;
Minimum distance (in pixels) between room edges. Ensures rooms are separated so there are corridors to navigate.
while (rooms.length < desiredRooms && attempts < maxAttempts) {
Keep generating rooms until we reach 14 rooms or give up after 280 failed attempts.
const w = random(260, 420);
Generate a random width for the candidate room: between 260 and 420 pixels.
const h = random(260, 420);
Generate a random height: between 260 and 420 pixels. Rooms are roughly square-ish.
const x = random(margin, MAP_W - margin - w);
Choose a random x position, but leave a 160-pixel margin from the map edges. Ensures the room fits on the map.
const candidate = { x, y, w, h };
Create a candidate rectangle. Will be checked for overlaps before being added.
let overlaps = false;
Assume the candidate does not overlap. Will be set to true if any existing room overlaps with it.
for (let other of rooms) {
Loop through all already-placed rooms.
if (rectsOverlap(candidate, other, padding)) {
Check if the candidate overlaps with this room (considering the padding gap). If so, mark overlaps = true and reject the candidate.
if (!overlaps) {
If the candidate does not overlap with any room, add it to the map.
const room = {
Create a room obstacle object with blocksMovement and blocksVision set to true (you cannot pass through or see through rooms).
const desiredWoodWalls = 12;
Target number of standalone wooden walls. These are smaller, destructible obstacles placed in corridors as cover.
const thickness = 18;
Wooden walls are thin (18 pixels). If horizontal, they are 120–260 pixels wide and 18 pixels tall.
const horizontal = random() < 0.5;
Randomly choose if the wall is horizontal (50% chance) or vertical (50% chance).
let overlapsAny = false;
Check if the candidate wall overlaps with ANY obstacle (rooms or already-placed walls).
for (let obs of obstacles) {
Loop through all existing obstacles (rooms and walls).
if (rectsOverlap(candidate, obs, 24)) {
Check overlap with a 24-pixel padding. Walls need space around them so they are not placed directly against rooms.
const wall = {
Create a wall obstacle. Unlike rooms, walls have hp=3, meaning they can be destroyed.
for (let room of rooms) {
For each room, randomly place a door on one of its four sides.
if (random() > 0.55) continue;
Only 45% of rooms get doors. The remaining 55% are sealed. This adds variety—some rooms are trapped.
const doorLen = 60;
Doors are 60 pixels long (either width or height depending on orientation).
const horizontal = random() < 0.5;
Randomly choose if the door is on the top/bottom (horizontal) or left/right (vertical) side of the room.
if (horizontal) {
If the door is on the top or bottom of the room, it is 60 pixels wide and 18 pixels tall.
side = random() < 0.5 ? "top" : "bottom";
Randomly choose if the door is on the top or bottom side.
dx = room.x + (room.w - dw) / 2;
Center the door horizontally within the room's width.
dy = (side === "top") ? (room.y - dh - 6) : (room.y + room.h + 6);
Place the door slightly above the top (or below the bottom) of the room. The -6 and +6 offsets create a small gap.
let fogRect;
A dark rectangle that hides the area behind the door until the door is destroyed. Prevents the player from seeing inside a sealed room.
const fogThickness = 260;
The fog region extends 260 pixels perpendicular to the door (into the room).
if (horizontal) {
If the door is on top/bottom, the fog extends upward/downward (perpendicular to the room's interior).
y: dy - fogThickness,
If the door is on top, the fog starts at 'dy - 260' (260 pixels above the door), covering the area inside the room.
fogRect.x = max(0, fogRect.x);
Clamp the fog rectangle to the map boundaries so it does not go off-screen.
const door = {
Create a door obstacle. Doors have hp=3 (destructible), blocksMovement and blocksVision (until destroyed), and a fogRect.
doors.push(door);
Add the door to the doors array and also to obstacles.
buildNavGrid();
After all obstacles are placed, convert them into a grid for BFS pathfinding. Bots use this grid to navigate around obstacles.

findPath(startPos, targetPos)

findPath() implements BFS (Breadth-First Search), a classic pathfinding algorithm. BFS explores the grid one layer at a time, guaranteeing the shortest path. It is fast enough for real-time games because the grid is coarse (70-pixel cells). The algorithm converts 2D grid coordinates to 1D array indices for efficient storage and uses a 'prev' array to reconstruct the path by backtracking. This is the foundation of bot navigation: they compute a path once, follow waypoints, and recompute only when the target moves far away (repath timer).

🔬 This array defines four directions (up, down, left, right). What if you add diagonal directions like { dx: 1, dy: 1 }? (Bots can then move diagonally and find shorter paths, but may get stuck on diagonal corners.)

  const dirs = [
    { dx: 1, dy: 0 },
    { dx: -1, dy: 0 },
    { dx: 0, dy: 1 },
    { dx: 0, dy: -1 }
  ];
function findPath(startPos, targetPos) {
  if (navGrid.length === 0) return null;

  const startCell = worldToCell(startPos);
  const goalCell = worldToCell(targetPos);
  const startIdx = cellIndex(startCell.cx, startCell.cy);
  const goalIdx = cellIndex(goalCell.cx, goalCell.cy);

  const totalCells = navCols * navRows;
  const visited = new Array(totalCells).fill(false);
  const prev = new Array(totalCells).fill(-1);

  const queue = [];
  queue.push(startIdx);
  visited[startIdx] = true;

  const dirs = [
    { dx: 1, dy: 0 },
    { dx: -1, dy: 0 },
    { dx: 0, dy: 1 },
    { dx: 0, dy: -1 }
  ];

  let found = false;

  while (queue.length > 0) {
    const current = queue.shift();
    if (current === goalIdx) {
      found = true;
      break;
    }

    const cx = current % navCols;
    const cy = floor(current / navCols);

    for (let d of dirs) {
      const nx = cx + d.dx;
      const ny = cy + d.dy;
      if (nx < 0 || ny < 0 || nx >= navCols || ny >= navRows) continue;
      const ni = cellIndex(nx, ny);
      if (visited[ni]) continue;

      if (navGrid[ni] === 1 && ni !== goalIdx) continue;

      visited[ni] = true;
      prev[ni] = current;
      queue.push(ni);
    }
  }

  if (!found) return null;

  const cells = [];
  let cur = goalIdx;
  while (cur !== startIdx) {
    cells.push(cur);
    cur = prev[cur];
    if (cur === -1) return null;
  }
  cells.reverse();

  const path = cells.map(i => {
    const cx = i % navCols;
    const cy = floor(i / navCols);
    return cellCenter(cx, cy);
  });

  return path;
}
Line-by-line explanation (40 lines)

🔧 Subcomponents:

calculation Convert world positions to grid indices const startIdx = cellIndex(startCell.cx, startCell.cy);

Convert the start and goal world positions into 1D array indices in the navGrid

calculation Initialize visited and parent tracking const visited = new Array(totalCells).fill(false);

Create arrays to track which cells have been visited and which cell led to each cell (for path reconstruction)

while-loop BFS breadth-first search while (queue.length > 0) {

Dequeue cells, check if the goal is reached, expand to unvisited neighbors, and repeat until a path is found or the queue is empty

for-loop Check all four cardinal neighbors for (let d of dirs) {

Try moving up, down, left, right from the current cell. Skip boundaries and already-visited cells.

conditional Obstacle blocking if (navGrid[ni] === 1 && ni !== goalIdx) continue;

Skip blocked cells (navGrid[ni] === 1 means obstacle), but allow stepping onto the goal even if it is blocked (a soldier might be there)

while-loop Reconstruct path from goal to start while (cur !== startIdx) {

Backtrack from the goal using the prev array to build the path, then reverse it to go from start to goal

calculation Convert grid indices to world waypoints const path = cells.map(i => {

Convert each grid cell index back to a world position (the center of the cell) for the soldier to navigate to

function findPath(startPos, targetPos) {
Finds a path from a start world position to a target world position using BFS on the navigation grid. Returns an array of waypoint positions, or null if no path exists.
if (navGrid.length === 0) return null;
If the navigation grid has not been built yet, return null (no path possible).
const startCell = worldToCell(startPos);
Convert the start world position (x, y) into a grid cell (cx, cy) using the NAV_CELL_SIZE.
const goalCell = worldToCell(targetPos);
Convert the target world position into a grid cell.
const startIdx = cellIndex(startCell.cx, startCell.cy);
Convert the 2D cell coordinates (cx, cy) into a 1D array index. The formula is cy * navCols + cx.
const goalIdx = cellIndex(goalCell.cx, goalCell.cy);
Convert the goal cell coordinates into a 1D array index.
const totalCells = navCols * navRows;
Calculate the total number of cells in the grid (e.g., 37×37 ≈ 1369 cells for a 2600×2600 map with 70-pixel cells).
const visited = new Array(totalCells).fill(false);
Create an array to track which cells have been visited (explored) during the search. Initially all false.
const prev = new Array(totalCells).fill(-1);
Create an array to store the parent cell of each cell. Used to reconstruct the path once the goal is found.
const queue = [];
Create a queue for BFS. Cells are added to the back and removed from the front (FIFO order).
queue.push(startIdx);
Add the start cell to the queue as the first cell to explore.
visited[startIdx] = true;
Mark the start cell as visited so it is not explored again.
const dirs = [
Define the four cardinal directions (up, down, left, right). BFS expands in these directions only (no diagonals).
while (queue.length > 0) {
Continue the BFS loop as long as there are cells in the queue. If the queue empties without finding the goal, no path exists.
const current = queue.shift();
Remove the first cell from the queue (FIFO). This is the cell to explore next.
if (current === goalIdx) {
If the current cell is the goal, we have found a path. Break out of the loop.
const cx = current % navCols;
Convert the 1D cell index back to 2D coordinates. cx = index % width.
const cy = floor(current / navCols);
cy = index / width (integer division).
for (let d of dirs) {
Check all four neighbors of the current cell.
const nx = cx + d.dx;
Calculate the neighbor's x coordinate (cx + 1 for right, cx - 1 for left, etc.).
const ny = cy + d.dy;
Calculate the neighbor's y coordinate.
if (nx < 0 || ny < 0 || nx >= navCols || ny >= navRows) continue;
If the neighbor is outside the grid boundaries, skip it.
const ni = cellIndex(nx, ny);
Convert the neighbor's 2D coordinates to a 1D index.
if (visited[ni]) continue;
If the neighbor has already been visited, skip it (avoid revisiting cells).
if (navGrid[ni] === 1 && ni !== goalIdx) continue;
If the neighbor is blocked (navGrid[ni] === 1), skip it UNLESS it is the goal cell (we allow stepping onto obstacles if they are the target).
visited[ni] = true;
Mark the neighbor as visited.
prev[ni] = current;
Record that the current cell is the parent of this neighbor. Used to reconstruct the path later.
queue.push(ni);
Add the neighbor to the queue so it is explored next (FIFO order ensures BFS explores cells in layers).
if (!found) return null;
If the goal was never reached, no path exists. Return null.
const cells = [];
Create an array to store the path as a sequence of cell indices.
let cur = goalIdx;
Start backtracking from the goal cell.
while (cur !== startIdx) {
Follow parent pointers from the goal back to the start.
cells.push(cur);
Add the current cell to the path.
cur = prev[cur];
Move to the parent cell.
if (cur === -1) return null;
Safety check: if the parent is -1, the path is broken. Return null.
cells.reverse();
Reverse the path so it goes from start to goal instead of goal to start.
const path = cells.map(i => {
Convert each cell index to a world position (the center of the cell).
const cx = i % navCols;
Recover the cell's 2D coordinates from the 1D index.
return cellCenter(cx, cy);
Return the world-space center of this cell (used as a waypoint).
return path;
Return the path as an array of world waypoints. The soldier will move through these points in order.

updateBomb(dt)

updateBomb() implements the capture mechanic of Siege-style bomb modes. The bomb's progress is a float from 0 to 1. It increments only when attackers are alone in the zone (unopposed), and it decays when defenders are present. This creates a natural ebb-and-flow of battles: attackers push in, defenders rush back, progress regresses, and the cycle repeats. The asymmetry (3.5 seconds to capture, but decay at 1.5× speed) slightly favors defenders holding the zone, which is intentional game balance.

function updateBomb(dt) {
  if (!bomb || bomb.captured) return;

  let attackersNear = 0;
  let defendersNear = 0;

  for (let a of attackers) {
    if (!a.alive) continue;
    const d = p5.Vector.dist(a.pos, bomb.pos);
    if (d <= bomb.radius) attackersNear++;
  }
  for (let d of defenders) {
    if (!d.alive) continue;
    const dd = p5.Vector.dist(d.pos, bomb.pos);
    if (dd <= bomb.radius) defendersNear++;
  }

  if (attackersNear > 0 && defendersNear === 0) {
    bomb.progress += dt / bomb.plantTime;
  } else if (bomb.progress > 0 && (attackersNear === 0 || defendersNear > 0)) {
    bomb.progress -= dt / (bomb.plantTime * 1.5);
  }

  bomb.progress = constrain(bomb.progress, 0, 1);

  if (bomb.progress >= 1) {
    bomb.captured = true;
    triggerCameraShake();
    checkRoundEnd();
  }
}
Line-by-line explanation (18 lines)

🔧 Subcomponents:

for-loop Count attackers and defenders in bomb zone for (let a of attackers) {

Check how many attackers and defenders are currently inside the bomb's radius

conditional Increase progress when attackers are alone if (attackersNear > 0 && defendersNear === 0) {

If at least one attacker is in the zone and no defenders are present, increment the capture progress

conditional Decrease progress when contested or abandoned } else if (bomb.progress > 0 && (attackersNear === 0 || defendersNear > 0)) {

If attackers leave or defenders arrive, the progress decays (faster decay rate: 1.5× speedup)

conditional Check if bomb is fully captured if (bomb.progress >= 1) {

When progress reaches 100%, the bomb is captured, the round ends, and the attack team wins

function updateBomb(dt) {
Called every frame during gameplay. Updates the bomb's capture progress based on how many soldiers are in the zone.
if (!bomb || bomb.captured) return;
If the bomb does not exist or is already captured, do nothing.
let attackersNear = 0;
Counter for how many alive attackers are inside the bomb's radius.
let defendersNear = 0;
Counter for how many alive defenders are inside the bomb's radius.
for (let a of attackers) {
Loop through all attackers.
if (!a.alive) continue;
Skip dead attackers.
const d = p5.Vector.dist(a.pos, bomb.pos);
Calculate the distance from this attacker to the bomb center.
if (d <= bomb.radius) attackersNear++;
If the distance is within the bomb's radius (70 pixels), increment the counter.
for (let d of defenders) {
Do the same for defenders.
if (attackersNear > 0 && defendersNear === 0) {
If at least one attacker is in the zone AND no defenders are in the zone, the bomb is being captured.
bomb.progress += dt / bomb.plantTime;
Increment progress. bomb.plantTime is 3.5 seconds, so it takes 3.5 seconds to reach 100% (progress = 1.0). The increment is dt / 3.5 per frame.
} else if (bomb.progress > 0 && (attackersNear === 0 || defendersNear > 0)) {
If attackers leave OR defenders arrive, progress decays (regresses).
bomb.progress -= dt / (bomb.plantTime * 1.5);
Progress decreases at 1.5× the rate it increases. This means if defenders contest, the capture takes longer and can be interrupted.
bomb.progress = constrain(bomb.progress, 0, 1);
Clamp progress to the range [0, 1]. It cannot go below 0% or above 100%.
if (bomb.progress >= 1) {
When progress reaches 100%, the bomb is captured.
bomb.captured = true;
Set the captured flag to true. This stops progress from updating further.
triggerCameraShake();
Shake the camera to celebrate the successful capture.
checkRoundEnd();
Check if the round should end. Since the bomb is captured, attackers win.

drawWorld()

drawWorld() is the main rendering function. It uses a classic camera pattern: push() to save the transformation, translate() to move the world based on cameraPos, render all world objects, pop() to restore the transformation, and render screen-space HUD last. The order of drawing matters: objects drawn later appear on top. Fog must be drawn after soldiers so it hides them; HUD must be drawn after pop() so it is not affected by the camera transform.

function drawWorld() {
  background(18, 18, 24);

  const shake = getCameraShakeOffset();

  push();
  translate(width / 2 - cameraPos.x + shake.x, height / 2 - cameraPos.y + shake.y);

  noStroke();
  fill(40);
  rectMode(CORNER);
  rect(0, 0, MAP_W, MAP_H);

  drawBomb();
  drawObstacles();
  drawBlood();

  for (let b of bullets) {
    b.draw();
  }

  for (let s of soldiers) {
    if (s.alive) s.draw();
  }

  drawDoorFog();

  pop();

  drawHUD();
}
Line-by-line explanation (18 lines)

🔧 Subcomponents:

calculation Camera transformation translate(width / 2 - cameraPos.x + shake.x, height / 2 - cameraPos.y + shake.y);

Translate the canvas so the camera position (cameraPos) is centered on the screen, plus any camera shake offset

function-call Draw world elements drawBomb();

Draw the bomb, obstacles, blood splatters, bullets, and soldiers to the world canvas (inside the camera transform)

function-call Draw fog of war drawDoorFog();

Draw dark fog rectangles behind closed doors (must be drawn AFTER soldiers so it appears on top)

function-call Draw HUD overlay drawHUD();

Draw the HUD after popping the camera transform (so HUD is in screen space, not world space)

function drawWorld() {
Called once per frame during gameplay. Renders the entire game world and HUD.
background(18, 18, 24);
Clear the canvas with a dark blue color (RGB: 18, 18, 24). This happens every frame, so nothing persists.
const shake = getCameraShakeOffset();
Get a random camera shake offset (caused by deaths). This is a small random displacement added to the camera each frame when cameraShakeTimer > 0.
push();
Save the current transformation matrix (identity). This allows us to transform the world without affecting the HUD.
translate(width / 2 - cameraPos.x + shake.x, height / 2 - cameraPos.y + shake.y);
Transform the canvas so that cameraPos appears at the center of the screen. The formula: center the screen (width/2), subtract cameraPos to move the world, and add shake for camera shake effect.
noStroke();
Disable stroke for all shapes drawn in the world (obstacles, bomb, etc.).
fill(40);
Set the fill color to a dark gray (40, 40, 40). This is the floor color.
rect(0, 0, MAP_W, MAP_H);
Draw the entire map as a dark rectangle (2600×2600 pixels). This is the floor.
drawBomb();
Draw the bomb object (circle outline, progress arc, and icon).
drawObstacles();
Draw all rooms (dark rectangles), wooden walls (brown), and doors (tan rectangles).
drawBlood();
Draw all blood splatters (red circles fading over time).
for (let b of bullets) {
Loop through all bullets and draw them.
b.draw();
Each bullet draws itself as a small bright point (point()). Bullets are very fast, so they appear as streaks.
for (let s of soldiers) {
Loop through all soldiers and draw them.
if (s.alive) s.draw();
Only draw soldiers that are alive. Dead soldiers are removed from the visual scene.
drawDoorFog();
Draw dark fog rectangles behind closed doors. This must happen AFTER soldiers so the fog appears on top and hides what is behind the door.
pop();
Restore the transformation matrix. Now the HUD is drawn in screen space (not world space).
drawHUD();
Draw the HUD (text, status info, rank, weapon, compass arrow to bomb). This is in screen space, so it does not move with the camera.

📦 Key Variables

MAP_W, MAP_H number

Map dimensions (2600×2600 pixels). All soldiers, obstacles, and bullets are confined to this area.

const MAP_W = 2600; const MAP_H = 2600;
SOLDIER_RADIUS number

Collision radius for soldiers (14 pixels). Used in collision detection with obstacles and bullets.

const SOLDIER_RADIUS = 14;
PLAYER_SPEED number

How many pixels per second the player moves (180). Bots have difficulty-scaled speeds.

const PLAYER_SPEED = 180;
currentRankIndex number

Index into the RANKS array (0 = Copper, 7 = Champion). Persists between rounds and controls bot difficulty.

let currentRankIndex = 0;
consecutiveWins, consecutiveLosses number

Win and loss streaks. 3 wins promote rank; 2 losses demote rank. Reset on opposite outcome.

let consecutiveWins = 0; let consecutiveLosses = 0;
navGrid array

1D array representing the navigation grid. navGrid[index] = 0 (walkable) or 1 (blocked). Built from obstacles after map generation.

let navGrid = [];
soldiers, attackers, defenders array

Arrays of Soldier objects. 'soldiers' contains all, 'attackers' and 'defenders' are subsets for faster targeting.

let soldiers = []; let attackers = []; let defenders = [];
obstacles, rooms, woodWalls, doors array

Arrays of obstacle rectangles. 'obstacles' is the master array; others are subsets. Used for collision detection and rendering.

let obstacles = []; let rooms = []; let woodWalls = []; let doors = [];
bullets array

Array of Bullet objects. Each frame, bullets are updated (moved), collided with obstacles and soldiers, and drawn.

let bullets = [];
bomb object

The bomb object with properties: pos (position), radius (collision radius), progress (0–1 capture %), captured (boolean), plantTime (seconds to capture).

let bomb = { pos, radius: 70, progress: 0, captured: false, plantTime: 3.5 };
player Soldier

Reference to the player's Soldier object. null if the player is dead and spectating. Used for camera follow and input handling.

let player = null;
playerTeam, enemyTeam string

Either 'attack' or 'defend'. Determines which team the player is on and what the enemy team is.

let playerTeam = TEAM_ATTACK; let enemyTeam = TEAM_DEFEND;
gameState string

Current game state: 'menu', 'weaponSelect', 'playing', or 'roundOver'. Controls which screen is drawn and what logic runs.

let gameState = "menu";
cameraPos p5.Vector

The camera's world position. Used to render the correct portion of the world. Smoothly follows the player or a living teammate.

let cameraPos;
cameraShakeTimer, cameraShakeDuration, maxShakeMag number

Control camera shake effect. Triggered by deaths. Timer counts down from cameraShakeDuration, shake magnitude decreases as timer approaches 0.

let cameraShakeTimer = 0; let cameraShakeDuration = 0.35; let maxShakeMag = 12;
bloodSplatters array

Array of objects representing blood drops. Each has x, y, radius, and alpha. Alpha decreases each frame until the splatter is removed.

let bloodSplatters = [];

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

PERFORMANCE hasLineOfSight() function

The line-of-sight check samples 10+ points along every bot-to-target line every frame. With 8 bots checking 4+ enemies each, this is ~400 samples per frame—wasteful.

💡 Add early exit: if the straight-line distance to the target is >1000 pixels, skip the detailed check. Or cache line-of-sight results for 0.5 seconds per bot-target pair.

BUG Soldier.updateAI()

When an attacker picks a 'flanking position' near the bomb while defenders are in the zone, the randomOffset can place a candidate outside the map or deep in an obstacle. findFreeSpot() mitigates this but costs CPU.

💡 Pre-sample flanking positions as 'safe zones' around the bomb at map generation time, then bots can pick from this list instantly.

STYLE Soldier class

Player and bot use different bullet properties: player uses weapon.bulletSpeed/count/spread, bots use this.botBulletSpeed/count/spread. This inconsistency makes it hard to balance weapon vs bot difficulty.

💡 Store weapon stats in a normalized format for both. E.g., always use this.selectedWeapon for both player and bots, and apply difficulty multipliers (e.g., '* difficulty.speedScalar') when needed.

FEATURE Obstacle destruction

Wood walls and doors have hp=3, but there is no visual indicator of damage (e.g., cracks or color change) before they break. Players and bots can shoot blindly and not know if their shots are working.

💡 Draw obstacles with alpha or color that changes based on remaining hp. E.g., hp=3 is opaque, hp=2 is slightly transparent, hp=1 is very faded. This gives visual feedback.

BUG spawnTeam()

If findFreeSpot() cannot find a valid spawn position after 50 tries, it returns the center of the requested area. On a crowded map, this could place a soldier inside an obstacle or off-screen.

💡 If the fallback is used, log a warning and try expanding the search area or spreading team spawns over a larger region.

PERFORMANCE buildNavGrid()

buildNavGrid() calls isCircleFree() for every cell, and isCircleFree() loops through ALL obstacles. On a map with 14 rooms + 12 walls, this is ~1400 cells × ~26 obstacles ≈ 36,400 checks per rebuild.

💡 Cache room/wall/door rectangles in a spatial index (e.g., grid) so isCircleFree() only checks nearby obstacles, not all of them.

🔄 Code Flow

Code flow showing setup, draw, resetgame, soldier, generateobstacles, findpath, updatebombing, drawworld

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Initialization] setup --> difficulty-setup[Difficulty Parameter Initialization] setup --> button-setup[UI Button Creation] setup --> resetgame[resetGame] resetgame --> clear-arrays[Clear Game Object Arrays] resetgame --> reset-state[Reset Game State Variables] resetgame --> generate-map[Generate the Map] generate-map --> room-generation[Non-overlapping Room Generation] room-generation --> overlap-check[Collision Check with Existing Rooms] overlap-check --> room-generation generate-map --> wood-wall-generation[Standalone Wooden Wall Generation] wood-wall-generation --> wood-wall-generation generate-map --> door-generation[Door Placement Near Rooms] door-generation --> fog-rect-creation[Fog Region Behind Door] fog-rect-creation --> door-generation generate-map --> nav-grid-build[Build Navigation Grid] nav-grid-build --> index-conversion[Convert World Positions to Grid Indices] nav-grid-build --> visited-array[Initialize Visited and Parent Tracking] setup --> draw[draw loop] draw --> state-menu[Menu State Handler] state-menu --> draw draw --> state-weapon-select[Weapon Selection State Handler] state-weapon-select --> draw draw --> delta-time-clamp[Frame Time Clamping] draw --> game-loop[Active Game Update Loop] game-loop --> updatebombing[updateBomb] game-loop --> spawn-teams[Spawn Both Teams] spawn-teams --> assign-roles[Randomly Assign Player Team] spawn-teams --> constructor[Soldier Constructor] constructor --> update-dispatch[Update Method Dispatch] update-dispatch --> player-movement[Player Keyboard Input Handling] update-dispatch --> ai-target-search[Find Nearest Enemy in Engagement Range] ai-target-search --> defender-logic[Defender Movement Decision] ai-target-search --> attacker-logic[Attacker Movement Decision] game-loop --> pathfinding-core[Path-following and Repath Logic] pathfinding-core --> findpath[findPath] findpath --> bfs-loop[BFS Breadth-First Search] bfs-loop --> neighbor-expansion[Check All Four Cardinal Neighbors] neighbor-expansion --> bfs-loop bfs-loop --> obstacle-check[Obstacle Blocking] obstacle-check --> bfs-loop bfs-loop --> path-reconstruction[Reconstruct Path from Goal to Start] path-reconstruction --> waypoint-conversion[Convert Grid Indices to World Waypoints] game-loop --> world-drawing[Draw World Elements] world-drawing --> camera-setup[Camera Transformation] world-drawing --> fog-drawing[Draw Fog of War] fog-drawing --> world-drawing draw --> hud-drawing[Draw HUD Overlay] hud-drawing --> draw draw --> win-check[Check if Bomb is Fully Captured] win-check --> draw click setup href "#fn-setup" click draw href "#fn-draw" click resetgame href "#fn-resetgame" click updatebombing href "#fn-updatebombing" click generate-map href "#fn-generateobstacles" click findpath href "#fn-findpath" click world-drawing href "#fn-drawworld" click canvas-creation href "#sub-canvas-creation" click difficulty-setup href "#sub-difficulty-setup" click button-setup href "#sub-button-setup" click state-menu href "#sub-state-menu" click state-weapon-select href "#sub-state-weapon-select" click delta-time-clamp href "#sub-delta-time-clamp" click game-loop href "#sub-game-loop" click clear-arrays href "#sub-clear-arrays" click reset-state href "#sub-reset-state" click spawn-teams href "#sub-spawn-teams" click assign-roles href "#sub-assign-roles" click constructor href "#sub-constructor" click update-dispatch href "#sub-update-dispatch" click player-movement href "#sub-player-movement" click ai-target-search href "#sub-ai-target-search" click defender-logic href "#sub-defender-logic" click attacker-logic href "#sub-attacker-logic" click pathfinding-core href "#sub-pathfinding-core" click bfs-loop href "#sub-bfs-loop" click neighbor-expansion href "#sub-neighbor-expansion" click obstacle-check href "#sub-obstacle-check" click path-reconstruction href "#sub-path-reconstruction" click waypoint-conversion href "#sub-waypoint-conversion" click camera-setup href "#sub-camera-setup" click fog-drawing href "#sub-fog-drawing" click hud-drawing href "#sub-hud-drawing" click win-check href "#sub-win-check"

❓ Frequently Asked Questions

What visual elements are present in the ScuffedSixSiege sketch?

The ScuffedSixSiege sketch features a top-down view of a battlefield with randomly generated room obstacles, destructible wood walls, and visual effects like camera shake and blood splatter during character deaths.

How can players interact with the ScuffedSixSiege game?

Players can engage by assuming either an attacker or defender role, navigating the map, shooting obstacles or doors, and participating in rounds until they are eliminated.

What creative coding concepts are showcased in the ScuffedSixSiege sketch?

The sketch demonstrates random generation of game elements, pathfinding algorithms for bots, and a ranking system that evolves based on player performance.

Preview

ScuffedSixSiege - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of ScuffedSixSiege - Code flow showing setup, draw, resetgame, soldier, generateobstacles, findpath, updatebombing, drawworld
Code Flow Diagram