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.

Slot Machine and Casino Games in HTML5: Free Code (No Real Money)

This slot machine is one HTML file of 323 lines that uses play credits only: three reels on fixed strips, five paylines, a paytable, reels that stop one by one and an exact return figure computed from every outcome. Copy it to learn how casino-style games work. It's a game-mechanics tutorial, not gambling, and we don't sell real-money games.

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

Slot machines look like luck, but under the art they are a small, exact piece of maths: a few lists of symbols, a random number per reel and a table of payouts. The game above, Reel Rush, shows all of it in one file you can read. This page walks through that HTML5 casino game source code: how reel strips set the odds, how the result is chosen fairly before the reels move, how paylines are checked and how the game computes its own return to player. Further down, we explain how poker, bingo and roulette games are structured.

How to run it

  1. Press Copy code above, or Download .html.
  2. Save it as casino-games.html and open it in any browser.
  3. Tap or press Space to start, then press SPIN. Change the bet with the − and + buttons or the arrow keys.

You can also paste the code into the HTML game code runner, edit a reel strip or a payout and press Run to see how the return figure in the paytable changes.

How the HTML5 slot game code works

The script has six parts: settings, randomness, rules, state, drawing and input. The rules don't know anything about animation, and the animation doesn't decide anything, which is the most important design choice in any slot game.

1. Reel strips: where the odds live

A physical slot reel has a fixed number of stops, and so does this one. Each reel is a string of 24 letters, one per stop. A symbol that appears seven times on a strip lands seven times as often as one that appears once, so the strips, not the drawing code, decide how often each result happens.

casino-games.html
const REELS = ["CLBCSLC7LBCGLSCBLCSBLGC7", "LCBSCLGCBL7CSLBCLSGCBLC7", "CBLSCGLBC7LSCBLCGSLBCL7S"];
const L = 24;                                  // stops per strip
const PAYS = { 7: 100, G: 50, S: 30, B: 20, L: 15, C: 10 };   // three on a line, × credits bet per line
const TWO_CHERRIES = 4;                        // cherry, cherry, anything, reading from the left
const LINES = [[1, 1, 1], [0, 0, 0], [2, 2, 2], [0, 1, 2], [2, 1, 0]];   // the row used on each reel
const LINE_COLORS = ["#ffd166", "#06d6a0", "#4cc9f0", "#f72585", "#ff9f1c"];
const BETS = [1, 2, 3];                        // play credits per line; a spin costs bet × 5 lines
const START_CREDITS = 100;

Each reel has two sevens and two gems, and between six and seven cherries, so cherries are common and sevens rare. LINES lists, for each payline, which row to read on each reel: the three rows plus the two diagonals.

2. Fair randomness

A spin needs one random stop per reel, a whole number from 0 to 23. The common shortcut, Math.floor(Math.random() * 24), is fine for a demo, but it isn't designed for fairness. This file uses crypto.getRandomValues, which gives cryptographically strong random numbers, and rejection sampling: 2³² isn't divisible by 24, so the few values at the very top of the range are thrown away instead of being folded into the low stops.

js
function randomInt(n) {
  const buf = new Uint32Array(1), limit = Math.floor(0x100000000 / n) * n;
  do crypto.getRandomValues(buf); while (buf[0] >= limit);
  return buf[0] % n;
}

Without that while, stops 0 to 15 would come up very slightly more often than stops 16 to 23. The bias is tiny here, but the same mistake in a card shuffle or a dice roll is a classic bug, and it is easy to avoid.

3. Decide the result first, then animate

When you press SPIN, the cost comes off your credits and result is chosen at once. Only then do the reels start moving, and each one is given exactly the distance it needs to finish on its chosen stop. Nothing about the animation, the timing of your tap or the previous spin affects the outcome.

js
credits -= BETS[bet] * LINES.length;
spins++; wins = []; message = "Spinning…";
result = reels.map(() => randomInt(L));      // the outcome is decided first...
reels.forEach((r, i) => {                    // ...then each reel travels to it
  const base = 12 + i * 8;                   // later reels spin longer, so they travel further
  r.from = r.pos; r.t = 0; r.spinning = true;
  r.dist = base + mod(r.pos - base - result[i], L);
});
state = "spinning";

