Single-Player Pong: Implementation Guide

A very basic single-player Pong game built with HTML5 Canvas and vanilla JavaScript. Covers paddle movement, ball physics, wall and paddle collision, and a simple scoring system.

1

What This Game Does

This is Pong reduced to its simplest single-player form: one paddle, one ball, no opponent. The top, bottom, and right edges of the canvas act as walls the ball bounces off automatically. The left edge is guarded by a paddle the player moves up and down.

Every time the ball bounces off the paddle, the score increases by one. If the ball reaches the left edge without hitting the paddle, the round ends. There's no AI, no second player, and no difficulty ramp; just enough moving parts to demonstrate a full game loop.

2

Live Demo

Try it: Click the game area first so it has keyboard focus, then use ↑ / ↓ or W / S, or click and drag with a mouse or finger, to move the paddle. Missing the ball ends the round; the Restart button appears on top of the game when it does.

↑ / ↓ or W / S, or click and drag, to move the paddle

3

HTML Structure

The whole game lives in a single <canvas> element. The paddle, ball, score, and game-over message are all drawn onto it with JavaScript; none of them are separate HTML elements. The one exception is the Restart button, which is a real <button> positioned on top of the canvas with CSS rather than drawn into it, so it stays a focusable, screen-reader- friendly control instead of a shape someone has to click in just the right spot. A tabindex attribute makes the canvas itself focusable too, so keyboard events can be scoped to it instead of hijacking arrow keys for the whole page.

<!-- .pong-stage gives the button something to position against -->
<div class="pong-stage">
    <canvas id="pongCanvas"
            width="600"
            height="400"
            tabindex="0"></canvas>

    <!-- hidden by default; shown again only when the round ends -->
    <button id="pongRestart" class="pong-restart-btn" hidden>Restart</button>
</div>

tabindex="0" makes a non-interactive element like a canvas part of the normal tab order. Combined with attaching the keyboard listeners directly to the canvas (Section 7) rather than to document, the page only responds to arrow keys while the game itself is focused. The button's plain hidden attribute removes it from the layout, the tab order, and the accessibility tree all at once; JavaScript toggles it (Section 6) instead of a CSS class.

4

Game State

Three plain objects hold everything the game needs to know between frames: the paddle's position and size, the ball's position and velocity, and the running score.

var PADDLE_SPEED = 6;

var paddle = {
    x: 20,
    y: 160,
    width: 10,
    height: 80
};

var ball = {
    x: 300,
    y: 200,
    radius: 8,
    vx: -4,
    vy: 3
};

var score = 0;
var running = true;

vx and vy are how far the ball moves on each axis every frame. A negative vx means "moving left," toward the paddle. Flipping the sign of either one is the entire bounce mechanic; there's no separate "direction" concept to track.

5

The Game Loop

Every frame does the same two things in order: move everything (update), then draw the result (draw). requestAnimationFrame schedules the next frame to run right before the browser's next repaint, which keeps the animation smooth and pauses it automatically in a background tab.

function loop() {
    if (running) {
        update();
    }
    draw();
    window.requestAnimationFrame(loop);
}

window.requestAnimationFrame(loop);

Splitting running out of the loop itself means draw() still runs after a miss, so the game-over message stays visible on screen instead of the canvas freezing on its last frame.

6

Ball Physics and Collisions

update() moves the ball, then checks four conditions in order: did it hit the top or bottom wall, did it hit the right wall, did it hit the paddle, and did it pass the paddle entirely. Each check nudges the ball back inside the boundary it crossed before flipping a velocity component, so it can't visually sink into a wall or the paddle for a frame.

