Sketch 2026-03-12 15:43

This is a local multiplayer fighting game inspired by ROUNDS, where two players battle across randomly selected arenas using weapons, abilities, and collectible upgrade cards. Players shoot, block, and jump through platforms while selecting between powerful roguelike upgrades after each round to create emergent playstyles.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make gravity stronger — Higher gravity makes players fall faster and feel heavier—try 1.2 for intense gameplay where air control matters less
  2. Add a third map — Create a new arena layout by adding an object to the MAPS array—players will randomly spawn on it after each round
  3. Make shots spread wider — Shotgun-like spread makes battles more chaotic and close-range weapons more effective—find the line `let spreadAngle = 0.15` in the shoot() function
  4. Change default starting health — Lower health makes all cards balance differently and rounds end faster; try 50 for a frantic game
  5. Slow down the AI — The AI re-evaluates its strategy every 30-90 frames; make that 60-180 for a slower, more predictable opponent
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a polished fighting game where two players (or one player vs AI) duel across four uniquely designed maps. The game combines multiple p5.js techniques: physics simulation with gravity and collision detection, real-time input handling for both keyboard and mouse, particle effects for visual feedback, and a roguelike card system that permanently upgrades player abilities between rounds. The result feels like a complete indie game rather than a coding exercise.

The code is organized into distinct state machines (TITLE, PLAYING, CARD_SELECT, GAME_OVER), custom classes for entities (Player, Bullet, Particle, Shockwave, EnergyRing), and helper functions for rendering and physics. By studying it, you'll learn how professional game coders structure complex systems: managing multiple game states, handling collisions between different entity types, creating satisfying visual feedback through particles and screen shake, and balancing roguelike progression mechanics.

⚙️ How It Works

  1. When the sketch loads, setup() creates a 1000×600 canvas and initializes two Player objects with different keyboard and mouse controls. The title screen lets players select single-player or multiplayer mode and adjust the target score.
  2. Once a game starts, the main draw() loop runs the PLAYING state, which continuously calls updatePlaying() to advance physics, collisions, and AI every frame.
  3. Each player updates their position based on keyboard input or AI logic, with gravity pulling them down and platforms stopping their fall. Players aim toward opponents (using mouse aim for P1, keyboard for P2 or AI), shoot bullets that inherit special properties from collected cards, and press block to parry incoming fire.
  4. When a bullet hits an opponent without a block active, the opponent takes damage and is removed from play. If blocked, the bullet reverses direction and swaps ownership—a core mechanic that creates explosive duels.
  5. When one player's HP reaches zero, the game transitions to ROUND_OVER, plays death effects, then moves to CARD_SELECT where the loser chooses one of five random upgrades from ALL_CARDS—permanently boosting stats, firepower, or adding special abilities like explosive rounds or homing bullets.
  6. After card selection, a new map is built, positions reset, and the next round begins. The first player to reach pointsToWin rounds wins the match and returns to the title screen.

🎓 Concepts You'll Learn

Game state machinesPhysics simulation (gravity, velocity, collision)Real-time input handling and aimingAI pathfinding and decision-makingRoguelike progression and card systemsParticle effects and screen shakeObject-oriented design with classes

📝 Code Breakdown

setup()

setup() runs once when the sketch loads. It initializes the canvas, the two players with their controls, and builds the first map. This is where you configure the game's basic parameters.

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 (7 lines)
createCanvas(1000, 600);
Creates the main 1000×600 pixel game canvas where all gameplay happens
textFont('Impact');
Sets the font to Impact for all text rendering, giving the UI a bold arcade game look
document.addEventListener('contextmenu', event => event.preventDefault());
Blocks the right-click context menu so right-click can be used for blocking instead of showing browser menus
let p1Controls = { up: 87, down: 83, left: 65, right: 68 };
Defines Player 1's keyboard controls as WASD (key codes 87=W, 83=S, 65=A, 68=D)
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Creates Player 1 as a human-controlled blue player with ID 1 and cyan color
p2 = new Player(2, color(255, 50, 100), p2Controls, false);
Creates Player 2 as a human-controlled red player with ID 2 and magenta color (isAI=false initially)
buildMap(currentMapIndex);
Loads the first map geometry, spawns platforms, and repositions both players at starting positions

draw()

draw() is the game's main loop, running 60 times per second. It implements a state machine pattern: each game phase (TITLE, PLAYING, CARD_SELECT, etc.) branches to its own rendering and update functions. This is how professional games structure their flow.

🔬 This is the state machine—the core of the game's flow. What if you added a new state like `else if (gameState === "PAUSED")` between PLAYING and ROUND_OVER? What would you need to render there?

  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 a grid effect as visual reference

switch-case Game state dispatcher if (gameState === "TITLE") { drawTitle(); } else if ...

Routes the game to different rendering and logic paths based on the current game phase

