ROUNDS

ROUNDS is a chaotic local-multiplayer combat game inspired by the roguelike shooter of the same name. Two players battle across platform-filled arenas, dodging bullets and blocking attacks, while collecting power-up cards between rounds that fundamentally reshape their playstyle with unique abilities, stat boosts, and weapon modifiers.

🧪 Try This!

Experiment with the code by making these changes:

  1. Adjust match length — Change the pointsToWin variable to a different number to make matches shorter (3) or longer (10).
  2. Double gravity strength — Increase GRAVITY to make players fall much faster and jump arcs much tighter—changes the feel of platforming dramatically.
  3. Faster bullet bouncing — Reduce the Bouncy card's bounce limit threshold or increase bounce count—watch bullets ricochet more times before stopping.
  4. Disable screen shake
  5. Make cards more powerful — Boost the Tank card's HP gain to make tankier builds even more viable.
  6. Faster homing bullets — Reduce the homing turn rate so homing bullets snap to target much more quickly.
  7. Taller canvas — Increase canvas height to create a taller, more vertical arena.
Prefer the full editor? Open it there →

📖 About This Sketch

ROUNDS creates an intense, screen-shaking duel between two fighters on destructible platforms. One or two players battle using WASD/arrows to move and jump, mouse or directional keys to aim, and click or keyboard to shoot or block. What makes it special is the card system: after each round, the loser picks from five randomized power-ups that might grant explosive bullets, speed boosts, healing, or wild abilities like homing projectiles and protective orbitals. The game uses p5.js drawing functions, transform matrices (translate, rotate, scale), physics simulation with gravity and friction, collision detection between bullets and players, particle systems for visual feedback, and a multi-state game machine (title, playing, round-over, card select, game-over) that keeps the action flowing.

The code is organized as a complex object-oriented sketch with Player, Bullet, Particle, Shockwave, and EnergyRing classes, plus a global game state machine. You will learn how to build a real, playable game by studying the interaction between input handling (mouse and keyboard), physics updates each frame, collision checking loops, and UI rendering. The sketch demonstrates best practices for separating concerns: each class owns its own update() and draw() logic, special effects are stored in arrays and culled when inactive, and the game state controls which logic runs and what the player sees.

⚙️ How It Works

  1. On startup, setup() creates a 1000×600 canvas, initializes two Player objects with starting positions loaded from a map, and prepares the title screen. The player sees controls displayed and can press 1 for single-player AI mode or 2 for local multiplayer.
  2. Once a game starts, the draw loop runs 60 times per second. If the game state is PLAYING, updatePlaying() runs: each player's update() method processes input (or AI logic), applies gravity, checks collision with platforms, updates ammo and ability cooldowns. Bullets and special effects also update their positions and check for hits; particles fade and are removed when their life reaches zero.
  3. Collision detection happens in two passes: first, bullets check if they hit a player by testing circle-to-rectangle distance; if so, they deal damage (or bounce if the player blocked, or trigger lifesteal if the shooter has that card). Platform collision is axis-aligned bounding-box checking done separately for X and Y so players can slide smoothly along walls.
  4. When a player's HP drops to zero, the game transitions to ROUND_OVER: the winner's score increments, and after a 100-frame delay, the loser enters the CARD_SELECT state. Five random cards from the ALL_CARDS array are offered; the loser picks one (or AI picks automatically), the card's apply() function modifies the player's stats, and a fresh map loads for the next round.
  5. If a player reaches the target score (default 5 wins), they win the match and the game moves to GAME_OVER. Pressing space returns to the title screen.
  6. Throughout, p5.js drawing creates the arena platforms, renders both players with limbs, eyes, and gun/shield accessories, draws all bullets with motion trails, spawns particles on impact and jumps, and applies screen shake by translating the entire viewport briefly when explosions occur.

🎓 Concepts You'll Learn

Object-oriented design with classesGame state machine (TITLE, PLAYING, ROUND_OVER, CARD_SELECT, GAME_OVER)Collision detection (circle-to-rectangle and axis-aligned bounding boxes)Physics simulation (gravity, friction, velocity, jumping)Particle systems and visual effectsInput handling (keyboard and mouse)AI pathfinding and decision-makingProcedural card system with apply() callbacksCanvas transforms and layered drawingAnimation via frameCount and sine/cosine waves

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, sets fonts, creates the two players, and loads the first map. This is where you configure your game's starting state.

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 (8 lines)

🔧 Subcomponents:

calculation Canvas Creation createCanvas(1000, 600);

Sets up the drawing surface to 1000×600 pixels—the arena where all game action occurs

calculation Right-Click Prevention document.addEventListener('contextmenu', event => event.preventDefault());

Prevents the browser context menu from appearing when player right-clicks to block, keeping the game responsive

calculation Player Instantiation p1 = new Player(1, color(50, 200, 255), p1Controls, false);

Creates the first player with ID 1, blue color, WASD controls, and false (not AI)

createCanvas(1000, 600);
Creates a 1000×600 pixel canvas—the visible game arena. All drawing happens on this canvas.
textFont('Impact');
Sets the font for all text rendering to Impact, matching the bold game aesthetic.
document.addEventListener('contextmenu', event => event.preventDefault());
Disables the browser's right-click context menu so right-click blocking works without interruption.
let p1Controls = { up: 87, down: 83, left: 65, right: 68 };
Defines key codes for player 1: 87=W, 83=S, 65=A, 68=D. These are stored as an object and passed to the Player constructor.
let p2Controls = { up: UP_ARROW, down: DOWN_ARROW, left: LEFT_ARROW, right: RIGHT_ARROW, shoot: 190, block: 191 };
Defines key codes for player 2 using arrow keys for movement, 190 (period) for shoot, 191 (slash) for block.
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Creates player 1 with ID 1, cyan color, keyboard controls, and isAI=false (human player).
p2 = new Player(2, color(255, 50, 100), p2Controls, false);
Creates player 2 with ID 2, magenta color, arrow key controls, and isAI=false (also human by default, but can change in startGame).
buildMap(currentMapIndex);
Loads the starting map (index 0) and positions both players at their spawn points on that map.

draw()

draw() is called 60 times per second. It clears the canvas, draws the grid, and routes through a state machine: depending on what gameState is (TITLE, PLAYING, ROUND_OVER, CARD_SELECT, or GAME_OVER), different logic runs. This is a classic game architecture pattern—keeping game logic separate by state prevents spaghetti code and makes adding new screens easy.

🔬 This is the state machine. What happens if you swap the order of updatePlaying() and drawPlaying()—does the game still work, and do you notice any visual changes?

  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);
    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 (9 lines)

🔧 Subcomponents:

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

Draws vertical lines every 40 pixels to create the decorative grid backdrop

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

Routes to different update and draw functions based on the current game state

conditional Round End Delay roundEndTimer--; if (roundEndTimer <= 0) { handleRoundEnd(...); }

Counts down from 100 frames before transitioning to card select, letting the victory message display

