Sketch 2026-03-12 15:43

This sketch creates a local multiplayer fighting game inspired by ROUNDS, where two players battle across multiple maps, collect upgrades via a card system, and compete to 15 points. Players can move, jump, shoot bullets with various properties, and parry incoming fire in a physics-based arena.

🧪 Try This!

Experiment with the code by making these changes:

  1. Double player size — Both players become much larger, making them easier targets and slowing their movement slightly
  2. Decrease gravity for floatier movement — Lower gravity makes players fall more slowly and feel lighter—jumps take longer to come back down
  3. Faster firing rate — Players shoot more frequently (lower fireRate number = faster shots), making combat much more intense
  4. Make the match much shorter — Players only need to win 3 rounds instead of 15, creating quick, snappy matches
  5. Increase bullet size — All bullets become much larger and easier to spot, making it harder to dodge and easier to land hits
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a fully playable 1v1 fighting game inspired by the indie hit ROUNDS. Two players spawn on randomized platforms, dodge and fire bullets with upgradeable properties (bouncing, homing, explosive), and can parry shots back at their opponent. The game combines real-time physics simulation, sprite animation, collision detection, and a roguelike card upgrade system—making it one of the most complete creative coding projects you can study.

The code is structured around three core systems: a Player class that handles movement, jumping, aiming, and shooting; a Bullet class that tracks projectiles with special effects; and a game state machine (TITLE → PLAYING → ROUND_OVER → CARD_SELECT → GAME_OVER) that orchestrates when each system runs. By reading it, you will understand how to build networked game logic, manage multiple entities with collision, animate complex UI interactions, and implement AI opponents that learn player strategies.

⚙️ How It Works

  1. When the sketch loads, setup() creates a 1000×600 canvas, initializes two Player objects (one controlled by mouse/keyboard, one by AI or a second player), and calls buildMap() to populate platforms and reset player positions.
  2. The draw loop runs 60 times per second. In PLAYING state, updatePlaying() runs every frame: it calls p1.update() and p2.update() to handle input, apply gravity, check collisions, and manage status effects; it updates all bullets and checks for hits against players; particles fade and despawn; and the camera shakes briefly after loud collisions.
  3. When a player's HP drops to zero, the game transitions to ROUND_OVER state, freezes gameplay briefly (updating only every other frame), and displays the winner for 100 frames.
  4. After the screen transition, handleRoundEnd() is called: if the winner reaches 15 points they win the match and enter GAME_OVER; otherwise the loser enters CARD_SELECT where they choose one of five random upgrade cards. Applying a card modifies the loser's stats (speed, damage, ammo, etc.) and resets both players for the next round.
  5. AI players in CARD_SELECT automatically pick a random upgrade after 120 frames. The AI in combat uses smoothed aiming (tracking the opponent with a delay), spacing logic to maintain distance, edge detection to avoid falling, and 10% chance to parry incoming bullets.
  6. The game returns to the TITLE screen after the match ends, allowing players to start a new game in single-player (P1 vs AI) or multiplayer (P1 vs P2) mode.

🎓 Concepts You'll Learn

Game state managementReal-time physics simulationCollision detection and responsePlayer input handling (keyboard and mouse)AI decision making and targetingParticle effects and visual feedbackDynamic card-based progression systemSprite animation and rotation

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas size, sets up both players with their control schemes, and loads the first map. The isAI parameter on Player 2 will be changed to true during startGame() if single-player mode is selected.

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:

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

Prevents the browser's right-click menu from appearing so parry blocks work smoothly

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

Creates the human-controlled blue player with WASD controls and mouse aiming

calculation Player 2 Initialization p2 = new Player(2, color(255, 50, 100), p2Controls, false);

Creates the second player (red) with arrow key controls—can be human or AI depending on game mode

createCanvas(1000, 600);
Creates a 1000-pixel-wide by 600-pixel-tall canvas—the game's playing field
textFont('Impact');
Sets the font for all text to Impact (a bold, game-like font)
document.addEventListener('contextmenu', event => event.preventDefault());
Disables the right-click menu so the right mouse button triggers parry without interference
let p1Controls = { up: 87, down: 83, left: 65, right: 68 };
Defines Player 1 controls as key codes: 87=W, 83=S, 65=A, 68=D (WASD layout)
let p2Controls = { up: UP_ARROW, down: DOWN_ARROW, left: LEFT_ARROW, right: RIGHT_ARROW, shoot: 190, block: 191 };
Defines Player 2 controls using arrow keys and 190=Period and 191=Slash for shoot/parry
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Creates a blue Player object with ID 1, WASD controls, and isAI=false (human-controlled)
p2 = new Player(2, color(255, 50, 100), p2Controls, false);
Creates a red Player object with ID 2, arrow controls, and isAI=false (but will be set to true in single-player)
buildMap(currentMapIndex);
Loads the first map (index 0), populates platforms, and positions both players at spawn points

draw()

The draw() function is the heartbeat of p5.js—it runs 60 times per second. This sketch uses it as a state machine dispatcher: depending on gameState, it calls different update and draw routines. This is the most common way to structure multi-screen games. Notice how ROUND_OVER still runs updatePlaying() every other frame to keep the world feeling alive while the text displays.

