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.

Drag-and-Drop Game in HTML5: Code, Tutorial and Template

This drag-and-drop sorting game is one HTML file of about 300 lines: cards you drag into labelled bins, Pointer Events for mouse and touch, hit-testing, a snap-back animation for wrong drops, scoring, and tap or keyboard alternatives to dragging. Play it above, copy the code and save it as drag-and-drop-game.html. It's MIT-licensed, and the groups are plain data you can swap for any lesson.

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

In a drag-and-drop game the player picks something up, carries it and lets go over a target, and the game decides whether that was right. This HTML5 drag and drop game tutorial builds a complete sorting game around that loop: nine animal cards, three bins, points for each right drop and a snap-back for wrong ones. Along the way you'll see the parts every drag-and-drop game needs, whether it sorts animals, matches words to definitions or drops furniture into a room: hit-testing, grabbing at the right offset, following the pointer and resolving the drop.

How to run it

  1. Press Copy code above (or Download .html).
  2. Paste it into a new file called drag-and-drop-game.html in any text editor.
  3. Double-click the file. It opens in your browser and runs.

You don't need a server or a library. To try your own groups straight away, paste the code into the HTML game code runner, edit the GROUPS list and press Run.

How the code works

The whole game is one <canvas> and one script. Cards rest in a tray at the top and the bins sit along the bottom. Every card has a home slot in the tray, and the game only ever stores three things about where a card is: at home, in the player's hand, or flying back home. That small idea keeps the code short.

1. The content is data

The title, the instruction and the groups sit at the top of the file. Each group has a name, a colour and a pool of items; every round deals PER_GROUP random items from each pool, so the game changes each time you play. The animals were chosen to include a few classic mix-ups: whales, dolphins and bats are mammals, penguins and ostriches are birds, and seahorses and eels are fish.

drag-and-drop-game.html
// ----- Content: edit the title, the instruction and the groups to make your own sorting game -----
const TITLE = "Sort It Out";
const PROMPT = "Drag each animal into its group";
const GROUPS = [
  { name: "Mammals", color: "#e8875f", items: ["Dolphin", "Bat", "Whale", "Elephant", "Kangaroo", "Hedgehog"] },
  { name: "Birds", color: "#4aa3ec", items: ["Penguin", "Ostrich", "Eagle", "Owl", "Parrot", "Flamingo"] },
  { name: "Fish", color: "#38c172", items: ["Shark", "Salmon", "Seahorse", "Tuna", "Clownfish", "Eel"] },
];

2. Layout: a tray and a row of bins

resize() gives the bins about a quarter of the screen height (between 110 and 190 pixels), splits the width between them, and lays the cards out above in two, three or four columns depending on the width. Nothing is positioned by hand, so the same code works on a phone in portrait and a laptop window.

js
binH = Math.round(Math.max(110, Math.min(190, H * 0.26)));
binW = (W - PAD * (GROUPS.length + 1)) / GROUPS.length;
binY = H - binH - PAD;
cols = W >= 640 ? 4 : W >= 420 ? 3 : 2;
const rows = Math.ceil((GROUPS.length * PER_GROUP) / cols);
const trayH = binY - HUD - PAD * 2;
rowH = Math.min(66, trayH / rows);
cardH = Math.max(24, Math.min(52, rowH - 10));
cardW = Math.min(170, (W - PAD * (cols + 1)) / cols);
trayX = (W - cols * cardW - (cols - 1) * PAD) / 2;
trayY = HUD + PAD + (trayH - rows * rowH) / 2;

A card's resting place is never stored in pixels. home(slot) works it out from the current layout, so after a resize every card is automatically in the right place.

3. Picking a card up

On pointerdown the game finds the card under the pointer and remembers the offset between the pointer and the card's corner. Without that offset the card would jump so that its corner sits under your finger, which feels broken. setPointerCapture keeps the move and up events coming to the canvas even if the finger slides off it.