The mod() term is the distance from where the reel is now to the chosen stop, wrapped around the strip; base adds whole extra symbols so the reel visibly spins before it gets there.

4. Paylines and the paytable

evaluate() turns three stops into a 3 × 3 window (each stop plus the next two symbols on its strip), then reads every payline through it. Three matching symbols pay the amount in PAYS; two cherries from the left pay a small consolation. Payouts are multiplied by the bet per line when the spin settles.

js
function evaluate(stops) {
  // The 3 × 3 window: row 0 shows the stop itself, rows 1 and 2 the next two symbols on the strip.
  const grid = stops.map((s, r) => [0, 1, 2].map((row) => REELS[r][(s + row) % L]));
  const wins = [];
  LINES.forEach((rows, line) => {
    const [a, b, c] = rows.map((row, r) => grid[r][row]);
    const three = a === b && b === c;
    const pay = three ? PAYS[a] : a === "C" && b === "C" ? TWO_CHERRIES : 0;
    if (pay) wins.push({ line, pay, count: three ? 3 : 2 });
  });
  return wins;
}

Because evaluate() is a pure function of the stops, you can test it without drawing anything, which is what the next step does.

5. RTP and hit rate, computed exactly

Return to player (RTP) is the share of all bets a game pays back on average over a very long run. With 24 stops per reel there are only 24 × 24 × 24 = 13,824 possible spins, so the game simply tries every one at load time and adds up the payouts. No simulation and no guessing:

js
function computeOdds() {
  let paid = 0, hits = 0;
  for (let a = 0; a < L; a++) for (let b = 0; b < L; b++) for (let c = 0; c < L; c++) {
    const wins = evaluate([a, b, c]);
    paid += wins.reduce((sum, w) => sum + w.pay, 0);
    if (wins.length) hits++;
  }
  return { rtp: paid / (L ** 3 * LINES.length), hitRate: hits / L ** 3 };
}

For the strips and paytable in this file, the result is a return of 96.0% and a win on 48.9% of spins, both printed at the bottom of the paytable. The missing 4% is the house edge: on average the machine keeps 4 of every 100 credits bet, which is why the credits always run out in the end, however lucky a session feels. Change one letter in a strip, press Run, and the new figures appear. This is the most useful part of the file for anyone learning slot game maths.

6. Reels that stop one by one

Each reel has its own stop time in SPIN_TIME (1.0, 1.5 and 2.0 seconds), so they land left to right. travel() moves a reel at constant speed for the first 60% of its time and then brakes smoothly, with the position and speed both continuous, so there's no visible jerk:

js
// Constant speed for 60% of the time, then a smooth stop (position and speed stay continuous).
function travel(u) {
  const a = 0.6, v = 2 / (1 + a);
  return u <= a ? v * u : 1 - (v * (1 - u) ** 2) / (2 * (1 - a));
}

When a reel arrives, its position is set to exactly the chosen stop, and a 0.2-second "kick" nudges the symbols down and back for a mechanical feel. Once all three have stopped, settle() pays the wins, highlights the winning lines and lowers the bet if you can no longer afford it.

7. Credits that reset when you run out

When credits fall below the cheapest spin (5), the game shows how many spins you made and your biggest win, then refills 100 play credits on the next tap. A short delay stops a late tap on SPIN from skipping that screen.

Make it work on phones

The machine is laid out in a fixed 360 × 640 design space and scaled to fit the screen, so it's a natural portrait layout that also fits a landscape monitor. The scale and offset are applied once with setTransform, and the pointer handler applies the same conversion in reverse before it tests the buttons:

js
canvas.addEventListener("pointerdown", (e) => {
  const x = (e.clientX - ox) / scale, y = (e.clientY - oy) / scale;
  const hit = (name) => { const [bx, by, bw, bh] = BUTTONS[name]; return x >= bx && x <= bx + bw && y >= by && y <= by + bh; };
  if (state === "ready" || state === "broke" || hit("spin") || hit("reels")) spin();
  else if (hit("minus")) changeBet(-1);
  else if (hit("plus")) changeBet(1);
});

