Test store. Prices are the planned list prices stored as test data, license terms are drafts, and payments go to a Stripe test-mode account. No real money moves.

Sports Games in HTML5: Basketball Code, Plus Soccer, Golf and Pool

This basketball game is one HTML file of 294 lines: drag back and release to shoot, with gravity, rim and backboard bounces, 2- and 3-point shots, a swish bonus and a 60-second clock. Play it above and copy the source code. The same projectile and collision code is the starting point for soccer, golf and pool games, covered below.

Runs in your browser in a sandbox · 295 lines · no downloads

Most sports games in a browser come down to two pieces of code: a ball that flies under gravity, and rules for what happens when it touches something. The basketball game above, Buzzer Hoops, has both in a small space: you drag back from the ball to aim, release to shoot, and the ball bounces off the rim and backboard until it drops through the net or hits the floor. Below, we walk through the HTML5 basketball game source code step by step, then show how the same ideas give you a penalty shootout, a golf game and a pool table.

How to run it

  1. Press Copy code above, or Download .html.
  2. Save it as sports-game.html (any name ending in .html works).
  3. Open the file in a browser. Tap or press Space, then drag back from the ball and let go.

To experiment, paste the code into the HTML game code runner and change GRAVITY or MAX_SPEED, then press Run.

How the basketball game source code works

The script has six parts: settings, sizing, shooting, physics, drawing and input. Physics and drawing never share units, which is the first thing worth copying into your own games.

1. A court in world units

Every position in the game is in "world units" on a court 600 wide and 700 tall, whatever the screen size. The rim is described as two points seen from the side, because in a side view that's all a ball can hit: the front edge and the back edge.

sports-game.html
const WORLD_W = 600, WORLD_H = 700, FLOOR = 650;
const GRAVITY = 1500;                  // units per second squared
const BALL_R = 17;
const RIM_Y = 330, FRONT_X = 478, BACK_X = 548, RIM_R = 5;   // the rim, seen from the side: two points
const BOARD = { x1: 556, x2: 566, y1: 228, y2: 350 };        // the backboard
const THREE_X = 200;                   // shots from left of this line are worth 3
const MIN_SPEED = 300, MAX_SPEED = 1150, PULL_TO_SPEED = 6;
const ROUND_TIME = 60;

resize() then works out one number, scale, plus an offset, and two tiny helpers convert world units to screen pixels when drawing. The physics doesn't change between a phone and a monitor, so a shot that scores on one scores on the other.

js
scale = Math.min(W / WORLD_W, (H - hud) / WORLD_H);
ox = (W - WORLD_W * scale) / 2;
oy = hud + (H - hud - WORLD_H * scale);              // court sits on the bottom edge
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();

2. From angle and power to a velocity

The player controls two numbers, aim.angle in degrees and aim.speed in world units per second. A shot turns them into a velocity with sine and cosine. Canvas y grows downwards, so "up" is a negative y velocity.

js
function shoot() {
  if (state !== "playing" || ball.flying || timeLeft <= 0) return;
  const a = aim.angle * Math.PI / 180;
  ball.vx = Math.cos(a) * aim.speed;
  ball.vy = -Math.sin(a) * aim.speed;  // negative = up on screen
  ball.flying = true;
  shots++;
}

Each new ball appears at a random spot between 70 and 330 units from the left wall, so you can't repeat the same shot all round. Spots left of THREE_X are worth 3 points.

3. Gravity in small steps

Each physics step adds gravity to the vertical speed, then moves the ball by its speed. That's explicit Euler integration, the simplest method there is, and it's fine for a game this size. The important detail is in update(): every frame runs four physics steps of a quarter frame each.

js
function update(dt) {
  timeLeft = Math.max(0, timeLeft - dt);
  if (ball.flying) {
    for (let i = 0; i < 4; i++) physicsStep(dt / 4);   // small steps so a fast ball can't pass through the rim
    ball.t += dt;
    // The shot is over after two floor bounces, or a while after a basket, or after 5 seconds.
    if (ball.bounces >= 2 || ball.t > 5 || (ball.scored && ball.y > RIM_Y + 150)) newBall();
  }
  // The buzzer: a ball already in the air still counts.
  if (timeLeft <= 0 && !ball.flying) { state = "over"; best = Math.max(best, score); overAt = performance.now(); drag = null; }
}