background(20, 20, 24);
Clears the canvas each frame with a very dark blue-gray color (nearly black).
stroke(255, 10); strokeWeight(1);
Sets the grid lines to white with very low alpha (transparency), making them subtle background decoration.
for(let i=0; i<width; i+=40) line(i, 0, i, height);
Draws vertical grid lines every 40 pixels from top to bottom of the canvas.
for(let j=0; j<height; j+=40) line(0, j, width, j);
Draws horizontal grid lines every 40 pixels from left to right, completing the grid.
if (gameState === "TITLE") { drawTitle(); }
When the game state is TITLE, render the main menu with controls and mode selection.
} else if (gameState === "PLAYING") { updatePlaying(); drawPlaying(); }
When PLAYING, call updatePlaying() to advance game logic, then drawPlaying() to render all game elements.
roundEndTimer--; if (roundEndTimer <= 0) { handleRoundEnd(...); }
Count down from 100 frames in ROUND_OVER state; when timer reaches zero, transition to CARD_SELECT and award the winner a point.
} else if (gameState === "CARD_SELECT") { drawPlaying(); drawCardSelect(); }
Show the card selection screen overlaid on the game arena; if AI player, auto-pick a card after a delay.
} else if (gameState === "GAME_OVER") { drawGameOver(); }
When a player reaches the target score, show the game over screen and wait for space to return to title.

updatePlaying()

updatePlaying() is the beating heart of the game loop. Every frame it updates physics, bullets, particles, and special effects. The bullet loop demonstrates backward iteration for safe deletion, and the hit detection shows circle-to-rectangle collision math without heavy physics libraries. The screen shake decay (multiply by 0.8) is a common technique for smooth, damped animations.

🔬 This is the hit detection logic—it uses circle-to-rectangle collision. What happens if you change b.r * b.r to (b.r * 2) * (b.r * 2)? Will bullets have a bigger hit zone?

      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") {
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 (14 lines)

🔧 Subcomponents:

calculation Player Physics & Input p1.update(p2); p2.update(p1);

Updates each player's position, applies physics, checks platform collisions, and processes input or AI logic

for-loop Bullet Update & Collision for (let i = bullets.length - 1; i >= 0; i--) { ... }