js
canvas.addEventListener("pointerdown", (e) => {
  if (state !== "playing") { start(); return; }
  const p = point(e);
  const bin = binAt(p);
  if (selected && bin >= 0) { dropSelected(bin, false); return; }   // second tap: the bin
  const card = cards.find((c) => {
    const q = cardPos(c);
    return !c.placed && p.x >= q.x && p.x <= q.x + cardW && p.y >= q.y && p.y <= q.y + cardH;
  });
  if (!card) { selected = null; return; }   // tapping empty space clears the selection
  const q = cardPos(card);
  card.x = q.x; card.y = q.y; card.back = null;
  drag = { card, dx: p.x - q.x, dy: p.y - q.y, point: p, start: p };   // where the card was grabbed
  selected = null;
  canvas.setPointerCapture(e.pointerId);
});

The hit test is a plain rectangle check. Cards never overlap at rest, so the first match is the right one; the card in your hand is drawn last so it always appears on top.

4. Moving and letting go

While a card is held, pointermove moves it with the pointer, keeping the grab offset. On pointerup the game measures how far the pointer travelled. More than 8 pixels is a drag, and the card is dropped wherever the pointer is. Less than that is a tap, which selects the card instead (more on that below).

js
canvas.addEventListener("pointermove", (e) => {
  if (!drag) return;
  drag.point = point(e);
  drag.card.x = drag.point.x - drag.dx;
  drag.card.y = drag.point.y - drag.dy;
});
canvas.addEventListener("pointerup", (e) => {
  if (!drag) return;
  const card = drag.card, p = point(e);
  const moved = Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 8;
  drag = null;
  if (moved) { drop(card, binAt(p)); return; }
  selected = card;                     // a tap: select the card, the next tap on a bin sorts it
  card.back = { x: card.x, y: card.y, t: 0 };
});

The drop target is decided by the pointer, not by the card's centre. Players aim with their finger, so that's what feels accurate. While you drag, the bin under the pointer gets a thicker border so you know where the card will land.

5. The rules: right, wrong or nowhere

drop() has three outcomes. A right bin keeps the card, adds 100 points and flashes green. A wrong bin costs 25 points, counts a mistake, shakes red and sends the card home. Dropping outside every bin just sends the card home with no penalty, because letting go by accident shouldn't cost anything.

js
function drop(card, bin) {
  if (bin >= 0 && card.group === bin) {
    card.placed = true;
    score += RIGHT;
    sorted[bin].push(card.text);
    flash[bin] = { good: true, t: 0.4 };
    if (cards.every((c) => c.placed)) {
      state = "over";
      best = best === null ? score : Math.max(best, score);
    }
    return;
  }
  if (bin >= 0) {
    score -= WRONG;                    // the score can go below zero: mistakes always cost
    mistakes++;
    flash[bin] = { good: false, t: 0.4 };
  }
  card.back = { x: card.x, y: card.y, t: 0 };   // fly back to the tray
}

The score is allowed to go below zero on purpose. If it stopped at zero, a mistake made before the first right answer would be free.

6. The snap-back animation

A wrong card doesn't teleport home; it flies back over a quarter of a second. card.back stores where the flight started and a progress value t that the loop raises from 0 to 1. cardPos() blends between the start and the home slot with an ease-out curve, so the card leaves quickly and settles softly.

js
function cardPos(c) {
  if (drag && drag.card === c) return { x: c.x, y: c.y };
  const h = home(c.slot);
  if (!c.back) return h;
  const t = 1 - Math.pow(1 - c.back.t, 3);   // ease-out: fast start, soft landing
  return { x: c.back.x + (h.x - c.back.x) * t, y: c.back.y + (h.y - c.back.y) * t };
}

Because the target is recalculated every frame from home(), a card that's mid-flight during a window resize still lands in the right place.

7. Drawing the held card