At full power the ball moves about 19 units in one 60 Hz frame, while the ball and a rim edge only touch when their centres are less than 22 units apart. With one big step per frame, a fast ball can jump deep into the rim, or past its edge, between two checks and get pushed out on the wrong side. Four smaller steps keep each move under 5 units at 60 Hz, so contacts are caught early. This is the usual fix for "my ball goes through walls", and it applies to every game on this page.

4. Bouncing off the rim and the backboard

One function handles every collision. It treats the obstacle as a point with a radius: if the ball overlaps it, the ball is pushed out along the line between the two centres, and the part of its velocity heading into the point is reflected and scaled by the restitution (how bouncy the contact is).

js
function bounceOff(px, py, radius, restitution) {
  const dx = ball.x - px, dy = ball.y - py, dist = Math.hypot(dx, dy), min = BALL_R + radius;
  if (dist >= min || dist === 0) return;
  const nx = dx / dist, ny = dy / dist;
  ball.x = px + nx * min; ball.y = py + ny * min;
  const vn = ball.vx * nx + ball.vy * ny;              // speed towards the point
  if (vn < 0) { ball.vx -= (1 + restitution) * vn * nx; ball.vy -= (1 + restitution) * vn * ny; }
  ball.touched = true;
}

The rim edges use it directly, with a restitution of 0.55, so rim shots die quickly. The backboard is a rectangle, and the trick is to clamp the ball's centre to the rectangle first: the clamped point is the closest point of the board, and from there it's the same circle test with a radius of 0. Clamping a point to a box is the standard way to collide a circle with a rectangle.

5. When does a basket count?

A basket is the ball's centre crossing the rim line, going down, between the two rim edges. The extra fromBelow flag stops a ball that bounced up through the hoop from scoring on its way back down.

js
// A basket: the centre crosses the rim line going down, between the two rim edges (and never came up through it).
const inside = ball.x > FRONT_X && ball.x < BACK_X;
if (prevY > RIM_Y && ball.y <= RIM_Y && inside) ball.fromBelow = true;
if (!ball.scored && !ball.fromBelow && prevY < RIM_Y && ball.y >= RIM_Y && ball.vy > 0 && inside) scoreBasket();
if (ball.scored && ball.y < RIM_Y + 60 && ball.x > FRONT_X - 10 && ball.x < BACK_X + 10) { ball.vx *= 0.9; ball.vy *= 0.97; }  // the net slows it

ball.touched is set by every collision, so a basket with touched still false is a swish and earns a bonus point. After a basket, the ball is drawn behind the rim and net, and the net damps its speed for a few frames so it drops instead of shooting out sideways.

6. An aim preview that doesn't give the answer away

While you aim, the game draws nine dots along the first 0.45 seconds of the flight using the projectile formula, position plus velocity times time plus half of gravity times time squared. Showing the whole arc would make every shot easy; showing the start gives a feel for angle and power and leaves the judgement to the player.

js
const a = aim.angle * Math.PI / 180, vx = Math.cos(a) * aim.speed, vy = -Math.sin(a) * aim.speed;
for (let i = 1; i <= 9; i++) {
  const t = i * 0.05;
  circle(sx(ball.x + vx * t), sy(ball.y + vy * t + 0.5 * GRAVITY * t * t), Math.max(2, (4 - i * 0.25) * scale), "rgba(255, 255, 255, 0.8)");
}

7. The buzzer

The clock counts down while you play, but the last line of update() only ends the game once the ball in the air has landed, so a shot released before zero still counts, like a real buzzer-beater. The end screen shows the score, the best score of the session and the shooting percentage.

Make it work on phones

The controls are the same for mouse and touch: press anywhere, drag away from the hoop and release. aimFromDrag() turns the drag into a pull in world units (dividing by scale keeps the feel the same on every screen), then into an angle and a speed:

js
const px = (drag.x - e.clientX) / scale, py = (e.clientY - drag.y) / scale;
if (Math.hypot(px, py) < 8) return false;
let angle = Math.atan2(py, px) * 180 / Math.PI;
if (angle < 10 || angle > 85) angle = angle > -90 && angle < 10 ? 10 : 85;
aim.angle = angle;
aim.speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, Math.hypot(px, py) * PULL_TO_SPEED));