Tapping the reels also spins, because on a phone that's the biggest target on the screen. For more on scaling and touch input, see our guide to HTML5 mobile game development.

How poker, bingo and roulette games are built

Every casino-style game follows the same pattern as the slot: a random outcome drawn fairly, a state machine that moves through fixed phases, and a pure function that scores the result. Here is how that pattern changes for the other common games, without full implementations.

HTML5 poker game tutorial: deck, deal and hand ranking

A poker game starts with a 52-card deck shuffled with the Fisher–Yates algorithm, using the same unbiased randomInt() as the slot. The simplest version to build is single-player video poker: deal five cards, let the player hold some, replace the rest, then score the hand against a paytable. The heart of the code is a hand evaluator that sorts the five cards and checks the categories from best to worst: straight flush, four of a kind, full house, flush, straight, three of a kind, two pair, one pair, high card. Texas Hold'em adds betting rounds and opponents, so the state machine grows (deal, pre-flop, flop, turn, river, showdown) and you need computer players. With real people, hidden cards must be dealt by a server, because anything sent to a browser can be read; our guide to multiplayer HTML5 games covers that architecture.

HTML5 bingo game code: cards and a caller

Bingo has two random parts. The caller shuffles all the balls once (1 to 75 in American bingo, 1 to 90 in British bingo) and draws them in order, which guarantees no repeats. Cards are generated with column rules: in 75-ball bingo the five columns take numbers from 1–15, 16–30, 31–45, 46–60 and 61–75, with a free centre square. After each call the game marks matching numbers and checks the winning patterns (a line, four corners, a full card), which is a small loop over the card, much like evaluate() above.

3D roulette in HTML5: decide first, then animate

A European roulette wheel has 37 pockets (0 to 36) and an American wheel has 38 (with a 00), and a single-number bet pays 35 to 1. That payout is why the house edge is 1/37, about 2.7%, on a European wheel. In code, roulette is the clearest example of "decide first, then animate": pick the winning pocket with randomInt(37), then animate the wheel and ball so they come to rest on it. A 3D wheel made with a library such as Three.js looks impressive, but the physics of the ball is only for show; letting a physics simulation pick the number would make the odds impossible to verify.

HTML5 slot game engines and open source casino games

Do you need an HTML5 slot game engine?

Not to learn, and not for a play-money game. This file already contains every part a slot engine provides: reel strips, paylines, a paytable, a random number source, reel animation and a state machine. For richer art and effects, a general 2D engine such as Phaser handles sprites, tweens, particles and sound well; our guide to HTML5 game engines compares the main options. Real-money slots are different: the outcome is decided on the operator's or supplier's server, never in the browser, because code that runs in a browser can be read and changed by the player.

Open source HTML5 casino games on GitHub

You'll find open source slot, poker and bingo projects on GitHub, and some are good teaching material. Before you reuse one, read its licence file: "public on GitHub" doesn't mean you may use it, and a repository with no licence file is, legally, all rights reserved. Commercial slots made for casinos are proprietary products, so a repository claiming to contain them for free is a reason for caution. The code on this page is MIT-licensed, so you can use it in your own projects. For more free game code with clear licences, see our guide to free and open source HTML5 games.

Real-money games are regulated

Offering a game where people stake or win real money is gambling, and in most countries that needs a licence. Regulators also set technical rules for the software. In Great Britain, for example, the Gambling Commission licenses operators and publishes technical standards that cover how random outcomes are generated. If you are building anything with real stakes, start with the rules where your players live and with specialist legal advice, not with a tutorial.

Ideas to extend it

  • Add a fourth and fifth reel and more paylines, then watch how the RTP figure changes.
  • Add a wild symbol that substitutes for any other symbol on a line.
  • Show a "how it works" panel that lists each strip and the probability of every payout.
  • Play reel-stop clicks and a win jingle with the Web Audio API, started after the first tap.
  • Add an autoplay toggle that spins ten times in a row, and a stop button.
  • Draw a motion blur on the reels while they move fast.

