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.
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.
Live Demo
Click or touch and drag to move the paddle
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.
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.
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.
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.
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.
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. |