DEV Community

mame-max
mame-max

Posted on

How to Build a Ping Pong Game in HTML

A retro-style ping pong game, built with nothing but HTML, CSS, and JavaScript. No game engine, no framework — just Canvas API and browser builtins. It features a 10-stage CPU mode, online P2P battles via WebRTC, and runs on both desktop and mobile.

Demo: https://mame-max.itch.io/ping-pong-arcade


Project Structure

index.html          — page wrapper
index-sub.html      — arcade monitor overlay
game-disp.html      — all game logic and Canvas rendering
css/my-style.css    — layout CSS
Enter fullscreen mode Exit fullscreen mode

The files load in layers via fetch() and innerHTML injection. All game code lives in game-disp.html.


HTML Skeleton

<div id="game-wrap">
  <div id="game-root">
    <canvas id="c" width="499" height="600"></canvas>
    <div id="scoreboard">...</div>
    <div id="overlay">...</div>  <!-- title / result screens -->
  </div>

  <!-- mobile d-pad -->
  <div id="dpad">
    <button class="dpad-btn" id="btn-up"></button>
    <button class="dpad-btn" id="btn-down"></button>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

canvas#c is the game surface. #overlay hosts the title and result screens as absolutely-positioned overlays.


Step 1: Canvas Rendering

Background, grid, and center line

function draw() {
  ctx.clearRect(0, 0, W, H);

  // Dark background
  ctx.fillStyle = '#0a0e1a';
  ctx.fillRect(0, 0, W, H);

  // Subtle grid
  ctx.strokeStyle = 'rgba(20,35,60,0.8)';
  ctx.lineWidth = 0.5;
  for (let x = 0; x < W; x += 40) {
    ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
  }
  for (let y = 0; y < H; y += 40) {
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
  }

  // Center dashed line
  ctx.setLineDash([10, 8]);
  ctx.strokeStyle = 'rgba(30,50,80,1)';
  ctx.beginPath(); ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H); ctx.stroke();
  ctx.setLineDash([]);
}
Enter fullscreen mode Exit fullscreen mode

Ball with glow and motion trail

// Trail — store last 10 positions, draw each smaller and more transparent
for (let i = 0; i < state.ball.trail.length; i++) {
  const t = state.ball.trail[i];
  const alpha = (i / state.ball.trail.length) * 0.4;
  const r = BALL_R * (i / state.ball.trail.length) * 0.8;
  ctx.beginPath();
  ctx.arc(t.x, t.y, r, 0, Math.PI * 2);
  ctx.fillStyle = `rgba(0,255,231,${alpha})`;
  ctx.fill();
}