A press with almost no drag doesn't shoot, so a stray touch never wastes a ball. The pointerdown handler calls setPointerCapture, which keeps the drag working when a finger slides off the canvas. On a phone held upright the court is anchored to the bottom of the screen, where thumbs reach. For keyboards, the arrow keys (or WASD) change angle and power one step at a time and Space shoots.

Other sports games with the same code

Basketball, soccer, golf and pool look different, but they share the loop above: a ball with a velocity, a force or two acting on it, and collision rules. Here is what changes for each.

HTML5 soccer game tutorial: a penalty shootout

A penalty kick is the easiest soccer game to make well, because there are no teams to simulate. The usual design is a view from behind the ball towards the goal. A swipe gives the shot direction (where the finger went), power (how fast it moved) and curl (how much the path bent, applied as a sideways force during the flight). The keeper picks a side and dives after a short reaction delay; making that delay shorter on harder levels is all the AI you need. In 2D canvas you can fake depth by shrinking the ball as it travels towards the goal. Our own Penalty Clash is a finished example of this pattern in 3D with Three.js: swipe to shoot with power and curl, then read the taker and dive to save, across tournament shootouts.

HTML5 canvas golf game: power, friction and the cup

A top-down golf or mini-golf game reuses the drag-to-aim input almost unchanged: drag back, release, and the pull sets the ball's speed. Gravity goes away (or only applies on slopes), and friction takes its place: multiply the velocity by a factor below 1 every step, or subtract a fixed deceleration, and stop the ball when its speed drops under a small threshold. Walls use the same reflect-the-velocity idea as the backboard here.

Common golf questions: the cup, walls and stopping

The questions that come up most often when people build a canvas golf game have short answers. To make the ball drop into the cup, check two conditions together: the ball's centre is inside the hole's radius and its speed is below a limit; a fast ball should roll over the hole, as it does in real golf. If the ball passes through thin walls at high speed, use the substeps shown above. If it never quite stops, add the speed threshold, because multiplying by a friction factor only approaches zero.

HTML5 pool game source code: ball-to-ball collisions

Pool adds the one thing basketball doesn't need: moving balls hitting each other. For two balls of equal mass and a perfectly elastic hit, the maths is short: find the line between their centres, and swap the parts of their velocities that lie along that line, leaving the sideways parts alone. Add cushions (walls), six pockets (the golf cup test) and friction, and you have a playable table. Spin, throws and many balls touching at once are where a physics engine such as Matter.js starts to save time. Read more in our guide to HTML5 game physics.

Ideas to extend it

  • Move the hoop sideways after every 10 points.
  • Add a "money ball" every fifth shot that counts double.
  • Show the full flight path of your last shot as a faint trail.
  • Play a net sound on a basket with the Web Audio API, starting audio after the first tap.
  • Add a two-player mode where players take turns with the same clock.
  • Replace the drag-back control with a flick towards the hoop, which some players find more natural on phones.

From prototype to a game you can sell

This file is a complete little game and a good base for learning physics, but it is a prototype. A sports game players return to needs much more:

This basketball fileA production HTML5 sports game
ContentOne court, one 60-second modeTournaments, levels, daily challenges, unlocks
Art and soundCanvas shapes, no audioOriginal art or 3D models, animation, crowd and ball sounds
PhysicsPoint-and-circle collisionsTuned ball flight, spin, AI opponents
SavesBest score lost on reloadProgress, cosmetics and settings saved per player
PlatformsDesktop and phone browsersPortal SDK hooks, ads, six languages
QualityTested by hand and in a sandboxed playerUnit tests and cross-browser end-to-end tests
LicenceMIT, do anythingA commercial licence for your use

We sell one sports game today, and it's football. We don't have a basketball, golf or pool game in the catalog. Penalty Clash ships as a TypeScript project on Three.js with touch, mouse, keyboard and gamepad input, local saves and six languages:

If you need another sport, suggest it on our roadmap and vote, or request an exclusive game built only for you. You can also browse all our HTML5 games with source code, or try simpler ball physics in the free HTML game code collection, starting with Pong.

The complete code

One file, no libraries. Save it as sports-game.html and open it in any browser. Free to use under the MIT license: copy it, change it, ship it.