🔬 These if-statements act as a state machine—they decide what happens based on gameState. What if you added a new else-if branch that checks for gameState === "PAUSE" and called a new drawPause() function instead? Try sketching what that would look like (no need to implement it, just understand the pattern).

  if (gameState === "TITLE") {
    drawTitle();
  } else if (gameState === "PLAYING") {
    updatePlaying();
    drawPlaying();
  } else if (gameState === "ROUND_OVER") {
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) {
        offeredCards[hoveredCardIndex].apply(currentLoser);
        resetRoundPositions();
        gameState = "PLAYING";
      }
    }
  } else if (gameState === "GAME_OVER") {
    drawGameOver();
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Background Grid 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);

Draws a 40-pixel grid behind the gameplay to make the arena feel structured and help judge distances

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

Routes execution to the correct update and draw functions based on the current game phase

conditional Round End Timer roundEndTimer--; if (roundEndTimer <= 0) {

Counts down 100 frames before transitioning from ROUND_OVER to CARD_SELECT, giving time for the victory message to display

conditional AI Card Selection if (currentLoser.isAI) { aiCardTimer--; if (frameCount % 15 === 0) hoveredCardIndex = floor(random(offeredCards.length));

If the loser is AI, automatically selects a random card upgrade after 120 frames instead of waiting for human input

background(20, 20, 24);
Clears the canvas with a dark gray color (20, 20, 24) every frame—prevents trails and keeps visuals clean
stroke(255, 10); strokeWeight(1); for(let i=0; i<width; i+=40) line(i, 0, i, height);
Draws vertical grid lines every 40 pixels with very faint white (alpha 10)—creates a subtle background pattern
if (gameState === "TITLE") { drawTitle();
If the game is on the title screen, call drawTitle() to show the main menu
} else if (gameState === "PLAYING") { updatePlaying(); drawPlaying();
If gameplay is active, run updatePlaying() to move players and bullets, then drawPlaying() to render them
} else if (gameState === "ROUND_OVER") { if (frameCount % 2 === 0) { updatePlaying(); }
During the round over screen, still update gameplay every other frame (so the world feels alive) but let players see the victory message
roundEndTimer--; if (roundEndTimer <= 0) { let roundLoserLocal = p1.hp <= 0 ? p1 : p2; handleRoundEnd(roundWinnerLocal, roundLoserLocal);
Count down the victory screen timer; when it reaches zero, call handleRoundEnd() to advance to card selection or game over
} else if (gameState === "CARD_SELECT") { drawPlaying(); drawCardSelect();
Show the card selection UI on top of the frozen game world—gives context to why the player is upgrading
if (currentLoser.isAI) { aiCardTimer--; if (frameCount % 15 === 0) hoveredCardIndex = floor(random(offeredCards.length));
If the loser is an AI player, randomly highlight a card every 15 frames and decrement the AI timer
if (aiCardTimer <= 0) { offeredCards[hoveredCardIndex].apply(currentLoser); resetRoundPositions(); gameState = "PLAYING";
After 120 frames, apply the AI's randomly selected card to their stats and return to gameplay

buildMap(index)

buildMap() is called at the start of each round (via resetRoundPositions()). It loads a map from the MAPS array, adds invisible borders, resets both players to their spawn points with full health, and clears all bullets and particles. The deep copy using JSON.parse/stringify ensures the map definition isn't mutated.

🔬 These four lines add invisible walls on all sides. What if you commented out the bottom border (the second platforms.push)? What would happen to players who jump too high or try to escape downward?

  let b = 20; 
  platforms.push({ x: 0, y: 0, w: width, h: b }); // Top
  platforms.push({ x: 0, y: height - b, w: width, h: b }); // Bottom
  platforms.push({ x: 0, y: 0, w: b, h: height }); // Left
  platforms.push({ x: width - b, y: 0, w: b, h: height }); // Right
function buildMap(index) {
  let mapData = MAPS[index];
  platforms = JSON.parse(JSON.stringify(mapData.geometry)); // Deep copy
  
  // UNIVERSAL BORDERS!
  let b = 20; 
  platforms.push({ x: 0, y: 0, w: width, h: b }); // Top
  platforms.push({ x: 0, y: height - b, w: width, h: b }); // Bottom
  platforms.push({ x: 0, y: 0, w: b, h: height }); // Left
  platforms.push({ x: width - b, y: 0, w: b, h: height }); // Right
  
  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 = [];
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Map Data Load let mapData = MAPS[index]; platforms = JSON.parse(JSON.stringify(mapData.geometry));

Retrieves the map definition from the MAPS array and deep-copies its geometry to avoid mutation

for-loop Border Platform Addition let b = 20; platforms.push({ x: 0, y: 0, w: width, h: b });

Adds invisible 20-pixel-thick walls on all four edges of the canvas to contain players and bullets

calculation Player Spawn Reset p1.x = mapData.spawns[0].x; p1.y = mapData.spawns[0].y;

Places both players at their map-specific spawn points and resets their velocity to zero

calculation Health and Ammo Reset p1.hp = p1.maxHp; p2.hp = p2.maxHp;

Refills both players' health and ammo to their current max values and clears all status effects

calculation Projectile and Particle Clear bullets = []; particles = [];

Empties the bullets and particles arrays so leftover projectiles from the last round don't linger

let mapData = MAPS[index];
Retrieves the map definition (geometry and spawn points) from the MAPS constant array by index
platforms = JSON.parse(JSON.stringify(mapData.geometry));
Deep-copies the map's platforms array (not a reference) so modifying it doesn't affect the original definition
let b = 20;
Sets the border thickness to 20 pixels—invisible walls that prevent players from falling off the edges
platforms.push({ x: 0, y: 0, w: width, h: b }); // Top
Adds an invisible platform at the top edge of the canvas to catch players and bullets
p1.x = mapData.spawns[0].x; p1.y = mapData.spawns[0].y;
Places Player 1 at their spawn point defined in the map data (usually top-left area)
p1.vx = 0; p1.vy = 0; p2.vx = 0; p2.vy = 0;
Resets both players' velocity to zero so they don't carry momentum from the previous round
p1.hp = p1.maxHp; p2.hp = p2.maxHp;
Restores both players' health to full—uses their current maxHp (which may have been upgraded)
p1.ammo = p1.maxAmmo; p2.ammo = p2.maxAmmo;
Refills both players' ammunition to their current max
p1.frostTimer = 0; p1.poisonTimer = 0; p2.frostTimer = 0; p2.poisonTimer = 0;
Clears all status effect timers so players don't start the next round frozen or poisoned
bullets = []; particles = [];
Empties both the bullets and particles arrays—removes any lingering projectiles or visual effects from the previous round

resetRoundPositions()

resetRoundPositions() is called whenever a round ends and a new one begins. It picks a random map from the MAPS array and calls buildMap() to load it. This ensures variety—players never fight on the same arena twice in a row.

function resetRoundPositions() {
  currentMapIndex = floor(random(MAPS.length));
  buildMap(currentMapIndex);
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

calculation Random Map Selection currentMapIndex = floor(random(MAPS.length));

Randomly picks a map index (0-3) so each round takes place on a different arena

currentMapIndex = floor(random(MAPS.length));
Generates a random integer from 0 to the number of maps (4), then stores it in currentMapIndex
buildMap(currentMapIndex);
Calls buildMap() with the randomly selected index to load that map and reset player positions

updatePlaying()

updatePlaying() is the core game loop called every frame while gameState === 'PLAYING'. It updates both players, updates all bullets and checks collision against players (applying damage, parry, lifesteal), updates particles, and detects when a round ends. Notice the collision check uses circle-rect (constrain-based) rather than AABB—more accurate for fast-moving projectiles.

🔬 This is the parry logic—it reverses the bullet's velocity and changes ownership. What happens if you change the -1.5 multiplier to -2.0? What about -0.5? How does the bullet's bouncing speed change?

            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 = 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 (10 lines)

🔧 Subcomponents:

calculation Player Update p1.update(p2); p2.update(p1);

Updates both players every frame—handles input, gravity, collisions, and status effects

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

Updates every active bullet's position and checks for collisions with both players

conditional Parry Block Detection if (target.blockingFrames > 0) { b.vx *= -1.5; b.vy *= -1.5;

If a player is actively blocking, reflect the bullet back at double speed and shift ownership

conditional Damage and Effects Application } else { target.hp -= b.damage; if (b.frost) target.frostTimer = 120;

If not blocking, apply damage and apply any status effects (frost, poison) to the hit player

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

If the shooter has lifesteal, heal them by a percentage of the damage dealt

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

Updates all particles' positions and fades; removes particles when their life reaches zero

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

Detects when a player dies and transitions to the ROUND_OVER state with particle bursts and camera shake

p1.update(p2); p2.update(p1);
Calls each player's update() method, passing the opponent so the player can check distance for AI decisions
for (let i = bullets.length - 1; i >= 0; i--) {
Loops through the bullets array backwards (important when splicing) to update and check collisions
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;
Performs circle-rect collision: finds the closest point on the player's rectangle to the bullet, then checks distance
if (distSq <= b.r * b.r && gameState !== "ROUND_OVER") {
If the distance squared is within the bullet's radius squared, there's a hit—and only if the game is still active
if (target.blockingFrames > 0) { b.vx *= -1.5; b.vy *= -1.5; b.owner = target;
If the target is blocking (parrying), reverse the bullet's velocity and change its owner so it damages the shooter instead
} else { target.hp -= b.damage; if (b.frost) target.frostTimer = 120;
If not blocking, subtract damage and apply any status effects (frost slows movement, poison deals passive damage)
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 upgrade, heal them by a percentage of the damage they dealt (capped at max HP)
for (let i = particles.length - 1; i >= 0; i--) { particles[i].update(); if (particles[i].life <= 0) particles.splice(i, 1); }
Updates all particles and removes them when their life fades to zero—backwards loop allows safe removal
if (screenShake > 0) { screenShake *= 0.8; if (screenShake < 0.5) screenShake = 0;
Slowly decays the screen shake value each frame so the shake effect gradually subsides
if ((p1.hp <= 0 || p2.hp <= 0) && gameState === "PLAYING") { gameState = "ROUND_OVER";
Detects when either player's health reaches zero and transitions to the round over state

handleRoundEnd(winner, loser)

handleRoundEnd() is called when a round finishes (one player's health reaches zero). It increments the winner's score and decides whether to end the match or move to the card selection screen. The POINTS_TO_WIN constant (15) controls match length.

function handleRoundEnd(winner, loser) {
  winner.score++;
  if (winner.score >= POINTS_TO_WIN) {
    matchWinner = winner;
    gameState = "GAME_OVER";
  } else {
    currentLoser = loser;
    prepareCardSelect();
    gameState = "CARD_SELECT";
    
    if (currentLoser.isAI) {
      aiCardTimer = 120; 
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Score Update winner.score++;

Adds one point to the winner's score

conditional Match Victory Check if (winner.score >= POINTS_TO_WIN) { matchWinner = winner; gameState = "GAME_OVER";

If the winner reaches 15 points, end the entire match and transition to game over

conditional Card Selection Setup } else { currentLoser = loser; prepareCardSelect(); gameState = "CARD_SELECT";

Otherwise, set up the card selection screen for the loser to choose an upgrade

winner.score++;
Increments the winner's score by 1—tracks progress toward 15 points
if (winner.score >= POINTS_TO_WIN) {
Checks if the winner has reached the required 15 points
matchWinner = winner; gameState = "GAME_OVER";
If so, stores the winner and transitions to GAME_OVER state to display the victory screen
} else { currentLoser = loser; prepareCardSelect(); gameState = "CARD_SELECT";
If not, store the loser, prepare the card selection, and transition to CARD_SELECT state
if (currentLoser.isAI) { aiCardTimer = 120;
If the loser is an AI player, start a 120-frame timer before it auto-selects a card

drawPlaying()

drawPlaying() renders the game world: platforms, particles, bullets, and players. It applies the screen shake effect to all entities, then draws the HUD on top. Notice the push/pop around the shake—this contains the effect so the HUD doesn't jitter.

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 p of particles) p.draw();
  for (let b of bullets) b.draw();
  p1.draw();
  p2.draw();
  pop();

  fill(255);
  noStroke();
  textSize(40);
  textAlign(CENTER, TOP);
  fill(p1.color); text(`${p1.score}`, width/4, 20);
  fill(p2.color); text(`${p2.score}`, (width/4)*3, 20);
  fill(255); textSize(24); text(`FIRST TO ${POINTS_TO_WIN}`, width/2, 30);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

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

Randomly jitters the entire camera when screenShake > 0, creating visual feedback for impacts

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

Draws all platforms (visible and invisible borders) as rounded rectangles

calculation Entity Drawing for (let p of particles) p.draw(); for (let b of bullets) b.draw(); p1.draw(); p2.draw();

Renders all visual entities in order: particles (behind), bullets, then players (on top)

calculation HUD Rendering fill(p1.color); text(`${p1.score}`, width/4, 20); fill(p2.color); text(`${p2.score}`, (width/4)*3, 20);

Draws the score display for both players in the top corners

push(); if (screenShake > 0) { translate(random(-screenShake, screenShake), random(-screenShake, screenShake)); }
Saves the current transform state, then randomly offsets the entire scene by screenShake pixels—creates a jitter effect
for (let p of platforms) { fill(35, 35, 45); stroke(80); strokeWeight(3); rect(p.x, p.y, p.w, p.h, 6); }
Loops through all platforms and draws each as a dark rounded rectangle with a slightly lighter border
for (let p of particles) p.draw(); for (let b of bullets) b.draw(); p1.draw(); p2.draw();
Draws all entities in back-to-front order: particles (behind everything), then bullets, then players on top
pop();
Restores the previous transform state (undoes the screen shake) so the HUD is drawn at normal position
fill(p1.color); text(`${p1.score}`, width/4, 20);
Draws Player 1's current score in their color (blue) at the top-left of the screen
fill(255); textSize(24); text(`FIRST TO ${POINTS_TO_WIN}`, width/2, 30);
Displays the match goal ('FIRST TO 15') in the center top in white

drawTitle()

drawTitle() renders the main menu. It uses text offset (drop shadow technique) to make the title pop, displays control instructions for both players, and prompts them to select single or multiplayer mode.

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

  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 + 60);
  fill(p2.color); text(p2Text, width*0.75, height/2 + 60);

  fill(255);
  textSize(30);
  text("PRESS '1' for SINGLE PLAYER", width/2, height - 120);
  text("PRESS '2' for MULTIPLAYER", width/2, height - 70);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Title Text fill(0); textSize(100); text("ROUNDS", width/2 + 5, height/2 - 145); fill(255); text("ROUNDS", width/2, height/2 - 150);

Draws the title 'ROUNDS' twice with an offset—the black shadow makes the white text pop

calculation Control Instructions 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";

Creates control instruction strings for both players and displays them in their respective colors

textAlign(CENTER, CENTER);
Sets all text to center-align both horizontally and vertically
fill(0); textSize(100); text("ROUNDS", width/2 + 5, height/2 - 145);
Draws a black shadow version of 'ROUNDS' slightly offset down and right
fill(255); text("ROUNDS", width/2, height/2 - 150);
Draws the white 'ROUNDS' title on top, creating a drop-shadow effect
textSize(24); fill(180); text("Local Multiplayer & Single Player AI", width/2, height/2 - 60);
Displays a subtitle describing the game mode options
let p1Text = "PLAYER 1 (Blue)\nWASD : Move/Jump\n...";
Creates a multi-line string with Player 1's controls (\n is a line break)
fill(p1.color); text(p1Text, width/4, height/2 + 60);
Draws Player 1's controls on the left side in their color (blue)
fill(255); textSize(30); text("PRESS '1' for SINGLE PLAYER", width/2, height - 120);
Displays the game mode selection instructions at the bottom in large white text

drawCardSelect()

drawCardSelect() renders the card upgrade screen. It darkens the world with an overlay, displays 5 random card options, highlights whichever card the mouse hovers over, and displays different prompts for human vs. AI players. The two-pass drawing ensures the hovered card appears on top.

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 below`, 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);
    }
  }
  
  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 (7 lines)

🔧 Subcomponents:

calculation Dark Overlay fill(0, 200); rect(0, 0, width, height);

Draws a semi-transparent black overlay to dim the game world behind the card selection

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

If the player is human, checks if their mouse is over a card; if AI, uses the automatic selection

conditional Card Draw Order if (!isHovered) { drawSingleCard(offeredCards[i], cx, cy, cardW, cardH, currentLoser.color, false); } } if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) {

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

fill(0, 200); rect(0, 0, width, height);
Draws a semi-transparent black rectangle over the entire canvas (alpha 200 makes it 78% opaque)
textAlign(CENTER, CENTER); fill(currentLoser.color); textSize(50); text(`CHOOSE YOUR UPGRADE`, width/2, height/2 - 200);
Displays the upgrade prompt in large text using the loser's player color
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 below`, width/2, height/2 - 150); }
Shows different messages depending on whether the loser is human or AI
let cardW = 160; let cardH = 240; let spacing = 20; let totalW = (cardW * 5) + (spacing * 4); let startX = (width - totalW) / 2 + cardW / 2;
Calculates card dimensions and positions to center all 5 cards horizontally on screen
if (!currentLoser.isAI) hoveredCardIndex = -1;
Resets hover index if the player is human (will be recalculated based on mouse position)
if (abs(mouseX - cx) < cardW/2 && abs(mouseY - cy) < cardH/2) { hoveredCardIndex = i; isHovered = true;
Checks if the mouse is within cardW/2 pixels left/right and cardH/2 pixels up/down from the card's center
if (!isHovered) { drawSingleCard(offeredCards[i], cx, cy, cardW, cardH, currentLoser.color, false); } } 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); }
Two-pass draw: first all non-hovered cards (normal state), then the hovered card (highlighted, on top)

drawSingleCard(card, x, y, w, h, playerColor, isHovered)

drawSingleCard() renders an individual upgrade card. It uses translate() and scale() to create a hover animation—the card lifts up and grows larger when the mouse is over it. This is a common UX pattern for clickable UI elements.

function drawSingleCard(card, x, y, w, h, playerColor, isHovered) {
  push();
  translate(x, y);
  
  if (isHovered) {
    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 + 20);

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

🔧 Subcomponents:

calculation Card Rectangle fill(25, 25, 30); stroke(playerColor); strokeWeight(isHovered ? 6 : 4); rectMode(CENTER); rect(0, 0, w, h, 15);

Draws the card's background as a rounded rectangle with a border that thickens when hovered

conditional Hover Animation if (isHovered) { translate(0, -20); scale(1.1); cursor(HAND); }

Moves the card up and scales it 1.1x when hovered, plus changes cursor to a hand pointer

push();
Saves the current transform state so the card's transforms don't affect other elements
translate(x, y);
Moves the coordinate system to the card's center position
if (isHovered) { translate(0, -20); scale(1.1); cursor(HAND);
If hovered, move the card up 20 pixels, scale it 10% larger, and show a hand cursor for better UX
fill(25, 25, 30); stroke(playerColor); strokeWeight(isHovered ? 6 : 4); rectMode(CENTER); rect(0, 0, w, h, 15);
Draws the card background as a rounded rectangle (15px radius) with a thicker border when hovered
noStroke(); fill(255); textSize(22); textAlign(CENTER, TOP); text(card.name, 0, -h/2 + 20);
Draws the card's name in white, centered at the top of the card
fill(180); textSize(16); textAlign(CENTER, CENTER); text(card.desc, 0, 10);
Draws the card's description text in gray in the center of the card
if (isHovered) { fill(playerColor); textSize(18); textAlign(CENTER, BOTTOM); text("SELECT", 0, h/2 - 15); }
If hovered, displays 'SELECT' in the player's color at the bottom of the card as a hint
pop();
Restores the previous transform state so the next card isn't affected

drawGameOver()

drawGameOver() shows a simple game over screen with the match winner's name and a prompt to restart. It uses an overlay to focus attention on the text.

function drawGameOver() {
  textAlign(CENTER, CENTER);
  fill(0, 220);
  rect(0, 0, width, height);

  fill(matchWinner.color);
  textSize(70);
  text(`PLAYER ${matchWinner.id} WINS!`, width/2, height/2 - 40);
  
  fill(255);
  textSize(30);
  text("Press SPACE to return to menu", width/2, height/2 + 60);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Game Over Overlay fill(0, 220); rect(0, 0, width, height);

Draws a semi-transparent black overlay covering the entire screen

textAlign(CENTER, CENTER);
Centers all text both horizontally and vertically
fill(0, 220); rect(0, 0, width, height);
Draws a semi-transparent dark overlay (alpha 220) to dim everything behind the game over message
fill(matchWinner.color); textSize(70); text(`PLAYER ${matchWinner.id} WINS!`, width/2, height/2 - 40);
Displays the victory message in large text using the winner's player color
fill(255); textSize(30); text("Press SPACE to return to menu", width/2, height/2 + 60);
Displays the restart instruction in white below the victory message

keyPressed()

keyPressed() is the p5.js callback that runs whenever any key is pressed. This sketch uses it to dispatch game state transitions (title screen, card selection, game over) and handle quick card selection via number keys (1-3).

function keyPressed() {
  if (gameState === "TITLE") {
    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 (choice >= 0 && choice < 3) {
      offeredCards[choice].apply(currentLoser);
      resetRoundPositions();
      gameState = "PLAYING";
    }
  } else if (gameState === "GAME_OVER") {
    if (key === ' ') {
      setup(); 
      gameState = "TITLE";
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Title Screen Input if (gameState === "TITLE") { if (key === '1') { playMode = "SINGLE"; p2.isAI = true; startGame();

Listens for '1' or '2' on the title screen to select single-player or multiplayer mode

conditional Card Selection Input } 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;

Listens for number keys 1-3 to quickly select one of the first three cards as an alternative to clicking

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

Listens for SPACE on the game over screen to restart the game

if (gameState === "TITLE") {
Checks if the game is on the title screen
if (key === '1') { playMode = "SINGLE"; p2.isAI = true; startGame();
If the player presses '1', set play mode to single-player (p2 becomes AI) and start the game
} else if (key === '2') { playMode = "MULTI"; p2.isAI = false; startGame();
If the player presses '2', set play mode to multiplayer (p2 stays human-controlled) and start the game
} else if (gameState === "CARD_SELECT" && !currentLoser.isAI) {
Checks if the game is in card selection AND the loser is human (AI auto-selects)
let choice = -1; if (key === '1') choice = 0; if (key === '2') choice = 1; if (key === '3') choice = 2;
Maps number keys 1-3 to card indices 0-2 for quick selection via keyboard
if (choice >= 0 && choice < 3) { offeredCards[choice].apply(currentLoser); resetRoundPositions(); gameState = "PLAYING";
If a valid card choice was made, apply that card's upgrade and return to gameplay
} else if (gameState === "GAME_OVER") { if (key === ' ') { setup(); gameState = "TITLE";
If the game is over and the player presses SPACE, reinitialize the game and return to the title screen

startGame()

startGame() is called when the player selects single or multiplayer from the title screen. It resets match scores, creates fresh Player objects with appropriate AI settings, loads a random map, and begins gameplay.

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

🔧 Subcomponents:

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

Resets both players' match scores to zero at the start of a new game

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

Creates fresh Player objects with reset stats; p2's isAI flag depends on the game mode

p1.score = 0; p2.score = 0;
Resets both match scores to 0
let p1Controls = { up: 87, down: 83, left: 65, right: 68 };
Defines Player 1's key bindings (WASD)
let p2Controls = { up: UP_ARROW, down: DOWN_ARROW, left: LEFT_ARROW, right: RIGHT_ARROW, shoot: 190, block: 191 };
Defines Player 2's key bindings (arrow keys + period/slash)
p1 = new Player(1, color(50, 200, 255), p1Controls, false);
Creates a new blue Player 1 object, always human-controlled (isAI = false)
p2 = new Player(2, color(255, 50, 100), p2Controls, playMode === "SINGLE");
Creates a new red Player 2 object; isAI is true if playMode is 'SINGLE', false if 'MULTI'
resetRoundPositions(); gameState = "PLAYING";
Loads a random map and transitions to the PLAYING state

mousePressed()

mousePressed() fires when the mouse button is pressed. It sets global flags (leftMousePressed, rightMousePressed) used for shooting and parrying, and handles card selection clicks during the card selection screen.

function mousePressed() {
  if (mouseButton === LEFT) leftMousePressed = true;
  if (mouseButton === RIGHT) rightMousePressed = true;
  
  if (gameState === "CARD_SELECT" && !currentLoser.isAI && leftMousePressed) {
    if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) {
      offeredCards[hoveredCardIndex].apply(currentLoser);
      resetRoundPositions();
      gameState = "PLAYING";
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Mouse Button Tracking if (mouseButton === LEFT) leftMousePressed = true; if (mouseButton === RIGHT) rightMousePressed = true;

Sets global flags for left and right mouse button state (used for shooting and parrying)

conditional Card Click Detection if (gameState === "CARD_SELECT" && !currentLoser.isAI && leftMousePressed) { if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) {

If a human player clicks on a hovered card during card selection, apply that upgrade

if (mouseButton === LEFT) leftMousePressed = true;
Sets leftMousePressed flag to true when the left mouse button is pressed—checked in Player.updateInput() for shooting
if (mouseButton === RIGHT) rightMousePressed = true;
Sets rightMousePressed flag to true when the right mouse button is pressed—used for parrying
if (gameState === "CARD_SELECT" && !currentLoser.isAI && leftMousePressed) {
Only handles card clicks if the game is in card selection, the loser is human, and left mouse was pressed
if (hoveredCardIndex !== -1 && hoveredCardIndex < offeredCards.length) { offeredCards[hoveredCardIndex].apply(currentLoser); resetRoundPositions(); gameState = "PLAYING";
If a card is hovered, apply its upgrade and resume gameplay

mouseReleased()

mouseReleased() is the counterpart to mousePressed()—it fires when a mouse button is released. This sketch uses it to clear the button flags so the player must continuously hold a mouse button to keep shooting or parrying.

function mouseReleased() {
  if (mouseButton === LEFT) leftMousePressed = false;
  if (mouseButton === RIGHT) rightMousePressed = false;
}
Line-by-line explanation (2 lines)
if (mouseButton === LEFT) leftMousePressed = false;
Clears the leftMousePressed flag when the left mouse button is released
if (mouseButton === RIGHT) rightMousePressed = false;
Clears the rightMousePressed flag when the right mouse button is released

rectIntersect(x1, y1, w1, h1, x2, y2, w2, h2)

rectIntersect() implements the standard AABB collision detection formula used throughout the sketch for checking platform collisions. It's a performance-optimized alternative to checking pixel-perfect collisions.

function rectIntersect(x1, y1, w1, h1, x2, y2, w2, h2) {
  return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && h1 + y1 > y2;
}
Line-by-line explanation (1 lines)
return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && h1 + y1 > y2;
AABB (axis-aligned bounding box) collision check: returns true if the two rectangles overlap, false otherwise

createParticles(x, y, count, col, isSmoke)

createParticles() is a helper function that spawns visual effects. It's called whenever something interesting happens (shooting, hitting, jumping, blocking) to create visual feedback. The Particle class handles animation.

function createParticles(x, y, count, col, isSmoke = false) {
  for (let i = 0; i < count; i++) {
    particles.push(new Particle(x, y, col, isSmoke));
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Particle Creation Loop for (let i = 0; i < count; i++) { particles.push(new Particle(x, y, col, isSmoke)); }

Creates 'count' particle objects and adds them to the particles array

function createParticles(x, y, count, col, isSmoke = false) {
Function signature: takes position (x, y), a count, a color, and optional isSmoke flag (defaults to false)
for (let i = 0; i < count; i++) { particles.push(new Particle(x, y, col, isSmoke)); }
Loops 'count' times, creating a new Particle object at (x, y) with the given color and type, and adds it to the particles array

prepareCardSelect()

prepareCardSelect() is called when a round ends to generate the 5 random upgrade options for the loser. It shuffles the ALL_CARDS array and takes the first 5 results.

function prepareCardSelect() {
  let shuffled = [...ALL_CARDS].sort(() => 0.5 - Math.random());
  offeredCards = shuffled.slice(0, 5);
  hoveredCardIndex = -1;
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Card Shuffling let shuffled = [...ALL_CARDS].sort(() => 0.5 - Math.random());

Creates a copy of ALL_CARDS and shuffles it randomly using a comparator sort

calculation Top 5 Card Selection offeredCards = shuffled.slice(0, 5);

Takes the first 5 cards from the shuffled array to offer as upgrades

let shuffled = [...ALL_CARDS].sort(() => 0.5 - Math.random());
Spreads ALL_CARDS into a new array, then shuffles it using a random comparator (Fisher-Yates would be more optimal, but this works)
offeredCards = shuffled.slice(0, 5);
Selects the first 5 cards from the shuffled array as the upgrade options
hoveredCardIndex = -1;
Resets the hover state so no card is initially highlighted

📦 Key Variables

gameState string

Tracks the current game phase (TITLE, PLAYING, ROUND_OVER, CARD_SELECT, GAME_OVER) to control which update/draw functions run

let gameState = "TITLE";
playMode string

Stores whether the game is single-player or multiplayer (affects whether p2 is AI)

let playMode = "MULTI";
p1 object

The first Player object (blue, always human-controlled)

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

The second Player object (red, can be human or AI depending on playMode)

p2 = new Player(2, color(255, 50, 100), p2Controls, playMode === 'SINGLE');
platforms array

Array of platform objects defining the arena geometry—used for collision detection and drawing

let platforms = [];
bullets array

Array of active Bullet objects currently in flight

let bullets = [];
particles array

Array of Particle objects creating visual effects (smoke, sparks, etc.)

let particles = [];
screenShake number

Intensity of camera shake effect—decrements each frame and applied as translation offset

let screenShake = 0;
leftMousePressed boolean

Tracks whether the left mouse button is currently held down (used for P1 shooting)

let leftMousePressed = false;
rightMousePressed boolean

Tracks whether the right mouse button is currently held down (used for P1 parrying)

let rightMousePressed = false;
currentMapIndex number

Index of the current map in the MAPS array (0-3)

let currentMapIndex = 0;
offeredCards array

Array of 5 randomly selected cards offered to the loser during card selection

let offeredCards = [];
roundWinner object

Reference to the player who won the current round

let roundWinner = null;
matchWinner object

Reference to the player who won the entire match (reached 15 points)

let matchWinner = null;
currentLoser object

Reference to the player who lost the current round (used during card selection)

let currentLoser = null;
hoveredCardIndex number

Index of the card currently being hovered over (-1 if none)

let hoveredCardIndex = -1;
roundEndTimer number

Countdown (100 frames) during ROUND_OVER before transitioning to card selection

let roundEndTimer = 0;
aiCardTimer number

Countdown (120 frames) before AI player auto-selects an upgrade card

let aiCardTimer = 0;
GRAVITY number

Acceleration applied downward every frame—controls how fast players fall

const GRAVITY = 0.6;
FRICTION number

Multiplier applied to horizontal velocity when a player is on ground and not moving—simulates friction

const FRICTION = 0.65;
AIR_FRICTION number

Multiplier applied to horizontal velocity when a player is in the air—air resistance

const AIR_FRICTION = 0.9;
MAX_FALL_SPEED number

Maximum downward velocity—prevents players from falling infinitely fast

const MAX_FALL_SPEED = 15;
POINTS_TO_WIN number

Number of rounds a player must win to claim the match (15)

const POINTS_TO_WIN = 15;
MAPS array

Array of 4 map definitions, each containing spawn points and platform geometry

const MAPS = [ ... ];
ALL_CARDS array

Array of 45+ card definitions, each with a name, description, and stat-modifying apply() function

const ALL_CARDS = [ ... ];

🔧 Potential Improvements (8)

Here are some ways this code could be enhanced:

BUG Bullet.update() homing logic

Homing bullets can exceed their target's bulletSpeed significantly if the target is moving away, creating unrealistic velocity values

💡 Cap the homing bullet's speed to a maximum multiple of the owner's bulletSpeed: speed = min(speed, this.owner.bulletSpeed * 2);

PERFORMANCE draw() and drawPlaying()

The background grid is redrawn every frame using two for-loops even when it never changes—wasteful

💡 Draw the grid once to a p5.Graphics object at setup time, then display that image every frame instead

STYLE Player class constructor

The constructor is very long (50+ lines) and mixes initialization concerns—stat defaults, input handling, and state flags are all together

💡 Extract stat defaults into a separate resetStats() method and separate input configuration into a parseControls() helper for clarity

BUG updateAI() parry logic

The AI only parries bullets traveling toward it (dotProd > 0) but should also account for bullet trajectory by checking when the bullet will reach them in the future

💡 Predict the bullet's position 10-20 frames ahead and parry if it will be in range then, not just if it's approaching now

FEATURE Card selection screen

Players can only pick one of five random cards—there's no way to retry or see other upgrades

💡 Add a 'REROLL' button (costs 2 rounds skipped?) or show a history of previous cards offered for context

PERFORMANCE Particle and Bullet arrays

Arrays are looped backwards with splice() for safe removal, but this is done for both bullets and particles every frame—consider using an active flag and batch cleanup

💡 Mark bullets/particles as inactive, collect only active ones during rendering, then batch-remove inactive ones at round end

BUG buildMap() border creation

Borders are 20 pixels thick, but if screenShake causes the camera to pan more than 20 pixels, bullets can escape off-screen past the border

💡 Either disable screen shake at the edges or increase border thickness to 40 pixels

STYLE ALL_CARDS array

Card descriptions use \n for line breaks but some are too long and overflow the card UI, creating visual clutter

💡 Truncate descriptions to 2 lines max or implement text wrapping in drawSingleCard()

🔄 Code Flow

Code flow showing setup, draw, buildmap, resetroundpositions, updateplaying, handleround_end, drawplaying, drawtitle, drawcardsselect, drawsinglelcard, drawgameover, keypressed, startgame, mousepressed, mousereleased, rectintersect, createparticles, preparecardselect

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

graph TD start[Start] --> setup[setup] setup --> p1init[p1-player-init] setup --> p2init[p2-player-init] setup --> draw[draw loop] click setup href "#fn-setup" click p1init href "#sub-p1-player-init" click p2init href "#sub-p2-player-init" draw --> state[state-dispatch] state -->|gameState === 'PLAYING'| update[updateplaying] state -->|gameState === 'ROUND_OVER'| roundover[round-over-countdown] state -->|gameState === 'CARD_SELECT'| cardselect[drawcardsselect] state -->|gameState === 'TITLE'| title[drawtitle] state -->|gameState === 'GAME_OVER'| gameover[drawgameover] click draw href "#fn-draw" click state href "#sub-state-dispatch" update --> playerupdate[player-update-loop] update --> bulletupdate[bullet-update-loop] update --> particleupdate[particle-update-loop] update --> roundcheck[round-end-check] click update href "#fn-updateplaying" click playerupdate href "#sub-player-update-loop" click bulletupdate href "#sub-bullet-update-loop" click particleupdate href "#sub-particle-update-loop" click roundcheck href "#sub-round-end-check" roundcheck -->|player dies| handleround[handleround_end] click handleround href "#fn-handleround_end" handleround --> score[score-increment] handleround --> matchcheck[match-win-check] handleround --> cardsetup[card-select-setup] click score href "#sub-score-increment" click matchcheck href "#sub-match-win-check" click cardsetup href "#sub-card-select-setup" cardselect --> overlay[overlay-draw] cardselect --> cardhover[hover-detection] cardselect --> cardorder[card-draw-order] click overlay href "#sub-overlay-draw" click cardhover href "#sub-hover-detection" click cardorder href "#sub-card-draw-order" title --> titletext[title-draw] title --> controls[control-labels] click titletext href "#sub-title-draw" click controls href "#sub-control-labels" gameover --> gameoveroverlay[gameover-overlay] click gameoveroverlay href "#sub-gameover-overlay" roundover --> roundtimer[round-over-countdown] click roundtimer href "#sub-round-over-countdown" playerupdate --> input[mouse-button-track] playerupdate --> parry[parry-logic] playerupdate --> damage[damage-logic] playerupdate --> lifesteal[lifesteal-logic] click input href "#sub-mouse-button-track" click parry href "#sub-parry-logic" click damage href "#sub-damage-logic" click lifesteal href "#sub-lifesteal-logic" bulletupdate --> bulletcollision[bullet-update-loop] click bulletcollision href "#sub-bullet-update-loop" particleupdate --> particleloop[particle-loop] click particleloop href "#sub-particle-loop" setup --> buildmap[buildmap] buildmap --> mapload[map-load] buildmap --> borders[borders-add] buildmap --> spawn[spawn-reset] buildmap --> health[health-reset] buildmap --> arrayclear[array-clear] buildmap --> randommap[random-map-select] click buildmap href "#fn-buildmap" click mapload href "#sub-map-load" click borders href "#sub-borders-add" click spawn href "#sub-spawn-reset" click health href "#sub-health-reset" click arrayclear href "#sub-array-clear" click randommap href "#sub-random-map-select"

❓ Frequently Asked Questions

What visuals can users expect from the Sketch 2026-03-12 15:43?

This sketch creates a dynamic local multiplayer or single-player game featuring various maps with unique platform layouts and physics-based interactions.

How do players interact with the game in this sketch?

Users can engage by controlling characters, shooting bullets, and selecting cards to enhance their gameplay experience in a competitive environment.

What creative coding techniques are showcased in this sketch?

The sketch demonstrates concepts such as real-time physics simulation, map design, and game state management, all essential for creating interactive gaming experiences.

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, buildmap, resetroundpositions, updateplaying, handleround_end, drawplaying, drawtitle, drawcardsselect, drawsinglelcard, drawgameover, keypressed, startgame, mousepressed, mousereleased, rectintersect, createparticles, preparecardselect
Code Flow Diagram