Sketch 2026-03-31 21:42

This sketch creates a fast-paced two-player tennis game viewed from the side. Players control paddles that slide left and right to hit a bouncing ball over a net with realistic gravity, racing to 5 points with serving mechanics and dynamic scoring.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make gravity stronger — The ball will fall faster and arc lower, making rallies shorter and more intense
  2. Slow down the serve — Serves will be less aggressive, giving players more time to react
  3. Make paddles bigger — Larger paddles are easier to hit with and more forgiving, slowing down the pace
  4. Change the winning score to 3 — Matches finish faster, making quicker rounds
  5. Make the ball glow brighter — The ball becomes harder to miss visually
  6. Speed up player movement — Paddles respond faster to input, requiring quicker reflexes
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a fully playable two-player tennis match using p5.js. The game combines multiple p5.js techniques: the draw loop for animation, vector physics with gravity, collision detection between the ball and net/ground, keyboard input handling for two simultaneous players, and game state management to track serves, rallies, and scoring. The result is an engaging competitive game where players race to 5 points by timing their racket swings.

The code is organized into modular functions: initialization (setup and initGame), physics updates (updateBall), collision handling (checkNetCollision, handleBallLanded), scoring logic (awardPoint), and rendering (drawCourt, drawPlayers, drawBall). By studying this sketch, you will learn how to build a complete game loop, manage complex state across multiple objects, and structure code so that physics, input, and graphics stay synchronized.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas and initGame() resets the court, positions both players on opposite sides of the net, and places the ball with the serving player.
  2. Each frame, draw() calls handleMovement() to read keyboard input (A/D and arrow keys) and slide each player left or right within their half of the court.
  3. If the game state is 'rally', updateBall() applies gravity to the ball's vertical velocity, updates its position, and checks for collisions with the net, ground, or out-of-bounds zones.
  4. When the ball collides with the net or lands on the court, collision handlers determine who wins the point: if the ball lands on the same side as the last hitter, they lose; if it lands on the opponent's side, they win.
  5. When a player presses W (Player 1) or Up Arrow (Player 2), attemptHit() checks if the ball is within racket range and applies a random velocity to send it back over the net with upward arc.
  6. The game automatically switches between 'serve' state (waiting for a serve), 'rally' state (ball in play), and 'gameOver' state (when one player reaches 5 points), updating the score and visual UI on each transition.

🎓 Concepts You'll Learn

Game state managementPhysics simulation with gravityCollision detectionKeyboard input and real-time controlScore tracking and win conditionsObject-oriented game architecture

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it creates the canvas and delegates to initGame() to keep initialization logic organized.