// Ball with neon glow via shadowBlur
ctx.beginPath();
ctx.arc(state.ball.x, state.ball.y, BALL_R, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.shadowColor = '#00ffe7';
ctx.shadowBlur = 15;
ctx.fill();
ctx.shadowBlur = 0;  // Always reset after use
Enter fullscreen mode Exit fullscreen mode

shadowBlur alone gives you a convincing neon glow. Always reset to 0 after drawing — otherwise every subsequent shape picks it up.

Paddles

function drawPaddle(x, y, color) {
  ctx.shadowColor = color;
  ctx.shadowBlur = 18;
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.roundRect(x, y, PAD_W, PAD_H, 4);
  ctx.fill();
  ctx.shadowBlur = 0;

  // Highlight strip for a sense of depth
  ctx.fillStyle = 'rgba(255,255,255,0.2)';
  ctx.beginPath();
  ctx.roundRect(x + 2, y + 4, 3, PAD_H - 8, 2);
  ctx.fill();
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Frame-Rate Independent Game Loop

Calculate deltaTime from requestAnimationFrame timestamps so the game runs at the same speed on 60Hz, 120Hz, and 240Hz monitors.

let lastTime = 0;

function loop(timestamp) {
  // Normalize to 60fps baseline; cap at 3x to survive tab switching
  const deltaTime = lastTime === 0 ? 1 : Math.min((timestamp - lastTime) / (1000 / 60), 3);
  lastTime = timestamp;
  update(deltaTime);
  draw();
  if (gameRunning) animId = requestAnimationFrame(loop);
}
Enter fullscreen mode Exit fullscreen mode
Frame rate deltaTime
60fps 1.0
120fps 0.5
240fps 0.25

Multiply all movement by dt:

const PAD_SPD = 2.25;

if (btnUp)   state.p1.y = Math.max(0, state.p1.y - PAD_SPD * dt);
if (btnDown) state.p1.y = Math.min(H - PAD_H, state.p1.y + PAD_SPD * dt);
Enter fullscreen mode Exit fullscreen mode

Step 3: Ball Physics

const BALL_R = 7;

function physicsBall(dt) {
  // Record trail
  state.ball.trail.push({ x: state.ball.x, y: state.ball.y });
  if (state.ball.trail.length > 10) state.ball.trail.shift();

  state.ball.x += state.ball.vx * dt;
  state.ball.y += state.ball.vy * dt;

  // Wall bounce
  if (state.ball.y - BALL_R <= 0) {
    state.ball.y = BALL_R;
    state.ball.vy = Math.abs(state.ball.vy);
  }
  if (state.ball.y + BALL_R >= H) {
    state.ball.y = H - BALL_R;
    state.ball.vy = -Math.abs(state.ball.vy);
  }

  // Player paddle hit (left side)
  if (state.ball.vx < 0 &&
      state.ball.x - BALL_R <= P1X + PAD_W &&
      state.ball.x - BALL_R >= P1X - 1 &&
      state.ball.y + BALL_R >= state.p1.y &&
      state.ball.y - BALL_R <= state.p1.y + PAD_H) {
    state.ball.vx = Math.abs(state.ball.vx) * 1.04;  // slight speed increase each hit
    // Angle from distance to paddle center — edge hit = steep, center = straight
    state.ball.vy = ((state.ball.y - (state.p1.y + PAD_H / 2)) / (PAD_H / 2)) * 3;
    state.ball.x = P1X + PAD_W + BALL_R;  // prevent clipping
    if (state.ball.vx > 9) state.ball.vx = 9;
  }
}
Enter fullscreen mode Exit fullscreen mode

The collision check uses BALL_R so it detects the full ball, not just its center. The angle formula (offset / halfHeight) * 3 makes edge shots steep and center shots flat.


Step 4: CPU AI

The CPU uses lerp (linear interpolation) to track the ball. A tiny lerp factor means sluggish reaction; a larger one means tight tracking. This one number controls difficulty.

function getCpuParams(stage) {
  const t = (stage - 1) / (TOTAL_STAGES - 1);
  return {
    speed: PAD_SPD,
    lag: 0.012 + t * 0.063,  // lerp: 0.012 (stage 1) → 0.075 (stage 10)
  };
}

function cpuMove(dt) {
  const { speed, lag } = getCpuParams(currentStage);
  let targetY = state.ball.y;
  let trackLag = lag;

  // Stage 5+: return to center after hitting the ball
  if (currentStage >= 5 && state.ball.vx < 0) {
    targetY = H / 2;
    const st = (currentStage - 5) / 5;
    trackLag = 0.010 + st * 0.035;
  }
  // Stage 7+: aim for the opposite corner when player is near an edge
  else if (currentStage >= 7 && state.ball.vx > 0) {
    const edgeThresh = H / 4;
    const aimOffset = PAD_H * 0.38;
    if (state.p1.y <= edgeThresh)
      targetY = state.ball.y - aimOffset;
    else if (state.p1.y >= H - PAD_H - edgeThresh)
      targetY = state.ball.y + aimOffset;
  }

  cpuTrackY += (targetY - cpuTrackY) * trackLag;
  const cy = state.p2.y + PAD_H / 2;
  const diff = cpuTrackY - cy;
  if (Math.abs(diff) > 1) {
    state.p2.y += diff > 0 ? Math.min(speed * dt, diff) : Math.max(-speed * dt, diff);
  }
  state.p2.y = Math.max(0, Math.min(H - PAD_H, state.p2.y));
}
Enter fullscreen mode Exit fullscreen mode

The lerp-based tracking feels more natural than position snapping — the CPU looks like it's reacting, not teleporting.


Step 5: Online Multiplayer with PeerJS

Room ID generation

function generateRoomId() {
  // Exclude ambiguous chars: 0, O, 1, I, L
  const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
  let id = '';
  for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)];
  return id;
}
Enter fullscreen mode Exit fullscreen mode

Smooth sync: throttled sends + dead reckoning

Sending game state every frame floods the WebRTC data channel and causes buffering. Instead, the host sends at 30fps and the guest predicts the ball's position between packets.

// Host: cap sends at 30fps
const now = performance.now();
if (now - lastSendTime >= 33) {
  sendMsg({
    type: 's',
    p1: state.p1.y, p2: state.p2.y,
    bx: state.ball.x, by: state.ball.y,
    vx: state.ball.vx, vy: state.ball.vy,
    s1: state.p1.score, s2: state.p2.score
  });
  lastSendTime = now;
}

// Guest: extrapolate ball between packets
state.ball.x += state.ball.vx * dt;
state.ball.y += state.ball.vy * dt;

// On packet arrival: snap if error > 20px, lerp otherwise
const dx = msg.bx - state.ball.x;
const dy = msg.by - state.ball.y;
if (Math.abs(dx) > 20 || Math.abs(dy) > 20) {
  state.ball.x = msg.bx; state.ball.y = msg.by;
} else {
  state.ball.x += dx * 0.5; state.ball.y += dy * 0.5;
}
Enter fullscreen mode Exit fullscreen mode

Summary

Feature Approach
Canvas rendering 2D Canvas API + shadowBlur for neon glow
Frame-rate independence requestAnimationFrame + deltaTime
Ball physics vx/vy + BALL_R-aware collision
CPU difficulty Lerp tracking with per-stage factor
Online multiplayer PeerJS (WebRTC) + dead reckoning
Mobile support Touch events + CSS transform scale

Pure browser APIs, one external library (PeerJS). Give it a try:

https://mame-max.itch.io/ping-pong-arcade

Top comments (0)