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 left edges of the canvas act as walls the ball bounces off automatically. The right edge is guarded by a paddle the player moves up and down by clicking or touching the canvas and dragging.

Every time the ball bounces off the paddle, the score increases by one. If the ball reaches the right 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 and drag with a mouse, or touch and drag with a finger, to move the paddle. Missing the ball ends the round; the Restart button appears on top of the game when it does.

Click or touch 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. It holds only an icon, so an aria-label gives it an accessible name a visible text label would otherwise provide. A tabindex attribute makes the canvas itself focusable too, which keeps it in the normal tab order even though nothing on it actually responds to a keyboard.

<!-- .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" aria-label="Restart game" hidden>
        <!-- refresh icon -->
    </button>
</div>

tabindex="0" makes a non-interactive element like a canvas part of the normal tab order, mainly so the :focus-visible outline (Section 2's CSS) and the canvas's aria-label are reachable by a screen reader, even though the paddle itself only responds to a pointer. 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 = {
    x: 570,
    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 positive vx means "moving right," 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 left 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. The paddle itself isn't moved here at all; a pointer drag (Section 7) sets paddle.y directly, so update() only ever reads it.

function update() {

    // 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 left wall (the "opponent" side)
    if (ball.x - ball.radius <= 0) {
        ball.x = 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 paddleFar = paddle.x + paddle.width;
    if (ball.x + ball.radius >= paddle.x &&
        ball.x + ball.radius <= paddleFar &&
        ball.y >= paddle.y &&
        ball.y <= paddle.y + paddle.height &&
        ball.vx > 0) {
        ball.x = paddle.x - ball.radius;
        ball.vx = -ball.vx;
        score++;
    }

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

Because the paddle check requires ball.vx > 0 (moving right), 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 moves only by clicking or touching the canvas and dragging; there's no keyboard control. 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) {
    canvas.focus();
    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. pointerdown also focuses the canvas, since that's the only interaction the game has to hang a focus call on now that there's no keyboard shortcut to press first.

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 right edge.
ball Position, radius, and per-axis velocity (vx, vy) of the moving circle.
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.