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 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.
Live Demo
↑ / ↓ or W / S, or click 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. 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.
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.
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 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.
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.
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.
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. |