The card in your hand gets a soft drop shadow, the cheapest way to make it look lifted off the table. Canvas shadows apply to everything drawn after them, so the shadow settings sit between save() and restore() and switch off again before the text.

js
function drawCard(c, lifted) {
  const p = cardPos(c);
  ctx.save();
  if (lifted) { ctx.shadowColor = "rgba(0, 0, 0, 0.45)"; ctx.shadowBlur = 16; ctx.shadowOffsetY = 6; }
  ctx.fillStyle = COLORS.card;
  ctx.beginPath();
  ctx.roundRect(p.x, p.y, cardW, cardH, 10);
  ctx.fill();
  ctx.restore();

8. Sorting without dragging

Dragging is hard for some players: people with tremors, people using a switch or a head pointer, and anyone on a trackpad. The WCAG 2.2 rule on dragging movements asks for a single-pointer alternative, so the game has two. Tap a card to select it and tap a bin to sort it, or use the keyboard: arrow keys or WASD move the highlight and the number keys 1 to 3 send the card to that bin. Both paths share one function:

js
function dropSelected(bin, keepGoing) {
  const open = cards.filter((c) => !c.placed).sort((a, b) => a.slot - b.slot);
  const card = selected, h = home(card.slot);
  card.x = h.x; card.y = h.y;
  drop(card, bin);
  if (!card.placed) return;
  const rest = open.filter((c) => c !== card);   // keyboard: highlight the next card
  selected = keepGoing && rest.length ? rest[Math.min(open.indexOf(card), rest.length - 1)] : null;
}

Pointer Events or the HTML Drag and Drop API?

Search for "HTML5 drag and drop" and you'll find the HTML Drag and Drop API: draggable="true" plus dragstart, dragover and drop events. It was designed for moving data such as files, links and text between elements and applications. For games it has drawbacks: it only works on HTML elements, not on shapes drawn on a canvas; the browser draws the drag image, so you can't animate it; and touch support has long been inconsistent between mobile browsers.

Pointer Events avoid all three. One set of listeners handles mouse, pen and touch, you move the object yourself, and you decide what a drop means. That's why this game builds dragging on pointer input, as engines such as Phaser do. The same three listeners work on HTML elements too: set position: absolute on a <div> and update its left and top in pointermove.

Make it work on phones

Three details make dragging work on touch screens. First, touch-action: none on the page tells the browser not to scroll or zoom when a finger moves; without it, the browser takes over the gesture and you get a pointercancel instead of moves. Second, pointer capture keeps the drag alive when the finger leaves the canvas. Third, the cards are large: about 170 by 52 pixels on a 390-pixel phone, and the bins are over 100 pixels wide, so a thumb can't miss them.

The pointercancel handler matters too. If the system interrupts a drag, for a notification or a gesture, the card flies home instead of staying stuck in mid-air. For more on touch input and screen sizes, read our guide to HTML5 mobile game development.

Use it as a drag and drop game template for lessons

Sorting into groups is one of the most common activities in e-learning, and this file works as an HTML5 drag and drop game template without any code changes. Edit TITLE, PROMPT and GROUPS:

  • Science: solids, liquids and gases; vertebrates and invertebrates.
  • Language: nouns, verbs and adjectives; words by their first sound.
  • Maths: odd and even numbers; fractions smaller or larger than one half.
  • Everyday skills: recycling, compost and general waste.

Two or four groups work as well as three; on a phone, keep it to four so the bins stay wide enough to read. For matching instead of sorting, such as dragging a word onto its definition, give each group a single item and use a short definition as the group name. Our memory match game and quiz game cover the other two classic lesson formats, and the guide to HTML5 educational games covers e-learning more broadly.

Ideas to extend it

  • Add a countdown and a bonus for every second left when the last card is sorted.
  • Let cards carry a short hint that appears after a wrong drop ("Dolphins breathe air").
  • Replace the text cards with simple drawings on the canvas.
  • Add rounds that get harder: more groups, more cards, less time.
  • Play a soft click on pick-up and a chime on a right drop with the Web Audio API.
  • Turn it into a placement puzzle: drop shapes onto exact outlines instead of into bins.

From prototype to a game you can sell

This file teaches the drag-and-drop loop and works as a lesson activity. A commercial game built on dragging and placing needs much more:

This sorting fileA production HTML5 game
ContentThree groups, nine cards a roundMany levels, new mechanics over time, daily challenges
Art and soundText cards, no audioOriginal art, animation, music and effects
FeelShadow and snap-backParticles, squash and stretch, haptics, tuned easing
SavesScore lost on reloadProgress and settings saved on the device
InputTouch, mouse and keyboardTouch, mouse, keyboard and gamepad
QualityTested by handUnit tests and cross-browser end-to-end tests
LicenceMIT, do anythingA commercial licence that covers your use (see licensing)

These three games from our catalog are about putting things in the right place, in the right order. Each is finished and tested, and ships as a TypeScript project with its levels in data files. In House Renovator you clean, repair, paint and decorate damaged rooms; in Yarn Bloom you route coloured yarn strands into matching spools; and in Screwscape Workshop you remove screws in the right order without overfilling the trays.

You can also browse all our puzzle games with source code or the full catalog. If you need a sorting game built around your own course or brand, the exclusive game request is free to send.

The complete code

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

drag-and-drop-game.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Sort It Out: a complete HTML5 drag-and-drop sorting game in one 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>Sort It Out (HTML5 drag and drop)</title>
<style>
  html, body { margin: 0; height: 100%; background: #13171f; color: #f1f3f8; font: 16px system-ui, sans-serif; overflow: hidden; touch-action: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Content: edit the title, the instruction and the groups to make your own sorting game -----
const TITLE = "Sort It Out";
const PROMPT = "Drag each animal into its group";
const GROUPS = [
  { name: "Mammals", color: "#e8875f", items: ["Dolphin", "Bat", "Whale", "Elephant", "Kangaroo", "Hedgehog"] },
  { name: "Birds", color: "#4aa3ec", items: ["Penguin", "Ostrich", "Eagle", "Owl", "Parrot", "Flamingo"] },
  { name: "Fish", color: "#38c172", items: ["Shark", "Salmon", "Seahorse", "Tuna", "Clownfish", "Eel"] },
];
const PER_GROUP = 3;                 // cards dealt from each group per round
const RIGHT = 100, WRONG = 25;       // points won for a right drop, lost for a wrong one
const RETURN_TIME = 0.25;            // seconds a wrong card takes to fly back
const COLORS = { bg: "#13171f", card: "#fbf8f1", ink: "#1f2430", text: "#f1f3f8", dim: "#9aa3b8", select: "#ffb020", good: "#38c172", bad: "#e5484d" };

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const HUD = 48, PAD = 12;
let W = 0, H = 0, cols = 2, rowH = 60, cardW = 150, cardH = 48, trayX = 0, trayY = 0, binW = 100, binH = 150, binY = 0;

// ----- Sizing: tray of cards on top, one bin per group along the bottom -----
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);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  binH = Math.round(Math.max(110, Math.min(190, H * 0.26)));
  binW = (W - PAD * (GROUPS.length + 1)) / GROUPS.length;
  binY = H - binH - PAD;
  cols = W >= 640 ? 4 : W >= 420 ? 3 : 2;
  const rows = Math.ceil((GROUPS.length * PER_GROUP) / cols);
  const trayH = binY - HUD - PAD * 2;
  rowH = Math.min(66, trayH / rows);
  cardH = Math.max(24, Math.min(52, rowH - 10));
  cardW = Math.min(170, (W - PAD * (cols + 1)) / cols);
  trayX = (W - cols * cardW - (cols - 1) * PAD) / 2;
  trayY = HUD + PAD + (trayH - rows * rowH) / 2;
  draw();
}

