ROUNDS (improved)

ROUNDS is a fast-paced local multiplayer (or AI) battle arena where two characters duel across shifting platform maps, firing projectiles, blocking attacks, and collecting powerful upgrade cards between rounds. The winning player reaches a target score by eliminating their opponent through combat, blocking, and strategic ability stacking.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change background grid size — The grid lines update instantly—try spacing them farther apart (80 or 100) for a more minimal look, or closer (10 or 20) for a busier cyberpunk feel.
  2. Triple jump height — Characters bounce higher when they jump—great for testing platforming on tricky maps or letting them reach unreachable areas.
  3. Disable gravity — Characters float in place—test air combat, wall-sliding, and see how long the game lasts without gravity pulling players down.
  4. Start with mega stats — Both players begin with huge damage, ammo, and fire rate—test late-game bullet chaos.
  5. Make AI harder — AI shoots and blocks more frequently, making it a serious threat—remove the random() conditions that gate its actions.
  6. Instant block cooldown — Blocking has no cooldown—spam the parry button freely to test the block mechanic and shockwave abilities.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a complete multiplayer battle arena game with two players dueling across destructible platforms. Characters move, jump, and wall-slide across shifting geometry while firing bullets with different properties (explosive, homing, piercing, poisoning, freezing). Between rounds, players pick from randomized upgrade cards that stack dynamically—taking multiple 'Shotgun' cards makes you more chaotic; multiple 'Tank' cards makes you gigantic. Visual effects shine through particle explosions, screen shake on impacts, and animated card previews that show exactly what each upgrade does.

The code is organized into a state machine (TITLE → PLAYING → ROUND_OVER → CARD_SELECT → GAME_OVER), a Player class that handles both human input and AI logic, several special effect classes (Shockwave, EnergyRing, Bullet, Particle), and a complete UI layer for menus and in-game HUD. By studying it, you'll learn how to build game state management, AI pathfinding and aiming, collision detection, particle systems, canvas transformations for screen shake, and how to make cards that meaningfully alter player abilities through a modular apply() pattern.

⚙️ How It Works

  1. When setup() runs, it creates a 1000×600 canvas, initializes two Player objects with their control schemes, and loads the first random map. The MAPS array stores platform geometry and spawn positions for each arena.
  2. In PLAYING state, the draw loop calls updatePlaying() which updates both players (applying gravity, collision, jumping, wall-sliding, input/AI), all bullets (moving, bouncing, hitting targets), and all particles (expanding, fading). After collision checks, if a player's HP drops to 0, the game transitions to ROUND_OVER.
  3. When a round ends, ROUND_OVER displays the winner's name and waits 100 frames, then calls handleRoundEnd(). If the winner has reached the target score, it's GAME_OVER; otherwise it's CARD_SELECT.
  4. In CARD_SELECT, prepareCardSelect() shuffles the 30+ card deck and offers 5 random cards. The loser clicks (or the AI auto-picks) an upgrade, which immediately calls card.apply(player) to modify stats like maxHp, damage, fireRate, or special flags like explosive or homing.
  5. Cards stack dynamically—taking 'Block Blast' twice quadruples your shockwave damage. Taking 'Tank' increases maxHp, which automatically scales the player's visual size (w and h). Taking 'Ghost' cuts your maxHp but sets your alpha to 150, making you semi-transparent.
  6. The game loop draws platforms, special effects (Shockwaves and EnergyRings), particles, bullets, and both players. A score indicator (dots) in the top-left tracks rounds won. Hovering over inventory cards in the top-right shows tooltips. Screen shake is applied via translate() when screenShake > 0.

🎓 Concepts You'll Learn

State machine (TITLE → PLAYING → CARD_SELECT → GAME_OVER)Collision detection (AABB rectangles with platform geometry)Physics simulation (gravity, velocity, friction, jumping)AI pathfinding and decision-makingParticle systems and visual feedbackCard stacking and modular ability applicationScreen shake and canvas transformsBullet types and special properties (homing, explosive, piercing)Multi-player input handling vs AI control

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It's the perfect place to initialize the canvas, set fonts, create game objects, and listen for events like right-click. The Player constructor accepts an id, color, control scheme, and an isAI flag.

function setup() {
  createCanvas(1000, 600); 
  textFont('Impact'); 

  document.addEventListener('contextmenu', event => event.preventDefault());

  let p1Controls = { up: 87, down: 83, left: 65, right: 68 }; 
  let p2Controls = { up: UP_ARROW, down: DOWN_ARROW, left: LEFT_ARROW, right: RIGHT_ARROW, shoot: 190, block: 191 };

  p1 = new Player(1, color(50, 200, 255), p1Controls, false);
  p2 = new Player(2, color(255, 50, 100), p2Controls, false);

  buildMap(currentMapIndex);
}
Line-by-line explanation (6 lines)
createCanvas(1000, 600);
Creates a 1000-pixel-wide by 600-pixel-tall canvas where the game will render.
textFont('Impact');
Sets the font to 'Impact' so all text renders in that bold, readable style.
document.addEventListener('contextmenu', event => event.preventDefault());
Prevents the browser's right-click menu from appearing, so the right mouse button is free for blocking.
let p1Controls = { up: 87, down: 83, left: 65, right: 68 };
Defines key codes for Player 1: 87=W, 83=S, 65=A, 68=D (WASD movement).
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Creates Player 1 (blue, human-controlled). The last 'false' means not AI.
buildMap(currentMapIndex);
Loads the first map geometry, platforms, and spawn positions for both players.

draw()

The draw() function is p5.js's main game loop, running ~60 times per second. This sketch uses it to implement a state machine—a pattern where the game behaves differently depending on which 'state' it's in (TITLE menu, active gameplay, round end, card selection, game over). Each state has its own update and draw logic. The grid background is purely visual and doesn't affect gameplay.

🔬 This is the state machine's heart. What happens if you swap the order of updatePlaying() and drawPlaying()—does drawing before updating break anything? Why or why not?

  if (gameState === "TITLE") {
    drawTitle();
  } else if (gameState === "PLAYING") {
    updatePlaying();
    drawPlaying();
  }