function update() {

    // Paddle movement, clamped so it can't leave the canvas
    if (keys.up)   paddle.y -= PADDLE_SPEED;
    if (keys.down) paddle.y += PADDLE_SPEED;
    paddle.y = clamp(paddle.y, 0, canvas.height - paddle.height);

    // Move the ball along its current velocity
    ball.x += ball.vx;
    ball.y += ball.vy;

    // Bounce off the top and bottom walls
    if (ball.y - ball.radius <= 0) {
        ball.y = ball.radius;
        ball.vy = -ball.vy;
    } else if (ball.y + ball.radius >= canvas.height) {
        ball.y = canvas.height - ball.radius;
        ball.vy = -ball.vy;
    }

    // Bounce off the right wall (the "opponent" side)
    if (ball.x + ball.radius >= canvas.width) {
        ball.x = canvas.width - ball.radius;
        ball.vx = -ball.vx;
    }

    // Paddle collision: the ball must be at the paddle's depth,
    // within its vertical span, and already moving toward it.
    // That vx check stops a single bounce from re-triggering on
    // the very next frame while the ball is still overlapping.
    var paddleRight = paddle.x + paddle.width;
    if (ball.x - ball.radius <= paddleRight &&
        ball.x - ball.radius >= paddle.x &&
        ball.y >= paddle.y &&
        ball.y <= paddle.y + paddle.height &&
        ball.vx < 0) {
        ball.x = paddleRight + ball.radius;
        ball.vx = -ball.vx;
        score++;
    }

    // Miss: the ball cleared the paddle's edge entirely
    if (ball.x + ball.radius < paddle.x) {
        running = false;
        restartBtn.hidden = false;
    }
}

Because the paddle check requires ball.vx < 0 (moving left), it only fires on the approach, not while the ball is already bouncing away. Without that guard, a ball moving slowly enough relative to the paddle's width could trigger the collision on two consecutive frames and cancel its own bounce. The miss branch is also the only place restartBtn.hidden gets set back to false; it stays hidden for the rest of the round otherwise.

7

Paddle Controls

The paddle doesn't move directly inside the keyboard event handler. Instead, keydown and keyup just flip booleans in a small keys object, and update() reads those booleans every frame. That keeps movement speed tied to the game loop's frame rate instead of how often the browser fires key events.

var keys = { up: false, down: false };

function setKey(e, value) {
    if (e.key === "ArrowUp" || e.key === "w" || e.key === "W") {
        keys.up = value;
        e.preventDefault();
    } else if (e.key === "ArrowDown" || e.key === "s" || e.key === "S") {
        keys.down = value;
        e.preventDefault();
    }
}

canvas.addEventListener("keydown", function (e) { setKey(e, true); });
canvas.addEventListener("keyup",   function (e) { setKey(e, false); });

e.preventDefault() stops the arrow keys from scrolling the page while playing. Because the listeners are attached to the canvas element rather than document, that only happens while the game itself has focus, not anywhere else on the page.

Mouse and touch, via Pointer Events

A keyboard isn't available on a phone, so the paddle also follows whatever is pressed against the canvas. The Pointer Events API (pointerdown / pointermove / pointerup) covers mouse, touch, and pen with the same three events, instead of needing separate mouse and touch handlers.

var dragging = false;

// Converts a pointer event's page position into the paddle's y,
// accounting for the canvas being scaled down by CSS on small screens.
function paddleYFromPointer(e) {
    var rect = canvas.getBoundingClientRect();
    var scaleY = canvas.height / rect.height;
    var y = (e.clientY - rect.top) * scaleY;
    return clamp(y - paddle.height / 2, 0, canvas.height - paddle.height);
}

canvas.addEventListener("pointerdown", function (e) {
    dragging = true;
    canvas.setPointerCapture(e.pointerId);
    paddle.y = paddleYFromPointer(e);
});

canvas.addEventListener("pointermove", function (e) {
    if (dragging && running) {
        paddle.y = paddleYFromPointer(e);
    }
});

canvas.addEventListener("pointerup",     function () { dragging = false; });
canvas.addEventListener("pointercancel", function () { dragging = false; });

setPointerCapture keeps pointermove events coming even if the finger or cursor drifts outside the canvas while still pressed down, so a fast drag near the edge doesn't drop the paddle. The canvas also has touch-action: none in its CSS, which is what stops the browser from treating that drag as a page scroll or pinch-zoom gesture instead of game input.

8

Reference

A quick summary of every moving part and what it's responsible for.

Name Responsibility
paddle Position and size of the player-controlled rectangle on the left edge.
ball Position, radius, and per-axis velocity (vx, vy) of the moving circle.
keys Tracks whether the up/down controls are currently held, set by keydown/keyup.
dragging True while the mouse or a finger is held down on the canvas, set by the pointer events.
restartBtn The overlay button. Hidden by default; shown when a round ends, hidden again on restart.
update() Moves the paddle and ball, then resolves wall, paddle, and miss collisions for one frame.
draw() Clears the canvas and redraws the paddle, ball, score, and (if stopped) the game-over message.
loop() Calls update() only while running, always calls draw(), then schedules the next frame.