Iterates through all bullets backward (so removing bullets doesn't skip any), updates their positions, and checks collision with each player

conditional Block Bounce Logic if (target.blockingFrames > 0) { b.vx *= -1.5; ... }

If the target is blocking, reverse the bullet's velocity and change ownership so it can hit the shooter

conditional Hit Damage & Effects target.hp -= b.damage; if (b.frost) target.frostTimer = 120;

Deals damage and applies status effects (frost slows, poison ticks) to the hit target

conditional Lifesteal Healing if (b.owner.lifesteal > 0) { b.owner.hp = min(b.owner.maxHp, ...); }

If the shooter has the Vampire card, heal them for a percentage of the damage they dealt

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

Transitions to ROUND_OVER state when either player's health drops to zero or below

p1.update(p2); p2.update(p1);
Update both players each frame: process movement input, apply gravity, check platform collisions, reload ammo, update cooldowns, and if AI, compute movement and aiming.
for (let i = bullets.length - 1; i >= 0; i--) {
Loop through bullets backward (from end to start) so that removing an inactive bullet doesn't cause the loop to skip the next bullet.
b.update();
Update the bullet's position (move by velocity, handle homing if enabled, check bounce off platforms).
let testX = constrain(b.x, target.x, target.x + target.w);
Find the closest point on the player's bounding box to the bullet's center—clamping the bullet's X to the player's X range.
let distSq = (b.x - testX)**2 + (b.y - testY)**2;
Calculate squared distance from bullet center to closest point on player box (avoids expensive sqrt for circle-to-rectangle collision).
if (distSq <= b.r * b.r && gameState !== "ROUND_OVER") {
If distance is within bullet radius (a hit) and the round is still active, handle collision logic.
if (target.blockingFrames > 0) { b.vx *= -1.5; b.vy *= -1.5; b.owner = target;
If the target is actively blocking (shield up), reverse the bullet's velocity, change ownership to the target, and push them back slightly.
target.hp -= b.damage;
Subtract the bullet's damage from the target's health.
if (b.frost) target.frostTimer = 120; if (b.poison) target.poisonTimer = 180;
If the bullet has frost or poison effects, set the target's timer so they'll slow down or take DOT damage in the next frames.
if (b.owner.lifesteal > 0) { b.owner.hp = min(b.owner.maxHp, b.owner.hp + (b.damage * b.owner.lifesteal)); }
Vampire card healing: the shooter recovers a percentage of the damage dealt (e.g., 20% lifesteal on a 25 damage hit = +5 HP).
if (!b.active) bullets.splice(i, 1);
Remove the bullet from the array if it's no longer active (destroyed, bounced away, or out of bounds).
if (particles.length > 200) particles.splice(0, particles.length - 200);
Cull old particles: if there are more than 200, remove the oldest ones (from the start of the array) to prevent lag.
if (screenShake > 0) { screenShake *= 0.8; if (screenShake < 0.5) screenShake = 0; }
Decay screen shake each frame: multiply by 0.8 to reduce magnitude, set to zero once it's negligible so the camera stops jittering.
if ((p1.hp <= 0 || p2.hp <= 0) && gameState === "PLAYING") { gameState = "ROUND_OVER"; }
Detect when a round ends: if either player's HP drops to zero and the game is still playing, switch to ROUND_OVER state.

drawPlaying()

drawPlaying() separates rendering into layers: first the game world (platforms, effects, bullets, players) using push/pop to isolate screen shake, then the unshaken HUD on top. This is a common pattern in games—separate fixed UI from camera-affected world. The card inventory shows how to dynamically generate UI from a player's collected cards array.

🔬 The push/pop and translate create screen shake. What if you remove the push/pop but keep the translate? How will the HUD be affected?

  push();
  if (screenShake > 0) {
    translate(random(-screenShake, screenShake), random(-screenShake, screenShake));
  }

  for (let p of platforms) {
    fill(35, 35, 45);
    stroke(80);
    strokeWeight(3);
    rect(p.x, p.y, p.w, p.h, 6);
  }
function drawPlaying() {
  push();
  if (screenShake > 0) {
    translate(random(-screenShake, screenShake), random(-screenShake, screenShake));
  }

  for (let p of platforms) {
    fill(35, 35, 45);
    stroke(80);
    strokeWeight(3);
    rect(p.x, p.y, p.w, p.h, 6);
  }

  for (let e of specialEffects) e.draw(); 
  for (let p of particles) p.draw();
  for (let b of bullets) b.draw();
  p1.draw();
  p2.draw();
  pop();

  // ==========================================
  // TOP LEFT HUD: SCORES
  // ==========================================
  noStroke();
  let dotSize = 10;
  let spacing = 16;
  
  for(let i=0; i<pointsToWin; i++) {
      if (i < p1.score) fill(p1.color); else fill(50);
      circle(30 + i * spacing, 30, dotSize);
  }
  for(let i=0; i<pointsToWin; i++) {
      if (i < p2.score) fill(p2.color); else fill(50);
      circle(30 + i * spacing, 50, dotSize);
  }

  let hoveredHUDCard = null;

  // ==========================================
  // TOP RIGHT HUD: INVENTORY
  // ==========================================
  for(let i=0; i<p1.cards.length; i++) {
      let words = p1.cards[i].name.split(' ');
      let initials = words.length === 1 ? words[0].substring(0,2) : words[0][0] + words[1][0];
      initials = initials.toUpperCase();
      
      let cx = width - 50 - i*35;
      let cy = 25;
      fill(40); stroke(p1.color); strokeWeight(2);
      rectMode(CENTER);
      rect(cx, cy, 30, 30, 4);
      fill(255); noStroke(); textSize(14); textAlign(CENTER, CENTER);
      text(initials, cx, cy);

      if (abs(mouseX - cx) < 15 && abs(mouseY - cy) < 15) {
          hoveredHUDCard = {card: p1.cards[i], color: p1.color, x: cx, y: cy};
      }
  }
  
  for(let i=0; i<p2.cards.length; i++) {
      let words = p2.cards[i].name.split(' ');
      let initials = words.length === 1 ? words[0].substring(0,2) : words[0][0] + words[1][0];
      initials = initials.toUpperCase();
      
      let cx = width - 50 - i*35;
      let cy = 65;
      fill(40); stroke(p2.color); strokeWeight(2);
      rectMode(CENTER);
      rect(cx, cy, 30, 30, 4);
      fill(255); noStroke(); textSize(14); textAlign(CENTER, CENTER);
      text(initials, cx, cy);

      if (abs(mouseX - cx) < 15 && abs(mouseY - cy) < 15) {
          hoveredHUDCard = {card: p2.cards[i], color: p2.color, x: cx, y: cy};
      }
  }

  if (hoveredHUDCard) {
      drawSingleCard(hoveredHUDCard.card, hoveredHUDCard.x - 90, hoveredHUDCard.y + 140, 160, 240, hoveredHUDCard.color, false, true);
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Screen Shake Effect if (screenShake > 0) { translate(random(-screenShake, screenShake), random(-screenShake, screenShake)); }

Applies a random jitter to the viewport when screenShake > 0, creating a visceral impact effect during explosions

for-loop Platform Rendering for (let p of platforms) { fill(35, 35, 45); ... rect(p.x, p.y, p.w, p.h, 6); }

Draws all arena platforms as dark gray rounded rectangles with a slightly lighter border

for-loop Score Indicator Dots for(let i=0; i<pointsToWin; i++) { if (i < p1.score) fill(p1.color); else fill(50); circle(...); }

Draws a row of dots in the top-left: filled dots for wins, empty dots for losses remaining

for-loop Card Inventory Display for(let i=0; i<p1.cards.length; i++) { ... rect(cx, cy, 30, 30, 4); text(initials, ...); }

Shows each collected card as a small box in the top-right corner with its initials, hovering reveals a tooltip

push();
Save the current drawing state (transforms, colors, etc.) so screen shake only affects game elements, not the HUD.
if (screenShake > 0) { translate(random(-screenShake, screenShake), random(-screenShake, screenShake)); }
If screen shake is active, apply a random translation each frame—creates a camera jitter effect during impacts.
for (let p of platforms) { fill(35, 35, 45); stroke(80); strokeWeight(3); rect(p.x, p.y, p.w, p.h, 6); }
Draw each platform as a dark gray rounded rectangle with a slightly lighter outline—the arena structure.
for (let e of specialEffects) e.draw(); for (let p of particles) p.draw(); for (let b of bullets) b.draw();
Draw all active special effects (shockwaves, energy rings), particles, and bullets on top of the platforms.
p1.draw(); p2.draw();
Draw both player characters with their bodies, eyes, limbs, gun, and shield—complex rendering with transforms.
pop();
Restore the drawing state, undoing screen shake so the HUD (score dots, inventory) render unshaken.
for(let i=0; i<pointsToWin; i++) { if (i < p1.score) fill(p1.color); else fill(50); circle(30 + i * spacing, 30, dotSize); }
Draw score dots for player 1: a row of pointsToWin circles, filled with player 1's color if won, gray if not.
let words = p1.cards[i].name.split(' '); let initials = words.length === 1 ? words[0].substring(0,2) : words[0][0] + words[1][0];
Extract card initials: if card name is one word, take first two letters; if two words, take first letter of each.
if (abs(mouseX - cx) < 15 && abs(mouseY - cy) < 15) { hoveredHUDCard = {...}; }
Detect mouse hover over card icon: if within 15 pixels (a small box), set hoveredHUDCard to display a tooltip.
if (hoveredHUDCard) { drawSingleCard(hoveredHUDCard.card, hoveredHUDCard.x - 90, ...); }
If a card is hovered, draw a large expanded card tooltip showing its full name, description, and preview animation.

Player.update(opponent)

Player.update() is massive because the player is the most complex entity. It handles input/AI, physics (gravity + velocity clamping), collision with platforms (separate X and Y to allow sliding), wall detection for sliding, cooldown management (shooting, blocking), status effects (poison, frost), and passive abilities (regen, orbitals). This method shows how to layer multiple systems (input + physics + collision + abilities) without one overwhelming the others.

🔬 Frost creates blue particles while active. What happens if you change random() < 0.2 to random() < 0.8? Will you see more or fewer particles?

    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)); 
    }
  update(opponent) {
    if (this.hp <= 0) return;

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

    if (this.hasOrbitals) {
      this.orbitalAngle += 0.08;
      for(let i=0; i<2; i++) {
        let ox = this.x + this.w/2 + cos(this.orbitalAngle + i*PI) * 45;
        let oy = this.y + this.h/2 + sin(this.orbitalAngle + i*PI) * 45;
        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 -= 8;
            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 (15 lines)

🔧 Subcomponents:

calculation Cooldown Decrement this.shootTimer = max(0, this.shootTimer - 1); this.blockTimer = max(0, this.blockTimer - 1);

Count down ability cooldowns each frame, clamping to 0 so they don't go negative

for-loop Orbital Ability Damage if (this.hasOrbitals) { for(let i=0; i<2; i++) { ... if (dist(...) < 20 + opponent.w/2) { opponent.hp -= 8; } } }

If player has orbitals card, orbit two circles around them and deal 8 damage if they touch the opponent every 10 frames

conditional Ammo & Reload if (this.reloadTimer > 0) { this.reloadTimer--; if (this.reloadTimer <= 0) { this.ammo = this.maxAmmo; } }

Count down reload timer; when it reaches zero, refill ammo and show reload particles

conditional Regeneration if (this.regen > 0 && this.hp > 0 && this.hp < this.maxHp) { this.hp = min(this.maxHp, this.hp + this.regen); }

If player has regen stat from cards, slowly heal up to maxHp each frame

conditional Poison Damage Over Time if (this.poisonTimer > 0) { this.poisonTimer--; if (frameCount % 15 === 0) this.hp -= 1; }

Poison deals 1 damage every 15 frames while the timer is active, with green particle feedback

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

While frostTimer > 0, reduce horizontal velocity to 60% each frame, creating a slow/freeze effect

calculation Gravity Application this.vy += GRAVITY; this.vy = constrain(this.vy, -25, MAX_FALL_SPEED);

Apply constant downward acceleration and clamp fall speed to prevent infinite acceleration

for-loop Horizontal Platform Collision this.x += this.vx; for (let p of platforms) { if (rectIntersect(...)) { if (this.vx > 0) this.x = p.x - this.w; ... } }

Move horizontally and push back out of any overlapping platforms, zeroing vx

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

Move vertically and land/ceiling-bonk; when landing, reset jumps and set onGround flag

for-loop Wall Slide Detection if (!this.onGround) { let checkL = false, checkR = false; for (let p of platforms) { if (rectIntersect(this.x - 2, this.y, ...)) checkL = true; } if (checkL) this.wallDir = -1; }

While airborne, check if player is touching a wall on left or right; if so, set wallDir and reduce fall speed

if (this.hp <= 0) return;
Dead players don't update—exit early to skip all movement and ability logic.
this.shootTimer = max(0, this.shootTimer - 1);
Count down the shoot cooldown each frame, clamping to zero so it can't go negative.
if (this.hasOrbitals) { this.orbitalAngle += 0.08;
If player has the Orbitals card, rotate the orbital angle slightly each frame.
let ox = this.x + this.w/2 + cos(this.orbitalAngle + i*PI) * 45;
Calculate orbital position using cos/sin at a radius of 45 pixels, offset by multiples of PI so two orbitals are opposite.
if (frameCount % 10 === 0 && dist(ox, oy, opponent.x+opponent.w/2, opponent.y+opponent.h/2) < 20 + opponent.w/2)
Every 10 frames, check if an orbital is close enough to the opponent (touching distance); if so, deal 8 damage.
if (this.reloadTimer > 0) { this.reloadTimer--; if (this.reloadTimer <= 0) { this.ammo = this.maxAmmo; }
Reload system: if reloading, count down the timer; when it reaches zero, refill ammo.
if (this.ammo <= 0) { this.reloadTimer = this.reloadTime; }
If ammo is depleted and not already reloading, start the reload timer.
if (this.regen > 0 && this.hp > 0 && this.hp < this.maxHp) { this.hp = min(this.maxHp, this.hp + this.regen); }
Regeneration: if player has regen stat, heal each frame (capped at maxHp).
if (this.poisonTimer > 0) { this.poisonTimer--; if (frameCount % 15 === 0) this.hp -= 1;
Poison effect: count down the timer, and every 15 frames deal 1 damage as long as timer > 0.
if (this.frostTimer > 0) { this.vx *= 0.6; }
Frost effect: multiply horizontal velocity by 0.6 each frame, making the player move slower.
this.vy += GRAVITY; this.vy = constrain(this.vy, -25, MAX_FALL_SPEED);
Apply gravity (constant downward acceleration) and clamp fall speed to a maximum so falling doesn't accelerate indefinitely.
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;
If player overlaps a platform, push them out horizontally by moving them to the left or right edge based on their velocity direction.
if (this.vy > 0) { this.y = p.y - this.h; this.onGround = true; this.jumpsLeft = this.maxJumps; }
Landing on a platform from above: push player above it, set onGround flag, and reset jumps.
if (checkL) this.wallDir = -1; if (checkR) this.wallDir = 1;
If a wall is detected on left or right while airborne, set wallDir to -1 or 1 so player can wall-slide or wall-jump.
if (this.wallDir !== 0 && this.vy > 0) { this.vy *= 0.75; ... createParticles(...); }
While sliding on a wall downward, reduce fall velocity and emit particles to show wall friction.

Player.draw()

Player.draw() is a masterclass in layered p5.js drawing. It uses push/pop extensively to isolate transforms, rotate() to point the gun in the aim direction, translate() to move anatomy relative to the body center, and conditional alpha values to show poison status. The walk animation (sine wave feet bobbing), body stretch (velocity-based deformation), and eye-following (aiming at cursor/AI target) make a simple rectangle feel alive and expressive.

🔬 Walk animation uses sin/cos to bob feet. What if you remove the onGround check—will players' feet bob even while airborne/falling? Try it.

    let stretchFactor = constrain(vMag * 0.04, 0, 0.4);

    translate(cx, cy); 
    
    let walkPhase = frameCount * 0.4;
    let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * 8 : 0;
    let lift2 = (this.onGround && abs(this.vx) > 0.5) ? max(0, -sin(walkPhase)) * 8 : 0;
  draw() {
    if (this.hp <= 0) return; 

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

    let gunDist = 55; 
    let shieldDist = 35;

    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.hasOrbitals) {
      for(let i=0; i<2; i++) {
        let ox = cx + cos(this.orbitalAngle + i*PI) * 45;
        let oy = cy + bodyOffset + sin(this.orbitalAngle + i*PI) * 45;
        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); 
    
    let walkPhase = frameCount * 0.4;
    let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * 8 : 0;
    let lift2 = (this.onGround && abs(this.vx) > 0.5) ? max(0, -sin(walkPhase)) * 8 : 0;
    
    let foot1X = -8; 
    let foot1Y = 10 - lift1; 
    
    let foot2X = 8;  
    let foot2Y = 10 - 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(6);
    strokeCap(ROUND);
    line(0, bodyOffset + 5, foot1X, foot1Y);
    line(0, bodyOffset + 5, foot2X, foot2Y);

    noStroke();
    fill(cR, cG, cB, this.alpha);
    circle(foot1X, foot1Y, 10);
    circle(foot2X, foot2Y, 10);

    stroke(this.color.levels[0]*0.8, this.color.levels[1]*0.8, this.color.levels[2]*0.8, this.alpha);
    strokeWeight(5);
    line(0, bodyOffset + 2, shieldX, shieldY);
    line(0, bodyOffset + 2, 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 - 6, eyeOffsetY, eyeW); 
    circle(eyeOffsetX + 6, eyeOffsetY, eyeW); 
    
    fill(20, 20, 24, this.alpha);
    circle(eyeOffsetX - 6 + this.aimVec.x * 2, eyeOffsetY + this.aimVec.y * 2, eyeW * 0.4);
    circle(eyeOffsetX + 6 + 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);
    rect(this.x - 5, this.y - 45, 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 - 45, hpW, 6, 2);
  }
Line-by-line explanation (17 lines)

🔧 Subcomponents:

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

When not blocking, draw a faint line from gun to show where bullets will travel

for-loop Orbital Rendering if (this.hasOrbitals) { for(let i=0; i<2; i++) { ... circle(ox, oy, 12); } }

If player has orbitals, render two circles orbiting at the calculated positions

calculation Velocity-Based Stretch let vMag = dist(0, 0, this.vx, this.vy); let stretchFactor = constrain(vMag * 0.04, 0, 0.4);

Calculate how much to squash/stretch the body based on movement speed, clamped to 0-0.4

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

Animate feet bobbing up and down using sine wave when walking on ground, idle when standing

conditional Block Shield Animation if (this.blockingFrames > 0) { ... let progress = 1 - (this.blockingFrames / this.blockDuration); let shieldSize = this.w * 2.5 + (progress * 40);

Grow shield circle from small to large while blocking, then shrink when block ends

calculation Eyes Follow Aim let eyeOffsetX = this.aimVec.x * (this.w * 0.2); ... circle(eyeOffsetX - 6, eyeOffsetY, eyeW);

Eyes position themselves to look in the direction the player is aiming

calculation Gun Shape & Ammo Display let gunLen = 22 + min(this.maxAmmo * 2, 35); ... beginShape(); vertex(...); endShape(CLOSE);

Draw gun barrel length based on ammo capacity, and display ammo dots (filled = loaded, empty = used)

calculation Health Bar Above Head rect(this.x - 5, this.y - 45, 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);

Draw red background bar and green (or poison-green) health bar above the player's head showing current HP

if (this.hp <= 0) return;
Dead players are invisible—exit early without rendering.
let bodyOffset = -15;
Offset for where the head/body is relative to the center point—used throughout for positioning limbs and gun.
let cx = this.x + this.w/2; let cy = this.y + this.h/2;
Calculate the player's center point in world space—the pivot for transforms.
let gunHX = this.aimVec.x * gunDist; let gunHY = bodyOffset + this.aimVec.y * gunDist;
Position gun 55 pixels away from center in the aim direction—used for drawing both the aim line and the gun model.
if (this.hp > 0 && !this.blockingFrames) { stroke(...); line(gunX, gunY, endX, endY); }
Draw a faint aim line from gun extending 1200 pixels in the aim direction—shows where bullets will go.
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);
Calculate body squash/stretch: speed magnitude, direction angle, and clamped factor (0-0.4) for visual exaggeration.
translate(cx, cy);
Move the origin to the player's center so all subsequent transforms are relative to the player, not the canvas.
let walkPhase = frameCount * 0.4; let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * 8 : 0;
Walk animation: foot lifts up to 8 pixels using sine wave phase, but only when on ground and moving faster than 0.5 px/frame.
let cR = red(this.color) * 0.7; let cG = green(this.color) * 0.7; let cB = blue(this.color) * 0.7;
Darken player's color by 30% for limbs—creates visual separation from the main body.
line(0, bodyOffset + 5, foot1X, foot1Y); circle(foot1X, foot1Y, 10);
Draw left leg (line) and left foot (circle)—the foot bobs up and down based on walkPhase.
ellipse(0, 0, this.w * (1.1 + stretchFactor), this.h * (1.1 - stretchFactor * 0.5));
Stretch body horizontally as velocity increases (wider), and squash vertically (taller becomes shorter)—exaggerates motion.
let eyeW = this.w * 0.35; fill(255, this.alpha); circle(eyeOffsetX - 6, eyeOffsetY, eyeW); circle(eyeOffsetX + 6, eyeOffsetY, eyeW);
Draw two large white eye circles offset by aim direction, then dark pupils on top for looking direction.
if (this.blockingFrames > 0) { ... let shieldSize = this.w * 2.5 + (progress * 40); circle(0, bodyOffset, shieldSize); }
When blocking, draw expanding shield circle that grows over the block duration—visual feedback of block strength.
let gunLen = 22 + min(this.maxAmmo * 2, 35);
Gun barrel length scales with max ammo (longer mag = longer gun barrel), capped at +35 pixels.
if (this.reloadTimer > 0) { let progress = 1 - (this.reloadTimer / this.reloadTime); rect(..., barW * progress, ...); }
While reloading, show a progress bar filling left-to-right as reload timer counts down.
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); }
Draw ammo indicator circles above gun: gold for loaded rounds, gray for spent ammo—visual ammo counter.
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);
HP bar above head: color changes to poisoned green if affected by poison, scaled proportionally to current HP.

Bullet.update()

Bullet.update() demonstrates two physics systems: homing (angle-based steering using atan2 and angle wrapping) and bouncing (overlap-based reflection). The history array creates a motion trail efficiently by keeping only recent positions. The angle difference wrapping (handling -PI to PI wraparound) is a key trick for smooth rotation in any direction—without it, bullets would spin the long way around.

🔬 Homing steers 15% toward target each frame. What if you comment out currentAngle += diff * 0.15 entirely? Will homing bullets still work, and what will happen?

    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;
  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 (14 lines)

🔧 Subcomponents:

calculation Motion Trail History this.history.push({x: this.x, y: this.y}); if (this.history.length > 5) this.history.shift();

Keep last 5 positions to draw a motion trail behind the bullet

conditional Homing Target Steering if (this.homing && this.active && this.target.hp > 0) { ... let diff = targetAngle - currentAngle; ... currentAngle += diff * 0.15;

If bullet has homing, steer velocity toward opponent by adjusting angle gradually (0.15 = 15% per frame)

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

Calculate which side of platform bullet hit and flip velocity accordingly, consume a bounce charge

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

Mark bullet inactive if it travels far off-screen (with 100px buffer) to cull from memory

this.history.push({x: this.x, y: this.y}); if (this.history.length > 5) this.history.shift();
Store current position in history array (FIFO queue of last 5 frames) to draw a motion trail behind the bullet.
if (this.homing && this.active && this.target.hp > 0) {
Only apply homing if bullet has homing enabled, is active, and target is still alive.
let dx = this.target.x + this.target.w/2 - this.x; let targetAngle = atan2(dy, dx);
Calculate vector to opponent's center and convert to angle in radians (atan2 gives -PI to PI).
let currentAngle = atan2(this.vy, this.vx);
Convert bullet's velocity vector to its current angle of travel.
let diff = targetAngle - currentAngle; while (diff < -PI) diff += TWO_PI; while (diff > PI) diff -= TWO_PI;
Calculate shortest angle difference (handles wraparound at ±PI)—ensures bullet steers the short way, not the long way around.
currentAngle += diff * 0.15;
Steer: move 15% of the way toward the target angle each frame (0.15 controls how responsive/smooth the turn is).
let speed = dist(0, 0, this.vx, this.vy); speed = min(speed + 0.3, this.owner.bulletSpeed * 1.5);
Accelerate homing bullet slightly each frame (up to 1.5x its base speed) to make it feel more powerful.
this.x += this.vx; this.y += this.vy;
Move bullet by its velocity vector (or updated velocity if homing).
if (!this.piercing) {
Only check platform collisions if bullet doesn't have piercing—piercing bullets ignore platforms.
let overlapLeft = (this.x + this.r) - p.x;
How far the bullet extends past the platform's left edge—used to determine bounce direction.
let minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
Find the smallest overlap on any side—that's the side the bullet hit and should bounce off.
if (minOverlap === overlapLeft || minOverlap === overlapRight) this.vx *= -1;
If closest hit is on left or right side, flip horizontal velocity.
if (this.bounces > 0) { this.bounces--; ... return; } else { this.destroy(); }
If bounces remain, consume one and reverse velocity; if no bounces left, destroy the bullet.
if (this.x < -100 || this.x > width+100 || this.y < -100 || this.y > height+100) { this.active = false; }
Cull bullet if it travels 100+ pixels off-screen—prevents memory buildup from stray shots.

buildMap(index)

buildMap() is the round reset function. It loads a new arena geometry, adds invisible boundary walls, positions both players at their spawn points, resets all their stats and status conditions, and clears all game objects. This is a critical function: it's the dividing line between rounds, ensuring a clean slate for each battle.

function buildMap(index) {
  let mapData = MAPS[index];
  platforms = mapData.geometry.map(p => ({...p})); // Safe clone
  
  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 (8 lines)

🔧 Subcomponents:

calculation Safe Map Clone let mapData = MAPS[index]; platforms = mapData.geometry.map(p => ({...p}));

Load map data and clone platform objects so modifying them doesn't affect the original MAPS array

calculation Arena Boundary Walls let b = 20; platforms.push({ x: 0, y: 0, w: width, h: b });

Add invisible boundary walls on all four edges (20px thick) to keep players from falling off

calculation Player Position & Stats Reset p1.x = mapData.spawns[0].x - p1.w/2; p1.y = mapData.spawns[0].y - p1.h/2; p1.vx = 0; p1.vy = 0;

Position players at spawn points (centered on spawn x/y), reset velocity and all status conditions

calculation State Arrays Reset bullets = []; particles = []; specialEffects = [];

Clear all game objects from the previous round so they don't persist

let mapData = MAPS[index];
Fetch the map definition (geometry and spawns) for the given index from the global MAPS array.
platforms = mapData.geometry.map(p => ({...p}));
Spread-clone each platform object to create independent copies—prevents accidental mutations of the original map.
let b = 20; platforms.push({ x: 0, y: 0, w: width, h: b });
Create an invisible 20px wall at the top edge (x=0, y=0, full width)—keeps players from escaping upward.
p1.x = mapData.spawns[0].x - p1.w/2;
Position player 1 at the first spawn point, adjusted so the spawn position is the center of their body.
p1.vx = 0; p1.vy = 0; p2.vx = 0; p2.vy = 0;
Stop both players (zero velocity)—prevents carry-over momentum from previous round.
p1.hp = p1.maxHp; p2.hp = p2.maxHp;
Fully heal both players at start of new round.
p1.frostTimer = 0; p1.poisonTimer = 0; p2.frostTimer = 0; p2.poisonTimer = 0;
Clear all status effects (frost slowness and poison) from the previous round.
bullets = []; particles = []; specialEffects = [];
Empty all game object arrays so no bullets or effects from the previous round persist into the new round.

drawCardSelect()

drawCardSelect() creates a classic menu UI with hover detection, layered drawing (unhovered first, hovered last), and centering math. It also resets hoveredCardIndex each frame for human players but lets AI keep its choice persistent. This shows how the same menu can support both human interaction (mouse hover) and AI automation (timer-based decision).

function drawCardSelect() {
  fill(0, 200);
  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 (9 lines)

🔧 Subcomponents:

calculation Semi-Transparent Overlay fill(0, 200); rect(0, 0, width, height);

Draw dark overlay to dim the background game and focus attention on card selection

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

Layout 5 cards in a horizontal row, detect hover, draw unhovered cards normally and hovered card enlarged

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

Check if mouse is within 80px (half width) and 120px (half height) of card center

fill(0, 200); rect(0, 0, width, height);
Draw a semi-transparent (alpha 200) black overlay covering the entire canvas—dims the game behind the menu.
fill(currentLoser.color); textSize(50); text(`CHOOSE YOUR UPGRADE`, ...);
Show the prompt in the loser's color (blue or magenta) so they know it's their turn.
let cardW = 160; let cardH = 240; let spacing = 20;
Define card layout: each card is 160 wide, 240 tall, with 20px gaps between them.
let totalW = (cardW * 5) + (spacing * 4); let startX = (width - totalW) / 2 + cardW / 2;
Calculate total width of 5 cards + gaps, then center the layout on the screen, offset by half a card width.
if (!currentLoser.isAI) hoveredCardIndex = -1;
Reset hover tracking each frame for human players—AI will control this separately.
let cx = startX + i * (cardW + spacing);
Position each card: start position plus spacing between cards.
if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) { hoveredCardIndex = i; isHovered = true; }
Check if mouse is within the card's rectangular bounds (±80px horizontally, ±120px vertically).
if (!isHovered) { drawSingleCard(offeredCards[i], cx, cy, cardW, cardH, currentLoser.color, false); }
Draw unhovered cards normally (no scaling)—saves the hovered card for last so it draws on top and larger.
if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) { ... drawSingleCard(..., true); }
Draw the hovered card last with isHovered=true, which scales it up 1.1x and applies the HAND cursor for visual feedback.

drawSingleCard(card, x, y, w, h, playerColor, isHovered, isHUDPreview=false)

drawSingleCard() is a template for game UI cards with interactivity. It uses translate/scale transforms to enlarge and lift on hover, conditional rendering to show/hide details, and delegation (calling drawCardPreview for animation). The isHUDPreview flag shows how the same card layout can be reused in two contexts: the main selection menu and in-game inventory tooltips.

function drawSingleCard(card, x, y, w, h, playerColor, isHovered, isHUDPreview = false) {
  push();
  translate(x, y);
  
  let showAnim = isHovered || isHUDPreview;

  if (isHovered && !isHUDPreview) {
    translate(0, -20);
    scale(1.1); 
    cursor(HAND); 
  }
  
  fill(25, 25, 30);
  stroke(playerColor);
  strokeWeight(isHovered ? 6 : 4);
  rectMode(CENTER);
  rect(0, 0, w, h, 15);

  noStroke();
  fill(255);
  textSize(22);
  textAlign(CENTER, TOP);
  text(card.name, 0, -h/2 + 15);

  // VIDEO PREVIEW SCREEN
  push();
  translate(0, -25);
  drawCardPreview(card.name, w - 40, 70, showAnim, playerColor);
  pop();

  fill(180);
  textSize(14);
  textAlign(CENTER, CENTER);
  text(card.desc, 0, 40); 
  
  if (isHovered && !isHUDPreview) {
    fill(playerColor);
    textSize(18);
    textAlign(CENTER, BOTTOM);
    text("SELECT", 0, h/2 - 10);
  }
  
  pop();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Hover Scale & Lift if (isHovered && !isHUDPreview) { translate(0, -20); scale(1.1); }

When hovered, move card up 20px and scale 1.1x larger—creates a "reaching for card" effect

calculation Card Border & Background fill(25, 25, 30); stroke(playerColor); strokeWeight(isHovered ? 6 : 4); rect(0, 0, w, h, 15);

Draw card background (dark) with player-colored border (thicker when hovered)

calculation Card Preview Animation drawCardPreview(card.name, w - 40, 70, showAnim, playerColor);

Render a small animated preview of the card effect in the middle of the card

let showAnim = isHovered || isHUDPreview;
Play preview animation only when card is hovered or shown as a tooltip—saves CPU by not animating off-screen cards.
if (isHovered && !isHUDPreview) { translate(0, -20); scale(1.1);
Hover effect: lift card up 20px and enlarge to 1.1x (110%) of normal size, but only in card-select screen (not HUD preview).
fill(25, 25, 30); stroke(playerColor); strokeWeight(isHovered ? 6 : 4);
Card styling: dark gray background, player-colored border (thicker when hovered for emphasis).
rect(0, 0, w, h, 15);
Draw rounded rectangle (15px corner radius) centered at (0, 0) with the given width and height.
text(card.name, 0, -h/2 + 15);
Card title positioned near the top (15px from top edge).
drawCardPreview(card.name, w - 40, 70, showAnim, playerColor);
Call helper function to draw the animated card preview—smaller when not hovered to save performance.
text(card.desc, 0, 40);
Card description text (stats and flavor)—wrapped by the ALL_CARDS array definition with \n newlines.
if (isHovered && !isHUDPreview) { fill(playerColor); ... text("SELECT", ...); }
When hovered in card select (not HUD), show a "SELECT" prompt at the bottom to indicate interactivity.

keyPressed()

keyPressed() is a p5.js built-in that fires once per key press. It routes input based on game state, preventing accidental menu navigation during gameplay. Notice it uses both key (for character keys like '1', '2') and keyCode (for special keys like UP_ARROW)—a common pattern in p5.js.

function keyPressed() {
  if (gameState === "TITLE") {
    if (keyCode === UP_ARROW) pointsToWin = min(50, pointsToWin + 1);
    if (keyCode === DOWN_ARROW) pointsToWin = max(1, pointsToWin - 1);
    
    if (key === '1') {
      playMode = "SINGLE";
      p2.isAI = true;
      startGame();
    } else if (key === '2') {
      playMode = "MULTI";
      p2.isAI = false;
      startGame();
    }
  } else if (gameState === "CARD_SELECT" && !currentLoser.isAI) {
    let choice = -1;
    if (key === '1') choice = 0;
    if (key === '2') choice = 1;
    if (key === '3') choice = 2;
    if (key === '4') choice = 3;
    if (key === '5') choice = 4;

    if (choice >= 0 && choice < offeredCards.length) {
      let picked = offeredCards[choice];
      picked.apply(currentLoser);
      currentLoser.cards.push(picked);
      resetRoundPositions();
      gameState = "PLAYING";
    }
  } else if (gameState === "GAME_OVER") {
    if (key === ' ') {
      setup(); 
      gameState = "TITLE";
    }
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Title Screen Controls if (gameState === "TITLE") { if (keyCode === UP_ARROW) pointsToWin = min(50, pointsToWin + 1); ... if (key === '1') { startGame(); } }

Adjust match win target or start game in single/multiplayer mode

conditional Card Number Selection if (gameState === "CARD_SELECT" && !currentLoser.isAI) { if (key === '1') choice = 0; ... picked.apply(currentLoser);

Press 1-5 to pick a card; apply its effects and advance to next round

conditional Game Over Reset } else if (gameState === "GAME_OVER") { if (key === ' ') { setup(); gameState = "TITLE"; } }

Press space to return to title screen and restart

if (gameState === "TITLE") {
Only respond to these inputs on the title screen.
if (keyCode === UP_ARROW) pointsToWin = min(50, pointsToWin + 1);
Up arrow increases the win target (capped at 50 so game doesn't go forever).
if (keyCode === DOWN_ARROW) pointsToWin = max(1, pointsToWin - 1);
Down arrow decreases win target (minimum 1 so at least one win is needed).
if (key === '1') { playMode = "SINGLE"; p2.isAI = true; startGame(); }
Press 1 for single-player: set p2 as AI and start the game.
} else if (gameState === "CARD_SELECT" && !currentLoser.isAI) {
Only process keyboard card selection if a human player is making the choice (AI doesn't use keyboard).
let choice = -1; if (key === '1') choice = 0; if (key === '2') choice = 1; ... if (key === '5') choice = 4;
Map keys 1-5 to card indices 0-4 for quick selection.
picked.apply(currentLoser); currentLoser.cards.push(picked);
Apply the card's effect function to the loser's stats, and add the card to their collection.
resetRoundPositions(); gameState = "PLAYING";
Load a new map and transition back to gameplay.
} else if (gameState === "GAME_OVER") { if (key === ' ') { setup(); gameState = "TITLE"; } }
On game over screen, pressing space re-runs setup() to reset the game and return to the title.

startGame()

startGame() initializes a new match with fresh Player objects and clean scores. It's called from the title screen when the player presses 1 or 2. By using playMode to control the AI flag, the same code supports both single and multiplayer without branching.

function startGame() {
  p1.score = 0; p2.score = 0;
  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, playMode === "SINGLE");
  resetRoundPositions();
  gameState = "PLAYING";
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Score Initialization p1.score = 0; p2.score = 0;

Reset both players' round wins to zero at the start of a new match

calculation Player Instantiation p1 = new Player(1, color(50, 200, 255), p1Controls, false); p2 = new Player(2, color(255, 50, 100), p2Controls, playMode === "SINGLE");

Create fresh Player objects with clean stats; p2 is AI if SINGLE mode, human if MULTI

p1.score = 0; p2.score = 0;
Reset match scores so the match starts at 0-0.
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Create player 1 fresh: always human (fourth parameter is false).
p2 = new Player(2, color(255, 50, 100), p2Controls, playMode === "SINGLE");
Create player 2: fourth parameter is true (AI) only if playMode is "SINGLE", false (human) otherwise.
resetRoundPositions(); gameState = "PLAYING";
Load the first map and transition to the playing state—ready to battle.

📦 Key Variables

gameState string

Tracks the current screen/mode: TITLE, PLAYING, ROUND_OVER, CARD_SELECT, or GAME_OVER—controls what logic and UI render

let gameState = "TITLE";
playMode string

Stores whether the game is SINGLE (vs AI) or MULTI (2 humans) so startGame() can set p2.isAI correctly

let playMode = "MULTI";
p1, p2 Player object

The two Player instances representing the competing characters—hold all stats, abilities, position, and input state

p1 = new Player(1, color(50, 200, 255), p1Controls, false);
platforms array of objects

Array of platform geometry {x, y, w, h} that define the arena layout—used for collision detection and rendering

platforms = [{x: 0, y: 560, w: 1000, h: 40}, ...];
bullets array of Bullet

All active bullets in flight—updated each frame and checked for collisions with players and platforms

bullets.push(new Bullet(x, y, vx, vy, owner));
particles array of Particle

Visual feedback objects that fade and move—spawned on impacts, jumps, and explosions to make the game feel responsive

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

Active ability effects like shockwaves and energy rings—updated and drawn each frame until they expire

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

Current intensity of screen shake (jitter), decayed each frame—causes viewport to jitter on impacts for visceral feedback

screenShake = 30;
GRAVITY, FRICTION, AIR_FRICTION, MAX_FALL_SPEED number constants

Physics constants that control player movement feel—gravity pulls down, friction slows on ground/air, fall speed is capped

const GRAVITY = 0.6; const FRICTION = 0.65;
MAPS array of map objects

Four arena layouts with platform geometry and spawn points—randomly selected each round

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

Master pool of 30+ cards with names, descriptions, and apply() functions—five are randomly picked each card select phase

const ALL_CARDS = [{name: "Tank", desc: "...", apply: (p) => {...}}, ...];
offeredCards array of card objects

The five cards randomly selected from ALL_CARDS for the current card selection phase

offeredCards = shuffled.slice(0, 5);
currentLoser Player reference

Pointer to the player who lost the last round—used to show which player is picking cards in CARD_SELECT

currentLoser = p1;
hoveredCardIndex number

Index of the card the mouse is hovering over (or AI has selected) in the card select screen—-1 means none

hoveredCardIndex = 2;
pointsToWin number

How many round wins are needed to win the match—adjustable on title screen (default 5)

let pointsToWin = 5;
roundWinner, matchWinner Player reference

Cached references to winners for drawing victory screens—set when a player's HP reaches zero

roundWinner = p1;
leftMousePressed, rightMousePressed boolean

Track left/right mouse button states across frames (p5.js mousePressed() doesn't persist)—used for shooting and blocking

if (leftMousePressed) { player.shoot(); }

🔧 Potential Improvements (8)

Here are some ways this code could be enhanced:

BUG Player collision detection (update method)

Player can get partially stuck in platforms if velocity is very high due to card boosts. X and Y collisions are checked separately, sometimes allowing overshooting.

💡 Use continuous collision detection or cap player velocity further. Alternatively, shrink collision box by a few pixels on each axis to create a safety margin.

BUG Bullet.update() homing logic

If target dies while bullet is homing, the check `this.target.hp > 0` prevents steering updates, but the bullet's old velocity persists, potentially looking like it's still tracking.

💡 When target dies, either nullify the bullet's velocity or disable homing flag entirely instead of just skipping the steer.

PERFORMANCE updatePlaying() - particle culling

Particles are culled to 200 max, but this happens every frame by splicing from the array, which is O(n). Under heavy particle load (many explosions), this causes frame drops.

💡 Use a pooling system where particles are reused instead of created/deleted, or use a flag-based culling pass (mark inactive, then filter once per frame).

PERFORMANCE draw() - grid background

Grid lines are drawn every frame at full opacity (alpha 10), creating many small draw calls.

💡 Render the grid once to an off-screen graphics buffer (createGraphics) and reuse it instead of redrawing 50+ lines per frame.

STYLE Player class constructor

Many stat properties are initialized individually, making the constructor very long and hard to scan.

💡 Group related stats into sub-objects: e.g., `this.stats = {maxHp: 100, damage: 25, ...}` and `this.abilities = {explosive: false, piercing: false, ...}` for clarity.

FEATURE Card system

No way to see what cards a player has collected in the PLAYING state (HUD shows only initials with hover tooltip). New players may forget what they've picked.

💡 Add a 'C' key (stats screen) that pauses the game and shows full card list with descriptions for both players.

FEATURE Game balance

Some cards (Spray & Pray, Flak Cannon) are extremely overpowered because high fire rate + high projectile count can overwhelm the opponent instantly.

💡 Rebalance damage output and spread multipliers, or add more defensive options like Quick Block on lower cooldown.

BUG drawCardSelect() - AI card selection

AI picks a card every 120 frames automatically, but if multiple cards animate at the same time, it can be visually confusing.

💡 Mute animation for non-hovered cards in the CARD_SELECT state so only the AI's target card animates.

🔄 Code Flow

Code flow showing setup, draw, updateplaying, drawplaying, playerupdate, playerdraw, bulletupdate, buildmap, drawcardselect, drawsingel card, keyPressed, startgame

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

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas Creation] setup --> context-menu[Right-Click Prevention] setup --> player-creation[Player Instantiation] setup --> buildmap[buildMap] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click context-menu href "#sub-context-menu" click player-creation href "#sub-player-creation" click buildmap href "#fn-buildmap" draw --> bg-grid[Grid Background] draw --> state-machine[Game State Dispatcher] click draw href "#fn-draw" click bg-grid href "#sub-bg-grid" click state-machine href "#sub-state-machine" state-machine --> updateplaying[updatePlaying] state-machine --> drawplaying[drawPlaying] state-machine --> drawcardselect[drawCardSelect] state-machine --> game-over-reset[game-over-reset] click updateplaying href "#fn-updateplaying" click drawplaying href "#fn-drawplaying" click drawcardselect href "#fn-drawcardselect" click game-over-reset href "#sub-game-over-reset" updateplaying --> player-update[Player Physics & Input] updateplaying --> bullet-loop[Bullet Update & Collision] updateplaying --> death-check[Round End Detection] updateplaying --> screen-shake[Screen Shake Effect] click player-update href "#sub-player-update" click bullet-loop href "#sub-bullet-loop" click death-check href "#sub-death-check" click screen-shake href "#sub-screen-shake" player-update --> gravity-apply[Gravity Application] player-update --> x-collision[Horizontal Platform Collision] player-update --> y-collision[Vertical Platform Collision] player-update --> wall-detection[Wall Slide Detection] player-update --> status-timers[Cooldown Decrement] player-update --> regen-logic[Regeneration] player-update --> poison-logic[Poison Damage Over Time] player-update --> frost-slow[Frost Status] click gravity-apply href "#sub-gravity-apply" click x-collision href "#sub-x-collision" click y-collision href "#sub-y-collision" click wall-detection href "#sub-wall-detection" click status-timers href "#sub-status-timers" click regen-logic href "#sub-regen-logic" click poison-logic href "#sub-poison-logic" click frost-slow href "#sub-frost-slow" bullet-loop --> bulletupdate[Bullet Update] bullet-loop --> block-collision[Block Bounce Logic] bullet-loop --> hit-damage[Hit Damage & Effects] bullet-loop --> lifesteal[Lifesteal Healing] bullet-loop --> out-of-bounds[Out-of-Bounds Check] click bulletupdate href "#fn-bulletupdate" click block-collision href "#sub-block-collision" click hit-damage href "#sub-hit-damage" click lifesteal href "#sub-lifesteal" click out-of-bounds href "#sub-out-of-bounds" drawplaying --> platform-drawing[Platform Rendering] drawplaying --> score-hud[Score Indicator Dots] drawplaying --> inventory-hud[Card Inventory Display] drawplaying --> playerdraw[Player.draw()] drawplaying --> bulletupdate[Bullet.draw()] click platform-drawing href "#sub-platform-drawing" click score-hud href "#sub-score-hud" click inventory-hud href "#sub-inventory-hud" click playerdraw href "#fn-playerdraw" click bulletupdate href "#fn-bulletupdate" playerdraw --> body-stretch[Velocity-Based Stretch] playerdraw --> walk-animation[Walking Animation] playerdraw --> eye-follow[Eyes Follow Aim] playerdraw --> gun-render[Gun Shape & Ammo Display] playerdraw --> hp-bar[Health Bar Above Head] click body-stretch href "#sub-body-stretch" click walk-animation href "#sub-walk-animation" click eye-follow href "#sub-eye-follow" click gun-render href "#sub-gun-render" click hp-bar href "#sub-hp-bar" drawcardselect --> overlay[Semi-Transparent Overlay] drawcardselect --> card-grid[Card Display Loop] click overlay href "#sub-overlay" click card-grid href "#sub-card-grid" card-grid --> hover-detection[Mouse Hover Detection] card-grid --> hover-transform[Hover Scale & Lift] card-grid --> card-frame[Card Border & Background] card-grid --> card-preview[Card Preview Animation] click hover-detection href "#sub-hover-detection" click hover-transform href "#sub-hover-transform" click card-frame href "#sub-card-frame" click card-preview href "#sub-card-preview"

❓ Frequently Asked Questions

What visual experience does the ROUNDS sketch offer?

The ROUNDS sketch creates a vibrant and colorful battlefield filled with dynamic platforms and engaging animations, enhancing the excitement of multiplayer shootouts.

How can players interact with the ROUNDS game?

Players can engage in fast-paced battles either solo or with a friend, dodging bullets and utilizing power-ups between rounds to customize their gameplay.

What creative coding concepts are showcased in the ROUNDS sketch?

The sketch demonstrates principles of game physics, including gravity and friction, as well as local multiplayer mechanics and procedural map design.

Preview

ROUNDS - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of ROUNDS - Code flow showing setup, draw, updateplaying, drawplaying, playerupdate, playerdraw, bulletupdate, buildmap, drawcardselect, drawsingel card, keyPressed, startgame
Code Flow Diagram