conditional Round-over animation and transition if (gameState === "ROUND_OVER") { if (frameCount % 2 === 0) updatePlaying();

Pauses gameplay updates but continues rendering to show the death, then transitions to card select

background(20, 20, 24);
Clears the canvas with a very dark blue-gray color every frame, erasing the previous frame's drawings
stroke(255, 10);
Sets the stroke color to near-white but with alpha=10, making it very faint for the grid lines
for(let i=0; i<width; i+=40) line(i, 0, i, height);
Draws vertical lines every 40 pixels across the full height—visual guide for players
for(let j=0; j<height; j+=40) line(0, j, width, j);
Draws horizontal lines every 40 pixels to complete the grid pattern
if (gameState === "TITLE") { drawTitle(); }
When on the title screen, render the menu and wait for player input to start a game
else if (gameState === "PLAYING") { updatePlaying(); drawPlaying(); }
During gameplay, update all physics and collisions, then render the updated game state
if (frameCount % 2 === 0) updatePlaying();
During round-over, skip physics updates every other frame to slow the action as the round concludes
roundEndTimer--; if (roundEndTimer <= 0) { handleRoundEnd(...); }
Count down the round-over animation timer, and when it expires, transition to the card selection screen
if (currentLoser.isAI) { aiCardTimer--; if (frameCount % 15 === 0) hoveredCardIndex = floor(random(offeredCards.length));
For AI opponents during card select, decrement the selection timer and randomly cycle through available cards as if the AI is 'thinking'

updatePlaying()

updatePlaying() is the core gameplay loop. It updates both players, advances all bullets and checks for collisions with both players (applying damage or parrying if blocked), updates particles and special effects, and detects when a player dies. This function is where the actual game rules are enforced.

🔬 This is the core parry mechanic—it reverses the bullet and swaps ownership. What if you changed `b.owner = target` to NOT swap ownership? The bullet would reverse but stay as the original shooter's. Try it and watch the blocked bullet pass right through.

            if (target.blockingFrames > 0) {
              b.vx *= -1.5; 
              b.vy *= -1.5;
              b.owner = target;
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 (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 Update both players p1.update(p2); p2.update(p1);

Runs physics, input handling, and AI for each player every frame

for-loop Bullet update and collision for (let i = bullets.length - 1; i >= 0; i--) { let b = bullets[i]; b.update(); ... }

Updates all active bullets and checks if they hit players or walls; applies damage and status effects on impact

conditional Block detection and bullet reversal if (target.blockingFrames > 0) { b.vx *= -1.5; b.vy *= -1.5; b.owner = target;

If the target is blocking, reverse the bullet's velocity and ownership so it becomes a weapon for the defender

conditional Direct hit and damage } else { target.hp -= b.damage; if (b.frost) target.frostTimer = 120;

If not blocking, apply damage and trigger any status effect timers (frost, poison)

for-loop Update special effects for (let i = specialEffects.length - 1; i >= 0; i--) { specialEffects[i].update();

Updates shockwaves and energy rings, removing them when their lifetime expires

conditional Death detection and round-over transition if ((p1.hp <= 0 || p2.hp <= 0) && gameState === "PLAYING") { gameState = "ROUND_OVER";

Detects when a player is eliminated, triggers death particles and screen shake, and transitions to round-over state

p1.update(p2);
Updates Player 1's position, velocity, input handling, and AI (if enabled), using Player 2 as the opponent reference
p2.update(p1);
Updates Player 2's position, velocity, input handling, and AI, using Player 1 as the opponent reference
for (let i = bullets.length - 1; i >= 0; i--) {
Loops through bullets backwards (from last to first) so removing bullets during the loop doesn't skip any
b.update();
Advances the bullet's position, checks for wall bounces or piercing, and applies homing if enabled
let testX = constrain(b.x, target.x, target.x + target.w);
Finds the closest point on the target's bounding box to the bullet—used for circle-to-rectangle collision
let distSq = (b.x - testX)**2 + (b.y - testY)**2;
Calculates squared distance from bullet center to the closest point on the target (avoids expensive sqrt)
if (distSq <= b.r * b.r && gameState !== "ROUND_OVER") {
If the bullet and target overlap AND the round is still active, process the collision
if (target.blockingFrames > 0) { b.vx *= -1.5; b.vy *= -1.5; b.owner = target;
If target is currently blocking, reverse the bullet's velocity (multiply by -1.5 to add extra speed) and swap ownership so it now belongs to the blocker
target.hp -= b.damage;
If target is not blocking, subtract the bullet's damage from the target's health
if (b.frost) target.frostTimer = 120;
If the bullet has the frost status effect, set the target's frost timer to 120 frames of slowdown
if (b.owner.lifesteal > 0) { b.owner.hp = min(b.owner.maxHp, b.owner.hp + (b.damage * b.owner.lifesteal));
If the shooter has the lifesteal card, heal them for a percentage of the damage dealt
if (screenShake > 0) { screenShake *= 0.8; if (screenShake < 0.5) screenShake = 0; }
Decay the screen shake value each frame so the camera vibration naturally settles
let deadPlayer = p1.hp <= 0 ? p1 : p2;
Determines which player died so death particles can spawn from their position
for (let i = 0; i < 40; i++) { let p = new Particle(...); particles.push(p); }
Creates 40 colorful particles exploding from the dead player's center for dramatic visual effect

Player class

The Player constructor initializes all the stats and state needed for a player character. Stats like speed, damage, and health are modified by collected cards, creating the roguelike progression loop. Understanding the Player class structure is essential to tweaking game balance.

class Player {
  constructor(id, color, controls, isAI = false) {
    this.id = id;
    this.w = 30;
    this.h = 30;
    this.color = color;
    this.alpha = 255;
    this.controls = controls; 
    this.isAI = isAI;
    
    this.x = 0; this.y = 0;
    this.vx = 0; this.vy = 0;
    this.facing = id === 1 ? 1 : -1;
    this.score = 0;
    
    // Inventory List to display HUD
    this.cards = []; 

    // Base Stats
    this.maxHp = 100;
    this.hp = 100;
    this.regen = 0;
    this.speed = 4.5;
    this.jumpForce = 12;
    this.maxJumps = 1;
    this.damage = 25;
    this.fireRate = 35;
    this.bulletSpeed = 15;
    this.bulletSize = 8;
    this.bounces = 0;
    this.projectiles = 1;
    this.spreadMult = 1;
    this.lifesteal = 0;

    // Special Flags
    this.explosive = false;
    this.piercing = false;
    this.homing = false;
    this.frost = false;
    this.poison = false;
    
    this.hasShockwave = false;
    this.hasEnergyRing = false;
    this.hasOrbitals = false;
    this.orbitalAngle = 0;

    this.frostTimer = 0;
    this.poisonTimer = 0;

    // AMMO SYSTEM
    this.maxAmmo = 3;
    this.ammo = 3;
    this.reloadTime = 60; 
    this.reloadTimer = 0;

    // State
    this.jumpsLeft = 1;
    this.wallDir = 0; 
    this.shootTimer = 0;
    this.onGround = false;
    this.aimVec = { x: this.facing, y: 0 };
    this.prevUp = false; 
    this.prevUpAI = false; 
    
    // Block system
    this.blockingFrames = 0;
    this.blockTimer = 0;
    this.maxBlockCooldown = 90;
    this.blockDuration = 18; 

    // AI logic state
    this.aiAimAngle = this.facing === 1 ? 0 : PI;
    this.aiMoveDir = 0;
    this.aiMoveTimer = 0;
  }
Line-by-line explanation (9 lines)
constructor(id, color, controls, isAI = false) {
Creates a new Player with an ID (1 or 2), a color, keyboard controls object, and an optional AI flag
this.w = 30; this.h = 30;
Sets the player's collision box to 30×30 pixels—cards can modify this to make players bigger or smaller
this.maxHp = 100; this.hp = 100;
Sets the player's max health and current health—cards can increase max HP, and damage reduces current HP
this.speed = 4.5; this.jumpForce = 12; this.maxJumps = 1;
Base movement stats: horizontal speed per frame, vertical jump force, and how many mid-air jumps allowed
this.damage = 25; this.fireRate = 35; this.bulletSpeed = 15;
Base weapon stats: damage per bullet, frames between shots, and bullet velocity in pixels per frame
this.explosive = false; this.piercing = false; this.homing = false;
Special ability flags that get set to true when cards are collected—control bullet behavior
this.maxAmmo = 3; this.ammo = 3; this.reloadTime = 60;
Ammo system: max bullets per clip, current bullets remaining, and frames between firing and auto-reload
this.blockingFrames = 0; this.blockTimer = 0; this.maxBlockCooldown = 90;
Block state: frames currently blocking, frames until block is available again, and max cooldown duration
this.cards = [];
Array to store collected cards—displayed in the HUD and used to track player progression

Player.update(opponent)

Player.update() is called every frame and is responsible for: decrementing all timers, processing ability logic (orbitals), managing ammo and status effects, applying gravity and collisions, and routing to either human input or AI. This is the largest and most complex function in the sketch.

🔬 This is the collision system. It moves the player FIRST, then checks for overlap and pushes them OUT if they're stuck in a platform. What if you reversed it—checked BEFORE moving? (This is called "continuous collision" and prevents tunneling through thin walls.)

    // 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;
  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 Timer countdowns this.shootTimer = max(0, this.shootTimer - 1);

Decrements all state timers (shoot cooldown, block cooldown, duration) each frame

for-loop Orbital ability if (this.hasOrbitals) { this.orbitalAngle += 0.08; for(let i=0; i<2; i++) {

Rotates two orbs around the player and checks if they touch the opponent

conditional Ammo and reload system if (this.reloadTimer > 0) { this.reloadTimer--; if (this.reloadTimer <= 0) { this.ammo = this.maxAmmo;

Manages auto-reload when ammo is empty

conditional Poison and frost timers if (this.poisonTimer > 0) { this.poisonTimer--; if (frameCount % 15 === 0) this.hp -= 1;

Applies damage-over-time and slowness effects

conditional Input or AI routing if (this.isAI) { this.updateAI(opponent); } else { this.updateInput(); }

Routes to either human input handling or AI decision-making

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

Applies gravity every frame and caps fall speed and jump height

for-loop Horizontal collision detection this.x += this.vx; for (let p of platforms) { if (rectIntersect(...)) {

Moves the player horizontally, then resolves collisions with platforms

for-loop Vertical collision detection this.y += this.vy; this.onGround = false; for (let p of platforms) {

Moves the player vertically and detects if they're standing on ground

conditional Wall slide detection and effects if (this.wallDir !== 0 && this.vy > 0) { this.vy *= 0.75;

Slows downward velocity when sliding on a wall and creates smoke particles

if (this.hp <= 0) return;
Dead players don't update—exit the function immediately if health is zero or below
this.shootTimer = max(0, this.shootTimer - 1);
Counts down the shoot cooldown timer; when it reaches 0, the player can shoot again
this.orbitalAngle += 0.08;
Rotates the orbital ability's angle every frame so two orbs circle the player
if (frameCount % 10 === 0 && dist(...) < 20 + opponent.w/2) {
Every 10 frames, checks if either orbital is touching the opponent; if so, deals 8 damage
if (this.reloadTimer <= 0) { this.ammo = this.maxAmmo;
When the reload timer expires, fully refill the ammo and show refill particles
if (this.ammo <= 0) { this.reloadTimer = this.reloadTime; }
If ammo reaches zero, start the reload timer
if (this.regen > 0 && this.hp > 0 && this.hp < this.maxHp) { this.hp = min(this.maxHp, this.hp + this.regen);
If the player has the Regeneration card (regen > 0) and isn't at max health, slowly heal each frame
if (this.poisonTimer > 0) { this.poisonTimer--; if (frameCount % 15 === 0) this.hp -= 1;
Decrements poison timer; every 15 frames deals 1 damage and creates green poison particles
if (this.frostTimer > 0) { this.frostTimer--; this.vx *= 0.6; }
Decrements frost timer and reduces horizontal velocity to 60% each frame—creating a slow effect
this.vy += GRAVITY;
Accelerates the player downward each frame based on the GRAVITY constant
this.vy = constrain(this.vy, -25, MAX_FALL_SPEED);
Limits vertical velocity: caps upward jump velocity at -25 and downward fall at MAX_FALL_SPEED
this.x += this.vx; for (let p of platforms) { if (rectIntersect(...)) {
Moves the player horizontally, then checks each platform for collision and repositions if overlapping
if (this.vy > 0) { this.y = p.y - this.h; this.onGround = true; this.jumpsLeft = this.maxJumps;
If the player landed on top of a platform (moving downward), place them above it, set onGround to true, and restore jump count
if (checkL) this.wallDir = -1;
If there's a platform to the left, set wallDir to -1 so the player can wall-slide on that side
if (this.wallDir !== 0 && this.vy > 0) { this.vy *= 0.75;
While wall-sliding downward, reduce fall speed to 75% each frame for a slower descent

Player.shoot()

shoot() is called when the player presses shoot and ammo/cooldown are ready. It creates one or more Bullet objects with spread, applies recoil to the player, spawns special effects like Energy Rings if the player has the card, and creates visual feedback through particles and screen shake.

🔬 This loop fires `this.projectiles` bullets at different angles. What if you changed it to fire in a CIRCLE around the player instead of a forward spread? (Hint: multiply i by `TWO_PI / this.projectiles` instead of `spreadAngle`)

    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;
    
    // Adjust shot origin to perfectly match the tip of the new long arms
    let bodyOffset = -15; 
    let cx = this.x + this.w/2;
    let cy = this.y + this.h/2 + bodyOffset;
    let gunDist = 55; // Extended barrel distance
    
    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.hasEnergyRing) {
      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 (15 lines)

🔧 Subcomponents:

calculation Ammo consumption this.ammo--;

Uses one bullet from the magazine

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

Calculates the angle range for multiple projectiles to fan out in a spread

for-loop Bullet creation loop for (let i = 0; i < this.projectiles; i++) { let ang = startAngle + (i * spreadAngle);

Creates multiple bullets in a spread pattern and adds them to the global bullets array

conditional Energy ring special effect if (this.hasEnergyRing) { let ringVx = cos(angleBase) * 5;

If the player has the Energy Ring card, spawn a slow-moving ring alongside the bullets

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

Pushes the player backward when they shoot, scaled by bullet size for visual believability

this.ammo--;
Subtracts one bullet from the magazine; when ammo reaches 0, the reload timer kicks in
this.shootTimer = this.fireRate;
Sets the shoot cooldown to fireRate frames so the player can't shoot again until this timer counts down
let bodyOffset = -15;
Raises the visual body 15 pixels so the long noodle legs fit within the collision box
let cx = this.x + this.w/2; let cy = this.y + this.h/2 + bodyOffset;
Calculates the center of the player's body for origin point of bullets and particles
let gunDist = 55;
The distance from body center to gun barrel tip—determines how far bullets spawn ahead
let angleBase = atan2(this.aimVec.y, this.aimVec.x);
Converts the aim vector (x, y) into an angle in radians pointing toward the target
let spreadAngle = 0.15 * this.spreadMult;
Calculates how far apart each bullet in a spread fans out—cards increase spreadMult for wider patterns
let startAngle = angleBase - (spreadAngle * (this.projectiles - 1)) / 2;
Centers the spread pattern around the aim direction so bullets radiate evenly on both sides
let ang = startAngle + (i * spreadAngle);
For each bullet i, calculates its individual angle within the spread pattern
let bx = cos(ang) * this.bulletSpeed; let by = sin(ang) * this.bulletSpeed;
Converts the bullet's angle and speed into velocity components (x and y direction)
bullets.push(new Bullet(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, bx, by, this));
Creates a new Bullet object at the gun barrel tip with the calculated velocity and pushes it to the global bullets array
if (this.hasEnergyRing) { specialEffects.push(new EnergyRing(...)); }
If the player has the Energy Ring card, also spawn a slow ring that follows the same trajectory
this.vx -= this.aimVec.x * 4 * (this.bulletSize / 8);
Applies recoil in the opposite direction of firing—scaled so bigger bullets push harder
createParticles(cx + this.aimVec.x*gunDist, cy + this.aimVec.y*gunDist, 5, this.color);
Spawns 5 particles at the gun barrel for visual feedback of the shot
screenShake += 4;
Adds shake to the camera effect proportional to the visual intensity of the shot

Player.block()

block() is called when the player presses the block button. It initiates a brief invulnerable window (blockingFrames) during which incoming bullets are reversed instead of hitting. It also triggers special effects like the Shockwave if the player has that card.

  block() {
    this.blockingFrames = this.blockDuration;
    this.blockTimer = this.maxBlockCooldown;
    
    let bodyOffset = -15;
    createParticles(this.x + this.w/2, this.y + this.h/2 + bodyOffset, 12, color(255));
    
    if (this.hasShockwave) {
      specialEffects.push(new Shockwave(this.x + this.w/2, this.y + this.h/2 + bodyOffset, this));
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Block duration and cooldown this.blockingFrames = this.blockDuration; this.blockTimer = this.maxBlockCooldown;

Sets how long the block lasts and how long until the player can block again

calculation Block particles createParticles(this.x + this.w/2, this.y + this.h/2 + bodyOffset, 12, color(255));

Creates visual feedback showing the block activation

conditional Shockwave ability if (this.hasShockwave) { specialEffects.push(new Shockwave(...)); }

If the player has the Shockwave card, spawns an expanding shockwave that pushes and damages

this.blockingFrames = this.blockDuration;
Sets how many frames the player will be in block state—this is checked each frame in updatePlaying() to determine if incoming bullets are blocked
this.blockTimer = this.maxBlockCooldown;
Sets the cooldown timer—the player can't block again until this counts down to zero
let bodyOffset = -15;
Offsets the visual body height to match where particles should spawn
createParticles(this.x + this.w/2, this.y + this.h/2 + bodyOffset, 12, color(255));
Spawns 12 white particles at the block position for satisfying visual feedback
if (this.hasShockwave) { specialEffects.push(new Shockwave(...)); }
If the player collected the Block Blast card, spawn a shockwave that expands outward and damages enemies

Player.draw()

Player.draw() is responsible for rendering the player's visual representation: animated legs, squishy body, expressive eyes, gun with ammo indicator, shield hand with cooldown pie, laser sight, and health bar. It's a great example of how many small p5.js techniques combine to create personality in a character.

🔬 This leg animation uses sin() and -sin() to lift alternating feet. Try changing `sin(walkPhase)` to `sin(walkPhase * 2)` and see the legs scurry twice as fast, or `sin(walkPhase * 0.5)` for a lazy walk.

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

    push();
    let bodyOffset = -15; // Lifts the visual body up so the long legs fit in the bounding box
    let cx = this.x + this.w/2;
    let cy = this.y + this.h/2; // Center of physics bounding box

    let gunDist = 55; // Super long arms
    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; 

    // Laser sight (spawns exactly from the gun barrel tip)
    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); 
    
    // ==========================================
    // NOODLE LEGS (Animated scurrying)
    // ==========================================
    // The player's physics box bottom is at y=15. 
    // We lift the legs up in a cycle so they don't clip down through the ground!
    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; // 10 instead of 15 to account for the 5px circle radius
    
    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;

    // Draw lines connecting body to feet
    stroke(cR, cG, cB, this.alpha);
    strokeWeight(6);
    strokeCap(ROUND);
    line(0, bodyOffset + 5, foot1X, foot1Y);
    line(0, bodyOffset + 5, foot2X, foot2Y);

    // Draw cute round feet
    noStroke();
    fill(cR, cG, cB, this.alpha);
    circle(foot1X, foot1Y, 10);
    circle(foot2X, foot2Y, 10);

    // ==========================================
    // NOODLE ARMS
    // ==========================================
    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);

    // Block shield renders behind the main body
    if (this.blockingFrames > 0) {
      noFill();
      stroke(255);
      strokeWeight(4);
      let progress = 1 - (this.blockingFrames / this.blockDuration);
      let shieldSize = this.w * 2.5 + (progress * 40); // Larger shield to cover long arms
      circle(0, bodyOffset, shieldSize);
    }

    // ==========================================
    // SQUISHY BODY
    // ==========================================
    push();
    translate(0, bodyOffset); // Render body higher up
    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);

    // TWO EYES
    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();

    // ==========================================
    // SHIELD HAND (With Pie Cooldown)
    // ==========================================
    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();

    // ==========================================
    // GUN & RIGHT HAND
    // ==========================================
    push();
    let aimAngle = atan2(this.aimVec.y, this.aimVec.x);
    translate(gunHX, gunHY);
    rotate(aimAngle);
    
    // Right Hand physically gripping the gun base
    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; 

    // L-Shaped Poly Gun
    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(); // Revert center translate

    // Health Bar 
    noStroke();
    fill(255, 0, 0);
    // Draw health bar higher up to clear the long body
    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 (14 lines)

🔧 Subcomponents:

calculation Laser sight line line(gunX, gunY, endX, endY);

Draws a faint line from the gun barrel showing aim direction

calculation Animated scurrying legs let walkPhase = frameCount * 0.4; let lift1 = (...) ? max(0, sin(walkPhase)) * 8 : 0;

Uses sine wave to lift feet up and down in alternation when walking

calculation Velocity-based body stretch 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);

Stretches the body in the direction of movement for visual motion appeal

conditional Shield hand with pie timer if (this.blockTimer > 0) { let p = 1 - (this.blockTimer / this.maxBlockCooldown); arc(0, 0, 18, 18, -HALF_PI, -HALF_PI + TWO_PI * p, PIE);

Draws a pie chart showing block cooldown progress

calculation Dynamic gun with ammo indicator let gunLen = 22 + min(this.maxAmmo * 2, 35); beginShape(); vertex(...)

Draws an L-shaped gun whose barrel length grows with max ammo, and displays ammo circles

calculation Health bar above player let hpW = map(max(0, this.hp), 0, this.maxHp, 0, this.w + 10); rect(this.x - 5, this.y - 45, hpW, 6, 2);

Renders a red background and green foreground bar showing remaining health

if (this.hp <= 0) return;
Don't draw dead players
let bodyOffset = -15;
Raises the visual body 15 pixels so the long legs fit in the 30×30 collision box
translate(cx, cy);
Moves the origin to the player's center—all subsequent drawing is relative to this point
let walkPhase = frameCount * 0.4;
Uses frameCount multiplied by 0.4 to create a variable that cycles smoothly over time
let lift1 = (this.onGround && abs(this.vx) > 0.5) ? max(0, sin(walkPhase)) * 8 : 0;
Lifts the left foot up and down using a sine wave when the player is on ground and moving; stays at 0 when idle or in air
let vMag = dist(0, 0, this.vx, this.vy);
Calculates the magnitude (length) of the velocity vector—used to stretch the body proportionally to speed
ellipse(0, 0, this.w * (1.1 + stretchFactor), this.h * (1.1 - stretchFactor * 0.5));
Draws the body as an ellipse that stretches horizontally when moving fast and compresses vertically
let eyeW = this.w * 0.35;
Eye size scales with body width so large players have big eyes and small players have tiny eyes
circle(eyeOffsetX - 6, eyeOffsetY, eyeW);
Draws the left eye offset 6 pixels left, positioned where the player is aiming
let p = 1 - (this.blockTimer / this.maxBlockCooldown);
Calculates a 0-1 progress value showing how far through the cooldown we are
arc(0, 0, 18, 18, -HALF_PI, -HALF_PI + TWO_PI * p, PIE);
Draws a pie slice filling up from the top as the cooldown counts down—when p=1, the pie is full
let gunLen = 22 + min(this.maxAmmo * 2, 35);
Barrel length is 22 pixels plus 2 pixels per ammo (capped at 35 extra pixels)
for(let i=0; i < this.maxAmmo; i++) { if (i < this.ammo) fill(255, 210, 50, this.alpha); else fill(80, this.alpha);
Draws a circle for each ammo slot—filled with yellow if available, dark gray if empty
let hpW = map(max(0, this.hp), 0, this.maxHp, 0, this.w + 10);
Maps the current HP (0 to maxHp) to a bar width (0 to w+10 pixels)

buildMap(index)

buildMap() resets the entire game state for a new round: loads the map, adds invisible boundary walls, resets player positions/health/ammo, clears all lingering projectiles and effects. This function is called once at the start of a game and again after each round ends.

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.y = mapData.spawns[0].y;
  p2.x = mapData.spawns[1].x; p2.y = mapData.spawns[1].y;
  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 (11 lines)

🔧 Subcomponents:

calculation Deep copy map geometry platforms = JSON.parse(JSON.stringify(mapData.geometry));

Creates an independent copy of the map so modifications don't affect the original MAPS constant

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

Adds invisible walls around the edges of the canvas so players can't fall off infinitely

calculation Reset player positions and velocities p1.x = mapData.spawns[0].x; p1.y = mapData.spawns[0].y; p1.vx = 0; p1.vy = 0;

Places players at spawn points and stops their movement

calculation Reset player stats p1.hp = p1.maxHp; p2.hp = p2.maxHp;

Refills health and ammo for the new round, clears status effect timers and block states

calculation Clear all entities bullets = []; particles = []; specialEffects = [];

Removes any remaining bullets or effects from the previous round

let mapData = MAPS[index];
Retrieves the map definition object (containing geometry array and spawn points) from the MAPS array
platforms = JSON.parse(JSON.stringify(mapData.geometry));
Deep copies the geometry array so each round has independent platform data that doesn't affect future rounds
let b = 20;
Thickness of the boundary walls in pixels
platforms.push({ x: 0, y: 0, w: width, h: b });
Adds an invisible wall at the top of the canvas (full width, 20 pixels thick)
p1.x = mapData.spawns[0].x; p1.y = mapData.spawns[0].y;
Places Player 1 at spawn point 0 from the map data
p1.vx = 0; p1.vy = 0;
Resets velocity to zero so players start stationary
p1.hp = p1.maxHp; p2.hp = p2.maxHp;
Fully heals both players for the new round
p1.ammo = p1.maxAmmo; p2.ammo = p2.maxAmmo;
Refills ammo to maximum
p1.frostTimer = 0; p1.poisonTimer = 0;
Clears any lingering status effects from the previous round
p1.blockingFrames = 0; p2.blockingFrames = 0;
Resets block state so players aren't invulnerable when the round starts
bullets = []; particles = []; specialEffects = [];
Empties all entity arrays so no projectiles or effects carry over from the previous round

drawPlaying()

drawPlaying() renders everything visible during gameplay: platforms, bullets, particles, players, and HUD elements. It applies screen shake by randomly translating the canvas each frame, draws all entities in a specific depth order so they layer correctly, and shows collected cards with interactive previews.

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 RIGHT HUD (Scores & Inventory)
  // ==========================================
  noStroke();
  let dotSize = 10;
  let spacing = 16;
  
  // P1 Score Dots
  for(let i=0; i<pointsToWin; i++) {
      if (i < p1.score) fill(p1.color); else fill(50);
      circle(width - 30 - i * spacing, 30, dotSize);
  }
  // P2 Score Dots
  for(let i=0; i<pointsToWin; i++) {
      if (i < p2.score) fill(p2.color); else fill(50);
      circle(width - 30 - i * spacing, 50, dotSize);
  }

  let hoveredHUDCard = null;

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

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

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

  // Pop up full card preview if hovered on HUD!
  if (hoveredHUDCard) {
      drawSingleCard(hoveredHUDCard.card, hoveredHUDCard.x - 140, hoveredHUDCard.y + 80, 160, 240, hoveredHUDCard.color, false, true);
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Screen shake camera effect if (screenShake > 0) { translate(random(-screenShake, screenShake), random(-screenShake, screenShake)); }

Randomly offsets the camera position each frame proportional to screenShake value for impact feedback

for-loop Render all platforms for (let p of platforms) { fill(35, 35, 45); stroke(80); rect(p.x, p.y, p.w, p.h, 6); }

Draws all map platforms as dark rounded rectangles

for-loop Render entities in depth order for (let e of specialEffects) e.draw(); for (let p of particles) p.draw(); for (let b of bullets) b.draw();

Renders effects, then particles, then bullets, then players so they layer correctly

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

Shows visual score tracker in top-right: filled dots for wins, empty dots for losses

for-loop Card inventory display for(let i=0; i<p1.cards.length; i++) { let cx = width - 40 - i*35;

Draws collected cards as small boxes with initials in the HUD

conditional Card preview on hover if (abs(mouseX - cx) < 15 && abs(mouseY - cy) < 15) { hoveredHUDCard = {...};

Detects when mouse hovers over a card and pops up a large preview

push();
Saves the current transformation matrix so screen shake doesn't affect the HUD
translate(random(-screenShake, screenShake), random(-screenShake, screenShake));
Randomly moves the camera each frame to create a vibration effect proportional to screenShake
for (let p of platforms) { fill(35, 35, 45); rect(p.x, p.y, p.w, p.h, 6); }
Draws each platform as a dark rounded rectangle
for (let e of specialEffects) e.draw();
Renders shockwaves and energy rings (drawn behind bullets and players)
for (let p of particles) p.draw();
Renders all particle effects (drawn between effects and bullets)
for (let b of bullets) b.draw();
Renders all active bullets (drawn in front of particles, behind players)
p1.draw(); p2.draw();
Renders both players on top (so they appear in front of everything)
for(let i=0; i<pointsToWin; i++) { if (i < p1.score) fill(p1.color); else fill(50);
Loops up to pointsToWin times, drawing a filled dot if that score position is won, empty dot if not
let initials = words.length === 1 ? words[0].substring(0,2) : words[0][0] + words[1][0];
Converts card name to 2-letter initials: first 2 letters if one word, first letters of first two words if two words
if (abs(mouseX - cx) < 15 && abs(mouseY - cy) < 15) { hoveredHUDCard = {...};
If mouse is within 15 pixels of a card (roughly touching it), store it for preview rendering

drawCardSelect()

drawCardSelect() creates the card selection screen shown after each round to the losing player. It calculates card positions to center them on screen, detects hovering (or cycles for AI), and renders cards in depth order so the hovered card appears on top. This screen is also where the animated character reaches toward the selected card.

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);
    }
  }
  
  // Draw the Player reaching for the card!
  drawPlayerReaching(currentLoser, startX, cardW, spacing, cardH);
  
  // Draw hovered card ON TOP of arm
  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 Card positioning math let totalW = (cardW * 5) + (spacing * 4); let startX = (width - totalW) / 2 + cardW / 2;

Calculates positions for 5 cards centered on screen with equal spacing

conditional Hover or AI selection if (!currentLoser.isAI) { if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) { hoveredCardIndex = i;

For human players, detects mouse hovering; for AI, uses the cycling hoveredCardIndex

for-loop Render non-hovered cards first if (!isHovered) { drawSingleCard(...); }

Draws all non-hovered cards, then the hovered card on top so it appears in front

fill(0, 200); rect(0, 0, width, height);
Draws a semi-transparent dark overlay to dim the background gameplay
fill(currentLoser.color); textSize(50); text(`CHOOSE YOUR UPGRADE`, width/2, height/2 - 200);
Displays the title in the losing player's color
let cardW = 160; let cardH = 240;
Card dimensions in pixels
let totalW = (cardW * 5) + (spacing * 4);
Total width needed to fit 5 cards plus spacing: (5 × 160) + (4 × 20) = 880 pixels
let startX = (width - totalW) / 2 + cardW / 2;
Centers all cards on screen: start from center of canvas, subtract half the total width, then add half a card width to account for center-mode rect drawing
if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) { hoveredCardIndex = i;
Detects if mouse is within half a card's width/height of the card center (axis-aligned rectangle collision)
if (!isHovered) { drawSingleCard(...); }
Draws non-hovered cards at normal depth first
drawPlayerReaching(currentLoser, startX, cardW, spacing, cardH);
Draws an animated character reaching for the hovered card
if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) { let cx = startX + hoveredCardIndex * (cardW + spacing); drawSingleCard(offeredCards[hoveredCardIndex], cx, cy, cardW, cardH, currentLoser.color, true);
Draws the hovered card on top with the isHovered=true flag so it renders with a lift animation

drawTitle()

drawTitle() renders the game's main menu. It shows the game title with a drop-shadow effect, explains controls for both players, and lets the player adjust the target score with arrow keys or choose single/multiplayer mode by pressing 1 or 2.

function drawTitle() {
  textAlign(CENTER, CENTER);
  
  fill(0);
  textSize(100);
  text("ROUNDS", width/2 + 5, height/2 - 165);
  fill(255);
  text("ROUNDS", width/2, height/2 - 170);
  
  textSize(24);
  fill(180);
  text("Local Multiplayer & Single Player AI", width/2, height/2 - 80);

  let p1Text = "PLAYER 1 (Blue)\nWASD : Move/Jump\nMouse : Aim\nLeft Click : Shoot\nRight Click : Parry";
  let p2Text = "PLAYER 2 (Red)\nArrows : Move/Aim/Jump\n. (Period) : Shoot\n/ (Slash) : Parry";
  
  textSize(20);
  fill(p1.color); text(p1Text, width/4, height/2 + 40);
  fill(p2.color); text(p2Text, width*0.75, height/2 + 40);

  // Score Selection
  fill(255, 200, 50);
  textSize(24);
  text(`TARGET SCORE: [ ${pointsToWin} ]`, width/2, height - 140);
  fill(150); textSize(16);
  text("(Press UP / DOWN arrows to adjust)", width/2, height - 115);

  fill(255);
  textSize(30);
  text("PRESS '1' for SINGLE PLAYER", width/2, height - 60);
  text("PRESS '2' for MULTIPLAYER", width/2, height - 20);
}
Line-by-line explanation (6 lines)
fill(0); text("ROUNDS", width/2 + 5, height/2 - 165);
Draws a black shadow version of the title offset by 5 pixels down-right
fill(255); text("ROUNDS", width/2, height/2 - 170);
Draws white title text on top creating a drop-shadow effect
let p1Text = "PLAYER 1 (Blue)\nWASD : Move/Jump\n...";
Multiline string with \n line breaks explaining Player 1's controls
fill(p1.color); text(p1Text, width/4, height/2 + 40);
Draws Player 1's controls in blue text at 1/4 across the screen
text(`TARGET SCORE: [ ${pointsToWin} ]`, width/2, height - 140);
Displays the current target score (modifiable with arrow keys) in a highlighted gold color
text("PRESS '1' for SINGLE PLAYER", width/2, height - 60);
Shows the key prompt to start a single-player game

Player.updateAI(target)

updateAI() implements the AI opponent behavior. It makes decisions about movement (approach/retreat/wander based on distance), jumping (lookahead to avoid gaps), aiming (smoothly rotates toward target), shooting (only when aimed), and blocking (reactively parries incoming bullets). The AI is challenging but beatable by a skilled player.

🔬 The AI re-evaluates its strategy every 30-90 frames. What if you changed that to `random(10, 30)` to make it fidgety, or `random(120, 300)` to make it predictably committed to a direction?

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

      if (distToTarget < 200) {
        this.aiMoveDir = dx > 0 ? -1 : 1;

🔬 The 0.12 value controls how fast the AI aims. What if you changed it to 0.25 for snappy aiming, or 0.05 for slow, sweeping aim?

    this.aiAimAngle += diff * 0.12;
  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 (16 lines)

🔧 Subcomponents:

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

AI decides whether to approach (run away if too close), retreat (chase if too far), or wander

for-loop Ground lookahead check for (let p of platforms) { if (checkX >= p.x && checkX <= p.x + p.w) {

Checks if there's ground ahead before deciding to jump or turn around

conditional AI jump decision if (this.onGround) { if ((moving && abs(this.vx) < 0.5) || (dy < -60 && abs(dx) < 200 && random() < 0.05)) wantsToJump = true;

AI jumps if stuck on ground, if enemy is above, or when wall-sliding

calculation Smooth aim angle interpolation this.aiAimAngle += diff * 0.12;

Slowly rotates aim toward the target instead of snapping to it, creating realistic looking aim

conditional Shoot decision if (this.shootTimer === 0 && !this.blockingFrames && this.ammo > 0 && this.reloadTimer === 0) { if (abs(diff) < 0.4 && distToTarget < 700 && random() < 0.015) {

Only shoots when aimed, in range, and has ammo; random chance prevents predictable shooting

for-loop Reactive blocking for (let b of bullets) { if (b.owner.id !== this.id && dist(this.x, this.y, b.x, b.y) < 65) {

Scans for incoming bullets and blocks them reactively

let dx = target.x - this.x; let dy = target.y - this.y;
Calculates the vector from the AI player to the opponent
let distToTarget = dist(this.x, this.y, target.x, target.y);
Calculates the distance to the opponent for range decisions
this.aiMoveTimer--; if (this.aiMoveTimer <= 0) { this.aiMoveTimer = floor(random(30, 90));
Every 30-90 frames, the AI re-evaluates its movement strategy to avoid predictable patterns
if (distToTarget < 200) { this.aiMoveDir = dx > 0 ? -1 : 1; }
If enemy is very close (< 200 pixels), move away from them
else if (distToTarget > 600) { this.aiMoveDir = dx > 0 ? 1 : -1; }
If enemy is very far (> 600 pixels), move toward them
let lookAhead = this.aiMoveDir * (abs(this.vx) * 10 + 30);
Calculates how far ahead to check for ground based on current speed
if (checkX >= p.x && checkX <= p.x + p.w) { if (p.y >= footY - 10 && p.y <= footY + 150) { groundFound = true;
Checks if the lookahead position has a platform below it within a reasonable range
if ((dx > 0 ? 1 : -1) === this.aiMoveDir && abs(dx) > 50) { wantsToJump = true; }
If moving toward the opponent and no ground ahead, jump over the gap
if ((moving && abs(this.vx) < 0.5) || (dy < -60 && abs(dx) < 200 && random() < 0.05)) wantsToJump = true;
Jump if stuck (moving but not gaining speed), or if enemy is above and close (5% chance each frame)
let targetAngle = atan2(dy, dx);
Calculates the angle pointing toward the opponent
let diff = targetAngle - this.aiAimAngle;
Calculates the angle difference—how much the AI needs to rotate to aim at the target
while (diff < -PI) diff += TWO_PI; while (diff > PI) diff -= TWO_PI;
Normalizes the angle difference to the shortest rotation path (prevents spinning the wrong way around)
this.aiAimAngle += diff * 0.12;
Smoothly rotates aim toward the target by 12% of the angle difference each frame
if (abs(diff) < 0.4 && distToTarget < 700 && random() < 0.015) { this.shoot(); }
Shoots when aimed (diff < 0.4 radians ≈ 23 degrees), in range, and 1.5% chance per frame
let dotProd = (this.x - b.x) * b.vx + (this.y - b.y) * b.vy;
Calculates dot product to determine if the bullet is moving toward the AI (positive dot product = heading toward)
if (dotProd > 0 && random() < 0.10) { this.block(); }
If bullet is incoming (dotProd > 0) and random chance succeeds (10%), block it

📦 Key Variables

gameState string

Tracks the current phase of the game: 'TITLE', 'PLAYING', 'ROUND_OVER', 'CARD_SELECT', or 'GAME_OVER'

let gameState = "TITLE";
playMode string

Determines whether the game is single-player AI ('SINGLE') or local multiplayer ('MULTI')

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

The two Player objects representing the players in the game

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

Array of platform rectangles defining the map geometry; each has x, y, w (width), h (height)

let p = { x: 0, y: 560, w: 1000, h: 40 };
bullets array of Bullet

All active projectiles currently in flight

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

All visual effect particles (sparks, smoke) currently active

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

Special ability effects like shockwaves and energy rings

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

Current intensity of screen shake effect; decays each frame

screenShake += 10;
pointsToWin number

How many rounds a player must win to win the match (adjustable on title screen)

let pointsToWin = 15;
GRAVITY const number

Downward acceleration applied each frame (pixels per frame squared)

const GRAVITY = 0.6;
MAPS const array of objects

Pre-defined map layouts, each with geometry (platforms) and spawns (player starting positions)

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

Complete pool of 30+ upgrade cards; each has name, description, and apply function

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

The 5 random cards presented to the player during card selection

offeredCards = shuffled.slice(0, 5);
hoveredCardIndex number

Index of the card the player is hovering over (or -1 if none)

if (hoveredCardIndex !== -1) { ... }
leftMousePressed, rightMousePressed boolean

Tracks left and right mouse button states for P1's shooting and blocking

let leftMousePressed = false;

🔧 Potential Improvements (9)

Here are some ways this code could be enhanced:

BUG updatePlaying() bullet-player collision

If a bullet's owner is eliminated before collision, the code can crash when accessing b.owner.lifesteal or when comparing b.owner.id

💡 Add a check: `if (!b.owner || b.owner.hp <= 0) { b.active = false; continue; }` at the start of the bullet-target loop to skip bullets from dead players

BUG drawCardSelect()

The keyboard card selection (1-5 keys) in keyPressed() doesn't check if selectedCard exists in offeredCards after AI shuffles—could select wrong card if array is shorter than expected

💡 Add length check: `if (choice >= 0 && choice < offeredCards.length)` is already there, so this is actually safe—no bug here! But the comment documents that it's been considered.

PERFORMANCE updatePlaying() and drawPlaying()

Both functions iterate through the entire bullets array every frame—with many bullets, this becomes O(n²) when checking collisions with 2 players

💡 Use spatial partitioning (divide canvas into grid cells) to only check bullets that are near each player, reducing collision checks from n*2 to ~n*cells_nearby

PERFORMANCE Player.update() platform collision

Both X and Y collision loops check every platform—with many platforms, this becomes slow

💡 Pre-sort platforms into a spatial grid so only nearby platforms are checked for each player's collision instead of all platforms

STYLE ALL_CARDS array

Card descriptions use manual \n for line breaks; if descriptions are displayed at different sizes or languages, the breaks won't scale

💡 Create a helper function drawTextWrapped(text, x, y, w) that automatically breaks text based on width, making cards responsive to UI changes

STYLE Multiple functions (updateInput, updateAI, shoot, etc.)

Magic numbers scattered throughout: gunDist=55, fireRate defaults, shieldDist=35, bodyOffset=-15 are defined in multiple places

💡 Define these as Player class properties or global CONFIG object so balance tweaks only need to be changed once

FEATURE gameState machine

No pause menu during gameplay, so players can't discuss strategy or take breaks

💡 Add a 'PAUSED' state that draws the game behind a semi-transparent overlay with 'RESUME' / 'QUIT' buttons; enter by pressing P

FEATURE Card system

Cards are applied instantly on selection with no visual feedback or animation—players don't see what changed

💡 Show a 'Upgrade Complete!' screen with before/after stat comparison before the next round starts

FEATURE Bullet.draw()

Bullets all look like colored rectangles; with many bullets, they're hard to distinguish

💡 Add bullet type indicators: explosive rounds could be round+spiky, homing could have a trailing line pointing at target, piercing could have a tracer effect

🔄 Code Flow

Code flow showing setup, draw, updateplaying, player, update, shoot, block, draw, buildmap, drawplaying, drawcardselect, drawtitle, updateai

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> game-state-switch[game-state-switch] game-state-switch -->|TITLE| drawtitle[drawTitle] game-state-switch -->|PLAYING| drawplaying[drawPlaying] game-state-switch -->|CARD_SELECT| drawcardselect[drawCardSelect] drawplaying --> player-updates[player-updates] player-updates --> updateplaying[updatePlaying] updateplaying --> player[player] player --> update[update] update --> timer-countdown[timer-countdown] timer-countdown --> physics-gravity[physics-gravity] physics-gravity --> collision-x[collision-x] collision-x --> collision-y[collision-y] collision-y --> wall-slide[wall-slide] player-updates --> updateai[updateAI] updateai --> move-direction-ai[move-direction-ai] move-direction-ai --> ground-lookahead[ground-lookahead] ground-lookahead --> jump-logic[jump-logic] updateplaying --> bullet-loop[bullet-loop] bullet-loop --> block-collision[block-collision] block-collision --> hit-collision[hit-collision] hit-collision --> death-check[death-check] death-check --> round-over-animation[round-over-animation] round-over-animation --> drawplaying drawplaying --> special-effects-loop[special-effects-loop] special-effects-loop --> energy-ring-spawn[energy-ring-spawn] energy-ring-spawn --> drawplaying drawcardselect --> card-layout[card-layout] card-layout --> hover-detection[hover-detection] hover-detection --> hover-preview[hover-preview] drawtitle --> score-hud[score-hud] score-hud --> inventory-hud[inventory-hud] click setup href "#fn-setup" click draw href "#fn-draw" click game-state-switch href "#sub-game-state-switch" click drawtitle href "#fn-drawtitle" click drawplaying href "#fn-drawplaying" click drawcardselect href "#fn-drawcardselect" click player-updates href "#sub-player-updates" click updateplaying href "#fn-updateplaying" click player href "#fn-player" click update href "#fn-update" click timer-countdown href "#sub-timer-countdown" click physics-gravity href "#sub-physics-gravity" click collision-x href "#sub-collision-x" click collision-y href "#sub-collision-y" click wall-slide href "#sub-wall-slide" click updateai href "#fn-updateai" click move-direction-ai href "#sub-move-direction-ai" click ground-lookahead href "#sub-ground-lookahead" click jump-logic href "#sub-jump-logic" click bullet-loop href "#sub-bullet-loop" click block-collision href "#sub-block-collision" click hit-collision href "#sub-hit-collision" click death-check href "#sub-death-check" click round-over-animation href "#sub-round-over-animation" click special-effects-loop href "#sub-special-effects-loop" click energy-ring-spawn href "#sub-energy-ring-spawn" click drawcardselect href "#fn-drawcardselect" click card-layout href "#sub-card-layout" click hover-detection href "#sub-hover-detection" click hover-preview href "#sub-hover-preview" click score-hud href "#sub-score-hud" click inventory-hud href "#sub-inventory-hud"

❓ Frequently Asked Questions

What visual elements can users expect to see in the Sketch 2026-03-12 15:43?

The sketch features multiple maps with various geometric platforms, creating a dynamic arena for gameplay with characters and projectiles.

How can players interact with the game in this sketch?

Users can engage in local multiplayer or single-player modes, controlling characters to shoot, dodge, and navigate the platforms in an action-packed environment.

What creative coding concepts are illustrated by this p5.js sketch?

The sketch showcases concepts such as game physics, collision detection, and state management to create an interactive multiplayer game experience.

Preview

Sketch 2026-03-12 15:43 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2026-03-12 15:43 - Code flow showing setup, draw, updateplaying, player, update, shoot, block, draw, buildmap, drawplaying, drawcardselect, drawtitle, updateai
Code Flow Diagram