sports-game.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Buzzer Hoops: a basketball shooting game in one HTML file. Free to use, change and ship (MIT license). -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Buzzer Hoops (HTML5 basketball game)</title>
<style>
  html, body { margin: 0; height: 100%; background: #16213e; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings (world units: the court is 600 wide and 700 tall, y grows downwards) -----
const WORLD_W = 600, WORLD_H = 700, FLOOR = 650;
const GRAVITY = 1500;                  // units per second squared
const BALL_R = 17;
const RIM_Y = 330, FRONT_X = 478, BACK_X = 548, RIM_R = 5;   // the rim, seen from the side: two points
const BOARD = { x1: 556, x2: 566, y1: 228, y2: 350 };        // the backboard
const THREE_X = 200;                   // shots from left of this line are worth 3
const MIN_SPEED = 300, MAX_SPEED = 1150, PULL_TO_SPEED = 6;
const ROUND_TIME = 60;

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let W = 0, H = 0, hud = 52, scale = 1, ox = 0, oy = 0;    // screen size, world → screen transform

// ----- Sizing: fit the court to the window, stay sharp on high-DPI screens -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  W = window.innerWidth; H = window.innerHeight;
  canvas.style.width = W + "px"; canvas.style.height = H + "px";
  canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
  hud = Math.round(Math.max(44, Math.min(60, H * 0.08)));
  scale = Math.min(W / WORLD_W, (H - hud) / WORLD_H);
  ox = (W - WORLD_W * scale) / 2;
  oy = hud + (H - hud - WORLD_H * scale);              // court sits on the bottom edge
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  draw();
}

// ----- Game state -----
let state = "ready";                   // ready → playing → over
let score = 0, best = 0, made = 0, shots = 0, timeLeft = ROUND_TIME, overAt = 0, last = 0;
let aim = { angle: 58, speed: 820 };   // degrees above horizontal, units per second
let ball, drag = null, pops = [], netSwing = 0;

function newBall() {
  const x = 70 + Math.random() * 260;  // a new spot for every shot
  ball = { x, y: 540, vx: 0, vy: 0, spin: 0, flying: false, scored: false, fromBelow: false, touched: false, bounces: 0, t: 0, three: x < THREE_X };
}

function start() {
  score = made = shots = 0;
  timeLeft = ROUND_TIME; pops = [];
  newBall();
  state = "playing";
}

function shoot() {
  if (state !== "playing" || ball.flying || timeLeft <= 0) return;
  const a = aim.angle * Math.PI / 180;
  ball.vx = Math.cos(a) * aim.speed;
  ball.vy = -Math.sin(a) * aim.speed;  // negative = up on screen
  ball.flying = true;
  shots++;
}

// ----- Physics: push the ball out of a point (a rim edge or the closest point of the board) -----
function bounceOff(px, py, radius, restitution) {
  const dx = ball.x - px, dy = ball.y - py, dist = Math.hypot(dx, dy), min = BALL_R + radius;
  if (dist >= min || dist === 0) return;
  const nx = dx / dist, ny = dy / dist;
  ball.x = px + nx * min; ball.y = py + ny * min;
  const vn = ball.vx * nx + ball.vy * ny;              // speed towards the point
  if (vn < 0) { ball.vx -= (1 + restitution) * vn * nx; ball.vy -= (1 + restitution) * vn * ny; }
  ball.touched = true;
}

function physicsStep(h) {
  const prevY = ball.y;
  ball.vy += GRAVITY * h;
  ball.x += ball.vx * h; ball.y += ball.vy * h;
  ball.spin += (ball.vx * h) / BALL_R;
  bounceOff(FRONT_X, RIM_Y, RIM_R, 0.55);
  bounceOff(BACK_X, RIM_Y, RIM_R, 0.55);
  const cx = Math.max(BOARD.x1, Math.min(BOARD.x2, ball.x)), cy = Math.max(BOARD.y1, Math.min(BOARD.y2, ball.y));
  bounceOff(cx, cy, 0, 0.7);
  // A basket: the centre crosses the rim line going down, between the two rim edges (and never came up through it).
  const inside = ball.x > FRONT_X && ball.x < BACK_X;
  if (prevY > RIM_Y && ball.y <= RIM_Y && inside) ball.fromBelow = true;
  if (!ball.scored && !ball.fromBelow && prevY < RIM_Y && ball.y >= RIM_Y && ball.vy > 0 && inside) scoreBasket();
  if (ball.scored && ball.y < RIM_Y + 60 && ball.x > FRONT_X - 10 && ball.x < BACK_X + 10) { ball.vx *= 0.9; ball.vy *= 0.97; }  // the net slows it
  if (ball.y + BALL_R > FLOOR) {                       // floor
    ball.y = FLOOR - BALL_R;
    if (ball.vy > 0) { ball.vy *= -0.55; ball.vx *= 0.8; ball.bounces++; }
  }
  if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx) * 0.5; }
  if (ball.x + BALL_R > WORLD_W) { ball.x = WORLD_W - BALL_R; ball.vx = -Math.abs(ball.vx) * 0.5; }
}