function draw() {
  background(20, 20, 24);
  stroke(255, 10);
  strokeWeight(1);
  for(let i=0; i<width; i+=40) line(i, 0, i, height);
  for(let j=0; j<height; j+=40) line(0, j, width, j);

  if (gameState === "TITLE") {
    drawTitle();
  } else if (gameState === "PLAYING") {
    updatePlaying();
    drawPlaying();
  } else if (gameState === "ROUND_OVER") {
    if (frameCount % 2 === 0) updatePlaying();
    drawPlaying();
    
    fill(0, 150);
    rectMode(CORNER);
    rect(0, 0, width, height);
    
    textAlign(CENTER, CENTER);
    textSize(80);
    let roundWinnerLocal = p1.hp > 0 ? p1 : p2;
    fill(roundWinnerLocal.color);
    text(`PLAYER ${roundWinnerLocal.id} DESTROYS`, width/2, height/2 - 20);
    
    roundEndTimer--;
    if (roundEndTimer <= 0) {
      let roundLoserLocal = p1.hp <= 0 ? p1 : p2;
      handleRoundEnd(roundWinnerLocal, roundLoserLocal);
    }
    
  } else if (gameState === "CARD_SELECT") {
    drawPlaying(); 
    drawCardSelect();
    
    if (currentLoser.isAI) {
      aiCardTimer--;
      if (frameCount % 15 === 0) hoveredCardIndex = floor(random(offeredCards.length));
      
      if (aiCardTimer <= 0) {
        let picked = offeredCards[hoveredCardIndex];
        picked.apply(currentLoser);
        currentLoser.cards.push(picked);
        resetRoundPositions();
        gameState = "PLAYING";
      }
    }
  } else if (gameState === "GAME_OVER") {
    drawGameOver();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Grid Background for(let i=0; i<width; i+=40) line(i, 0, i, height);

Draws vertical grid lines every 40 pixels to create a sci-fi arena aesthetic.

switch-case Game State Router if (gameState === "TITLE") { ... } else if (gameState === "PLAYING") { ... }

Routes execution to the correct game state handler (menus, gameplay, card selection, etc.) based on the current gameState string.

background(20, 20, 24);
Clears the canvas to a dark blue-gray each frame, erasing previous drawings.
stroke(255, 10);
Sets the line color to nearly-transparent white (alpha 10 out of 255) so grid lines are subtle.
for(let i=0; i<width; i+=40) line(i, 0, i, height);
Draws vertical grid lines at x=0, 40, 80, 120, etc., giving the arena a futuristic tiled look.
if (gameState === "TITLE") { drawTitle(); }
If we're at the start menu, call drawTitle() to display game controls and mode selection.
else if (gameState === "PLAYING") { updatePlaying(); drawPlaying(); }
During gameplay, update all game logic (players, bullets, collisions) then draw everything on screen.
else if (gameState === "ROUND_OVER") { ... roundEndTimer--; if (roundEndTimer <= 0) handleRoundEnd(...); }
When a player is defeated, show the victory message for 100 frames, then transition to card selection or match over.
else if (gameState === "CARD_SELECT") { drawPlaying(); drawCardSelect(); ... }
Show the card selection menu with the background game scene behind it. If AI is playing, auto-select a card after a delay.

updatePlaying()

updatePlaying() is the heart of the game loop. It updates all dynamic entities (players, bullets, particles, effects), checks for collisions between bullets and players, handles blocking and damage, and detects round-end conditions. Notice it loops backwards through bullets so we can splice (remove) them safely. The collision test uses circle-to-AABB (axis-aligned bounding box) math to detect when a circular bullet hits a rectangular player.

🔬 This is the block mechanic. The bullet's velocity is multiplied by -1.5 (not just -1). What does that extra 0.5 do to the bullet's speed? What if you changed it to -2 or -0.5?

            if (target.blockingFrames > 0) {
              b.vx *= -1.5; 
              b.vy *= -1.5;
              b.owner = target; 
              createParticles(b.x, b.y, 10, color(255));
              target.vx += b.vx * 0.2;
              screenShake += 8;
            }
function updatePlaying() {
  p1.update(p2);
  p2.update(p1);

  for (let i = bullets.length - 1; i >= 0; i--) {
    let b = bullets[i];
    b.update();

    if (b.active) {
      let targets = [p1, p2];
      for (let target of targets) {
        if (b.owner.id !== target.id) {
          let testX = constrain(b.x, target.x, target.x + target.w);
          let testY = constrain(b.y, target.y, target.y + target.h);
          let distSq = (b.x - testX)**2 + (b.y - testY)**2;

          if (distSq <= b.r * b.r && gameState !== "ROUND_OVER") {
            if (target.blockingFrames > 0) {
              b.vx *= -1.5; 
              b.vy *= -1.5;
              b.owner = target; 
              createParticles(b.x, b.y, 10, color(255));
              target.vx += b.vx * 0.2;
              screenShake += 8;
            } else {
              target.hp -= b.damage;
              
              if (b.frost) target.frostTimer = 120; 
              if (b.poison) target.poisonTimer = 180; 
              
              b.destroy(); 
              screenShake += 10;

              if (b.owner.lifesteal > 0) {
                b.owner.hp = min(b.owner.maxHp, b.owner.hp + (b.damage * b.owner.lifesteal));
              }
            }
          }
        }
      }
    }

    if (!b.active) bullets.splice(i, 1);
  }

  for (let i = specialEffects.length - 1; i >= 0; i--) {
    specialEffects[i].update();
    if (!specialEffects[i].active) specialEffects.splice(i, 1);
  }

  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    if (particles[i].life <= 0) particles.splice(i, 1);
  }
  
  if (particles.length > 200) particles.splice(0, particles.length - 200);

  if (screenShake > 0) {
    screenShake *= 0.8;
    if (screenShake < 0.5) screenShake = 0;
  }

  if (p1.hp <= 0 && p2.hp <= 0) p1.hp = 1; 
  
  if ((p1.hp <= 0 || p2.hp <= 0) && gameState === "PLAYING") {
    gameState = "ROUND_OVER";
    roundEndTimer = 100; 
    screenShake = 30; 
    
    let deadPlayer = p1.hp <= 0 ? p1 : p2;
    for (let i = 0; i < 40; i++) {
      let p = new Particle(deadPlayer.x + deadPlayer.w/2, deadPlayer.y + deadPlayer.h/2, deadPlayer.color);
      p.vx = random(-12, 12);
      p.vy = random(-12, 12);
      p.size = random(8, 24);
      particles.push(p);
    }
    for (let i = 0; i < 20; i++) {
      let p = new Particle(deadPlayer.x + deadPlayer.w/2, deadPlayer.y + deadPlayer.h/2, color(255), true);
      p.vx = random(-6, 6);
      p.vy = random(-6, 6);
      particles.push(p);
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Bullet-Player Collision Detection if (distSq <= b.r * b.r && gameState !== "ROUND_OVER") { ... }

Checks if a bullet's center is within its radius of a target player's bounding box. If blocked, bounces bullet; if not, applies damage and status effects.

conditional Particle Array Cleanup if (particles.length > 200) particles.splice(0, particles.length - 200);

Prevents frame-rate drops by deleting old particles if more than 200 exist at once.

conditional Round Over Trigger if ((p1.hp <= 0 || p2.hp <= 0) && gameState === "PLAYING") { gameState = "ROUND_OVER"; ... }

When one player's HP drops to 0, transition to ROUND_OVER state and create death particles.

p1.update(p2);
Updates Player 1's position, velocity, jumping, input handling, and collision with platforms. Passes Player 2 so AI can see the opponent.
for (let i = bullets.length - 1; i >= 0; i--) {
Loops through bullets backwards so we can safely splice (remove) them from the array during iteration without skipping any.
let testX = constrain(b.x, target.x, target.x + target.w);
Finds the closest point on the player's rectangle to the bullet's center—used for circle-to-AABB collision detection.
let distSq = (b.x - testX)**2 + (b.y - testY)**2;
Calculates squared distance from bullet to closest point on player. Using squared distance avoids expensive square root.
if (target.blockingFrames > 0) { b.vx *= -1.5; b.owner = target; }
If the player is actively blocking, the bullet bounces back (velocity reversed) and ownership switches so the blocked bullet now damages the shooter.
if (b.frost) target.frostTimer = 120;
If the bullet has frost property, apply slow status for 120 frames.
if (particles.length > 200) particles.splice(0, particles.length - 200);
Performance optimization: delete the oldest 50 particles if we exceed 200 to keep frame rate smooth.

buildMap(index)

buildMap() is a setup function called whenever a new round starts. It loads map geometry from the MAPS constant, adds invisible boundary walls, repositions and resets both players, and clears all bullets/particles from the previous round. The deep copy (JSON.parse/stringify) ensures each round gets its own independent platform list, so no cross-contamination between matches.

function buildMap(index) {
  let mapData = MAPS[index];
  platforms = JSON.parse(JSON.stringify(mapData.geometry)); 
  
  let b = 20; 
  platforms.push({ x: 0, y: 0, w: width, h: b }); 
  platforms.push({ x: 0, y: height - b, w: width, h: b }); 
  platforms.push({ x: 0, y: 0, w: b, h: height }); 
  platforms.push({ x: width - b, y: 0, w: b, h: height }); 
  
  p1.x = mapData.spawns[0].x - p1.w/2; 
  p1.y = mapData.spawns[0].y - p1.h/2;
  p2.x = mapData.spawns[1].x - p2.w/2; 
  p2.y = mapData.spawns[1].y - p2.h/2;
  p1.vx = 0; p1.vy = 0; p2.vx = 0; p2.vy = 0;
  
  p1.hp = p1.maxHp; p2.hp = p2.maxHp;
  p1.ammo = p1.maxAmmo; p2.ammo = p2.maxAmmo;
  p1.reloadTimer = 0; p2.reloadTimer = 0;
  p1.frostTimer = 0; p1.poisonTimer = 0;
  p2.frostTimer = 0; p2.poisonTimer = 0;
  p1.blockingFrames = 0; p2.blockingFrames = 0;
  
  bullets = [];
  particles = [];
  specialEffects = []; 
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Deep Copy Map Data platforms = JSON.parse(JSON.stringify(mapData.geometry));

Creates an independent copy of the map geometry so changes to one map don't affect others.

for-loop Canvas Boundary Walls platforms.push({ x: 0, y: 0, w: width, h: b });

Adds four invisible walls around the canvas edges so players and bullets don't escape the arena.

let mapData = MAPS[index];
Looks up the map data from the MAPS array using the provided index (0–3 for the 4 included maps).
platforms = JSON.parse(JSON.stringify(mapData.geometry));
Creates a deep copy of the map's platforms. JSON.stringify() converts it to a string, JSON.parse() converts back, creating independent copies.
let b = 20;
Sets the boundary wall thickness to 20 pixels. Larger values make walls thicker and take up more space.
platforms.push({ x: 0, y: 0, w: width, h: b });
Adds a solid wall at the top of the canvas (y=0) that's 20 pixels tall and spans the full width.
p1.x = mapData.spawns[0].x - p1.w/2;
Positions Player 1 at the first spawn point, centered by subtracting half their width.
p1.hp = p1.maxHp;
Resets Player 1's health to full for the new round.
bullets = [];
Clears all bullets from the previous round so they don't carry over into the new arena.

Player.update(opponent)

Player.update() is massive because players are complex entities. It handles velocity (gravity, speed limits), state timers (shooting, blocking), status effects (poison, frost), input (keyboard or AI), collision detection (platform intersections), and special abilities (orbitals). Notice the order: first apply gravity and constraints, then move X and check X collisions, then move Y and check Y collisions. This prevents tunneling (passing through solid objects). Wall detection is separate and allows wall-sliding.

🔬 The frost status is checked twice—once to create particles, and once to apply the slow (vx *= 0.6). Why is the slow applied AFTER input/AI update instead of before? What if you moved vx *= 0.6 to the first if block?

    if (this.frostTimer > 0) {
      this.frostTimer--;
      if (random() < 0.2) createParticles(this.x + this.w/2, this.y + this.h/2, 1, color(150, 200, 255)); 
    }

    if (this.isAI) {
      this.updateAI(opponent);
    } else {
      this.updateInput();
    }

    if (this.frostTimer > 0) {
      this.vx *= 0.6;
update(opponent) {
    if (this.hp <= 0) return;

    // DYNAMIC SIZE SCALING TIED TO MAX HP
    let targetSize = constrain(30 * Math.sqrt(this.maxHp / 100), 12, 100);
    if (abs(this.w - targetSize) > 0.5) {
        let diff = targetSize - this.w;
        this.w = targetSize;
        this.h = targetSize;
        this.y -= diff; // Push character up instantly to avoid clipping through floor
    }

    this.shootTimer = max(0, this.shootTimer - 1);
    this.blockTimer = max(0, this.blockTimer - 1);
    this.blockingFrames = max(0, this.blockingFrames - 1);

    if (this.orbitalDamage > 0) {
      this.orbitalAngle += 0.08;
      for(let i=0; i<2; i++) {
        let ox = this.x + this.w/2 + cos(this.orbitalAngle + i*PI) * (this.w + 15);
        let oy = this.y + this.h/2 + sin(this.orbitalAngle + i*PI) * (this.h + 15);
        if (frameCount % 10 === 0 && dist(ox, oy, opponent.x+opponent.w/2, opponent.y+opponent.h/2) < 20 + opponent.w/2) {
          if (opponent.blockingFrames === 0) {
            opponent.hp -= this.orbitalDamage;
            createParticles(ox, oy, 2, this.color);
          }
        }
      }
    }

    if (this.reloadTimer > 0) {
      this.reloadTimer--;
      if (this.reloadTimer <= 0) {
        this.ammo = this.maxAmmo;
        createParticles(this.x + this.w/2, this.y + this.h/2, 4, color(255, 255, 150));
      }
    } else {
      if (this.ammo <= 0) {
        this.reloadTimer = this.reloadTime;
      }
    }

    if (this.regen > 0 && this.hp > 0 && this.hp < this.maxHp) {
      this.hp = min(this.maxHp, this.hp + this.regen);
    }
    if (this.poisonTimer > 0) {
      this.poisonTimer--;
      if (frameCount % 15 === 0) this.hp -= 1; 
      if (random() < 0.2) createParticles(this.x + this.w/2, this.y + this.h/2, 1, color(100, 255, 100)); 
    }
    if (this.frostTimer > 0) {
      this.frostTimer--;
      if (random() < 0.2) createParticles(this.x + this.w/2, this.y + this.h/2, 1, color(150, 200, 255)); 
    }

    if (this.isAI) {
      this.updateAI(opponent);
    } else {
      this.updateInput();
    }

    if (this.frostTimer > 0) {
      this.vx *= 0.6;
    }

    this.vy += GRAVITY;
    this.vy = constrain(this.vy, -25, MAX_FALL_SPEED);
    this.vx = constrain(this.vx, -this.speed * 1.5, this.speed * 1.5); 

    // X Collision
    this.x += this.vx;
    for (let p of platforms) {
      if (rectIntersect(this.x, this.y, this.w, this.h, p.x, p.y, p.w, p.h)) {
        if (this.vx > 0) this.x = p.x - this.w;
        else if (this.vx < 0) this.x = p.x + p.w;
        this.vx = 0;
      }
    }

    // Y Collision
    this.y += this.vy;
    this.onGround = false;
    for (let p of platforms) {
      if (rectIntersect(this.x, this.y, this.w, this.h, p.x, p.y, p.w, p.h)) {
        if (this.vy > 0) {
          this.y = p.y - this.h;
          this.onGround = true;
          this.jumpsLeft = this.maxJumps;
        } else if (this.vy < 0) {
          this.y = p.y + p.h;
        }
        this.vy = 0;
      }
    }

    // Wall Detection
    this.wallDir = 0;
    if (!this.onGround) {
      let checkL = false, checkR = false;
      for (let p of platforms) {
        if (rectIntersect(this.x - 2, this.y, this.w, this.h, p.x, p.y, p.w, p.h)) checkL = true;
        if (rectIntersect(this.x + 2, this.y, this.w, this.h, p.x, p.y, p.w, p.h)) checkR = true;
      }
      if (checkL) this.wallDir = -1;
      if (checkR) this.wallDir = 1;
      
      if (this.wallDir !== 0 && this.vy > 0) {
        this.vy *= 0.75;
        if (frameCount % 6 === 0) {
          let px = this.wallDir === 1 ? this.x + this.w : this.x;
          createParticles(px, this.y + this.h, 1, color(180), true);
        }
      }
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Dynamic Size Scaling let targetSize = constrain(30 * Math.sqrt(this.maxHp / 100), 12, 100);

Calculates visual size based on maxHp using square root formula—Tank cards grow huge, Glass Cannon shrinks small.

for-loop Orbital Circle Attack if (this.orbitalDamage > 0) { ... }

Spawns two orbital projectiles that circle the player and deal damage every 10 frames if they touch the opponent.

conditional Ammo & Reload System if (this.reloadTimer > 0) { ... } else { if (this.ammo <= 0) { this.reloadTimer = this.reloadTime; } }

Manages ammunition count and auto-reloads when ammo reaches 0.

conditional Poison and Frost Status Ticks if (this.poisonTimer > 0) { ... if (frameCount % 15 === 0) this.hp -= 1; }

Applies damage-over-time and visual effects for poison and frost status conditions.

for-loop Horizontal Collision Detection for (let p of platforms) { if (rectIntersect(...)) { ... this.vx = 0; } }

Stops the player moving left/right if they hit a platform, preventing them from sliding through walls.

for-loop Vertical Collision Detection for (let p of platforms) { if (this.vy > 0) { this.y = p.y - this.h; this.onGround = true; } }

Handles falling onto platforms and bouncing off ceilings; sets onGround flag when player lands.

for-loop Wall-Slide Detection if (rectIntersect(this.x - 2, this.y, ...)) checkL = true;

Checks if player is touching a wall (for wall-jumping and wall-sliding momentum reduction).

if (this.hp <= 0) return;
Dead players don't update—exit the function immediately.
let targetSize = constrain(30 * Math.sqrt(this.maxHp / 100), 12, 100);
Calculates desired size: base 30 pixels scaled by square root of (maxHp / 100), then clamped between 12 and 100. Bigger maxHp = bigger player.
this.vy += GRAVITY;
Applies gravity constant (0.6) to vertical velocity each frame, making the player fall.
if (this.vx > 0) this.x = p.x - this.w;
If moving right and hit a platform, push player to the left edge of that platform so they stop against it.
if (this.vy > 0) { this.y = p.y - this.h; this.onGround = true; this.jumpsLeft = this.maxJumps; }
If falling down onto a platform, position player above it, set onGround flag, and restore jump count (so they can jump again).
if (rectIntersect(this.x - 2, this.y, this.w, this.h, p.x, p.y, p.w, p.h)) checkL = true;
Checks if player's left edge (x-2) intersects any platform. If so, they're touching a wall on their left side.

Player.shoot()

shoot() creates bullets on demand. It decrements ammo, sets a shooting cooldown (shootTimer), calculates the gun's position and aim direction, then spawns 1+ bullets in a fan pattern. The spread angle and projectile count are both modified by cards (Shotgun increases projectiles; Spray & Pray increases spread). Energy Ring cards spawn a secondary slow projectile. Finally, recoil pushes the player backward—a neat way to add physics feedback to shooting.

🔬 This loop fires multiple bullets in a fan. If you change the startAngle calculation to NOT subtract half the spread (remove the '-...'), where will the bullets aim? Will they fan left, right, or symmetrically?

    let spreadAngle = 0.15 * this.spreadMult; 
    let startAngle = angleBase - (spreadAngle * (this.projectiles - 1)) / 2;

    for (let i = 0; i < this.projectiles; i++) {
      let ang = startAngle + (i * spreadAngle);
      let bx = cos(ang) * this.bulletSpeed;
      let by = sin(ang) * this.bulletSpeed;
      bullets.push(new Bullet(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, bx, by, this));
    }
shoot() {
    this.ammo--; 
    this.shootTimer = this.fireRate;
    
    let bodyOffset = -this.h * 0.15; 
    let cx = this.x + this.w/2;
    let cy = this.y + this.h/2 + bodyOffset;
    let gunDist = this.w * 0.6 + 25; // Adjusted to match dynamic arm length
    
    let angleBase = atan2(this.aimVec.y, this.aimVec.x);
    let spreadAngle = 0.15 * this.spreadMult; 
    let startAngle = angleBase - (spreadAngle * (this.projectiles - 1)) / 2;

    for (let i = 0; i < this.projectiles; i++) {
      let ang = startAngle + (i * spreadAngle);
      let bx = cos(ang) * this.bulletSpeed;
      let by = sin(ang) * this.bulletSpeed;
      bullets.push(new Bullet(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, bx, by, this));
    }
    
    if (this.ringDamage > 0) {
      let ringVx = cos(angleBase) * 5;
      let ringVy = sin(angleBase) * 5;
      specialEffects.push(new EnergyRing(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, ringVx, ringVy, this));
    }
    
    this.vx -= this.aimVec.x * 4 * (this.bulletSize / 8);
    this.vy -= this.aimVec.y * 4 * (this.bulletSize / 8);
    createParticles(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, 5, this.color);
    screenShake += 4; 
  }
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Multi-Projectile Spread for (let i = 0; i < this.projectiles; i++) { let ang = startAngle + (i * spreadAngle); ... bullets.push(new Bullet(...)); }

Fires multiple bullets (1, 3, 5, 8+) in a fan pattern based on spreadMult. Wider spread = less accurate but more coverage.

conditional Energy Ring Special Effect if (this.ringDamage > 0) { ... specialEffects.push(new EnergyRing(...)); }

If player has ring damage (from Energy Ring card), spawn a slow projectile that orbits outward.

calculation Shooting Recoil this.vx -= this.aimVec.x * 4 * (this.bulletSize / 8);

Pushes the player backwards slightly when they shoot, proportional to bullet size—larger bullets have stronger recoil.

this.ammo--;
Reduces ammo count by 1 each shot.
this.shootTimer = this.fireRate;
Sets a cooldown timer—the player can't shoot again until shootTimer reaches 0. Higher fireRate values mean longer waits between shots.
let bodyOffset = -this.h * 0.15;
Offsets gun position upward by 15% of player height so bullets come from chest area, not from feet.
let gunDist = this.w * 0.6 + 25;
Calculates how far from the player's center the bullets spawn—scales with dynamic player size.
let angleBase = atan2(this.aimVec.y, this.aimVec.x);
Converts the aim direction (aimVec) into an angle in radians using inverse tangent.
let spreadAngle = 0.15 * this.spreadMult;
Calculates angle between bullets (in radians). spreadMult multiplies it—Shotgun cards increase spreadMult to spray bullets farther apart.
let ang = startAngle + (i * spreadAngle);
For bullet i, calculate its angle by offsetting from the starting angle by (i * spreadAngle). This fans bullets out.
this.vx -= this.aimVec.x * 4 * (this.bulletSize / 8);
Apply recoil: push player backward in the opposite direction of the aim vector, scaled by bullet size.

Player.block()

block() is called when the player activates their shield. It sets two timers: blockingFrames (how long the block lasts) and blockTimer (how long until they can block again). If the player has card abilities that trigger on block (Block Blast, Mending Guard, etc.), they activate here. The Shockwave class handles expanding ripples that damage the opponent; healBlock instantly restores HP.

block() {
    this.blockingFrames = this.blockDuration;
    this.blockTimer = this.maxBlockCooldown;
    
    let bodyOffset = -this.h * 0.15;
    createParticles(this.x + this.w/2, this.y + this.h/2 + bodyOffset, 12, color(255));
    
    if (this.shockwaveDamage > 0 || this.poisonNova > 0 || this.frostNova > 0) {
      specialEffects.push(new Shockwave(this.x + this.w/2, this.y + this.h/2 + bodyOffset, this));
    }
    if (this.healBlock > 0) {
      this.hp = min(this.maxHp, this.hp + this.healBlock);
      createParticles(this.x + this.w/2, this.y + this.h/2, 15, color(0, 255, 0));
    }
  }
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Shockwave Ability Trigger if (this.shockwaveDamage > 0 || this.poisonNova > 0 || this.frostNova > 0) { specialEffects.push(new Shockwave(...)); }

If player has any block-related stacking abilities (Block Blast, Toxic Aura, Flash Freeze), spawn a Shockwave that damages and applies effects to opponent.

conditional Healing on Block if (this.healBlock > 0) { this.hp = min(this.maxHp, this.hp + this.healBlock); }

If player picked Mending Guard card, restore healBlock HP when blocking (stacks multiplicatively).

this.blockingFrames = this.blockDuration;
Activates the block animation/shield for blockDuration frames (default 18). While blockingFrames > 0, bullets bounce back.
this.blockTimer = this.maxBlockCooldown;
Sets a cooldown timer—player can't block again until blockTimer reaches 0. Quick Block card halves maxBlockCooldown.
createParticles(this.x + this.w/2, this.y + this.h/2 + bodyOffset, 12, color(255));
Spawns 12 white particles at the player's center to show a visual 'block' effect.
if (this.shockwaveDamage > 0 || this.poisonNova > 0 || this.frostNova > 0) {
Checks if player has any block ability stacks. If yes, spawn a Shockwave that damages/slows the opponent.

Player.draw()

Player.draw() is a complex rendering function that creates a character with multiple animated parts: legs that walk, a body that stretches with velocity, eyes that follow aim, a gun that rotates, a shield that animates during blocks, and a health bar. It uses push/pop/translate/rotate heavily to manage coordinate transformations—each limb is positioned relative to the player's center, then drawn. The ammo display changes between individual bullets and a reload progress bar. The HP bar shows current health and turns green if the player is poisoned.

🔬 The body stretches based on velocity (stretchFactor), and legs only animate when on the ground AND moving sideways. What happens if you remove the onGround check, so legs animate even while in the air? Try it.

    let vMag = dist(0, 0, this.vx, this.vy);
    let stretchAngle = atan2(this.vy, this.vx);
    let stretchFactor = constrain(vMag * 0.04, 0, 0.4);

    translate(cx, cy); 
    
    // Dynamic Leg Placement
    let walkPhase = frameCount * 0.4;
    let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * (this.h*0.3) : 0;
draw() {
    if (this.hp <= 0) return; 

    push();
    let bodyOffset = -this.h * 0.15; 
    let cx = this.x + this.w/2;
    let cy = this.y + this.h/2; 

    let gunDist = this.w * 0.6 + 15; 
    let shieldDist = this.w * 0.4 + 10;

    let gunHX = this.aimVec.x * gunDist;
    let gunHY = bodyOffset + this.aimVec.y * gunDist;
    let shieldX = -this.aimVec.x * shieldDist; 
    let shieldY = bodyOffset - this.aimVec.y * shieldDist + 6; 

    if (this.hp > 0 && !this.blockingFrames) {
      let gunX = cx + gunHX;
      let gunY = cy + gunHY;
      let endX = gunX + this.aimVec.x * 1200;
      let endY = gunY + this.aimVec.y * 1200;
      stroke(this.color.levels[0], this.color.levels[1], this.color.levels[2], 30);
      strokeWeight(2);
      line(gunX, gunY, endX, endY);
    }
    
    if (this.orbitalDamage > 0) {
      for(let i=0; i<2; i++) {
        let ox = cx + cos(this.orbitalAngle + i*PI) * (this.w + 15);
        let oy = cy + bodyOffset + sin(this.orbitalAngle + i*PI) * (this.h + 15);
        fill(this.color); noStroke();
        circle(ox, oy, 12);
        fill(255); circle(ox, oy, 4);
      }
    }

    let vMag = dist(0, 0, this.vx, this.vy);
    let stretchAngle = atan2(this.vy, this.vx);
    let stretchFactor = constrain(vMag * 0.04, 0, 0.4);

    translate(cx, cy); 
    
    // Dynamic Leg Placement
    let walkPhase = frameCount * 0.4;
    let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * (this.h*0.3) : 0;
    let lift2 = (this.onGround && abs(this.vx) > 0.5) ? max(0, -sin(walkPhase)) * (this.h*0.3) : 0;
    
    let footYBase = this.h * 0.4; // Places feet slightly above the bottom of the physics box
    let foot1X = -this.w * 0.25; 
    let foot1Y = footYBase - lift1; 
    let foot2X = this.w * 0.25;  
    let foot2Y = footYBase - lift2;

    let cR = red(this.color) * 0.7;
    let cG = green(this.color) * 0.7;
    let cB = blue(this.color) * 0.7;

    stroke(cR, cG, cB, this.alpha);
    strokeWeight(max(4, this.w * 0.2));
    strokeCap(ROUND);
    line(0, bodyOffset + this.h*0.1, foot1X, foot1Y);
    line(0, bodyOffset + this.h*0.1, foot2X, foot2Y);

    noStroke();
    fill(cR, cG, cB, this.alpha);
    circle(foot1X, foot1Y, max(8, this.w*0.3));
    circle(foot2X, foot2Y, max(8, this.w*0.3));

    stroke(this.color.levels[0]*0.8, this.color.levels[1]*0.8, this.color.levels[2]*0.8, this.alpha);
    strokeWeight(max(3, this.w * 0.15));
    line(0, bodyOffset, shieldX, shieldY);
    line(0, bodyOffset, gunHX, gunHY);

    if (this.blockingFrames > 0) {
      noFill();
      stroke(255);
      strokeWeight(4);
      let progress = 1 - (this.blockingFrames / this.blockDuration);
      let shieldSize = this.w * 2.5 + (progress * 40); 
      circle(0, bodyOffset, shieldSize);
    }

    push();
    translate(0, bodyOffset); 
    rotate(stretchAngle);
    this.color.setAlpha(this.alpha);
    fill(this.color);
    noStroke();
    ellipse(0, 0, this.w * (1.1 + stretchFactor), this.h * (1.1 - stretchFactor * 0.5));
    this.color.setAlpha(255); 
    rotate(-stretchAngle);

    let eyeOffsetX = this.aimVec.x * (this.w * 0.2);
    let eyeOffsetY = this.aimVec.y * (this.h * 0.2);
    let eyeW = this.w * 0.35;
    
    fill(255, this.alpha);
    circle(eyeOffsetX - this.w*0.2, eyeOffsetY, eyeW); 
    circle(eyeOffsetX + this.w*0.2, eyeOffsetY, eyeW); 
    
    fill(20, 20, 24, this.alpha);
    circle(eyeOffsetX - this.w*0.2 + this.aimVec.x * 2, eyeOffsetY + this.aimVec.y * 2, eyeW * 0.4);
    circle(eyeOffsetX + this.w*0.2 + this.aimVec.x * 2, eyeOffsetY + this.aimVec.y * 2, eyeW * 0.4);
    pop();

    push();
    translate(shieldX, shieldY);
    fill(40, 40, 45, this.alpha); 
    noStroke();
    circle(0, 0, 18);
    
    if (this.blockTimer > 0) {
      fill(255, 100);
      let p = 1 - (this.blockTimer / this.maxBlockCooldown);
      arc(0, 0, 18, 18, -HALF_PI, -HALF_PI + TWO_PI * p, PIE);
    } else {
      fill(255, this.alpha);
      circle(0, 0, 18);
    }
    
    if (this.blockingFrames > 0) {
      fill(255, 150);
      circle(0, 0, 28);
    }
    pop();

    push();
    let aimAngle = atan2(this.aimVec.y, this.aimVec.x);
    translate(gunHX, gunHY);
    rotate(aimAngle);
    
    fill(this.color.levels[0], this.color.levels[1], this.color.levels[2], this.alpha);
    noStroke();
    circle(0, 0, 14);
    
    if (abs(aimAngle) > HALF_PI) {
        scale(1, -1);
    }
    
    this.color.setAlpha(this.alpha);
    fill(40, 40, 45, this.alpha);  
    stroke(255, 255, 255, this.alpha); 
    strokeWeight(2);
    
    let gunLen = 22 + min(this.maxAmmo * 2, 35); 
    let t = 8; 
    let h = 16; 

    beginShape();
    vertex(-t/2, 0);                 
    vertex(t/2, 0);                  
    vertex(t/2, -h + t);             
    vertex(gunLen, -h + t);             
    vertex(gunLen, -h);                 
    vertex(-t/2, -h);                
    endShape(CLOSE);

    noStroke();
    let ammoYOffset = -h - 6; 
    let barrelCenter = (gunLen - t/2) / 2; 
    let space = min((gunLen + t/2) / this.maxAmmo, 7); 
    let startX = barrelCenter - ((this.maxAmmo - 1) * space) / 2; 
    
    if (this.reloadTimer > 0) {
        let progress = 1 - (this.reloadTimer / this.reloadTime);
        fill(255, 255, 255, 220); 
        rectMode(CORNER);
        let barW = (this.maxAmmo - 1) * space + 6;
        rect(startX - 3, ammoYOffset - 3, barW * progress, 6, 2);
    } else {
        for(let i=0; i < this.maxAmmo; i++) {
            if (i < this.ammo) fill(255, 210, 50, this.alpha); 
            else fill(80, this.alpha); 
            circle(startX + i * space, ammoYOffset, 4); 
        }
    }
    
    this.color.setAlpha(255);
    pop();

    pop(); 

    noStroke();
    fill(255, 0, 0);
    rectMode(CORNER);
    rect(this.x - 5, this.y - this.h*0.5 - 25, this.w + 10, 6, 2);
    fill(this.poisonTimer > 0 ? color(100, 255, 100) : color(0, 255, 0)); 
    let hpW = map(max(0, this.hp), 0, this.maxHp, 0, this.w + 10);
    rect(this.x - 5, this.y - this.h*0.5 - 25, hpW, 6, 2);
  }
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Aiming Reticle if (this.hp > 0 && !this.blockingFrames) { ... line(gunX, gunY, endX, endY); }

Draws a faint line from the gun to far away, showing where the player is aiming.

calculation Leg Walking Animation let walkPhase = frameCount * 0.4; let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * (this.h*0.3) : 0;

Creates a sine-wave leg lift animation that plays when the player moves on the ground.

calculation Velocity-Based Body Squash let stretchAngle = atan2(this.vy, this.vx); ... ellipse(0, 0, this.w * (1.1 + stretchFactor), ...);

Rotates and stretches the body ellipse based on velocity direction—visual feedback that the player is moving fast.

calculation Eyes Following Aim let eyeOffsetX = this.aimVec.x * (this.w * 0.2); ... circle(eyeOffsetX - this.w*0.2 + this.aimVec.x * 2, ...);

Draws two eyes that follow the player's aiming direction, plus pupils that track aim angle.

conditional Ammo Counter UI if (this.reloadTimer > 0) { ... rect(startX - 3, ammoYOffset - 3, barW * progress, 6, 2); } else { ... circle(startX + i * space, ...); }

Shows either a reload progress bar or individual ammo dots above the gun barrel.

calculation Health Bar Display let hpW = map(max(0, this.hp), 0, this.maxHp, 0, this.w + 10); rect(this.x - 5, this.y - this.h*0.5 - 25, hpW, 6, 2);

Draws a red-to-green bar above the player showing current HP percentage.

if (this.hp <= 0) return;
Don't draw dead players—exit immediately.
let bodyOffset = -this.h * 0.15;
Moves the visual body upward by 15% of height so the character is centered on the physics box.
let cx = this.x + this.w/2;
Calculates center X for all drawing operations—the player's visual center.
translate(cx, cy);
Moves the origin to the player's center, so all subsequent drawing is relative to them. Makes rotation/scaling easy.
let walkPhase = frameCount * 0.4;
Increments a phase value each frame. Multiplied by 0.4 so the animation isn't too fast.
let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * (this.h*0.3) : 0;
If on ground and moving horizontally (vx > 0.5), lift leg 1 by sine(walkPhase) * height—creates a bobbing walk animation.
rotate(stretchAngle);
Rotates the body ellipse to face the direction the player is moving, creating a squash-and-stretch effect.
let eyeW = this.w * 0.35;
Eye size scales with player size—bigger players have bigger eyes.
if (i < this.ammo) fill(255, 210, 50, this.alpha);
Ammo dots are gold if they're loaded; gray if they're spent.
let hpW = map(max(0, this.hp), 0, this.maxHp, 0, this.w + 10);
Maps current HP (0 to maxHp) to bar width (0 to w+10). If HP is negative, constrain to 0 so the bar doesn't go backward.

Player.updateAI(target)

updateAI() is the AI opponent logic. Unlike human players who use keyboard input, AI calculates movement by looking at the opponent's distance and position. It periodically picks a new movement direction, checks for cliffs, decides when to jump, smoothly tracks the opponent with its aim, fires when the aim is close enough, and reactively blocks incoming bullets. The AI isn't perfect—it can be outplayed, but it's competitive and adaptive.

🔬 This decision tree makes AI flee if close (< 200) and approach if far (> 600). What if you change 200 to 400 and 600 to 300? What will happen to AI behavior?

      if (distToTarget < 200) {
        this.aiMoveDir = dx > 0 ? -1 : 1;
      } else if (distToTarget > 600) {
        this.aiMoveDir = dx > 0 ? 1 : -1;
      } else {
        let r = random();
        if (r < 0.2) this.aiMoveDir = 0; 
        else if (r < 0.6) this.aiMoveDir = 1; 
        else this.aiMoveDir = -1;
updateAI(target) {
    let dx = target.x - this.x;
    let dy = target.y - this.y;
    let distToTarget = dist(this.x, this.y, target.x, target.y);
    let moving = false;
    let wantsToJump = false;

    this.aiMoveTimer--;
    if (this.aiMoveTimer <= 0) {
      this.aiMoveTimer = floor(random(30, 90)); 

      if (distToTarget < 200) {
        this.aiMoveDir = dx > 0 ? -1 : 1;
      } else if (distToTarget > 600) {
        this.aiMoveDir = dx > 0 ? 1 : -1;
      } else {
        let r = random();
        if (r < 0.2) this.aiMoveDir = 0; 
        else if (r < 0.6) this.aiMoveDir = 1; 
        else this.aiMoveDir = -1; 
      }
    }

    if (this.onGround && this.aiMoveDir !== 0) {
      let lookAhead = this.aiMoveDir * (abs(this.vx) * 10 + 30); 
      let checkX = this.x + this.w/2 + lookAhead;
      let footY = this.y + this.h;
      let groundFound = false;

      for (let p of platforms) {
        if (checkX >= p.x && checkX <= p.x + p.w) {
          if (p.y >= footY - 10 && p.y <= footY + 150) { 
            groundFound = true;
            break;
          }
        }
      }

      if (!groundFound) {
        if ((dx > 0 ? 1 : -1) === this.aiMoveDir && abs(dx) > 50) {
          wantsToJump = true; 
        } else {
          this.aiMoveDir *= -1; 
        }
      }
    }

    if (this.aiMoveDir !== 0) {
      this.vx += this.aiMoveDir * 1.0; 
      moving = true;
    }

    if (this.onGround) {
      if ((moving && abs(this.vx) < 0.5) || (dy < -60 && abs(dx) < 200 && random() < 0.05)) wantsToJump = true;
      if (distToTarget < 300 && random() < 0.01) wantsToJump = true; 
    } else if (this.wallDir !== 0) {
      if (dy < -20 || (dx > 0 && this.wallDir === -1) || (dx < 0 && this.wallDir === 1)) wantsToJump = true;
    } else if (this.jumpsLeft > 0 && dy < -80) {
      wantsToJump = true;
    }

    if (wantsToJump && !this.prevUpAI) {
      this.executeJump();
      this.prevUpAI = true;
    } else if (!wantsToJump) {
      this.prevUpAI = false;
    }

    if (!moving && this.onGround) this.vx *= FRICTION;
    else if (!moving && !this.onGround) this.vx *= AIR_FRICTION;

    let targetAngle = atan2(dy, dx);
    let diff = targetAngle - this.aiAimAngle;
    while (diff < -PI) diff += TWO_PI;
    while (diff > PI) diff -= TWO_PI;
    
    this.aiAimAngle += diff * 0.12; 
    this.aimVec = { x: cos(this.aiAimAngle), y: sin(this.aiAimAngle) };

    if (this.shootTimer === 0 && !this.blockingFrames && this.ammo > 0 && this.reloadTimer === 0) {
      if (abs(diff) < 0.4 && distToTarget < 700 && random() < 0.015) {
        this.shoot();
      }
    }

    if (this.blockTimer === 0) {
      for (let b of bullets) {
        if (b.owner.id !== this.id && dist(this.x, this.y, b.x, b.y) < 65) {
          let dotProd = (this.x - b.x) * b.vx + (this.y - b.y) * b.vy;
          if (dotProd > 0 && random() < 0.10) { 
            this.block();
            break;
          }
        }
      }
    }
  }
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional AI Movement Decision if (distToTarget < 200) { this.aiMoveDir = dx > 0 ? -1 : 1; } else if (distToTarget > 600) { this.aiMoveDir = dx > 0 ? 1 : -1; }

AI chooses direction based on distance: if close, back away; if far, approach; otherwise random.

for-loop Cliff/Gap Avoidance for (let p of platforms) { if (checkX >= p.x && checkX <= p.x + p.w) { ... } }

AI looks ahead to see if there's ground in the direction it's about to move. If not, either jump or turn around.

calculation Smooth Aim Tracking let targetAngle = atan2(dy, dx); ... this.aiAimAngle += diff * 0.12;

AI smoothly turns its aiming toward the opponent at a fixed angular speed (0.12 radians/frame).

for-loop Reactive Blocking for (let b of bullets) { if (b.owner.id !== this.id && dist(...) < 65) { ... } }

AI scans nearby bullets and blocks them if they're approaching (dotProduct > 0).

let distToTarget = dist(this.x, this.y, target.x, target.y);
Calculates Euclidean distance between AI and opponent for range-based decision making.
if (distToTarget < 200) { this.aiMoveDir = dx > 0 ? -1 : 1; }
If opponent is close (< 200 px), flee in the opposite direction.
this.aiMoveTimer = floor(random(30, 90));
AI picks a new movement direction every 30–90 frames (0.5–1.5 seconds), creating unpredictable behavior.
let lookAhead = this.aiMoveDir * (abs(this.vx) * 10 + 30);
Checks a point 30+ pixels ahead in the direction AI is moving, to detect gaps or cliffs.
if (p.y >= footY - 10 && p.y <= footY + 150) { groundFound = true; }
Considers a platform 'safe' if it's within 150 pixels below the AI's feet—allows for some falling but prevents suicidal jumps.
let targetAngle = atan2(dy, dx);
Calculates the angle from AI to opponent using inverse tangent.
this.aiAimAngle += diff * 0.12;
Smoothly rotates AI's aiming by 12% of the angle difference each frame—creates gradual, natural-looking aim tracking.
let dotProd = (this.x - b.x) * b.vx + (this.y - b.y) * b.vy;
Dot product of (AI to bullet) and (bullet velocity). If positive, bullet is approaching; if negative, it's moving away.

Shockwave.update()

Shockwave is spawned when a player with block ability stacks activates their block. It's a circle that expands outward, dealing damage and status effects (frost, poison) if it hits an unblocking opponent. The hitTarget flag ensures damage is only applied once, even though the shockwave lasts for many frames. It creates knockback, spawns particles, and causes screen shake for visual impact.

update() {
    this.r += 12; 
    if (this.r >= this.maxR) this.active = false;
    
    let opp = this.owner.id === 1 ? p2 : p1;
    if (!this.hitTarget && dist(this.x, this.y, opp.x+opp.w/2, opp.y+opp.h/2) < this.r/2 + opp.w/2) {
      if (opp.blockingFrames === 0) {
        if (this.damage > 0) opp.hp -= this.damage;
        if (this.frost > 0) opp.frostTimer += this.frost;
        if (this.poison > 0) opp.poisonTimer += this.poison;
        
        opp.vx += (opp.x - this.x) > 0 ? 8 : -8; 
        createParticles(opp.x + opp.w/2, opp.y + opp.h/2, 10, this.getColor());
      }
      this.hitTarget = true;
      screenShake += 5;
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Expanding Radius this.r += 12;

Each frame, shockwave radius grows by 12 pixels until it reaches maxR and becomes inactive.

conditional Damage Application if (opp.blockingFrames === 0) { if (this.damage > 0) opp.hp -= this.damage; }

Only damages the opponent if they're not actively blocking. Applied only once per shockwave (hitTarget flag).

this.r += 12;
Expands shockwave radius by 12 pixels per frame.
if (this.r >= this.maxR) this.active = false;
Once shockwave reaches max radius, deactivate it so it can be removed from the specialEffects array.
if (!this.hitTarget && dist(...) < this.r/2 + opp.w/2) {
Checks if opponent's center is within the expanding ring. Only triggers once (!this.hitTarget flag).
if (opp.blockingFrames === 0) { if (this.damage > 0) opp.hp -= this.damage; }
If opponent is not blocking, apply damage. Blocking players take no damage from the shockwave.
opp.vx += (opp.x - this.x) > 0 ? 8 : -8;
Knockback: push opponent away from shockwave center (+8 or -8 pixels/frame of velocity).

Bullet.update()

Bullet.update() moves each bullet, handles homing by rotating toward the opponent, bounces off platforms if bounces > 0, and deactivates bullets that go off-screen. The history array stores recent positions for drawing a trail. The bounce detection is clever: it calculates overlap from all four directions and picks the smallest—that's the edge the bullet entered from, so bounce perpendicular to that edge.

🔬 This bounce detection finds the smallest overlap to determine which side the bullet hit. What if you added a third case that flips BOTH vx and vy if the bullet hits a corner? What would happen?

    if (!this.piercing) {
      for (let p of platforms) {
        if (
          this.x + this.r > p.x && this.x - this.r < p.x + p.w &&
          this.y + this.r > p.y && this.y - this.r < p.y + p.h
        ) {
          if (this.bounces > 0) {
            this.bounces--;
            let overlapLeft = (this.x + this.r) - p.x;
            let overlapRight = (p.x + p.w) - (this.x - this.r);
            let overlapTop = (this.y + this.r) - p.y;
            let overlapBottom = (p.y + p.h) - (this.y - this.r);
            let minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);

            if (minOverlap === overlapLeft || minOverlap === overlapRight) this.vx *= -1;
            if (minOverlap === overlapTop || minOverlap === overlapBottom) this.vy *= -1;
update() {
    this.history.push({x: this.x, y: this.y});
    if (this.history.length > 5) this.history.shift();

    if (this.homing && this.active && this.target.hp > 0) {
      let dx = this.target.x + this.target.w/2 - this.x;
      let dy = this.target.y + this.target.h/2 - this.y;
      let targetAngle = atan2(dy, dx);
      let currentAngle = atan2(this.vy, this.vx);
      
      let diff = targetAngle - currentAngle;
      while (diff < -PI) diff += TWO_PI;
      while (diff > PI) diff -= TWO_PI;
      
      currentAngle += diff * 0.15; 
      let speed = dist(0, 0, this.vx, this.vy);
      speed = min(speed + 0.3, this.owner.bulletSpeed * 1.5); 
      
      this.vx = cos(currentAngle) * speed;
      this.vy = sin(currentAngle) * speed;
    }

    this.x += this.vx;
    this.y += this.vy;

    if (!this.piercing) {
      for (let p of platforms) {
        if (
          this.x + this.r > p.x && this.x - this.r < p.x + p.w &&
          this.y + this.r > p.y && this.y - this.r < p.y + p.h
        ) {
          if (this.bounces > 0) {
            this.bounces--;
            let overlapLeft = (this.x + this.r) - p.x;
            let overlapRight = (p.x + p.w) - (this.x - this.r);
            let overlapTop = (this.y + this.r) - p.y;
            let overlapBottom = (p.y + p.h) - (this.y - this.r);
            let minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);

            if (minOverlap === overlapLeft || minOverlap === overlapRight) this.vx *= -1;
            if (minOverlap === overlapTop || minOverlap === overlapBottom) this.vy *= -1;
            
            createParticles(this.x, this.y, 4, color(255, 255, 100));
            screenShake += 1;
            return; 
          } else {
            this.destroy();
          }
        }
      }
    }

    if (this.x < -100 || this.x > width+100 || this.y < -100 || this.y > height+100) {
      this.active = false;
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Homing Tracking if (this.homing && this.active && this.target.hp > 0) { ... currentAngle += diff * 0.15; }

If bullet has homing property, steer it toward the opponent by rotating its angle 15% per frame toward target.

conditional Platform Bounce Mechanics if (this.bounces > 0) { ... let minOverlap = Math.min(...); if (minOverlap === overlapLeft || ...) this.vx *= -1; }

If bullet bounces, detect which side of the platform it hit (left/right or top/bottom) and flip the matching velocity component.

conditional Out-of-Bounds Cleanup if (this.x < -100 || this.x > width+100 || ...) { this.active = false; }

Deactivate bullets that travel too far off-screen to prevent memory leaks.

this.history.push({x: this.x, y: this.y});
Records the bullet's position each frame for drawing a trail.
if (this.history.length > 5) this.history.shift();
Keeps only the last 5 positions in the history array, so the trail is short and clean.
let targetAngle = atan2(dy, dx);
Calculates angle from bullet to target opponent.
currentAngle += diff * 0.15;
Smoothly rotates bullet's direction 15% of the way toward the target angle each frame.
let overlapLeft = (this.x + this.r) - p.x;
Calculates how far the bullet's right edge overlaps into the platform's left edge.
let minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
Finds the smallest overlap—that's the direction the bullet hit from, so bounce in the opposite direction.

handleRoundEnd(winner, loser)

handleRoundEnd() is the bridge between PLAYING and either GAME_OVER or CARD_SELECT. It increments the winner's score and checks if they've won the match. If not, it sets up the next card selection phase, giving the loser a chance to pick an upgrade. If the loser is AI, it sets a delay before auto-picking.

function handleRoundEnd(winner, loser) {
  winner.score++;
  if (winner.score >= pointsToWin) {
    matchWinner = winner;
    gameState = "GAME_OVER";
  } else {
    currentLoser = loser;
    prepareCardSelect();
    gameState = "CARD_SELECT";
    
    if (currentLoser.isAI) {
      aiCardTimer = 120; 
    }
  }
}
Line-by-line explanation (7 lines)
winner.score++;
Increments the winner's round score by 1.
if (winner.score >= pointsToWin) {
Checks if the winner has reached the target score (default 15). If yes, end the match.
matchWinner = winner;
Stores the overall match winner so it can be displayed in GAME_OVER.
gameState = "GAME_OVER";
Transitions to the game over state.
currentLoser = loser;
Sets the loser as the player who receives card upgrades next.
prepareCardSelect();
Shuffles the card deck and picks 5 random cards to offer.
aiCardTimer = 120;
If the loser is AI, give it 120 frames (~2 seconds) to think before auto-picking a card.

drawCardSelect()

drawCardSelect() renders the card selection UI: a semi-transparent overlay, five cards laid out horizontally, and a character figure at the bottom that visually reaches toward the hovered card. It handles both human mouse-based hovering and AI auto-selection. The hovered card is drawn last (on top) and appears scaled and lifted. This function also displays different prompts for human vs AI players.

function drawCardSelect() {
  fill(0, 200);
  rectMode(CORNER);
  rect(0, 0, width, height);

  textAlign(CENTER, CENTER);
  fill(currentLoser.color);
  textSize(50);
  text(`CHOOSE YOUR UPGRADE`, width/2, height/2 - 200);
  
  fill(255);
  textSize(24);
  
  if (currentLoser.isAI) {
    text(`Player ${currentLoser.id} (AI) is analyzing the options...`, width/2, height/2 - 150);
  } else {
    text(`Player ${currentLoser.id}, click a card or press 1-5 to select`, width/2, height/2 - 150);
  }

  let cardW = 160;
  let cardH = 240;
  let spacing = 20;
  let totalW = (cardW * 5) + (spacing * 4);
  let startX = (width - totalW) / 2 + cardW / 2;
  
  if (!currentLoser.isAI) hoveredCardIndex = -1;

  for (let i = 0; i < offeredCards.length; i++) {
    let cx = startX + i * (cardW + spacing);
    let cy = height/2 + 20;

    let isHovered = false;
    if (!currentLoser.isAI) {
      if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) {
        hoveredCardIndex = i;
        isHovered = true;
      }
    } else {
      isHovered = (i === hoveredCardIndex);
    }
    
    if (!isHovered) {
      drawSingleCard(offeredCards[i], cx, cy, cardW, cardH, currentLoser.color, false);
    }
  }
  
  drawPlayerReaching(currentLoser, startX, cardW, spacing, cardH);
  
  if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) {
    let cx = startX + hoveredCardIndex * (cardW + spacing);
    let cy = height/2 + 20;
    drawSingleCard(offeredCards[hoveredCardIndex], cx, cy, cardW, cardH, currentLoser.color, true);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Card Grid Layout for (let i = 0; i < offeredCards.length; i++) { let cx = startX + i * (cardW + spacing); ... }

Positions 5 cards in a horizontal row, spaced evenly across the screen.

conditional Card Hover Detection if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) { hoveredCardIndex = i; isHovered = true; }

Checks if mouse is over a card; if human player, mark that card as hovered. If AI, skip this.

fill(0, 200);
Semi-transparent black overlay to dim the background game scene.
let cardW = 160; let cardH = 240;
Sets card dimensions to 160 × 240 pixels.
let totalW = (cardW * 5) + (spacing * 4);
Calculates total width of the card row: 5 cards + 4 gaps between them.
let startX = (width - totalW) / 2 + cardW / 2;
Centers the card row on screen. Adds cardW/2 because positions are card centers, not left edges.
if (!currentLoser.isAI) hoveredCardIndex = -1;
Reset hover for human players each frame so hover status updates with mouse movement.

📦 Key Variables

gameState string

Tracks the current game phase (TITLE, PLAYING, ROUND_OVER, CARD_SELECT, GAME_OVER) to control what is drawn and updated each frame.

let gameState = "TITLE";
playMode string

Stores whether the game is single-player vs AI (SINGLE) or local multiplayer (MULTI).

let playMode = "MULTI";
p1 object (Player)

The first player instance, controlled by keyboard (WASD) and mouse aiming.

p1 = new Player(1, color(50, 200, 255), p1Controls, false);
p2 object (Player)

The second player instance, controlled either by keyboard (arrow keys) or AI logic.

p2 = new Player(2, color(255, 50, 100), p2Controls, true);
platforms array of objects

A list of solid rectangular platforms that players collide with and stand on. Each platform has x, y, w, h properties.

let platforms = [{ x: 0, y: 560, w: 1000, h: 40 }, { x: 200, y: 450, w: 150, h: 30 }];
bullets array of Bullet objects

Stores all active projectiles currently in flight. Bullets move, bounce, collide with players, and are removed when they expire.

bullets.push(new Bullet(cx, cy, vx, vy, player));
particles array of Particle objects

Stores visual effect particles that fade over time—used for impacts, reloads, healing, walking dust, etc.

particles.push(new Particle(x, y, color, isSmoke));
specialEffects array of (Shockwave or EnergyRing) objects

Stores temporary special effects like shockwaves from blocking or energy rings from shooting.

specialEffects.push(new Shockwave(x, y, owner));
screenShake number

A value (0+) that causes the entire canvas to jitter when > 0. Decays each frame by multiplying by 0.8.

screenShake += 10;
MAPS array of objects

Stores 4 pre-designed arena layouts with platform geometry and player spawn points.

const MAPS = [{ spawns: [...], geometry: [...] }, ...];
ALL_CARDS array of card objects

A deck of 30+ card definitions, each with a name, description, and an apply() function that modifies a player's stats.

{ name: "Tank", desc: "+80 Max HP...", apply: (p) => { p.maxHp += 80; } }
offeredCards array of card objects

The 5 cards currently being offered to the loser for selection (shuffled from ALL_CARDS).

let offeredCards = [];
FRICTION constant (number)

Deceleration multiplier for movement when standing on ground. Higher values = more slippery.

const FRICTION = 0.65;
GRAVITY constant (number)

How fast gravity accelerates downward each frame. Higher = heavier, falls faster.

const GRAVITY = 0.6;
leftMousePressed boolean

Tracks whether the left mouse button is currently held down (used by Player 1 to shoot).

let leftMousePressed = false;
rightMousePressed boolean

Tracks whether the right mouse button is currently held down (used by Player 1 to block).

let rightMousePressed = false;
currentMapIndex number

Index of the current arena map (0–3 for the 4 included maps). Randomized after each round.

let currentMapIndex = 0;
roundWinner Player object or null

Temporary storage of the player who won the current round (deprecated; see roundWinnerLocal in draw()).

let roundWinner = null;
pointsToWin number

How many rounds a player must win to claim victory. Adjustable at the title screen (1–50, default 15).

let pointsToWin = 15;

🔧 Potential Improvements (8)

Here are some ways this code could be enhanced:

PERFORMANCE updatePlaying() bullet collision loop

Bullet-player collision checks every bullet against every player (O(n) per frame), and players are checked for every bullet. With 50+ bullets this becomes slow.

💡 Use spatial partitioning or divide the canvas into grid cells, checking bullets only against cells they occupy. Or use a quad-tree.

BUG Player.update() collision detection

If a player moves fast enough (high velocity), they can tunnel through thin platforms or walls in a single frame without colliding.

💡 Use continuous collision detection: instead of just checking the final position, raytrace from the old position to the new position and find the first platform hit.

STYLE Player class constructor

The Player constructor is 50+ lines with many property initializations, making it hard to read and maintain.

💡 Refactor into separate methods: initStats(), initControls(), initAbilities(). Or create a PlayerStats object that Player wraps.

FEATURE drawCardSelect()

Card previews are static animations; they don't scale to player size or show how the card will affect the player visually.

💡 Add a live preview: render a small version of the current loser with the prospective card applied, showing how big they'll become or how much damage they'll do.

BUG Bullet.update() bounce detection

Bullets can bounce multiple times within a single frame if bounces > 1 and overlap is large, causing wild behavior.

💡 Add a 'lastBouncedTime' property; only allow one bounce per frame, or track which platform was just bounced off to prevent immediate re-collision.

PERFORMANCE draw() state machine

Every frame, draw() checks gameState with multiple if-else chains. For 100+ frames, this is negligible, but it's not elegant.

💡 Create a stateFunctions = { TITLE: drawTitle, PLAYING: drawPlaying, ... } object and call stateFunctions[gameState]() for cleaner dispatch.

FEATURE MAPS constant

Only 4 maps are provided. After a few rounds, the arena feels repetitive.

💡 Add 4+ more maps with different themes (gravity platforms, moving obstacles, hazards). Or procedurally generate maps.

BUG Player.shoot() recoil calculation

Recoil magnitude scales with bulletSize, but bullets can have bulletSize = 0 (from initialization), causing no recoil on early shots.

💡 Ensure bulletSize has a minimum value (e.g., max(8, owner.bulletSize)) when creating bullets.

🔄 Code Flow

Code flow showing setup, draw, updateplaying, buildmap, player, shoot, block, draw, updateai, shockwave, bullet, handlecardselect, drawcardselect

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> state[state-machine] state --> updateplaying[updatePlaying] updateplaying --> particlecleanup[particle-cleanup] updateplaying --> bulletcollision[bullet-collision] bulletcollision --> roundendcheck[round-end-check] updateplaying --> xcollision[x-collision] updateplaying --> ycollision[y-collision] updateplaying --> walldetection[wall-detection] updateplaying --> player[player] player --> sizecaling[size-scaling] player --> walkanimation[walk-animation] player --> bodyrotation[body-rotation] player --> eyes[eyes] player --> ammoDisplay[ammo-display] player --> hpBar[hp-bar] player --> shoot[shoot] shoot --> projectileLoop[projectile-loop] projectileLoop --> ringSpawn[ring-spawn] shoot --> recoil[recoil] player --> block[block] block --> shockwaveSpawn[shockwave-spawn] block --> healBlock[heal-block] updateplaying --> updateai[updateAI] updateai --> aiMoveDecision[ai-move-decision] aiMoveDecision --> aiCliffDetection[ai-cliff-detection] aiMoveDecision --> aiAiming[ai-aiming] aiMoveDecision --> aiBlocking[ai-blocking] updateplaying --> shockwave[shockwave] shockwave --> shockwaveExpansion[shockwave-expansion] shockwave --> damageApply[damage-apply] updateplaying --> bullet[bullet] bullet --> homingLogic[homing-logic] bullet --> bounceLogic[bounce-logic] bullet --> outOfBounds[out-of-bounds] draw --> gridBackground[grid-background] draw --> drawCardSelect[drawCardSelect] drawCardSelect --> cardGrid[card-grid] drawCardSelect --> hoverDetection[hover-detection] handleCardSelect[handleRoundEnd] --> drawCardSelect click setup href "#fn-setup" click draw href "#fn-draw" click state href "#sub-state-machine" click updateplaying href "#fn-updateplaying" click particlecleanup href "#sub-particle-cleanup" click bulletcollision href "#sub-bullet-collision" click roundendcheck href "#sub-round-end-check" click xcollision href "#sub-x-collision" click ycollision href "#sub-y-collision" click walldetection href "#sub-wall-detection" click player href "#fn-player" click sizecaling href "#sub-size-scaling" click walkanimation href "#sub-walk-animation" click bodyrotation href "#sub-body-rotation" click eyes href "#sub-eyes" click ammoDisplay href "#sub-ammo-display" click hpBar href "#sub-hp-bar" click shoot href "#fn-shoot" click projectileLoop href "#sub-projectile-loop" click ringSpawn href "#sub-ring-spawn" click recoil href "#sub-recoil" click block href "#fn-block" click shockwaveSpawn href "#sub-shockwave-spawn" click healBlock href "#sub-heal-block" click updateai href "#fn-updateai" click aiMoveDecision href "#sub-ai-move-decision" click aiCliffDetection href "#sub-ai-cliff-detection" click aiAiming href "#sub-ai-aiming" click aiBlocking href "#sub-ai-blocking" click shockwave href "#fn-shockwave" click shockwaveExpansion href "#sub-shockwave-expansion" click damageApply href "#sub-damage-apply" click bullet href "#fn-bullet" click homingLogic href "#sub-homing-logic" click bounceLogic href "#sub-bounce-logic" click outOfBounds href "#sub-out-of-bounds" click gridBackground href "#sub-grid-background" click drawCardSelect href "#fn-drawcardselect" click cardGrid href "#sub-card-grid" click hoverDetection href "#sub-hover-detection" click handleCardSelect href "#fn-handlecardselect"

❓ Frequently Asked Questions

What visual experience does the ROUNDS (improved) sketch provide?

The sketch creates an engaging visual experience with fast-paced duels featuring dynamic shifting platforms, bullets, explosions, and a shower of particles accompanied by screen shake effects.

How can players interact with the ROUNDS (improved) game?

Players can engage in local multiplayer or single-player modes, battling against friends or an AI while selecting powerful upgrade cards between rounds to enhance their abilities.

What creative coding concepts are showcased in the ROUNDS (improved) sketch?

This sketch demonstrates concepts such as game physics, local multiplayer interactions, and procedural map generation to create immersive gaming experiences.

Preview

ROUNDS (improved) - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of ROUNDS (improved) - Code flow showing setup, draw, updateplaying, buildmap, player, shoot, block, draw, updateai, shockwave, bullet, handlecardselect, drawcardselect
Code Flow Diagram