// Where a card sits when nobody is holding it.
function home(slot) {
  return { x: trayX + (slot % cols) * (cardW + PAD), y: trayY + Math.floor(slot / cols) * rowH + (rowH - cardH) / 2 };
}

// ----- Game state -----
let cards = [], sorted = [], flash = [], drag = null, selected = null;
let state = "ready";                 // ready → playing → over
let score = 0, mistakes = 0, time = 0, best = null, last = 0;

function shuffle(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

function deal() {
  cards = [];
  GROUPS.forEach((g, group) => {
    for (const text of shuffle(g.items.slice()).slice(0, PER_GROUP)) cards.push({ text, group, placed: false, back: null, x: 0, y: 0 });
  });
  shuffle(cards).forEach((c, i) => { c.slot = i; });
  sorted = GROUPS.map(() => []);
  flash = GROUPS.map(() => null);
  score = 0; mistakes = 0; time = 0; drag = null; selected = null;
}

function start() {
  if (state === "over") deal();
  state = "playing";
}

// Card position on screen: in the hand, flying home, or resting at home.
function cardPos(c) {
  if (drag && drag.card === c) return { x: c.x, y: c.y };
  const h = home(c.slot);
  if (!c.back) return h;
  const t = 1 - Math.pow(1 - c.back.t, 3);   // ease-out: fast start, soft landing
  return { x: c.back.x + (h.x - c.back.x) * t, y: c.back.y + (h.y - c.back.y) * t };
}

function binAt(p) {
  if (p.y < binY || p.y > binY + binH) return -1;
  const i = Math.floor((p.x - PAD / 2) / (binW + PAD));
  return i >= 0 && i < GROUPS.length ? i : -1;
}

// ----- Rules: a drop is right, wrong, or outside every bin -----
function drop(card, bin) {
  if (bin >= 0 && card.group === bin) {
    card.placed = true;
    score += RIGHT;
    sorted[bin].push(card.text);
    flash[bin] = { good: true, t: 0.4 };
    if (cards.every((c) => c.placed)) {
      state = "over";
      best = best === null ? score : Math.max(best, score);
    }
    return;
  }
  if (bin >= 0) {
    score -= WRONG;                    // the score can go below zero: mistakes always cost
    mistakes++;
    flash[bin] = { good: false, t: 0.4 };
  }
  card.back = { x: card.x, y: card.y, t: 0 };   // fly back to the tray
}

// Sort the selected card without dragging (keyboard, or tap a card then a bin).
function dropSelected(bin, keepGoing) {
  const open = cards.filter((c) => !c.placed).sort((a, b) => a.slot - b.slot);
  const card = selected, h = home(card.slot);
  card.x = h.x; card.y = h.y;
  drop(card, bin);
  if (!card.placed) return;
  const rest = open.filter((c) => c !== card);   // keyboard: highlight the next card
  selected = keepGoing && rest.length ? rest[Math.min(open.indexOf(card), rest.length - 1)] : null;
}

// ----- Main loop -----
function loop(now) {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  if (state === "playing") time += dt;
  for (const c of cards) if (c.back && (c.back.t += dt / RETURN_TIME) >= 1) c.back = null;
  flash.forEach((f, i) => { if (f && (f.t -= dt) <= 0) flash[i] = null; });
  draw();
  requestAnimationFrame(loop);
}

// ----- Drawing -----
function drawCard(c, lifted) {
  const p = cardPos(c);
  ctx.save();
  if (lifted) { ctx.shadowColor = "rgba(0, 0, 0, 0.45)"; ctx.shadowBlur = 16; ctx.shadowOffsetY = 6; }
  ctx.fillStyle = COLORS.card;
  ctx.beginPath();
  ctx.roundRect(p.x, p.y, cardW, cardH, 10);
  ctx.fill();
  ctx.restore();
  if (c === selected && !lifted) {
    ctx.strokeStyle = COLORS.select;
    ctx.lineWidth = 3;
    ctx.stroke();
  }
  ctx.fillStyle = COLORS.ink;
  ctx.font = `600 ${Math.round(Math.min(18, cardH * 0.4))}px system-ui, sans-serif`;
  ctx.textAlign = "center";
  ctx.fillText(c.text, p.x + cardW / 2, p.y + cardH / 2 + 1);
}

function drawBin(g, i) {
  const f = flash[i];
  const shake = f && !f.good ? Math.sin(f.t * 60) * 5 : 0;
  const x = PAD + i * (binW + PAD) + shake;
  const hover = drag && binAt(drag.point) === i;
  ctx.fillStyle = f ? (f.good ? COLORS.good : COLORS.bad) + "55" : g.color + (hover ? "40" : "22");
  ctx.strokeStyle = g.color;
  ctx.lineWidth = hover ? 4 : 2;
  ctx.beginPath();
  ctx.roundRect(x, binY, binW, binH, 14);
  ctx.fill();
  ctx.stroke();
  const font = Math.round(Math.min(18, Math.max(13, binW / 7)));
  ctx.fillStyle = COLORS.text;
  ctx.font = `700 ${font}px system-ui, sans-serif`;
  ctx.textAlign = "center";
  ctx.fillText(`${i + 1}  ${g.name}`, x + binW / 2, binY + font + 4);
  ctx.font = `500 ${Math.max(12, font - 2)}px system-ui, sans-serif`;
  ctx.fillStyle = COLORS.dim;
  sorted[i].forEach((t, k) => ctx.fillText(t, x + binW / 2, binY + font * 2.6 + k * (font + 2)));
}

const clock = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;

function draw() {
  ctx.fillStyle = COLORS.bg;
  ctx.fillRect(0, 0, W, H);
  ctx.textBaseline = "middle";
  GROUPS.forEach(drawBin);
  for (const c of cards) if (!c.placed && (!drag || drag.card !== c)) drawCard(c, false);
  if (drag) drawCard(drag.card, true);   // the held card is drawn last, on top

  const font = Math.round(Math.min(18, Math.max(13, W / 26)));
  ctx.font = `600 ${font}px system-ui, sans-serif`;
  ctx.fillStyle = COLORS.text;
  ctx.textAlign = "left";
  ctx.fillText(`Score ${score}`, PAD, HUD / 2);
  ctx.textAlign = "center";
  ctx.fillText(`Left ${cards.filter((c) => !c.placed).length}`, W / 2, HUD / 2);
  ctx.textAlign = "right";
  ctx.fillText(clock(time), W - PAD, HUD / 2);

  if (state === "playing") return;
  ctx.fillStyle = "rgba(8, 10, 16, 0.75)";
  ctx.fillRect(0, HUD, W, H - HUD);
  const cy = HUD + (H - HUD) / 2, pw = Math.min(W - 24, 480);
  ctx.fillStyle = "#1e2533";
  ctx.beginPath();
  ctx.roundRect((W - pw) / 2, cy - font * 4.2, pw, font * 8.4, 14);
  ctx.fill();
  ctx.fillStyle = COLORS.text;
  ctx.textAlign = "center";
  ctx.font = `700 ${font * 1.8}px system-ui, sans-serif`;
  ctx.fillText(state === "over" ? (mistakes ? "All sorted!" : "Perfect sort!") : TITLE, W / 2, cy - font * 2.2);
  ctx.font = `500 ${font}px system-ui, sans-serif`;
  ctx.fillText(state === "over" ? `${score} points, ${mistakes} mistake${mistakes === 1 ? "" : "s"}, ${clock(time)}` : PROMPT, W / 2, cy - font * 0.2);
  ctx.fillStyle = COLORS.dim;
  ctx.fillText(state === "over" ? `Best: ${best} points` : `Or tap a card, then a bin. Keys: arrows, 1-${GROUPS.length}`, W / 2, cy + font * 1.4);
  ctx.fillText(state === "over" ? "Tap or press Space to play again" : "Tap or press Space to start", W / 2, cy + font * 3);
}

// ----- Input: drag with mouse, pen or finger (Pointer Events) -----
function point(e) {
  const r = canvas.getBoundingClientRect();
  return { x: e.clientX - r.left, y: e.clientY - r.top };
}

canvas.addEventListener("pointerdown", (e) => {
  if (state !== "playing") { start(); return; }
  const p = point(e);
  const bin = binAt(p);
  if (selected && bin >= 0) { dropSelected(bin, false); return; }   // second tap: the bin
  const card = cards.find((c) => {
    const q = cardPos(c);
    return !c.placed && p.x >= q.x && p.x <= q.x + cardW && p.y >= q.y && p.y <= q.y + cardH;
  });
  if (!card) { selected = null; return; }   // tapping empty space clears the selection
  const q = cardPos(card);
  card.x = q.x; card.y = q.y; card.back = null;
  drag = { card, dx: p.x - q.x, dy: p.y - q.y, point: p, start: p };   // where the card was grabbed
  selected = null;
  canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", (e) => {
  if (!drag) return;
  drag.point = point(e);
  drag.card.x = drag.point.x - drag.dx;
  drag.card.y = drag.point.y - drag.dy;
});
canvas.addEventListener("pointerup", (e) => {
  if (!drag) return;
  const card = drag.card, p = point(e);
  const moved = Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 8;
  drag = null;
  if (moved) { drop(card, binAt(p)); return; }
  selected = card;                     // a tap: select the card, the next tap on a bin sorts it
  card.back = { x: card.x, y: card.y, t: 0 };
});
canvas.addEventListener("pointercancel", () => {
  if (drag) { drag.card.back = { x: drag.card.x, y: drag.card.y, t: 0 }; drag = null; }
});

// ----- Keyboard: arrows/WASD choose a card, number keys drop it in a bin -----
window.addEventListener("keydown", (e) => {
  if (e.code === "Space" || e.code === "Enter") {
    e.preventDefault();
    if (state !== "playing") { start(); selected = cards[0]; }   // keyboard players start with a card picked
    return;
  }
  if (state !== "playing" || drag) return;
  const open = cards.filter((c) => !c.placed).sort((a, b) => a.slot - b.slot);
  const step = { ArrowRight: 1, KeyD: 1, ArrowDown: 1, KeyS: 1, ArrowLeft: -1, KeyA: -1, ArrowUp: -1, KeyW: -1 }[e.code];
  if (step) {
    e.preventDefault();
    const i = open.indexOf(selected);
    selected = open[i < 0 ? 0 : (i + step + open.length) % open.length];
    return;
  }
  const bin = Number(e.key) - 1;
  if (selected && !selected.placed && bin >= 0 && bin < GROUPS.length) dropSelected(bin, true);
});

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

Frequently asked questions

Is this drag-and-drop game code free to use?

Yes. The file is MIT-licensed, so you can copy it, change it and use it in personal or commercial projects, including paid courses. Keep the licence line at the top of the file.

How do I make a drag and drop game in HTML5 without canvas?

Use HTML elements and the same three Pointer Events. Give each item position: absolute, move it by changing left and top in pointermove, and on pointerup use document.elementsFromPoint() or compare bounding rectangles to find the target. Add touch-action: none to the draggable elements.

Should I use the HTML5 Drag and Drop API for a game?

Usually not. It suits moving files or text between parts of a page. For games, Pointer Events give you control over the dragged object, work on canvas and behave the same with mouse, pen and touch.

How do I put this game on my website or in a course?

Upload drag-and-drop-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 save the best score after a reload?

Save best with localStorage.setItem when the round ends and read it when the page loads. Wrap both calls in try/catch, because some browsers and sandboxed frames, like the player on this page, block storage.

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