function scoreBasket() {
  ball.scored = true;
  const swish = !ball.touched;
  const points = (ball.three ? 3 : 2) + (swish ? 1 : 0);
  score += points; made++;
  netSwing = 1;
  pops.push({ x: (FRONT_X + BACK_X) / 2, y: RIM_Y - 40, text: `+${points}${swish ? " swish!" : ""}`, t: 0 });
}

function update(dt) {
  timeLeft = Math.max(0, timeLeft - dt);
  if (ball.flying) {
    for (let i = 0; i < 4; i++) physicsStep(dt / 4);   // small steps so a fast ball can't pass through the rim
    ball.t += dt;
    // The shot is over after two floor bounces, or a while after a basket, or after 5 seconds.
    if (ball.bounces >= 2 || ball.t > 5 || (ball.scored && ball.y > RIM_Y + 150)) newBall();
  }
  // The buzzer: a ball already in the air still counts.
  if (timeLeft <= 0 && !ball.flying) { state = "over"; best = Math.max(best, score); overAt = performance.now(); drag = null; }
}

// ----- Drawing -----
const sx = (x) => ox + x * scale, sy = (y) => oy + y * scale;

function circle(x, y, r, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); }

function text(str, x, y, size, color, align = "center", weight = 700) {
  ctx.font = `${weight} ${size}px system-ui, sans-serif`;
  ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = "middle";
  ctx.fillText(str, x, y);
}

function drawCourt() {
  const wall = ctx.createLinearGradient(0, hud, 0, sy(FLOOR));
  wall.addColorStop(0, "#16213e"); wall.addColorStop(1, "#24345e");
  ctx.fillStyle = wall; ctx.fillRect(0, hud, W, sy(FLOOR) - hud);
  ctx.fillStyle = "#c8894f"; ctx.fillRect(0, sy(FLOOR), W, H - sy(FLOOR));   // wooden floor
  ctx.fillStyle = "#b5773f";
  for (let x = ox % (40 * scale) - 40 * scale; x < W; x += 40 * scale) ctx.fillRect(x, sy(FLOOR), 2, H - sy(FLOOR));
  ctx.fillStyle = "#ffffff"; ctx.fillRect(sx(THREE_X) - 2, sy(FLOOR), 4, H - sy(FLOOR));
  text("3 PT", sx(THREE_X) - 8, sy(FLOOR) + 18 * scale, Math.max(11, 16 * scale), "#ffffff", "right");
  ctx.fillStyle = "#8d99ae"; ctx.fillRect(sx(BOARD.x2), sy(285), W - sx(BOARD.x2), 8 * scale);   // arm to the wall
  ctx.fillStyle = "#f8f9fa"; ctx.fillRect(sx(BOARD.x1), sy(BOARD.y1), (BOARD.x2 - BOARD.x1) * scale, (BOARD.y2 - BOARD.y1) * scale);
}

function drawHoop() {
  // Net: lines from the rim that narrow towards the bottom and swing after a basket.
  const sway = Math.sin(performance.now() / 40) * netSwing * 6;
  ctx.strokeStyle = "rgba(255, 255, 255, 0.85)"; ctx.lineWidth = Math.max(1, 1.5 * scale);
  ctx.beginPath();
  for (let i = 0; i <= 5; i++) {
    const top = FRONT_X + (BACK_X - FRONT_X) * (i / 5), bottom = FRONT_X + 12 + (BACK_X - FRONT_X - 24) * (i / 5) + sway;
    ctx.moveTo(sx(top), sy(RIM_Y)); ctx.lineTo(sx(bottom), sy(RIM_Y + 55));
  }
  for (let k = 1; k <= 3; k++) {
    const y = RIM_Y + k * 18, inset = k * 4;
    ctx.moveTo(sx(FRONT_X + inset) + sway * k / 3, sy(y)); ctx.lineTo(sx(BACK_X - inset) + sway * k / 3, sy(y));
  }
  ctx.stroke();
  ctx.strokeStyle = "#ff6b35"; ctx.lineWidth = RIM_R * 2 * scale; ctx.lineCap = "round";   // rim and bracket
  ctx.beginPath(); ctx.moveTo(sx(FRONT_X), sy(RIM_Y)); ctx.lineTo(sx(BOARD.x1), sy(RIM_Y)); ctx.stroke();
  ctx.lineCap = "butt";
}