function setup() {
  createCanvas(windowWidth, windowHeight);
  initGame();
}
Line-by-line explanation (2 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, making the game fullscreen and responsive
initGame();
Calls the initialization function to set up the court, players, ball, and reset all game variables

initGame()

initGame() is called both at the start (from setup) and when the player presses R. It resets all game variables and rebuilds the court, making it a clean way to restart without reloading the page.

function initGame() {
  p1Score = 0;
  p2Score = 0;
  server = 1;
  gameState = 'serve';
  lastHitter = 0;

  updateDimensions();
  createPlayers();
  createBall();
  resetBallForServe();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

assignment Score reset p1Score = 0; p2Score = 0;

Zeroes both player scores at the start of a new match

assignment Game state initialization server = 1; gameState = 'serve'; lastHitter = 0;

Establishes that Player 1 serves first, the game waits for a serve, and no one has hit yet

p1Score = 0;
Sets Player 1's score to zero to start a new match
p2Score = 0;
Sets Player 2's score to zero
server = 1;
Player 1 will serve the first point
gameState = 'serve';
The game starts waiting for a serve; the ball will not move until a player presses their hit key
lastHitter = 0;
Tracks who hit the ball last—0 means no one has hit it yet in this rally
updateDimensions();
Recalculates all canvas dimensions and physics scales based on window size
createPlayers();
Positions both players on their starting spots on the court
createBall();
Initializes the ball at the center of the court
resetBallForServe();
Places the ball in the server's hand position, ready to be served

updateDimensions()

This function ensures the game scales gracefully to any window size by making all measurements proportional to the canvas. It is called in setup() and again in windowResized() whenever the player resizes their browser.

function updateDimensions() {
  const unit = Math.min(windowWidth, windowHeight);

  // Court geometry
  court.left = width * 0.1;
  court.right = width * 0.9;
  court.groundY = height * 0.8;
  court.netX = width / 2;
  court.netHeight = height * 0.25;
  court.netWidth = 6;

  // Player sizes & movement
  playerWidth = unit * 0.04;
  playerHeight = unit * 0.16;
  playerSpeed = unit * 0.02;

  // Ball & physics
  ballRadius = unit * 0.018;
  gravity = unit * 0.0012;

  // Serve and shot speeds
  serveSpeedX = unit * 0.022;
  serveSpeedY = unit * 0.035;

  shotSpeedMin = unit * 0.018;
  shotSpeedMax = unit * 0.026;
  shotUpSpeedMin = unit * 0.028;
  shotUpSpeedMax = unit * 0.038;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Unit scale factor const unit = Math.min(windowWidth, windowHeight);

Uses the smaller dimension to scale all game elements proportionally so the game looks balanced on any screen size

assignment Court boundaries court.left = width * 0.1; court.right = width * 0.9; court.groundY = height * 0.8;

Defines the left edge, right edge, and ground level of the tennis court

assignment Physics parameter scaling gravity = unit * 0.0012; serveSpeedX = unit * 0.022;

Scales gravity and ball speeds to the window size so gameplay feels consistent on different devices

const unit = Math.min(windowWidth, windowHeight);
Takes the smaller of width or height as a reference scale—this ensures proportions stay balanced on portrait or landscape screens
court.left = width * 0.1;
The left edge of the court is 10% of the canvas width from the left
court.right = width * 0.9;
The right edge is 90% of the width, leaving 10% margin on the right
court.groundY = height * 0.8;
The ground (where the ball lands) is 80% down the canvas, leaving sky above
court.netX = width / 2;
The net is positioned at the exact horizontal center of the canvas
playerWidth = unit * 0.04;
Each player paddle is 4% of the smallest screen dimension wide
gravity = unit * 0.0012;
Gravity acceleration is scaled to the screen size so the ball trajectory looks natural on any device
serveSpeedX = unit * 0.022;
The serve's horizontal speed is proportional to screen size, keeping serve difficulty consistent

createPlayers()

This function creates player objects with position and size. Each player is an object with x, y (center position), w (width), and h (height)—this data structure makes it easy to draw and animate them.

function createPlayers() {
  const y = court.groundY - playerHeight / 2;

  player1 = {
    x: court.left + (court.netX - court.left) * 0.4,
    y,
    w: playerWidth,
    h: playerHeight
  };

  player2 = {
    x: court.right - (court.right - court.netX) * 0.4,
    y,
    w: playerWidth,
    h: playerHeight
  };
}
Line-by-line explanation (3 lines)
const y = court.groundY - playerHeight / 2;
Both players start with their center at ground level minus half their height, so they appear to stand on the court
x: court.left + (court.netX - court.left) * 0.4,
Player 1 starts 40% of the way from the left edge to the net—closer to their baseline
x: court.right - (court.right - court.netX) * 0.4,
Player 2 starts 40% of the way from the right edge toward the net—mirrored positioning for fairness

createBall()

The ball is an object with position (x, y), velocity (vx, vy), and radius. This structure makes physics updates and collision checks straightforward.

function createBall() {
  ball = {
    x: width / 2,
    y: court.groundY - ballRadius - 1,
    vx: 0,
    vy: 0,
    radius: ballRadius
  };
}
Line-by-line explanation (5 lines)
x: width / 2,
Ball starts at the horizontal center of the canvas
y: court.groundY - ballRadius - 1,
Ball starts just above the ground so it does not sink into it
vx: 0,
Horizontal velocity is zero—the ball is stationary until served
vy: 0,
Vertical velocity is zero at the start
radius: ballRadius
Stores the ball's radius for use in collision detection and drawing

resetBallForServe()

This function prepares the ball for a serve by placing it in the server's hand and resetting its velocity. It is called after every point to set up the next serve.

function resetBallForServe() {
  const serverPlayer = server === 1 ? player1 : player2;
  const dir = server === 1 ? 1 : -1;

  ball.x = serverPlayer.x + dir * (serverPlayer.w / 2 + ball.radius * 1.4);
  ball.y = serverPlayer.y - serverPlayer.h * 0.4;
  ball.vx = 0;
  ball.vy = 0;
  lastHitter = 0;
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Server selection const serverPlayer = server === 1 ? player1 : player2; const dir = server === 1 ? 1 : -1;

Determines which player is serving and sets direction (1 for left player, -1 for right player)

const serverPlayer = server === 1 ? player1 : player2;
Gets a reference to the player who is serving
const dir = server === 1 ? 1 : -1;
Direction 1 means the server is on the left and will hit right; -1 means right player hitting left
ball.x = serverPlayer.x + dir * (serverPlayer.w / 2 + ball.radius * 1.4);
Positions the ball next to the server's racket, slightly outside their paddle width
ball.y = serverPlayer.y - serverPlayer.h * 0.4;
Places the ball above the center of the server's body, at racket height
ball.vx = 0; ball.vy = 0;
Resets velocity to zero so the ball is stationary until the server hits it
lastHitter = 0;
Resets lastHitter to 0, marking that no one has hit the ball yet in this rally

draw()

draw() is the main game loop, running 60 times per second. It orchestrates everything: input, physics, collision checks, and rendering. The order matters—we update positions before drawing so the visuals show the current state.

🔬 This conditional stops the ball from moving during a serve. What happens if you remove this if-statement and call updateBall() every frame? Why would the ball suddenly become wild?

  if (gameState === 'rally') {
    updateBall();
  }
function draw() {
  background(30, 130, 70);

  handleMovement();

  if (gameState === 'rally') {
    updateBall();
  }

  drawCourt();
  drawPlayers();
  drawBall();
  drawUI();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Rally state check if (gameState === 'rally') { updateBall(); }

Only updates ball physics during a rally—the ball stays still during serve state

background(30, 130, 70);
Clears the canvas with a grass-green color, erasing everything from the previous frame
handleMovement();
Reads keyboard input and moves each player left or right based on what keys are held down
if (gameState === 'rally') { updateBall(); }
Only updates ball position and physics during a rally; the ball does not move while waiting for a serve
drawCourt();
Draws the tennis court, net, and boundary lines
drawPlayers();
Draws both players as rectangles with heads and rackets
drawBall();
Draws the yellow tennis ball at its current position
drawUI();
Draws the score, instructions, and game state messages on screen

handleMovement()

handleMovement() reads continuous key input using keyIsDown(), then constrains each player to their own half of the court. This happens every frame so movement feels responsive and fluid.

🔬 This line stops Player 1 from crossing the net. What happens if you remove the constrain() call on this line? Why would removing it break the game?

  // Constrain players to their half of the court
  const halfMargin = playerWidth / 2;
  player1.x = constrain(player1.x, court.left + halfMargin, court.netX - halfMargin * 2);
function handleMovement() {
  if (!player1 || !player2) return;

  // Player 1: A / D
  if (keyIsDown(65)) { // 'A'
    player1.x -= playerSpeed;
  }
  if (keyIsDown(68)) { // 'D'
    player1.x += playerSpeed;
  }

  // Player 2: Left / Right arrows
  if (keyIsDown(LEFT_ARROW)) {
    player2.x -= playerSpeed;
  }
  if (keyIsDown(RIGHT_ARROW)) {
    player2.x += playerSpeed;
  }

  // Constrain players to their half of the court
  const halfMargin = playerWidth / 2;
  player1.x = constrain(player1.x, court.left + halfMargin, court.netX - halfMargin * 2);
  player2.x = constrain(player2.x, court.netX + halfMargin * 2, court.right - halfMargin);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Player 1 movement input if (keyIsDown(65)) { // 'A' player1.x -= playerSpeed; } if (keyIsDown(68)) { // 'D' player1.x += playerSpeed; }

Reads A and D keys and moves Player 1 left or right

conditional Boundary constraint player1.x = constrain(player1.x, court.left + halfMargin, court.netX - halfMargin * 2); player2.x = constrain(player2.x, court.netX + halfMargin * 2, court.right - halfMargin);

Keeps each player within their own half of the court so they cannot cross the net

if (!player1 || !player2) return;
Safety check: if either player has not been created yet, exit early to avoid errors
if (keyIsDown(65)) { // 'A' player1.x -= playerSpeed; }
If the A key is held, move Player 1 to the left by subtracting playerSpeed from their x position
if (keyIsDown(68)) { // 'D' player1.x += playerSpeed; }
If the D key is held, move Player 1 to the right by adding playerSpeed to their x position
if (keyIsDown(LEFT_ARROW)) { player2.x -= playerSpeed; }
If the left arrow is held, move Player 2 left
if (keyIsDown(RIGHT_ARROW)) { player2.x += playerSpeed; }
If the right arrow is held, move Player 2 right
player1.x = constrain(player1.x, court.left + halfMargin, court.netX - halfMargin * 2);
Constrains Player 1's x between the left court boundary and a point before the net, preventing them from leaving their half
player2.x = constrain(player2.x, court.netX + halfMargin * 2, court.right - halfMargin);
Constrains Player 2's x between a point past the net and the right court boundary

keyPressed()

keyPressed() is called once per key press (not held). Use it for discrete actions like hitting the ball or restarting. For continuous motion (like sliding left/right), use keyIsDown() instead (as in handleMovement).

function keyPressed() {
  // Player 1 hit / serve
  if (key === 'w' || key === 'W') {
    attemptHit(1);
  }

  // Player 2 hit / serve
  if (keyCode === UP_ARROW) {
    attemptHit(2);
  }

  // Reset match
  if (key === 'r' || key === 'R') {
    initGame();
  }
}
Line-by-line explanation (3 lines)
if (key === 'w' || key === 'W') { attemptHit(1); }
When Player 1 presses W (upper or lowercase), attempt a hit or serve for Player 1
if (keyCode === UP_ARROW) { attemptHit(2); }
When Player 2 presses the up arrow, attempt a hit or serve for Player 2
if (key === 'r' || key === 'R') { initGame(); }
When any player presses R, reset the entire match to 0-0 and serve state

attemptHit(playerIndex)

attemptHit() is the game's core interaction. It handles three roles: serving (launching the ball to start a rally), hitting during a rally (bouncing the ball back), and aiming (biasing shot direction based on movement keys). The distance check ensures hits only work when the ball is actually reachable.

🔬 This code adjusts ball speed based on which direction the player is moving while hitting. What happens if you swap the multipliers (0.8 and 1.2)? Would the game feel better or worse?

    if (playerIndex === 1) {
      if (keyIsDown(65)) vx *= 0.8;  // A
      if (keyIsDown(68)) vx *= 1.2;  // D
    } else {
function attemptHit(playerIndex) {
  if (gameState === 'gameOver') return;

  const player = playerIndex === 1 ? player1 : player2;
  const dir = playerIndex === 1 ? 1 : -1;

  // Serving
  if (gameState === 'serve') {
    if (server === playerIndex) {
      lastHitter = playerIndex;
      ball.vx = dir * serveSpeedX;
      ball.vy = -serveSpeedY;
      gameState = 'rally';
    }
    return;
  }

  if (gameState !== 'rally') return;

  // Must be on the correct side of the net
  if (playerIndex === 1 && ball.x > court.netX) return;
  if (playerIndex === 2 && ball.x < court.netX) return;

  // Racket contact zone
  const hitX = player.x + dir * (player.w / 2 + ball.radius * 0.2);
  const hitY = player.y - player.h * 0.2;
  const maxDist = player.h * 0.9;

  const d = dist(ball.x, ball.y, hitX, hitY);
  if (d <= maxDist) {
    // Successful hit
    lastHitter = playerIndex;

    const speedX = random(shotSpeedMin, shotSpeedMax);
    const speedY = random(shotUpSpeedMin, shotUpSpeedMax);

    let vx = dir * speedX;

    // Simple aiming: movement while hitting slightly biases shot
    if (playerIndex === 1) {
      if (keyIsDown(65)) vx *= 0.8;  // A
      if (keyIsDown(68)) vx *= 1.2;  // D
    } else {
      if (keyIsDown(LEFT_ARROW)) vx *= 1.2;
      if (keyIsDown(RIGHT_ARROW)) vx *= 0.8;
    }

    ball.x = hitX + dir * ball.radius * 0.5;
    ball.y = hitY - ball.radius;
    ball.vx = vx;
    ball.vy = -speedY;
  }
}
Line-by-line explanation (20 lines)

🔧 Subcomponents:

conditional Serve logic if (gameState === 'serve') { if (server === playerIndex) { lastHitter = playerIndex; ball.vx = dir * serveSpeedX; ball.vy = -serveSpeedY; gameState = 'rally'; } return; }

Handles serves: only the current server can serve, and pressing the hit key launches the ball and starts the rally

conditional Court side validation if (playerIndex === 1 && ball.x > court.netX) return; if (playerIndex === 2 && ball.x < court.netX) return;

Ensures a player can only hit the ball when it is on their side of the net

conditional Racket distance check const d = dist(ball.x, ball.y, hitX, hitY); if (d <= maxDist) {

Verifies the ball is within racket range before allowing a hit

conditional Aiming movement bias if (playerIndex === 1) { if (keyIsDown(65)) vx *= 0.8; // A if (keyIsDown(68)) vx *= 1.2; // D } else { if (keyIsDown(LEFT_ARROW)) vx *= 1.2; if (keyIsDown(RIGHT_ARROW)) vx *= 0.8; }

Allows players to aim their shots slightly by holding a direction key while hitting

if (gameState === 'gameOver') return;
Prevents any hits after the match is over
const player = playerIndex === 1 ? player1 : player2;
Gets a reference to the player attempting the hit
const dir = playerIndex === 1 ? 1 : -1;
Direction 1 for Player 1 (hitting right), -1 for Player 2 (hitting left)
if (gameState === 'serve') {
If the game is waiting for a serve, handle the serve logic instead of a rally hit
if (server === playerIndex) {
Only the designated server can serve; other player's input is ignored
ball.vx = dir * serveSpeedX;
Launches the ball horizontally across the net at serve speed
ball.vy = -serveSpeedY;
Gives the ball an upward velocity so it arcs over the net
gameState = 'rally';
Transitions from serve state to rally state, allowing ball physics to update
if (playerIndex === 1 && ball.x > court.netX) return;
Player 1 cannot hit a ball that is on Player 2's side of the net
const hitX = player.x + dir * (player.w / 2 + ball.radius * 0.2);
The x-coordinate where the racket can contact the ball (slightly outside the player's paddle width)
const hitY = player.y - player.h * 0.2;
The y-coordinate of the racket head, a bit above the player's center
const maxDist = player.h * 0.9;
The maximum distance from the racket center where the ball can still be hit
const d = dist(ball.x, ball.y, hitX, hitY);
Calculates the distance between the ball and the racket center using p5.js dist() function
if (d <= maxDist) {
If the ball is within racket range, proceed with the hit
const speedX = random(shotSpeedMin, shotSpeedMax);
Generates a random horizontal speed between min and max, adding variety to shots
const speedY = random(shotUpSpeedMin, shotUpSpeedMax);
Generates a random upward speed so shots arc differently each time
if (keyIsDown(65)) vx *= 0.8; // A
If Player 1 is holding A while hitting, reduce the horizontal speed by 20% to aim a slower shot left
if (keyIsDown(68)) vx *= 1.2; // D
If Player 1 is holding D while hitting, increase horizontal speed by 20% to aim a faster shot right
ball.x = hitX + dir * ball.radius * 0.5;
Repositions the ball to the racket's contact point to avoid overlap
ball.vy = -speedY;
Sets upward velocity (negative because y increases downward in p5.js)

updateBall()

updateBall() is the physics engine. Each frame it applies gravity, updates position, and checks for all collisions. The order is important: gravity must be added before position updates, and net collision is checked before ground collision so the net takes priority.

🔬 These two lines move the ball each frame based on its velocity. What would happen if you doubled these updates, like ball.x += ball.vx * 2? Would the ball move twice as fast, or would something stranger happen?

  // Integrate motion
  ball.x += ball.vx;
  ball.y += ball.vy;
function updateBall() {
  // Gravity
  ball.vy += gravity;

  // Integrate motion
  ball.x += ball.vx;
  ball.y += ball.vy;

  // Net collision (if true, point is over)
  if (checkNetCollision()) return;

  // Ground contact ends rally immediately
  if (ball.y + ball.radius >= court.groundY) {
    ball.y = court.groundY - ball.radius;
    handleBallLanded();
    return;
  }

  // Out-of-bounds horizontally or far off-screen vertically
  if (
    ball.x + ball.radius < court.left ||
    ball.x - ball.radius > court.right ||
    ball.y - ball.radius > height * 1.2 ||
    ball.y + ball.radius < 0
  ) {
    handleBallOut();
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Gravity application ball.vy += gravity;

Increases downward velocity each frame to simulate falling motion

calculation Position integration ball.x += ball.vx; ball.y += ball.vy;

Updates the ball's position based on its velocity—fundamental physics integration

conditional Ground collision if (ball.y + ball.radius >= court.groundY) { ball.y = court.groundY - ball.radius; handleBallLanded(); return; }

Detects when the ball hits the ground and triggers scoring logic

conditional Out-of-bounds detection if ( ball.x + ball.radius < court.left || ball.x - ball.radius > court.right || ball.y - ball.radius > height * 1.2 || ball.y + ball.radius < 0 ) {

Detects when the ball leaves the playable area and triggers out-of-bounds scoring

ball.vy += gravity;
Gravity is constant acceleration—each frame the ball's downward velocity increases, making it fall faster over time
ball.x += ball.vx;
Updates horizontal position by adding horizontal velocity—this is Euler integration, the simplest physics method
ball.y += ball.vy;
Updates vertical position by adding vertical velocity (which increases due to gravity)
if (checkNetCollision()) return;
Checks if the ball hit the net; if so, scoring is handled and we exit early to avoid further collision checks this frame
if (ball.y + ball.radius >= court.groundY) {
Checks if the bottom of the ball has reached or passed the ground level
ball.y = court.groundY - ball.radius;
Positions the ball exactly on the ground to prevent sinking through
handleBallLanded();
Calls the scoring function to determine who wins the point based on where the ball landed
ball.x + ball.radius < court.left ||
True if the right edge of the ball is left of the court's left boundary
ball.x - ball.radius > court.right ||
True if the left edge of the ball is right of the court's right boundary
ball.y - ball.radius > height * 1.2
True if the top of the ball has fallen below 120% of screen height (off the bottom)
ball.y + ball.radius < 0
True if the bottom of the ball has risen above the screen (off the top)

checkNetCollision()

checkNetCollision() uses axis-aligned bounding box collision detection: it checks if the ball's rectangular bounds overlap the net's rectangular bounds. This is fast and works well for this game, though more complex shapes would need different algorithms.

function checkNetCollision() {
  const netTop = court.groundY - court.netHeight;
  const left = court.netX - court.netWidth / 2;
  const right = court.netX + court.netWidth / 2;

  if (ball.y + ball.radius > netTop && ball.y - ball.radius < court.groundY) {
    if (ball.x + ball.radius > left && ball.x - ball.radius < right) {
      // Hit the net: last hitter loses
      if (lastHitter === 0) {
        // No hitter yet: choose winner based on side of ball
        const side = ball.x < court.netX ? 1 : 2;
        awardPoint(side === 1 ? 2 : 1);
      } else {
        awardPoint(3 - lastHitter);
      }
      return true;
    }
  }
  return false;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Net boundary calculation const netTop = court.groundY - court.netHeight; const left = court.netX - court.netWidth / 2; const right = court.netX + court.netWidth / 2;

Defines the four corners of the net as an axis-aligned rectangle for collision detection

conditional Vertical bounds check if (ball.y + ball.radius > netTop && ball.y - ball.radius < court.groundY) {

Verifies the ball is at the right height range to potentially hit the net

conditional Horizontal bounds check if (ball.x + ball.radius > left && ball.x - ball.radius < right) {

Verifies the ball is within the net's horizontal bounds

const netTop = court.groundY - court.netHeight;
Calculates the top edge of the net by subtracting the net height from ground level
const left = court.netX - court.netWidth / 2;
Calculates the left edge of the net
const right = court.netX + court.netWidth / 2;
Calculates the right edge of the net
if (ball.y + ball.radius > netTop && ball.y - ball.radius < court.groundY) {
Checks if the ball's vertical range overlaps the net's vertical range (from top to ground)
if (ball.x + ball.radius > left && ball.x - ball.radius < right) {
Checks if the ball's horizontal range overlaps the net's horizontal range
if (lastHitter === 0) {
If no one has hit the ball yet (e.g., a serve whiff), award the point based on which side the ball came from
const side = ball.x < court.netX ? 1 : 2;
Determines which side of the court the ball is on—less than netX is Player 1's side
awardPoint(side === 1 ? 2 : 1);
Awards the point to the opposite side (if the ball originated from Player 1's side, Player 2 wins)
awardPoint(3 - lastHitter);
If the ball has been hit, the hitter loses the point. The formula 3 - lastHitter flips the winner: if lastHitter is 1, 3-1=2; if 2, 3-2=1
return true;
Returns true to signal that a collision was detected, so updateBall() will stop processing further collisions

handleBallLanded()

handleBallLanded() is the scoring logic for ground contact. It checks which side the ball landed on and compares it to who last hit it to determine the winner. This simple rule—your own side means you lose—is the core of tennis.

function handleBallLanded() {
  const side = ball.x < court.netX ? 1 : 2;

  if (lastHitter === 0) {
    // Dropped without a hit (e.g., serve whiff)
    awardPoint(side === 1 ? 2 : 1);
    return;
  }

  if (side === lastHitter) {
    // Landed on hitter's own side: they lose
    awardPoint(3 - lastHitter);
  } else {
    // Landed on opponent's side: hitter wins
    awardPoint(lastHitter);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Side determination const side = ball.x < court.netX ? 1 : 2;

Determines which half of the court the ball landed on

const side = ball.x < court.netX ? 1 : 2;
If the ball is left of center, it is on Player 1's side; if right, it is on Player 2's side
if (lastHitter === 0) {
If no one has hit the ball (e.g., on a failed serve), award the point to the opposite side
if (side === lastHitter) {
If the ball landed on the same side as the player who hit it, they failed to send it over the net
awardPoint(3 - lastHitter);
The hitter loses, so award the point to the opponent
awardPoint(lastHitter);
The hitter successfully sent the ball over the net to land on the opponent's side, so they win the point

handleBallOut()

handleBallOut() handles out-of-bounds situations. In real tennis, hitting the ball out is an automatic loss. This function enforces that rule by awarding the point to the opponent whenever the ball leaves the court.

function handleBallOut() {
  if (lastHitter === 0) {
    // Out without a hit — give point to opposite side of where ball is
    const side = ball.x < court.netX ? 1 : 2;
    awardPoint(side === 1 ? 2 : 1);
  } else {
    // Ball went out after a hit: hitter loses
    awardPoint(3 - lastHitter);
  }
}
Line-by-line explanation (4 lines)
if (lastHitter === 0) {
If no one has hit the ball yet and it goes out, award the point based on which side it escaped from
const side = ball.x < court.netX ? 1 : 2;
Determines which side the ball is on before it left the court
awardPoint(side === 1 ? 2 : 1);
Awards the point to the opposite side
awardPoint(3 - lastHitter);
If the ball was hit and went out, the hitter loses; the opponent wins

awardPoint(winner)

awardPoint() is the state machine hub—it updates the score, determines match outcome, changes game state, and resets the ball. Every point in the game flows through this function, making it critical to the overall game logic.

function awardPoint(winner) {
  if (winner === 1) {
    p1Score++;
  } else {
    p2Score++;
  }

  server = winner;

  if (p1Score >= maxScore || p2Score >= maxScore) {
    gameState = 'gameOver';
  } else {
    gameState = 'serve';
  }

  resetBallForServe();
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Score increment if (winner === 1) { p1Score++; } else { p2Score++; }

Adds one point to the winning player's score

conditional Win condition check if (p1Score >= maxScore || p2Score >= maxScore) { gameState = 'gameOver'; } else { gameState = 'serve'; }

Determines if the match is over or if it continues with a new serve

if (winner === 1) { p1Score++; } else { p2Score++; }
Increments the winner's score by one point using the ++ operator
server = winner;
The winner of the point serves next—this is how serves alternate in this simplified model
if (p1Score >= maxScore || p2Score >= maxScore) {
Checks if either player has reached the winning score (5 by default)
gameState = 'gameOver';
If someone won the match, change game state so the game freezes and displays the winner
gameState = 'serve';
Otherwise, transition back to serve state for the next point
resetBallForServe();
Positions the ball in the new server's hand and resets velocities for the next serve

drawCourt()

drawCourt() renders the tennis court including the surface, boundary lines, service line, and net. It is purely visual and does not affect collision detection—the physics uses the court object's stored values instead.

function drawCourt() {
  const courtHeight = height * 0.5;
  const topY = court.groundY - courtHeight;

  // Court surface
  noStroke();
  fill(20, 110, 60);
  rect(court.left, topY, court.right - court.left, court.groundY - topY);

  // Outer lines
  stroke(255);
  strokeWeight(3);
  noFill();
  rect(court.left, topY, court.right - court.left, court.groundY - topY);

  // Baseline (near camera)
  line(court.left, court.groundY, court.right, court.groundY);

  // Sidelines
  line(court.left, topY, court.left, court.groundY);
  line(court.right, topY, court.right, court.groundY);

  // Simple service line (visual only)
  const serviceY = topY + (court.groundY - topY) * 0.4;
  line(court.left, serviceY, court.right, serviceY);

  // Net
  const netTop = court.groundY - court.netHeight;
  stroke(255);
  strokeWeight(4);
  line(court.netX, court.groundY, court.netX, netTop);
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Court dimension calculation const courtHeight = height * 0.5; const topY = court.groundY - courtHeight;

Calculates the court's vertical extent so it looks proportional

const courtHeight = height * 0.5;
The court is 50% of screen height, leaving room for the UI above
const topY = court.groundY - courtHeight;
Calculates the top edge of the court by subtracting height from the ground level
fill(20, 110, 60);
Sets fill to dark grass green
rect(court.left, topY, court.right - court.left, court.groundY - topY);
Draws the filled court rectangle from top-left to bottom-right
stroke(255);
Sets stroke color to white for the lines
rect(court.left, topY, court.right - court.left, court.groundY - topY);
Draws the white outer boundary of the court
line(court.left, court.groundY, court.right, court.groundY);
Draws the baseline at ground level, the closest line to the camera
line(court.left, topY, court.left, court.groundY); line(court.right, topY, court.right, court.groundY);
Draws the left and right sidelines
const serviceY = topY + (court.groundY - topY) * 0.4;
Places the service line 40% of the way down the court for visual reference
line(court.netX, court.groundY, court.netX, netTop);
Draws the net as a vertical line from the ground up to the net height

drawPlayers()

drawPlayers() draws both players with distinct colors (yellow and blue), heads, and rackets. The racket position is calculated geometrically based on the player's center and size, so it naturally scales with screen size and stays properly positioned.

function drawPlayers() {
  if (!player1 || !player2) return;

  rectMode(CENTER);

  // Player 1 (left, yellow)
  noStroke();
  fill(240, 200, 80);
  rect(player1.x, player1.y, player1.w, player1.h, 6);
  // Head
  fill(255, 225, 200);
  circle(player1.x, player1.y - player1.h / 2 - player1.h * 0.2, player1.w * 0.7);
  // Racket
  const dir1 = 1;
  const r1x = player1.x + dir1 * (player1.w / 2 + player1.w * 0.6);
  const r1y = player1.y - player1.h * 0.15;
  stroke(255);
  strokeWeight(3);
  noFill();
  ellipse(r1x, r1y, player1.w * 0.7, player1.h * 0.4);
  line(
    player1.x + dir1 * (player1.w / 2),
    player1.y,
    r1x - dir1 * player1.w * 0.15,
    r1y + player1.h * 0.1
  );

  // Player 2 (right, blue)
  noStroke();
  fill(80, 170, 255);
  rect(player2.x, player2.y, player2.w, player2.h, 6);
  // Head
  fill(230, 230, 240);
  circle(player2.x, player2.y - player2.h / 2 - player2.h * 0.2, player2.w * 0.7);
  // Racket
  const dir2 = -1;
  const r2x = player2.x + dir2 * (player2.w / 2 + player2.w * 0.6);
  const r2y = player2.y - player2.h * 0.15;
  stroke(255);
  strokeWeight(3);
  noFill();
  ellipse(r2x, r2y, player2.w * 0.7, player2.h * 0.4);
  line(
    player2.x + dir2 * (player2.w / 2),
    player2.y,
    r2x - dir2 * player2.w * 0.15,
    r2y + player2.h * 0.1
  );

  rectMode(CORNER);
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Player 1 body rendering fill(240, 200, 80); rect(player1.x, player1.y, player1.w, player1.h, 6);

Draws Player 1's body as a rounded yellow rectangle

calculation Player 1 racket rendering const dir1 = 1; const r1x = player1.x + dir1 * (player1.w / 2 + player1.w * 0.6); const r1y = player1.y - player1.h * 0.15;

Calculates the position of Player 1's racket (ellipse head and line handle)

if (!player1 || !player2) return;
Safety check to exit if players have not been initialized
rectMode(CENTER);
Sets rectangles to be drawn from their center point instead of the top-left corner
fill(240, 200, 80);
Sets fill color to golden yellow for Player 1
rect(player1.x, player1.y, player1.w, player1.h, 6);
Draws Player 1's body as a rounded rectangle with a 6-pixel corner radius
fill(255, 225, 200);
Sets fill to skin tone for the head
circle(player1.x, player1.y - player1.h / 2 - player1.h * 0.2, player1.w * 0.7);
Draws a circular head above the body
const dir1 = 1;
Direction 1 means Player 1 faces right and swings right
const r1x = player1.x + dir1 * (player1.w / 2 + player1.w * 0.6);
Calculates the x position of the racket head (to the right of the player's body)
const r1y = player1.y - player1.h * 0.15;
Places the racket at shoulder height
stroke(255); strokeWeight(3);
Sets stroke color to white with 3-pixel thickness for the racket outline
ellipse(r1x, r1y, player1.w * 0.7, player1.h * 0.4);
Draws the racket head as an ellipse
line(...);
Draws a line from the body to the racket head as the handle

drawBall()

drawBall() renders the ball as a yellow circle with a decorative tennis ball seam. The seam is purely visual and adds realism without affecting physics; it is drawn using two arcs that mirror each other across the ball's center.

function drawBall() {
  if (!ball) return;

  noStroke();
  fill(255, 240, 150);
  circle(ball.x, ball.y, ball.radius * 2);

  // Simple tennis ball seam
  stroke(200, 220, 120);
  strokeWeight(2);
  noFill();
  const seamOffset = ball.radius * 0.6;
  arc(ball.x - seamOffset, ball.y, ball.radius, ball.radius * 1.4, -HALF_PI, HALF_PI);
  arc(ball.x + seamOffset, ball.y, ball.radius, ball.radius * 1.4, HALF_PI, -HALF_PI);
}
Line-by-line explanation (6 lines)
if (!ball) return;
Safety check: if the ball has not been created, exit early
noStroke(); fill(255, 240, 150);
Disables stroke and sets fill to pale yellow (tennis ball color)
circle(ball.x, ball.y, ball.radius * 2);
Draws the ball as a circle with diameter equal to ball.radius * 2
const seamOffset = ball.radius * 0.6;
Offsets the seam arcs so they curve symmetrically across the ball
arc(ball.x - seamOffset, ball.y, ball.radius, ball.radius * 1.4, -HALF_PI, HALF_PI);
Draws the left half of the tennis ball seam using an arc from -90° to 90°
arc(ball.x + seamOffset, ball.y, ball.radius, ball.radius * 1.4, HALF_PI, -HALF_PI);
Draws the right half of the seam from 90° to 270°, completing the curved seam pattern

drawUI()

drawUI() displays all textual information: the score, game state messages, and controls. It uses conditional logic to show different messages for serve, rally, and game-over states, keeping the player informed at all times.

function drawUI() {
  fill(255);
  noStroke();
  textAlign(CENTER, TOP);
  textSize(24);
  text(`Player 1  ${p1Score} : ${p2Score}  Player 2`, width / 2, 10);

  textSize(16);
  let msg = '';
  if (gameState === 'serve') {
    msg = `Player ${server} to serve — Player ${
      server === 1 ? '1 (press W)' : '2 (press ↑)'
    } to start the point`;
  } else if (gameState === 'rally') {
    msg = 'Rally! Hit the ball before it lands on your side.';
  } else if (gameState === 'gameOver') {
    const winner = p1Score > p2Score ? 'Player 1' : 'Player 2';
    msg = `${winner} wins! Press R to restart the match.`;
  }
  text(msg, width / 2, 40);

  textAlign(CENTER, BOTTOM);
  text(
    'Controls — Player 1: A/D move, W hit | Player 2: ←/→ move, ↑ hit | R: restart match',
    width / 2,
    height - 10
  );
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Score display text(`Player 1 ${p1Score} : ${p2Score} Player 2`, width / 2, 10);

Shows the current score at the top of the screen

conditional State-dependent messages if (gameState === 'serve') { msg = `Player ${server} to serve...`; } else if (gameState === 'rally') { msg = 'Rally! Hit the ball before it lands on your side.'; } else if (gameState === 'gameOver') {

Displays context-appropriate instructions based on game state

fill(255); noStroke(); textAlign(CENTER, TOP);
Sets text color to white, no outline, and horizontal/vertical alignment to center and top
textSize(24); text(`Player 1 ${p1Score} : ${p2Score} Player 2`, width / 2, 10);
Displays the score in a large font at the top center of the screen
if (gameState === 'serve') {
If waiting for a serve, display which player is serving and the key to press
msg = `Player ${server} to serve — Player ${server === 1 ? '1 (press W)' : '2 (press ↑)'}...`;
Uses template strings and a ternary operator to customize the message for the current server
} else if (gameState === 'rally') {
If a rally is in progress, remind the player to hit the ball
} else if (gameState === 'gameOver') {
If the match is over, determine the winner and display the victory message
const winner = p1Score > p2Score ? 'Player 1' : 'Player 2';
Uses a ternary operator to identify which player won based on score
textAlign(CENTER, BOTTOM);
Aligns text to the bottom center for the controls display
text(..., width / 2, height - 10);
Displays the control instructions at the bottom of the screen

windowResized()

windowResized() is called by p5.js whenever the browser window is resized. It ensures the entire game rescales gracefully, maintaining proportions and gameplay mechanics regardless of screen size. This makes the game responsive and playable on any device.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  updateDimensions();
  createPlayers();
  createBall();
  resetBallForServe();

  if (p1Score >= maxScore || p2Score >= maxScore) {
    gameState = 'gameOver';
  } else {
    gameState = 'serve';
  }
}
Line-by-line explanation (6 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the new window dimensions
updateDimensions();
Recalculates all scaled values (court size, player size, ball size, physics speeds) based on the new window size
createPlayers();
Repositions both players based on the new canvas size
createBall();
Resets the ball position and size
resetBallForServe();
Repositions the ball in the server's hand
if (p1Score >= maxScore || p2Score >= maxScore) { gameState = 'gameOver'; } else { gameState = 'serve'; }
Preserves the current match state—if a winner has been decided, keep showing the game-over screen; otherwise, return to serve state

📦 Key Variables

court object

Stores the court's geometric properties (left/right bounds, ground level, net position and size)

let court = {};
player1 object

Stores Player 1's position (x, y), dimensions (w, h), and other properties

player1 = { x: 200, y: 400, w: 20, h: 80 };
player2 object

Stores Player 2's position (x, y), dimensions (w, h), and other properties

player2 = { x: 600, y: 400, w: 20, h: 80 };
ball object

Stores the ball's position (x, y), velocity (vx, vy), and radius

ball = { x: 400, y: 200, vx: 0, vy: 0, radius: 10 };
playerWidth number

Width of each player paddle, scaled to screen size

playerWidth = 20;
playerHeight number

Height of each player paddle, scaled to screen size

playerHeight = 80;
playerSpeed number

Pixels per frame that a player moves when A/D or arrow keys are held

playerSpeed = 5;
ballRadius number

The tennis ball's radius, scaled to screen size

ballRadius = 10;
gravity number

Constant downward acceleration applied to the ball each frame

gravity = 0.3;
serveSpeedX number

Horizontal velocity given to the ball when served

serveSpeedX = 8;
serveSpeedY number

Upward velocity given to the ball when served

serveSpeedY = 12;
shotSpeedMin number

Minimum horizontal speed for a rally shot

shotSpeedMin = 6;
shotSpeedMax number

Maximum horizontal speed for a rally shot

shotSpeedMax = 9;
shotUpSpeedMin number

Minimum upward speed for a rally shot

shotUpSpeedMin = 10;
shotUpSpeedMax number

Maximum upward speed for a rally shot

shotUpSpeedMax = 13;
p1Score number

Player 1's current score (0 to maxScore)

p1Score = 3;
p2Score number

Player 2's current score (0 to maxScore)

p2Score = 2;
maxScore number

First player to reach this score wins the match

const maxScore = 5;
server number

Index of the player currently serving (1 or 2)

server = 1;
lastHitter number

Index of the player who last hit the ball (0 = none yet, 1 or 2 = player index)

lastHitter = 1;
gameState string

Current game mode: 'serve' (waiting for serve), 'rally' (ball in play), or 'gameOver' (match finished)

gameState = 'rally';

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG attemptHit() racket contact zone

The racket contact distance (maxDist = player.h * 0.9) is very large and can hit the ball from an unrealistic distance, especially on large screens

💡 Use a smaller multiple like player.h * 0.4 or measure distance to the actual racket head position instead of the player center

PERFORMANCE draw() rendering

All collision and scoring logic runs every frame even during serve state when the ball is stationary, wasting CPU cycles

💡 Move ball collision checks inside the 'if (gameState === "rally")' block so they only run when the ball is actually in play

STYLE attemptHit() and updateBall()

Magic numbers like 0.8 and 1.2 for aiming bias, and 0.2, 0.4 for hit positioning, are scattered throughout without clear meaning

💡 Extract these as named constants (e.g., 'const AIM_BIAS_WEAK = 0.8') at the top of the file for clarity and easy tuning

BUG checkNetCollision()

If the ball travels very fast, it can pass through the net without being detected, especially if velocity is high enough to jump over the hit detection bounding box in a single frame

💡 Use continuous collision detection (ray casting or swept circles) instead of discrete checks, or limit maximum ball speed

FEATURE attemptHit()

The aiming system (moving left/right while hitting to bias the shot) is subtle and not obvious to players—they may not realize it works

💡 Add a visual indicator (like a direction arrow or power meter) that shows how the movement keys are biasing the shot in real-time

STYLE drawPlayers()

Player 1 and Player 2 rendering code is nearly identical and duplicated, making it hard to maintain and change both uniformly

💡 Refactor into a helper function like drawPlayer(playerObj, directionMultiplier, color) to eliminate duplication

🔄 Code Flow

Code flow showing setup, initgame, updatedimensions, createplayers, createball, resetballforserve, draw, handlemovement, keypressed, attempthit, updateball, checknetcollision, handleballlanded, handleballout, awardpoint, drawcourt, drawplayers, drawball, drawui, windowresized

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

graph TD start[Start] --> setup[setup] setup --> initgame[initGame] setup --> updatedimensions[updateDimensions] setup --> createplayers[createPlayers] setup --> createball[createBall] setup --> draw[draw loop] click setup href "#fn-setup" click initgame href "#fn-initgame" click updatedimensions href "#fn-updatedimensions" click createplayers href "#fn-createplayers" click createball href "#fn-createball" draw --> handlemovement[handleMovement] draw --> keypressed[keyPressed] draw --> attempthit[attemptHit] draw --> updateball[updateBall] draw --> drawcourt[drawCourt] draw --> drawplayers[drawPlayers] draw --> drawball[drawBall] draw --> drawui[drawUI] click draw href "#fn-draw" click handlemovement href "#fn-handlemovement" click keypressed href "#fn-keypressed" click attempthit href "#fn-attemphit" click updateball href "#fn-updateball" click drawcourt href "#fn-drawcourt" click drawplayers href "#fn-drawplayers" click drawball href "#fn-drawball" click drawui href "#fn-drawui" handlemovement --> player1-input[player1-input] handlemovement --> boundary-constraint[boundary-constraint] click player1-input href "#sub-player1-input" click boundary-constraint href "#sub-boundary-constraint" keypressed --> serve-logic[serve-logic] click serve-logic href "#sub-serve-logic" attempthit --> side-check[side-check] attempthit --> distance-check[distance-check] attempthit --> aim-bias[aim-bias] click side-check href "#sub-side-check" click distance-check href "#sub-distance-check" click aim-bias href "#sub-aim-bias" updateball --> gravity-apply[gravity-apply] updateball --> position-update[position-update] updateball --> ground-collision[ground-collision] updateball --> out-of-bounds-check[out-of-bounds-check] updateball --> checknetcollision[checkNetCollision] click gravity-apply href "#sub-gravity-apply" click position-update href "#sub-position-update" click ground-collision href "#sub-ground-collision" click out-of-bounds-check href "#sub-out-of-bounds-check" click checknetcollision href "#sub-checknetcollision" handleballlanded[handleBallLanded] --> side-determination[side-determination] handleballlanded --> score-increment[score-increment] handleballlanded --> win-check[win-check] click handleballlanded href "#fn-handleballlanded" click side-determination href "#sub-side-determination" click score-increment href "#sub-score-increment" click win-check href "#sub-win-check" handleballout[handleBallOut] --> awardpoint[awardPoint] click handleballout href "#fn-handleballout" click awardpoint href "#fn-awardpoint" drawcourt --> court-dimensions[court-dimensions] click court-dimensions href "#sub-court-dimensions" drawplayers --> player1-body[player1-body] drawplayers --> player1-racket[player1-racket] click player1-body href "#sub-player1-body" click player1-racket href "#sub-player1-racket" drawui --> score-display[score-display] drawui --> state-messages[state-messages] click score-display href "#sub-score-display" click state-messages href "#sub-state-messages" windowresized[windowResized] --> updatedimensions click windowresized href "#fn-windowresized"

❓ Frequently Asked Questions

What visual elements does the p5.js tennis sketch include?

The sketch features a side-view tennis court, with two players, a bouncing ball, and a net, all rendered in a dynamic and engaging style.

How can players control their characters in this interactive tennis game?

Players can move Player 1 using the A and D keys, while Player 2 can use the arrow keys to slide left and right, aiming to hit the ball over the net.

What creative coding concepts are showcased in this two-player tennis match?

The sketch demonstrates physics-based motion, including gravity effects on the ball, and real-time user input handling for gameplay.

Preview

Sketch 2026-03-31 21:42 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2026-03-31 21:42 - Code flow showing setup, initgame, updatedimensions, createplayers, createball, resetballforserve, draw, handlemovement, keypressed, attempthit, updateball, checknetcollision, handleballlanded, handleballout, awardpoint, drawcourt, drawplayers, drawball, drawui, windowresized
Code Flow Diagram