From prototype to a game you can sell

This file is a teaching tool. A finished commercial HTML5 game, of any genre, needs a lot more around its core:

This slot fileA production HTML5 game
ContentOne machine, one modeLevels, modes, daily challenges, progression
Art and soundCanvas shapes, no audioOriginal art, animation, music and effects
OutcomesDecided in the browser (fine for play credits)Decided where the rules require, tested before release
SavesCredits reset on reloadProgress 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 don't sell casino, slot or real-money games, and none are in our catalog. Our 50 games span puzzle, merge, action, racing, strategy, simulation and sports, each with full TypeScript source. You can browse them all. If you need a play-money mechanic for a commercial project, you can suggest a game on our roadmap or send a brief for an exclusive game; we don't build anything for real-money gambling. For more small games to learn from, go back to the free HTML game code list.

The complete code

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

casino-games.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Reel Rush: a slot machine demo with play credits only (no real money) 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>Reel Rush (HTML5 slot machine, play credits only)</title>
<style>
  html, body { margin: 0; height: 100%; background: #12091f; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings -----
// Reel strips: each letter is one stop (C cherry, L lemon, B bell, S star, G gem, 7 seven).
// More copies of a symbol on a strip make it more likely to land.
const REELS = ["CLBCSLC7LBCGLSCBLCSBLGC7", "LCBSCLGCBL7CSLBCLSGCBLC7", "CBLSCGLBC7LSCBLCGSLBCL7S"];
const L = 24;                                  // stops per strip
const PAYS = { 7: 100, G: 50, S: 30, B: 20, L: 15, C: 10 };   // three on a line, × credits bet per line
const TWO_CHERRIES = 4;                        // cherry, cherry, anything, reading from the left
const LINES = [[1, 1, 1], [0, 0, 0], [2, 2, 2], [0, 1, 2], [2, 1, 0]];   // the row used on each reel
const LINE_COLORS = ["#ffd166", "#06d6a0", "#4cc9f0", "#f72585", "#ff9f1c"];
const BETS = [1, 2, 3];                        // play credits per line; a spin costs bet × 5 lines
const START_CREDITS = 100;
const SPIN_TIME = [1.0, 1.5, 2.0];             // seconds until each reel stops
const DESIGN_W = 360, DESIGN_H = 640;          // the machine is laid out in these units, then scaled

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let W = 0, H = 0, dpr = 1, scale = 1, ox = 0, oy = 0;
const mod = (n, m) => ((n % m) + m) % m;

// ----- Fair randomness: crypto.getRandomValues with rejection sampling, so no stop is favoured -----
function randomInt(n) {
  const buf = new Uint32Array(1), limit = Math.floor(0x100000000 / n) * n;
  do crypto.getRandomValues(buf); while (buf[0] >= limit);
  return buf[0] % n;
}

// ----- Rules: which lines win for a set of stops -----
function evaluate(stops) {
  // The 3 × 3 window: row 0 shows the stop itself, rows 1 and 2 the next two symbols on the strip.
  const grid = stops.map((s, r) => [0, 1, 2].map((row) => REELS[r][(s + row) % L]));
  const wins = [];
  LINES.forEach((rows, line) => {
    const [a, b, c] = rows.map((row, r) => grid[r][row]);
    const three = a === b && b === c;
    const pay = three ? PAYS[a] : a === "C" && b === "C" ? TWO_CHERRIES : 0;
    if (pay) wins.push({ line, pay, count: three ? 3 : 2 });
  });
  return wins;
}

// The exact odds: try all 24 × 24 × 24 stop combinations once, at load.
function computeOdds() {
  let paid = 0, hits = 0;
  for (let a = 0; a < L; a++) for (let b = 0; b < L; b++) for (let c = 0; c < L; c++) {
    const wins = evaluate([a, b, c]);
    paid += wins.reduce((sum, w) => sum + w.pay, 0);
    if (wins.length) hits++;
  }
  return { rtp: paid / (L ** 3 * LINES.length), hitRate: hits / L ** 3 };
}
const ODDS = computeOdds();

// ----- State -----
let state = "ready";                           // ready → idle ⇄ spinning → broke (refill) → idle
let credits = START_CREDITS, shown = START_CREDITS, bet = 0, spins = 0, biggest = 0;
let result = [0, 0, 0], wins = [], message = "", last = 0, brokeAt = 0;
const reels = [0, 1, 2].map(() => ({ pos: randomInt(L), from: 0, dist: 0, t: 0, spinning: false, kick: 1 }));

function spin() {
  if (state === "ready") { state = "idle"; message = "Press SPIN"; return; }
  if (state === "broke") {
    if (performance.now() - brokeAt < 800) return;     // don't skip the screen with a late tap
    credits = shown = START_CREDITS; state = "idle"; message = "100 play credits added";
    return;
  }
  if (state !== "idle") return;
  credits -= BETS[bet] * LINES.length;
  spins++; wins = []; message = "Spinning…";
  result = reels.map(() => randomInt(L));      // the outcome is decided first...
  reels.forEach((r, i) => {                    // ...then each reel travels to it
    const base = 12 + i * 8;                   // later reels spin longer, so they travel further
    r.from = r.pos; r.t = 0; r.spinning = true;
    r.dist = base + mod(r.pos - base - result[i], L);
  });
  state = "spinning";
}

// Constant speed for 60% of the time, then a smooth stop (position and speed stay continuous).
function travel(u) {
  const a = 0.6, v = 2 / (1 + a);
  return u <= a ? v * u : 1 - (v * (1 - u) ** 2) / (2 * (1 - a));
}

function settle() {
  wins = evaluate(result);
  const won = wins.reduce((sum, w) => sum + w.pay, 0) * BETS[bet];
  credits += won;
  biggest = Math.max(biggest, won);
  message = won ? `Win ${won} credits` : "No win this time";
  while (bet > 0 && credits < BETS[bet] * LINES.length) bet--;   // never offer a bet you can't cover
  state = credits < BETS[0] * LINES.length ? "broke" : "idle";
  if (state === "broke") brokeAt = performance.now();
}

function changeBet(d) {
  if (state !== "idle") return;
  const next = Math.max(0, Math.min(BETS.length - 1, bet + d));
  if (credits >= BETS[next] * LINES.length) bet = next;
}

function update(dt) {
  if (state === "spinning") {
    let moving = false;
    reels.forEach((r, i) => {
      if (!r.spinning) return;
      r.t += dt;
      const u = Math.min(1, r.t / SPIN_TIME[i]);
      r.pos = r.from - r.dist * travel(u);
      if (u < 1) moving = true;
      else { r.spinning = false; r.pos = result[i]; r.kick = 0; }   // land exactly on the chosen stop
    });
    if (!moving) settle();
  }
  reels.forEach((r) => { r.kick += dt; });
  shown += (credits - shown) * Math.min(1, dt * 8);             // credits count up or down smoothly
  if (Math.abs(credits - shown) < 0.5) shown = credits;
}

// ----- Drawing (in design units) -----
function rr(x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}

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 drawSymbol(id, x, y, s) {
  ctx.save(); ctx.translate(x, y); ctx.scale(s / 100, s / 100);   // each symbol is drawn in a 100 × 100 box
  ctx.lineWidth = 4; ctx.strokeStyle = "#3a1f04";
  if (id === "C") {
    ctx.strokeStyle = "#2d6a4f"; ctx.lineWidth = 5;
    ctx.beginPath(); ctx.moveTo(-18, 12); ctx.quadraticCurveTo(-10, -20, 6, -34); ctx.moveTo(16, 18); ctx.quadraticCurveTo(14, -12, 6, -34); ctx.stroke();
    ctx.fillStyle = "#52b788"; ctx.beginPath(); ctx.ellipse(18, -32, 14, 7, -0.4, 0, Math.PI * 2); ctx.fill();
    for (const [cx, cy] of [[-18, 20], [16, 24]]) {
      ctx.fillStyle = "#d00000"; ctx.beginPath(); ctx.arc(cx, cy, 17, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#ff8fa3"; ctx.beginPath(); ctx.arc(cx - 6, cy - 6, 5, 0, Math.PI * 2); ctx.fill();
    }
  } else if (id === "L") {
    ctx.fillStyle = "#ffd60a"; ctx.beginPath(); ctx.ellipse(0, 0, 36, 26, -0.35, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
    ctx.fillStyle = "#fff3b0"; ctx.beginPath(); ctx.ellipse(-12, -8, 10, 5, -0.35, 0, Math.PI * 2); ctx.fill();
  } else if (id === "B") {
    ctx.fillStyle = "#f4a261";
    ctx.beginPath(); ctx.moveTo(-32, 22); ctx.quadraticCurveTo(-26, 16, -26, -4); ctx.quadraticCurveTo(-24, -32, 0, -32);
    ctx.quadraticCurveTo(24, -32, 26, -4); ctx.quadraticCurveTo(26, 16, 32, 22); ctx.closePath(); ctx.fill(); ctx.stroke();
    ctx.fillStyle = "#e76f51"; ctx.beginPath(); ctx.arc(0, 28, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
    ctx.fillStyle = "#ffe8d6"; ctx.fillRect(-14, -22, 6, 26);
  } else if (id === "S") {
    ctx.fillStyle = "#9d4edd"; ctx.beginPath();
    for (let i = 0; i < 10; i++) {
      const a = -Math.PI / 2 + (i * Math.PI) / 5, r = i % 2 ? 16 : 38;
      ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
    }
    ctx.closePath(); ctx.fill(); ctx.stroke();
  } else if (id === "G") {
    ctx.fillStyle = "#48cae4"; ctx.beginPath();
    ctx.moveTo(-34, -10); ctx.lineTo(-18, -30); ctx.lineTo(18, -30); ctx.lineTo(34, -10); ctx.lineTo(0, 34); ctx.closePath(); ctx.fill(); ctx.stroke();
    ctx.strokeStyle = "#caf0f8"; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(-34, -10); ctx.lineTo(34, -10); ctx.moveTo(-10, -30); ctx.lineTo(-14, -10); ctx.lineTo(0, 34); ctx.lineTo(14, -10); ctx.lineTo(10, -30); ctx.stroke();
  } else {
    ctx.font = "900 78px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
    ctx.lineWidth = 8; ctx.strokeText("7", 0, 4); ctx.fillStyle = "#e63946"; ctx.fillText("7", 0, 4);
  }
  ctx.restore();
}

const REEL_X = (r) => 30 + r * 102, REEL_Y = 82, CELL_W = 96, CELL_H = 80;

function drawReels(time) {
  const winning = state === "idle" || state === "broke" ? wins : [];
  reels.forEach((r, i) => {
    const x = REEL_X(i);
    ctx.save();
    rr(x, REEL_Y, CELL_W, CELL_H * 3, 8); ctx.clip();
    const bg = ctx.createLinearGradient(0, REEL_Y, 0, REEL_Y + CELL_H * 3);
    bg.addColorStop(0, "#cfc6b8"); bg.addColorStop(0.5, "#fffaf0"); bg.addColorStop(1, "#cfc6b8");
    ctx.fillStyle = bg; ctx.fillRect(x, REEL_Y, CELL_W, CELL_H * 3);
    const kick = r.kick < 0.2 ? Math.sin((r.kick / 0.2) * Math.PI) * 0.12 : 0;   // small bounce on landing
    const p = r.pos - kick;
    for (let k = Math.floor(p) - 1; k <= Math.floor(p) + 3; k++) {
      const row = k - p;                       // 0 = top row; symbols move down as pos decreases
      const id = REELS[i][mod(k, L)];
      // Winning symbols pulse.
      const lit = !r.spinning && winning.some((w) => LINES[w.line][i] === row && i < w.count);
      drawSymbol(id, x + CELL_W / 2, REEL_Y + row * CELL_H + CELL_H / 2, lit ? 66 + Math.sin(time * 10) * 5 : 62);
    }
    ctx.restore();
  });
  winning.forEach((w, n) => {                  // win lines through the centre of each symbol
    ctx.strokeStyle = LINE_COLORS[w.line]; ctx.lineWidth = 5; ctx.globalAlpha = 0.7 + 0.3 * Math.sin(time * 6 + n);
    ctx.beginPath();
    LINES[w.line].forEach((row, r) => ctx.lineTo(REEL_X(r) + CELL_W / 2, REEL_Y + row * CELL_H + CELL_H / 2));
    ctx.stroke();
    ctx.globalAlpha = 1;
  });
}

const BUTTONS = { minus: [136, 386, 28, 30], plus: [204, 386, 28, 30], spin: [240, 364, 96, 56], reels: [24, 76, 312, 252] };

function draw(time) {
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = "#12091f"; ctx.fillRect(0, 0, W, H);
  ctx.setTransform(dpr * scale, 0, 0, dpr * scale, dpr * ox, dpr * oy);   // from here on: design units

  const body = ctx.createLinearGradient(0, 0, 0, DESIGN_H);
  body.addColorStop(0, "#3c096c"); body.addColorStop(1, "#240046");
  rr(8, 6, 344, 628, 22); ctx.fillStyle = body; ctx.fill();
  ctx.strokeStyle = "#ffb703"; ctx.lineWidth = 3; ctx.stroke();
  text("REEL RUSH", 180, 36, 30, "#ffd166", "center", 900);
  text("Play credits only · no real money", 180, 62, 13, "#e0c3fc", "center", 500);
  rr(22, 74, 316, 256, 12); ctx.fillStyle = "#10002b"; ctx.fill();
  drawReels(time);
  text(message, 180, 346, 17, message.startsWith("Win") ? "#ffd166" : "#e0c3fc");

  // Credits, bet and SPIN.
  rr(24, 364, 104, 56, 10); ctx.fillStyle = "#10002b"; ctx.fill();
  text("CREDITS", 76, 378, 11, "#c77dff", "center", 600);
  text(String(Math.round(shown)), 76, 401, 22, "#ffffff");
  rr(136, 364, 96, 56, 10); ctx.fillStyle = "#10002b"; ctx.fill();
  text("BET / LINE", 184, 378, 11, "#c77dff", "center", 600);
  text(String(BETS[bet]), 184, 401, 22, "#ffffff");
  for (const [name, label] of [["minus", "−"], ["plus", "+"]]) {
    const [x, y, w, h] = BUTTONS[name];
    rr(x + 3, y, w - 6, h - 4, 6); ctx.fillStyle = "#5a189a"; ctx.fill();
    text(label, x + w / 2, y + h / 2 - 2, 20, "#ffffff");
  }
  const [bx, by, bw, bh] = BUTTONS.spin, ready = state === "idle";
  rr(bx, by, bw, bh, 12); ctx.fillStyle = ready ? "#e63946" : "#6c757d"; ctx.fill();
  text("SPIN", bx + bw / 2, by + 22, 20, "#ffffff", "center", 900);
  text(`costs ${BETS[bet] * LINES.length}`, bx + bw / 2, by + 42, 11, "#ffe5ec", "center", 600);

  // Paytable, always visible.
  rr(24, 432, 312, 196, 12); ctx.fillStyle = "rgba(16, 0, 43, 0.7)"; ctx.fill();
  text("PAYTABLE · × bet per line", 180, 450, 13, "#ffd166", "center", 800);
  ["7", "G", "S", "B", "L", "C"].forEach((id, n) => {
    const x = 40 + (n % 2) * 152, y = 480 + Math.floor(n / 2) * 32;
    for (let k = 0; k < 3; k++) drawSymbol(id, x + k * 24, y, 24);
    text(`× ${PAYS[id]}`, x + 74, y, 15, "#ffffff", "left");
  });
  drawSymbol("C", 40, 576, 24); drawSymbol("C", 64, 576, 24);
  text(`+ any   × ${TWO_CHERRIES}`, 80, 576, 15, "#ffffff", "left");
  text("5 lines: the 3 rows and 2 diagonals", 180, 598, 12, "#e0c3fc", "center", 500);
  text(`Return ${(ODDS.rtp * 100).toFixed(1)}% · wins on ${(ODDS.hitRate * 100).toFixed(1)}% of spins (exact)`, 180, 616, 12, "#c77dff", "center", 500);

  if (state === "ready" || state === "broke") {
    rr(8, 6, 344, 628, 22); ctx.fillStyle = "rgba(10, 0, 25, 0.86)"; ctx.fill();
    if (state === "ready") {
      text("Reel Rush", 180, 200, 38, "#ffd166", "center", 900);
      text("A slot machine demo with play credits.", 180, 250, 15, "#ffffff", "center", 500);
      text("Credits have no value: you can't buy", 180, 280, 15, "#e0c3fc", "center", 500);
      text("them or cash them out.", 180, 302, 15, "#e0c3fc", "center", 500);
      text("Space or SPIN to spin · ← → change the bet", 180, 340, 13, "#c77dff", "center", 500);
      text("Tap or press Space to start", 180, 400, 18, "#ffd166");
    } else {
      text("Out of play credits", 180, 220, 28, "#ffd166", "center", 900);
      text(`${spins} spins · biggest win ${biggest}`, 180, 264, 15, "#ffffff", "center", 500);
      text("The machine keeps about " + (100 - ODDS.rtp * 100).toFixed(0) + "% of every bet on average,", 180, 300, 13, "#e0c3fc", "center", 500);
      text("so credits always run out in the end.", 180, 320, 13, "#e0c3fc", "center", 500);
      text("Tap or press Space for 100 more", 180, 380, 18, "#ffd166");
    }
  }
}

// ----- Sizing and loop -----
function resize() {
  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);
  scale = Math.min(W / DESIGN_W, H / DESIGN_H);
  ox = (W - DESIGN_W * scale) / 2; oy = (H - DESIGN_H * scale) / 2;
  draw(performance.now() / 1000);
}

function loop(now) {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  update(dt);
  draw(now / 1000);
  requestAnimationFrame(loop);
}

// ----- Input: buttons by mouse or touch, or the keyboard -----
canvas.addEventListener("pointerdown", (e) => {
  const x = (e.clientX - ox) / scale, y = (e.clientY - oy) / scale;
  const hit = (name) => { const [bx, by, bw, bh] = BUTTONS[name]; return x >= bx && x <= bx + bw && y >= by && y <= by + bh; };
  if (state === "ready" || state === "broke" || hit("spin") || hit("reels")) spin();
  else if (hit("minus")) changeBet(-1);
  else if (hit("plus")) changeBet(1);
});

window.addEventListener("keydown", (e) => {
  if (e.code === "Space" || e.code === "Enter") { e.preventDefault(); spin(); }
  else if (["ArrowLeft", "ArrowDown", "KeyA", "KeyS"].includes(e.code)) { e.preventDefault(); changeBet(-1); }
  else if (["ArrowRight", "ArrowUp", "KeyD", "KeyW"].includes(e.code)) { e.preventDefault(); changeBet(1); }
});

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

Frequently asked questions

Is this slot machine gambling?

No. It uses play credits that have no value: you can't buy them, and you can't cash them out. When they run out, the game gives you 100 more. It exists to show how slot mechanics such as reel strips, paylines and RTP work in code.

Can I use this slot machine source code commercially?

The file is MIT-licensed, so you can copy, change and ship it, keeping the licence line at the top. The licence covers the code only. Running any game with real-money stakes is a separate legal matter that needs a gambling licence where your players are.

Is Math.random good enough for a slot game?

For a demo, yes. For anything where fairness matters, use crypto.getRandomValues with rejection sampling as this file does, and for real-money games the outcome must come from a tested random number generator on a server, not from the browser.

How do I change the odds or the RTP?

Edit the reel strings in REELS or the values in PAYS, then reload. The game recomputes the exact return and hit rate from every combination at load and prints them at the bottom of the paytable, so you can see the effect of each change at once.

How do I add sound to the slot machine?

Create one AudioContext on the first tap, then play a short click when each reel lands (where r.kick is reset to 0) and a jingle in settle() when there's a win. MDN's Web Audio API guide shows how to play generated tones and sound files.

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