function drawBall() {
  const x = sx(ball.x), y = sy(ball.y), r = BALL_R * scale;
  ctx.fillStyle = "rgba(0, 0, 0, 0.25)";               // shadow on the floor
  ctx.beginPath(); ctx.ellipse(x, sy(FLOOR), r * 0.9, r * 0.25, 0, 0, Math.PI * 2); ctx.fill();
  circle(x, y, r, "#f77f00");
  ctx.save();                                          // seams turn with the ball
  ctx.translate(x, y); ctx.rotate(ball.spin);
  ctx.strokeStyle = "#5c2c06"; ctx.lineWidth = Math.max(1, 1.6 * scale);
  ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2);
  ctx.moveTo(-r, 0); ctx.lineTo(r, 0); ctx.moveTo(0, -r); ctx.lineTo(0, r);
  ctx.moveTo(r * 0.55, -r * 0.83); ctx.quadraticCurveTo(r * 0.15, 0, r * 0.55, r * 0.83);
  ctx.moveTo(-r * 0.55, -r * 0.83); ctx.quadraticCurveTo(-r * 0.15, 0, -r * 0.55, r * 0.83);
  ctx.stroke();
  ctx.restore();
}

function drawAim() {
  // Dots along the first 0.45 s of the flight: enough to aim, not enough to be sure.
  const a = aim.angle * Math.PI / 180, vx = Math.cos(a) * aim.speed, vy = -Math.sin(a) * aim.speed;
  for (let i = 1; i <= 9; i++) {
    const t = i * 0.05;
    circle(sx(ball.x + vx * t), sy(ball.y + vy * t + 0.5 * GRAVITY * t * t), Math.max(2, (4 - i * 0.25) * scale), "rgba(255, 255, 255, 0.8)");
  }
  const power = Math.round(((aim.speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)) * 100);
  text(`${Math.round(aim.angle)}° · ${power}%`, sx(ball.x), sy(ball.y + 52), Math.max(12, 18 * scale), "#ffffff", "center", 600);
}

function draw() {
  ctx.fillStyle = "#16213e"; ctx.fillRect(0, 0, W, H);
  drawCourt();
  if (ball) {
    if (state === "playing" && !ball.flying) drawAim();
    // A ball dropping through the hoop is drawn behind the front of the rim and net.
    if (ball.scored && ball.y < RIM_Y + 60) { drawBall(); drawHoop(); } else { drawHoop(); drawBall(); }
  } else drawHoop();
  const fs = Math.max(15, Math.min(22, W / 28));
  for (const p of pops) {
    ctx.globalAlpha = Math.max(0, 1 - p.t);
    text(p.text, sx(p.x), sy(p.y) - p.t * 40, fs * 1.2, "#ffd166");
    ctx.globalAlpha = 1;
  }

  ctx.fillStyle = "#0f1629"; ctx.fillRect(0, 0, W, hud);           // score bar
  text(`Score ${score}`, 12, hud / 2, fs, "#ffffff", "left");
  text(`${Math.ceil(timeLeft)}s`, W / 2, hud / 2, fs, timeLeft < 10 ? "#ff6b6b" : "#ffffff");
  text(`${made}/${shots}  Best ${best}`, W - 12, hud / 2, fs, "#bcd4ff", "right", 600);

  if (state !== "playing") {
    ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; ctx.fillRect(0, hud, W, H - hud);
    const cx = W / 2, cy = hud + (H - hud) * 0.42, big = Math.max(28, Math.min(46, W / 12));
    if (state === "ready") {
      text("Buzzer Hoops", cx, cy - fs * 4, big, "#ffd166");
      text("Drag back from the ball and let go to shoot.", cx, cy - fs * 1.5, fs * 0.95, "#ffffff", "center", 500);
      text("Keys: ← → power, ↑ ↓ angle, Space shoots", cx, cy, fs * 0.95, "#bcd4ff", "center", 500);
      text("2 points, 3 behind the line, +1 for a swish", cx, cy + fs * 1.5, fs * 0.95, "#bcd4ff", "center", 500);
      text("Tap or press Space to start", cx, cy + fs * 3.5, fs * 1.1, "#ffd166");
    } else {
      text("Buzzer!", cx, cy - fs * 4, big, "#ffd166");
      text(`Score ${score}   Best ${best}`, cx, cy - fs * 1.5, fs * 1.15, "#ffffff");
      text(`Made ${made} of ${shots} shots (${shots ? Math.round((made / shots) * 100) : 0}%)`, cx, cy, fs, "#bcd4ff", "center", 500);
      text("Tap or press Space to play again", cx, cy + fs * 2.5, fs * 1.1, "#ffd166");
    }
  }
}

