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
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>
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([]);
}
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
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();
}
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);
}
| 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);
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;
}
}
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));
}
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;
}
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;
}
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:
Top comments (0)