// ----- Main loop -----
function loop(now) {
  const dt = Math.min(1 / 30, (now - last) / 1000);
  last = now;
  if (state === "playing") update(dt);
  pops.forEach((p) => { p.t += dt; });
  pops = pops.filter((p) => p.t < 1);
  netSwing = Math.max(0, netSwing - dt * 1.5);
  draw();
  requestAnimationFrame(loop);
}

// ----- Input: drag back and release (mouse or touch), or aim with the keyboard -----
function startOrRestart() {
  if (state === "ready" || (state === "over" && performance.now() - overAt > 700)) start();
}

function aimFromDrag(e) {
  // The pull is the opposite of the drag, in world units with y up: drag down-left to shoot up-right.
  const px = (drag.x - e.clientX) / scale, py = (e.clientY - drag.y) / scale;
  if (Math.hypot(px, py) < 8) return false;
  let angle = Math.atan2(py, px) * 180 / Math.PI;
  if (angle < 10 || angle > 85) angle = angle > -90 && angle < 10 ? 10 : 85;
  aim.angle = angle;
  aim.speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, Math.hypot(px, py) * PULL_TO_SPEED));
  return true;
}

canvas.addEventListener("pointerdown", (e) => {
  if (state !== "playing") { startOrRestart(); return; }
  if (ball.flying) return;
  drag = { x: e.clientX, y: e.clientY };
  canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", (e) => { if (drag) aimFromDrag(e); });
canvas.addEventListener("pointerup", (e) => {
  if (!drag) return;
  const pulled = aimFromDrag(e);
  drag = null;
  if (pulled) shoot();
});
canvas.addEventListener("pointercancel", () => { drag = null; });

window.addEventListener("keydown", (e) => {
  const k = e.code;
  if (k === "Space" || k === "Enter") { e.preventDefault(); if (state === "playing") shoot(); else startOrRestart(); return; }
  if (k === "ArrowUp" || k === "KeyW") aim.angle = Math.min(85, aim.angle + 1);
  else if (k === "ArrowDown" || k === "KeyS") aim.angle = Math.max(10, aim.angle - 1);
  else if (k === "ArrowRight" || k === "KeyD") aim.speed = Math.min(MAX_SPEED, aim.speed + 10);
  else if (k === "ArrowLeft" || k === "KeyA") aim.speed = Math.max(MIN_SPEED, aim.speed - 10);
  else return;
  e.preventDefault();
});

window.addEventListener("resize", resize);
newBall();
resize();
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Can I use this basketball game code commercially?

Yes. The file is MIT-licensed: copy it, change it and use it in personal or commercial projects, and keep the licence line at the top. Our paid games use separate commercial licences, described on the licensing page.

Do I need a physics engine for a basketball game?

Not for one ball. Gravity, a few circle tests and substeps cover a shooting game, as this file shows. A physics engine pays off when many bodies touch at once (a pool rack, a pile of crates) or when you need joints, friction between objects and stable stacking.

How do I put this game on my website?

Upload sports-game.html to your web host and link to it, or show it inside a page with an <iframe>. Our guide to embedding an HTML5 game in a website covers WordPress, Wix and Google Sites.

How do I add sound effects?

Create an AudioContext on the first tap, then play short sounds in scoreBasket() and inside bounceOff(). MDN's Web Audio API guide shows how to decode and play a file. Browsers keep audio silent until the player interacts with the page.

How do I make shots easier or harder?

Raise RIM_R or the restitution values for a bouncier, harder rim; make the gap between FRONT_X and BACK_X wider for a forgiving hoop; or show more dots in drawAim() for a longer aim preview.

We make and sell HTML5 game source code; links to our games are marked as ours.