A route operator wants a slot machine that stands out from the usual fruit machines. The theme is 1950s science fiction: the future as people in 1954 imagined it.
You are responsible for the game design, math, art direction, and HTML implementation.
Create a complete slot machine as one self-contained HTML page.
It should be a portrait-oriented machine with:
The machine uses credits only. There is no cash, purchasing, accounts, or real-money gambling.
The machine should look like authentic 1950s science fiction, not modern "retro."
Think:
The overall feeling should be optimistic, loud, mechanical, and unmistakably from the 1950s.
Choose the 16 objects that appear on the reels.
This is an important design decision. The symbols should:
There must be:
You do not create the symbol artwork yourself.
Instead, provide prompts that will later be used to generate it.
The symbols will be delivered as one PNG:
art/symbols.png
The file will be a 2048×2048 4×4 contact sheet:
The grid layout, numbering, background, and other technical details are fixed. Your job is to specify what belongs in each cell.
The path art/symbols.png must appear exactly once in your HTML.
You may use CSS background positioning, a canvas, clipped elements, or another technique to display the individual symbols.
Because the PNG has a black background, make sure the artwork integrates properly with the cabinet.
The machine must still work before art/symbols.png exists.
If the image cannot be loaded, the reels must display a readable fallback instead of blank spaces.
Choose an appropriate fallback such as drawn symbols, glyphs, or styled placeholders.
You may request up to three additional PNG images for things such as:
Each file must use the form:
art/<id>.png
These images are optional decoration. The machine must still look finished without them.
The machine has:
Use 10–25 paylines.
Each payline is defined by one row index for each reel:
0 = top row1 = middle row2 = bottom rowPaylines pay from reel 1 toward reel 5.
Each reel has its own symbol strip containing 20–60 symbols.
Every one of the 16 symbols must appear on at least one reel strip.
There is exactly one wild symbol.
The wild substitutes for any symbol except the scatter.
There is exactly one scatter symbol.
Three or more scatters anywhere on the visible 5×3 grid start a feature.
The feature is up to you. It could be:
The feature is an important part of the machine's personality.
Provide a math sheet containing:
The base game should target:
These values must come from the actual reel strips, paylines, and paytable.
Do not simply claim a target percentage. Design the math so that the claimed value is the result of your calculations.
The checker runs 200,000 spins using a fixed seed.
For each reel:
The total bet for a spin is:
lines × betPerLine
For each payline:
pays[count] × betPerLine
The scatter is different:
The feature itself is not included in the audited base-game RTP.
The machine has exactly one control for playing.
Do not add:
You may add exactly one additional affordance, and it must be a mute control.
Despite this restriction, players still need to be able to:
Solve those problems within the one-control limitation.
All sound must be generated with the Web Audio API.
Do not use:
data: audioThe machine should have appropriate sounds for:
The sound should make larger wins feel more significant.
Make sure the audio works when the player presses the Play button. Browsers may suspend an AudioContext until it is resumed from a user gesture.
The page must support two URL modes.
#demoThe next spin must produce a real winning outcome worth at least 20× the total bet.
This must not be a fake animation.
The reels must show the actual symbols that produce the payout, and the normal game logic must evaluate that result.
#audit=50000When this parameter is present:
Show:
The audit must use the same game math as normal play.
The machine must be a single HTML file.
It must:
art/ filesDo not use:
localStoragesessionStorageindexedDBThe page must have no scrollbars at any window size.
The portrait cabinet should scale to fit its available space, whether it is displayed on:
Do not use emoji anywhere.
Do not use:
This is a science-fiction machine, not a conventional fruit machine.
Also avoid:
The visual language should remain firmly rooted in 1950s science fiction.
Your response must contain exactly these four sections.
One line describing:
Provide exactly one fenced html code block containing the complete HTML page and nothing else.
Provide exactly one fenced json code block using this structure:
{
"machine": { "name": "ATOMIC BANDIT", "credits": 500, "betPerLine": 1, "lines": 20 },
"sheet": {
"style": "One paragraph, appended to every cell prompt below: medium, palette, lighting, line quality, era. This is what makes sixteen separate objects look like one set.",
"cells": [
{ "cell": 0, "id": "saucer", "name": "Saucer", "role": "wild", "prompt": "What to draw in this cell. One object.", "pays": { "3": 25, "4": 100, "5": 500 } },
{ "cell": 1, "id": "beacon", "name": "Beacon", "role": "scatter", "prompt": "...", "pays": { "3": 2, "4": 10, "5": 50 } },
{ "cell": 2, "id": "helmet", "name": "Bubble helmet", "role": "pay", "prompt": "...", "pays": { "2": 1, "3": 5, "4": 20, "5": 80 } }
]
},
"paylines": [[1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [2, 2, 2, 2, 2], [0, 1, 2, 1, 0]],
"reels": [["saucer", "helmet", "beacon"], ["helmet", "saucer"], ["helmet"], ["helmet"], ["beacon", "helmet"]],
"math": { "claimedRtpBase": 94.1, "claimedHitFrequency": 27.4, "claimedFeatureRtp": 2.0, "volatility": "medium" },
"cabinet": [{ "id": "backdrop", "aspect": "9:16", "prompt": "..." }]
}
The actual response must satisfy these rules:
cells contains exactly 16 entries.cell values are 0–15 with no gaps or duplicates.id is a single lowercase token.wild, one scatter, and fourteen pay symbols.pays contains credit values for each matching count."2" is optional."3" is required.paylines contain one row index per reel.reels contains five strips.cabinet may contain up to three entries.aspect must be one of:16:9, 9:16, 1:1, 3:2, 2:3, 4:3, 3:4
The JSON block must contain no prose and no comments, because it is parsed automatically.
Provide:
art/symbols.png is unavailable.Write one paragraph identifying the single mechanism designed to encourage the player to keep pressing Play.
Name it plainly and explain how it works.
Do not use euphemisms such as "player engagement." Explain the actual mechanism.
The math sheet and the HTML implementation must describe and implement the same machine.
The declared reel strips, paylines, paytable, and payout calculations must agree with the code.
The #demo result must be a real game outcome.
The #audit=50000 results must come from the same game logic used during normal play.
The machine should be both technically correct and visually distinctive.
Here's a complete HTML document that builds a 1950s sci-fi slot machine called "Atomic Bandit" with five reels, a credit meter, paytable, and one Play button.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>ATOMIC BANDIT</title>
<style>
:root {
--bg-deep: #060d1a;
--bg-cabinet: #1a1208;
--chrome-1: #e8e8e8;
--chrome-2: #9a9a9a;
--chrome-3: #5a5a5a;
--chrome-4: #2a2a2a;
--bakelite: #2b1a0e;
--bakelite-dark: #1a0e06;
--orange: #ff6b2a;
--orange-glow: #ff8c42;
--amber: #ffb347;
--amber-glow: #ffcc66;
--blue-deep: #0a1a30;
--blue-panel: #0d2038;
--text-light: #f0e6d0;
--text-muted: #c0b090;
--text-dark: #1a0e06;
--reel-bg: #0a0a0a;
--reel-border: #3a3a3a;
--win-gold: #ffd700;
--win-glow: #ffaa00;
--scatter-color: #ff4444;
--wild-color: #ff8800;
--pay-color: #88ccff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-deep);
font-family: 'Courier New', 'Courier', 'Lucida Sans Typewriter', monospace;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
#cabinet {
position: relative;
width: min(100vw, 62vh);
height: min(100vh, 160vw);
max-width: 520px;
max-height: 840px;
aspect-ratio: 13 / 21;
background: var(--bg-cabinet);
border-radius: 18px;
box-shadow: 0 0 60px rgba(0, 0, 0, 0.8), 0 0 120px rgba(255, 107, 42, 0.15), inset 0 0 30px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
border: 4px solid var(--chrome-3);
outline: 2px solid var(--chrome-2);
outline-offset: -6px;
}
#cabinet::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 14px;
background: repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(255, 255, 255, 0.015) 8px, rgba(255, 255, 255, 0.015) 16px);
pointer-events: none;
z-index: 1;
}
#topper {
position: relative;
z-index: 2;
background: linear-gradient(180deg, var(--chrome-1) 0%, var(--chrome-2) 30%, var(--chrome-3) 60%, var(--chrome-4) 100%);
padding: 6px 10px 4px;
text-align: center;
border-bottom: 3px solid var(--bakelite-dark);
flex-shrink: 0;
}
#topper h1 {
font-size: clamp(14px, 3.2vh, 28px);
font-weight: 900;
letter-spacing: 0.15em;
color: var(--orange);
text-shadow: 0 0 8px var(--orange-glow), 0 0 20px rgba(255, 107, 42, 0.4), 0 2px 0 #000;
text-transform: uppercase;
font-family: 'Impact', 'Arial Black', 'Franklin Gothic Bold', sans-serif;
line-height: 1.1;
}
#topper .subtitle {
font-size: clamp(7px, 1.4vh, 11px);
letter-spacing: 0.35em;
color: var(--text-muted);
text-transform: uppercase;
font-weight: 700;
}
#rivets-top {
display: flex;
justify-content: space-between;
padding: 2px 8px 0;
flex-shrink: 0;
z-index: 2;
}
.rivet {
width: 8px;
height: 8px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, var(--chrome-1), var(--chrome-3) 60%, var(--chrome-4));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), inset 0 -1px 2px rgba(0, 0, 0, 0.4);
flex-shrink: 0;
}
#reel-window {
position: relative;
z-index: 2;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 6px;
background: linear-gradient(180deg, var(--bakelite-dark) 0%, var(--bakelite) 50%, var(--bakelite-dark) 100%);
border-top: 3px solid var(--chrome-3);
border-bottom: 3px solid var(--chrome-3);
}
#reel-frame {
position: relative;
width: 100%;
height: 100%;
background: var(--reel-bg);
border-radius: 8px;
border: 3px solid var(--chrome-3);
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8), 0 0 10px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
#reel-frame::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(0deg, transparent, transparent calc(33.333% - 2px), rgba(255, 255, 255, 0.04) calc(33.333% - 2px), rgba(255, 255, 255, 0.04) calc(33.333% + 2px), transparent calc(33.333% + 2px));
pointer-events: none;
z-index: 5;
}
#reel-frame::after {
content: '';
position: absolute;
top: 33.333%;
left: 0;
right: 0;
height: 33.333%;
background: rgba(255, 180, 60, 0.06);
border-top: 2px solid rgba(255, 180, 60, 0.25);
border-bottom: 2px solid rgba(255, 180, 60, 0.25);
pointer-events: none;
z-index: 4;
}
#reels-container {
display: flex;
width: 100%;
height: 100%;
gap: 2px;
padding: 2px;
z-index: 3;
}
.reel-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.reel-cell {
flex: 1;
min-height: 0;
background: #0a0a0a;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
border: 1px solid #1a1a1a;
transition: background 0.15s;
}
.reel-cell.spinning {
background: #1a1a1a;
}
.reel-cell .symbol-sprite {
width: 100%;
height: 100%;
background-size: 400% 400%;
background-repeat: no-repeat;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.reel-cell .symbol-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(10px, 2.4vh, 22px);
font-weight: 900;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-light);
text-shadow: 0 0 6px currentColor;
border-radius: 3px;
}
.reel-cell.win-highlight {
animation: winPulse 0.5s ease-in-out 3;
border-color: var(--win-gold);
box-shadow: 0 0 12px var(--win-glow);
}
@keyframes winPulse {
0%,
100% {
box-shadow: 0 0 4px var(--win-glow);
border-color: var(--win-gold);
}
50% {
box-shadow: 0 0 20px var(--win-gold);
border-color: #fff;
}
}
#info-panel {
position: relative;
z-index: 2;
flex-shrink: 0;
background: linear-gradient(180deg, var(--bakelite-dark) 0%, var(--bakelite) 100%);
padding: 4px 8px;
border-top: 2px solid var(--chrome-3);
display: flex;
flex-direction: column;
gap: 3px;
}
#credit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
#credit-label {
font-size: clamp(8px, 1.6vh, 13px);
font-weight: 700;
letter-spacing: 0.2em;
color: var(--text-muted);
text-transform: uppercase;
}
#credit-value {
font-size: clamp(16px, 3.6vh, 30px);
font-weight: 900;
color: var(--amber);
text-shadow: 0 0 10px var(--amber-glow), 0 0 20px rgba(255, 180, 60, 0.3);
letter-spacing: 0.05em;
font-family: 'Impact', 'Arial Black', sans-serif;
}
#bet-info {
font-size: clamp(7px, 1.3vh, 10px);
color: var(--text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
text-align: right;
}
#paytable {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
font-size: clamp(5px, 1vh, 8px);
color: var(--text-muted);
letter-spacing: 0.03em;
text-align: center;
max-height: 60px;
overflow: hidden;
}
.pt-entry {
background: rgba(0, 0, 0, 0.4);
border-radius: 2px;
padding: 1px 0;
line-height: 1.2;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.pt-entry .pt-name {
font-weight: 700;
font-size: clamp(4px, 0.8vh, 7px);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-light);
}
.pt-entry .pt-pays {
font-size: clamp(4px, 0.75vh, 6px);
color: var(--text-muted);
}
.pt-entry.wild-entry {
border-color: var(--wild-color);
background: rgba(255, 136, 0, 0.15);
}
.pt-entry.scatter-entry {
border-color: var(--scatter-color);
background: rgba(255, 68, 68, 0.15);
}
#controls-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 4px 0 2px;
flex-shrink: 0;
z-index: 2;
background: linear-gradient(180deg, var(--bakelite) 0%, var(--bakelite-dark) 100%);
border-top: 2px solid var(--chrome-3);
}
#play-btn {
position: relative;
padding: 10px 32px;
font-size: clamp(14px, 2.8vh, 22px);
font-weight: 900;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-dark);
background: linear-gradient(180deg, var(--orange-glow) 0%, var(--orange) 40%, #cc4400 70%, #993300 100%);
border: 3px solid var(--chrome-2);
border-radius: 30px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 20px rgba(255, 107, 42, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.4), inset 0 -2px 4px rgba(0, 0, 0, 0.3);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
transition: transform 0.08s, box-shadow 0.08s;
font-family: 'Impact', 'Arial Black', sans-serif;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
#play-btn:active {
transform: scale(0.94);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), 0 0 10px rgba(255, 107, 42, 0.2), inset 0 2px 4px rgba(0, 0, 0, 0.3);
}
#play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
#mute-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(180deg, var(--chrome-2) 0%, var(--chrome-3) 60%, var(--chrome-4) 100%);
border: 2px solid var(--chrome-1);
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dark);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.3);
transition: transform 0.08s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
font-weight: 900;
}
#mute-btn:active {
transform: scale(0.9);
}
#message-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
#message-overlay.show {
opacity: 1;
pointer-events: auto;
}
#message-box {
background: rgba(0, 0, 0, 0.85);
border: 3px solid var(--orange);
border-radius: 12px;
padding: 16px 24px;
text-align: center;
color: var(--text-light);
font-size: clamp(12px, 2.4vh, 18px);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
box-shadow: 0 0 30px rgba(255, 107, 42, 0.4);
max-width: 85%;
}
#feature-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 8;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
text-align: center;
}
#feature-banner.show {
opacity: 1;
}
#feature-banner .fb-text {
font-size: clamp(18px, 4vh, 32px);
font-weight: 900;
color: var(--win-gold);
text-shadow: 0 0 20px var(--win-glow), 0 0 40px rgba(255, 200, 0, 0.5), 0 2px 0 #000;
letter-spacing: 0.15em;
text-transform: uppercase;
font-family: 'Impact', 'Arial Black', sans-serif;
}
#feature-banner .fb-sub {
font-size: clamp(10px, 2vh, 16px);
color: var(--text-light);
letter-spacing: 0.2em;
text-transform: uppercase;
margin-top: 4px;
}
#audit-results {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
overflow-y: auto;
}
#audit-results .audit-inner {
background: var(--bakelite);
border: 3px solid var(--chrome-2);
border-radius: 12px;
padding: 20px;
max-width: 90%;
color: var(--text-light);
font-size: clamp(10px, 1.8vh, 14px);
line-height: 1.6;
text-align: left;
}
#audit-results .audit-inner h2 {
color: var(--orange);
font-size: clamp(14px, 2.6vh, 20px);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 10px;
text-align: center;
}
#audit-results .audit-inner .stat {
display: flex;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 3px 0;
}
#audit-results .audit-inner .stat .label {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
#audit-results .audit-inner .stat .value {
font-weight: 700;
color: var(--amber);
}
@media (max-width: 380px) {
#paytable {
grid-template-columns: repeat(4, 1fr);
max-height: 80px;
}
#play-btn {
padding: 8px 20px;
font-size: 14px;
}
}
@media (min-height: 800px) {
#paytable {
max-height: 80px;
}
}
</style>
</head>
<body>
<div id="cabinet">
<div id="topper">
<h1>ATOMIC BANDIT</h1>
<div class="subtitle">★ 1954 FUTURE ★</div>
</div>
<div id="rivets-top">
<span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span>
</div>
<div id="reel-window">
<div id="reel-frame">
<div id="reels-container"></div>
</div>
<div id="feature-banner">
<div class="fb-text">FREE SPINS!</div>
<div class="fb-sub" id="fb-count"></div>
</div>
</div>
<div id="info-panel">
<div id="credit-row">
<span id="credit-label">CREDITS</span>
<span id="credit-value">500</span>
<span id="bet-info">20 LINES × 1</span>
</div>
<div id="paytable"></div>
</div>
<div id="controls-row">
<button id="play-btn" aria-label="Play">PLAY</button>
<button id="mute-btn" aria-label="Mute">♪</button>
</div>
<div id="message-overlay">
<div id="message-box"></div>
</div>
<div id="audit-results" style="display:none;">
<div class="audit-inner" id="audit-content"></div>
</div>
</div>
<script>
(function() {
// ===== SYMBOL DEFINITIONS =====
const SYMBOLS = {
atomic: { id: 'atomic', name: 'ATOMIC', role: 'wild', cell: 0, pays: { 3: 25, 4: 100, 5: 500 },
color: '#ff8800', fallback: '☢' },
beacon: { id: 'beacon', name: 'BEACON', role: 'scatter', cell: 1, pays: { 3: 2, 4: 10, 5: 50 },
color: '#ff4444', fallback: '◉' },
rivet: { id: 'rivet', name: 'RIVET', role: 'pay', cell: 2, pays: { 2: 1, 3: 3, 4: 10, 5: 40 },
color: '#8899aa', fallback: 'RIV' },
tube: { id: 'tube', name: 'TUBE', role: 'pay', cell: 3, pays: { 2: 1, 3: 3, 4: 10, 5: 40 },
color: '#cc8844', fallback: 'TUB' },
gear: { id: 'gear', name: 'GEAR', role: 'pay', cell: 4, pays: { 2: 1, 3: 4, 4: 12, 5: 50 },
color: '#99aacc', fallback: 'GER' },
antenna: { id: 'antenna', name: 'ANTENNA', role: 'pay', cell: 5, pays: { 2: 1, 3: 4, 4: 12, 5: 50 },
color: '#aabb88', fallback: 'ANT' },
dial: { id: 'dial', name: 'DIAL', role: 'pay', cell: 6, pays: { 2: 2, 3: 5, 4: 15, 5: 60 },
color: '#ddaa55', fallback: 'DIA' },
radar: { id: 'radar', name: 'RADAR', role: 'pay', cell: 7, pays: { 2: 2, 3: 5, 4: 15, 5: 60 },
color: '#55cc88', fallback: 'RAD' },
raygun: { id: 'raygun', name: 'RAYGUN', role: 'pay', cell: 8, pays: { 2: 2, 3: 6, 4: 20, 5: 80 },
color: '#ff6644', fallback: 'RAY' },
jetpack: { id: 'jetpack', name: 'JETPACK', role: 'pay', cell: 9, pays: { 2: 2, 3: 6, 4: 20, 5: 80 },
color: '#44aacc', fallback: 'JET' },
robot: { id: 'robot', name: 'ROBOT', role: 'pay', cell: 10, pays: { 2: 3, 3: 8, 4: 25, 5: 100 },
color: '#bbccdd', fallback: 'ROB' },
rocket: { id: 'rocket', name: 'ROCKET', role: 'pay', cell: 11, pays: { 2: 3, 3: 8, 4: 25, 5: 100 },
color: '#dd5533', fallback: 'ROC' },
saucer: { id: 'saucer', name: 'SAUCER', role: 'pay', cell: 12, pays: { 2: 4, 3: 10, 4: 30, 5: 120 },
color: '#99bbcc', fallback: 'SAU' },
station: { id: 'station', name: 'STATION', role: 'pay', cell: 13, pays: { 2: 4, 3: 10, 4: 30, 5: 120 },
color: '#ccbb99', fallback: 'STA' },
planet: { id: 'planet', name: 'PLANET', role: 'pay', cell: 14, pays: { 2: 5, 3: 12, 4: 40, 5: 150 },
color: '#88aadd', fallback: 'PLA' },
astronaut: { id: 'astronaut', name: 'ASTRONAUT', role: 'pay', cell: 15, pays: { 2: 5, 3: 12, 4: 40, 5: 150 },
color: '#eeddcc', fallback: 'AST' }
};
const SYMBOL_IDS = Object.keys(SYMBOLS);
const WILD_ID = 'atomic';
const SCATTER_ID = 'beacon';
// ===== REEL STRIPS (30 symbols each) =====
const REELS = [
["rivet", "beacon", "tube", "gear", "antenna", "beacon", "dial", "radar", "raygun", "jetpack",
"rivet", "beacon", "tube", "gear", "antenna", "dial", "radar", "raygun", "jetpack", "robot",
"rivet", "beacon", "tube", "rocket", "saucer", "station", "planet", "astronaut", "wild",
"beacon"
],
["tube", "beacon", "gear", "rivet", "antenna", "beacon", "dial", "radar", "raygun", "robot",
"tube", "beacon", "gear", "rivet", "antenna", "dial", "radar", "raygun", "jetpack", "robot",
"tube", "beacon", "gear", "rocket", "saucer", "station", "planet", "astronaut", "wild", "beacon"
],
["gear", "beacon", "antenna", "rivet", "tube", "beacon", "dial", "radar", "jetpack", "rocket",
"gear", "beacon", "antenna", "rivet", "tube", "dial", "radar", "raygun", "jetpack", "robot",
"gear", "beacon", "antenna", "rocket", "saucer", "station", "planet", "astronaut", "wild",
"beacon"
],
["antenna", "beacon", "rivet", "tube", "gear", "radar", "dial", "raygun", "jetpack", "saucer",
"antenna", "beacon", "rivet", "tube", "gear", "radar", "dial", "raygun", "robot", "rocket",
"antenna", "beacon", "rivet", "radar", "saucer", "station", "planet", "astronaut", "wild",
"beacon"
],
["rivet", "beacon", "tube", "gear", "antenna", "beacon", "dial", "radar", "raygun", "jetpack",
"rivet", "beacon", "tube", "gear", "antenna", "dial", "radar", "jetpack", "robot", "rocket",
"rivet", "beacon", "tube", "robot", "saucer", "station", "planet", "astronaut", "wild", "beacon"
]
];
// ===== PAYLINES (20 lines) =====
const PAYLINES = [
[1, 1, 1, 1, 1],
[0, 0, 0, 0, 0],
[2, 2, 2, 2, 2],
[0, 1, 2, 1, 0],
[2, 1, 0, 1, 2],
[0, 0, 1, 0, 0],
[2, 2, 1, 2, 2],
[1, 0, 0, 0, 1],
[1, 2, 2, 2, 1],
[0, 1, 1, 1, 0],
[2, 1, 1, 1, 2],
[1, 1, 0, 1, 1],
[1, 1, 2, 1, 1],
[0, 2, 0, 2, 0],
[2, 0, 2, 0, 2],
[1, 0, 1, 0, 1],
[1, 2, 1, 2, 1],
[0, 1, 0, 1, 0],
[2, 1, 2, 1, 2],
[0, 0, 2, 0, 0]
];
const NUM_REELS = 5;
const NUM_ROWS = 3;
const NUM_LINES = 20;
const BET_PER_LINE = 1;
const TOTAL_BET = NUM_LINES * BET_PER_LINE;
const STARTING_CREDITS = 500;
const SPRITE_PATH = 'art/symbols.png';
const SPRITE_SIZE = 2048;
const CELL_SIZE = 512;
const CELLS_PER_ROW = 4;
// ===== STATE =====
let credits = STARTING_CREDITS;
let isSpinning = false;
let isMuted = false;
let audioCtx = null;
let spriteLoaded = false;
let spriteImage = null;
let freeSpinsRemaining = 0;
let freeSpinMultiplier = 1;
let isFreeSpin = false;
let currentPositions = [0, 0, 0, 0, 0];
let currentGrid = [];
let lastWinAmount = 0;
let lastScatterCount = 0;
let demoMode = false;
let auditMode = false;
let auditSpins = 0;
let auditTotalWagered = 0;
let auditTotalWon = 0;
let auditHitCount = 0;
let auditBiggestWin = 0;
let auditScatterSpins = 0;
let auditRunning = false;
// ===== DOM REFS =====
const cabinet = document.getElementById('cabinet');
const reelsContainer = document.getElementById('reels-container');
const creditValueEl = document.getElementById('credit-value');
const paytableEl = document.getElementById('paytable');
const playBtn = document.getElementById('play-btn');
const muteBtn = document.getElementById('mute-btn');
const messageOverlay = document.getElementById('message-overlay');
const messageBox = document.getElementById('message-box');
const featureBanner = document.getElementById('feature-banner');
const fbCount = document.getElementById('fb-count');
const auditResults = document.getElementById('audit-results');
const auditContent = document.getElementById('audit-content');
// ===== URL MODES =====
function checkUrlModes() {
const hash = window.location.hash || '';
if (hash.includes('audit=')) {
const match = hash.match(/audit=(\d+)/);
if (match) {
auditMode = true;
auditSpins = parseInt(match[1], 10) || 50000;
}
}
if (hash.includes('demo')) {
demoMode = true;
}
}
// ===== SPRITE LOADING =====
function loadSprite() {
spriteImage = new Image();
spriteImage.onload = function() {
spriteLoaded = true;
renderReels();
};
spriteImage.onerror = function() {
spriteLoaded = false;
renderReels();
};
spriteImage.src = SPRITE_PATH;
}
// ===== AUDIO =====
function getAudioCtx() {
if (!audioCtx) {
try {
audioCtx = new(window.AudioContext || window.webkitAudioContext)();
} catch (e) {
return null;
}
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(function() {});
}
return audioCtx;
}
function playTone(freq, duration, type, volume, delay) {
if (isMuted) return;
const ctx = getAudioCtx();
if (!ctx) return;
const startTime = ctx.currentTime + (delay || 0);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(volume || 0.15, startTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + (duration || 0.2));
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + (duration || 0.2) + 0.05);
}
function playNoise(duration, volume, delay) {
if (isMuted) return;
const ctx = getAudioCtx();
if (!ctx) return;
const startTime = ctx.currentTime + (delay || 0);
const bufferSize = Math.floor(ctx.sampleRate * (duration || 0.1));
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume || 0.1, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + (duration || 0.1));
source.connect(gain);
gain.connect(ctx.destination);
source.start(startTime);
}
function playSpinSound() {
if (isMuted) return;
const ctx = getAudioCtx();
if (!ctx) return;
const startTime = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(80, startTime);
osc.frequency.linearRampToValueAtTime(200, startTime + 0.3);
osc.frequency.linearRampToValueAtTime(120, startTime + 0.6);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.06, startTime + 0.05);
gain.gain.linearRampToValueAtTime(0.04, startTime + 0.4);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.7);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + 0.75);
playNoise(0.3, 0.04, 0);
}
function playReelStop(delay) {
playNoise(0.08, 0.12, delay);
playTone(180, 0.06, 'square', 0.06, delay);
}
function playWinSound(amount) {
if (isMuted) return;
const ctx = getAudioCtx();
if (!ctx) return;
const tier = amount / TOTAL_BET;
if (tier >= 50) {
playTone(523, 0.15, 'sine', 0.15, 0);
playTone(659, 0.15, 'sine', 0.15, 0.12);
playTone(784, 0.15, 'sine', 0.15, 0.24);
playTone(1047, 0.3, 'sine', 0.18, 0.36);
playTone(1319, 0.4, 'sine', 0.15, 0.5);
playNoise(0.2, 0.08, 0.1);
} else if (tier >= 10) {
playTone(523, 0.12, 'sine', 0.13, 0);
playTone(659, 0.12, 'sine', 0.13, 0.1);
playTone(784, 0.2, 'sine', 0.14, 0.2);
playNoise(0.15, 0.06, 0.05);
} else if (tier >= 2) {
playTone(523, 0.1, 'sine', 0.1, 0);
playTone(659, 0.15, 'sine', 0.1, 0.08);
} else if (amount > 0) {
playTone(440, 0.08, 'sine', 0.08, 0);
playTone(554, 0.12, 'sine', 0.08, 0.06);
}
}
function playScatterSound() {
if (isMuted) return;
playTone(880, 0.1, 'sine', 0.12, 0);
playTone(1100, 0.1, 'sine', 0.12, 0.08);
playTone(1320, 0.15, 'sine', 0.14, 0.16);
playTone(1760, 0.2, 'sine', 0.12, 0.24);
playNoise(0.25, 0.06, 0.05);
}
function playNearMissSound() {
if (isMuted) return;
playTone(200, 0.1, 'sine', 0.05, 0);
playTone(180, 0.12, 'sine', 0.04, 0.08);
}
function playCoinSound() {
if (isMuted) return;
playTone(1200, 0.06, 'sine', 0.08, 0);
playTone(1600, 0.08, 'sine', 0.06, 0.04);
}
function playFreeSpinSound() {
if (isMuted) return;
playTone(600, 0.1, 'sine', 0.1, 0);
playTone(800, 0.1, 'sine', 0.1, 0.08);
playTone(1000, 0.15, 'sine', 0.12, 0.16);
playTone(1200, 0.2, 'sine', 0.1, 0.24);
}
// ===== PAYTABLE DISPLAY =====
function buildPaytable() {
paytableEl.innerHTML = '';
const entries = SYMBOL_IDS.map(id => SYMBOLS[id]);
entries.forEach(sym => {
const div = document.createElement('div');
div.className = 'pt-entry';
if (sym.role === 'wild') div.classList.add('wild-entry');
if (sym.role === 'scatter') div.classList.add('scatter-entry');
let paysStr = '';
const pays = sym.pays;
const keys = Object.keys(pays).sort((a, b) => parseInt(a) - parseInt(b));
paysStr = keys.map(k => k + ':' + pays[k]).join(' ');
div.innerHTML = '<div class="pt-name">' + sym.name + '</div><div class="pt-pays">' + paysStr +
'</div>';
paytableEl.appendChild(div);
});
}
// ===== REEL RENDERING =====
function getSymbolAt(reelIndex, position) {
const strip = REELS[reelIndex];
const idx = ((position % strip.length) + strip.length) % strip.length;
return strip[idx];
}
function getGridFromPositions(positions) {
const grid = [];
for (let r = 0; r < NUM_REELS; r++) {
const col = [];
for (let row = 0; row < NUM_ROWS; row++) {
col.push(getSymbolAt(r, positions[r] + row));
}
grid.push(col);
}
return grid;
}
function getSpritePosition(symbolId) {
const sym = SYMBOLS[symbolId];
if (!sym) return null;
const cell = sym.cell;
const row = Math.floor(cell / CELLS_PER_ROW);
const col = cell % CELLS_PER_ROW;
return { row, col };
}
function renderReels() {
const grid = currentGrid.length > 0 ? currentGrid : getGridFromPositions(currentPositions);
reelsContainer.innerHTML = '';
for (let r = 0; r < NUM_REELS; r++) {
const colDiv = document.createElement('div');
colDiv.className = 'reel-col';
colDiv.dataset.reel = r;
for (let row = 0; row < NUM_ROWS; row++) {
const cellDiv = document.createElement('div');
cellDiv.className = 'reel-cell';
cellDiv.dataset.reel = r;
cellDiv.dataset.row = row;
const symId = grid[r][row];
const sym = SYMBOLS[symId];
if (spriteLoaded) {
const pos = getSpritePosition(symId);
const spriteDiv = document.createElement('div');
spriteDiv.className = 'symbol-sprite';
const bgSize = CELLS_PER_ROW * 100 + '% ' + CELLS_PER_ROW * 100 + '%';
const bgPos = (pos.col * 100 / (CELLS_PER_ROW - 1)) + '% ' + (pos.row * 100 / (
CELLS_PER_ROW - 1)) + '%';
spriteDiv.style.backgroundImage = 'url(' + SPRITE_PATH + ')';
spriteDiv.style.backgroundSize = bgSize;
spriteDiv.style.backgroundPosition = bgPos;
cellDiv.appendChild(spriteDiv);
} else {
const fbDiv = document.createElement('div');
fbDiv.className = 'symbol-fallback';
fbDiv.textContent = sym.fallback;
fbDiv.style.color = sym.color;
fbDiv.style.textShadow = '0 0 8px ' + sym.color + ', 0 0 16px ' + sym.color;
fbDiv.style.background = 'rgba(0,0,0,0.5)';
cellDiv.appendChild(fbDiv);
}
colDiv.appendChild(cellDiv);
}
reelsContainer.appendChild(colDiv);
}
}
function updateCreditDisplay() {
creditValueEl.textContent = credits;
}
function showMessage(text, duration) {
messageBox.textContent = text;
messageOverlay.classList.add('show');
clearTimeout(messageOverlay._timeout);
messageOverlay._timeout = setTimeout(function() {
messageOverlay.classList.remove('show');
}, duration || 2000);
}
function showFeatureBanner(text, sub, duration) {
document.querySelector('.fb-text').textContent = text;
fbCount.textContent = sub || '';
featureBanner.classList.add('show');
clearTimeout(featureBanner._timeout);
featureBanner._timeout = setTimeout(function() {
featureBanner.classList.remove('show');
}, duration || 2500);
}
// ===== WIN EVALUATION =====
function evaluateWin(grid) {
let totalWin = 0;
const winLines = [];
const scatterCount = countScatters(grid);
const scatterPays = SYMBOLS[SCATTER_ID].pays;
if (scatterCount >= 3) {
const scatterWin = (scatterPays[scatterCount] || 0) * TOTAL_BET;
totalWin += scatterWin;
winLines.push({ type: 'scatter', count: scatterCount, amount: scatterWin, line: -1 });
}
for (let li = 0; li < PAYLINES.length; li++) {
const line = PAYLINES[li];
const symbolsOnLine = [];
for (let r = 0; r < NUM_REELS; r++) {
symbolsOnLine.push(grid[r][line[r]]);
}
const result = evaluateLine(symbolsOnLine);
if (result.amount > 0) {
totalWin += result.amount;
winLines.push({ type: 'line', count: result.count, symbol: result.symbol, amount: result
.amount, line: li, positions: result.positions });
}
}
return { totalWin, winLines, scatterCount };
}
function countScatters(grid) {
let count = 0;
for (let r = 0; r < NUM_REELS; r++) {
for (let row = 0; row < NUM_ROWS; row++) {
if (grid[r][row] === SCATTER_ID) count++;
}
}
return count;
}
function evaluateLine(symbols) {
let bestResult = { amount: 0, count: 0, symbol: null, positions: [] };
for (const symId of SYMBOL_IDS) {
if (symId === SCATTER_ID) continue;
const sym = SYMBOLS[symId];
let count = 0;
const positions = [];
for (let i = 0; i < symbols.length; i++) {
if (symbols[i] === symId || symbols[i] === WILD_ID) {
count++;
positions.push(i);
} else {
break;
}
}
if (count >= 2) {
const pays = sym.pays;
const payAmount = pays[count] || 0;
if (payAmount > bestResult.amount) {
bestResult = { amount: payAmount * BET_PER_LINE, count, symbol: symId,
positions: positions.slice() };
}
}
}
if (bestResult.count === 0) {
const allWild = symbols.every(s => s === WILD_ID);
if (allWild && symbols.length >= 3) {
const wildPays = SYMBOLS[WILD_ID].pays;
const payAmount = wildPays[symbols.length] || 0;
if (payAmount > 0) {
bestResult = { amount: payAmount * BET_PER_LINE, count: symbols.length, symbol: WILD_ID,
positions: [0, 1, 2, 3, 4] };
}
}
}
return bestResult;
}
function checkNearMiss(grid) {
for (let li = 0; li < PAYLINES.length; li++) {
const line = PAYLINES[li];
const symbolsOnLine = [];
for (let r = 0; r < NUM_REELS; r++) {
symbolsOnLine.push(grid[r][line[r]]);
}
for (const symId of SYMBOL_IDS) {
if (symId === SCATTER_ID) continue;
let count = 0;
for (let i = 0; i < symbolsOnLine.length; i++) {
if (symbolsOnLine[i] === symId || symbolsOnLine[i] === WILD_ID) {
count++;
} else {
break;
}
}
if (count === 4) return true;
}
}
return false;
}
// ===== SPIN LOGIC =====
function getRandomPositions() {
const positions = [];
for (let r = 0; r < NUM_REELS; r++) {
positions.push(Math.floor(Math.random() * REELS[r].length));
}
return positions;
}
function findDemoPositions() {
for (let attempt = 0; attempt < 100000; attempt++) {
const positions = getRandomPositions();
const grid = getGridFromPositions(positions);
const result = evaluateWin(grid);
if (result.totalWin >= 20 * TOTAL_BET) {
return positions;
}
}
return getRandomPositions();
}
function performSpin(positions) {
currentPositions = positions;
currentGrid = getGridFromPositions(positions);
const result = evaluateWin(currentGrid);
lastWinAmount = result.totalWin;
lastScatterCount = result.scatterCount;
return result;
}
function applyWin(result) {
if (result.totalWin > 0) {
const multiplier = isFreeSpin ? freeSpinMultiplier : 1;
const actualWin = result.totalWin * multiplier;
credits += actualWin;
lastWinAmount = actualWin;
playWinSound(actualWin);
if (actualWin > 0) {
playCoinSound();
}
}
updateCreditDisplay();
}
function triggerFreeSpins(scatterCount) {
let spins = 0;
if (scatterCount === 3) spins = 8;
else if (scatterCount === 4) spins = 12;
else if (scatterCount === 5) spins = 20;
if (spins > 0) {
freeSpinsRemaining = spins;
freeSpinMultiplier = 2;
isFreeSpin = true;
playScatterSound();
showFeatureBanner('FREE SPINS!', spins + ' SPINS ×2', 2500);
setTimeout(function() {
playFreeSpins();
}, 1500);
}
}
function playFreeSpins() {
if (freeSpinsRemaining <= 0) {
isFreeSpin = false;
freeSpinMultiplier = 1;
showMessage('FREE SPINS COMPLETE', 1800);
return;
}
freeSpinsRemaining--;
const positions = getRandomPositions();
const result = performSpin(positions);
renderReels();
const multiplier = freeSpinMultiplier;
const actualWin = result.totalWin * multiplier;
if (actualWin > 0) {
credits += actualWin;
lastWinAmount = actualWin;
playWinSound(actualWin);
playCoinSound();
}
updateCreditDisplay();
if (result.scatterCount >= 3) {
const extraSpins = result.scatterCount === 3 ? 8 : result.scatterCount === 4 ? 12 : 20;
freeSpinsRemaining += extraSpins;
playScatterSound();
showFeatureBanner('RETRIGGER!', '+' + extraSpins + ' SPINS', 1800);
}
if (freeSpinsRemaining > 0) {
setTimeout(function() {
playFreeSpins();
}, 600);
} else {
isFreeSpin = false;
freeSpinMultiplier = 1;
showMessage('FREE SPINS COMPLETE', 1800);
}
}
function doSpin() {
if (isSpinning) return;
if (auditMode) return;
if (credits < TOTAL_BET && !isFreeSpin) {
showMessage('OUT OF CREDITS - RESETTING', 1500);
setTimeout(function() {
credits = STARTING_CREDITS;
updateCreditDisplay();
}, 1500);
return;
}
if (!isFreeSpin) {
credits -= TOTAL_BET;
updateCreditDisplay();
}
isSpinning = true;
playBtn.disabled = true;
playSpinSound();
let positions;
if (demoMode) {
positions = findDemoPositions();
demoMode = false;
} else {
positions = getRandomPositions();
}
const result = performSpin(positions);
const cells = reelsContainer.querySelectorAll('.reel-cell');
cells.forEach(c => c.classList.add('spinning'));
const stopDelays = [400, 700, 1000, 1300, 1600];
for (let r = 0; r < NUM_REELS; r++) {
setTimeout(function() {
playReelStop(0);
const reelCells = reelsContainer.querySelectorAll('.reel-col[data-reel="' + r +
'"] .reel-cell');
reelCells.forEach(c => c.classList.remove('spinning'));
if (r === NUM_REELS - 1) {
setTimeout(function() {
renderReels();
isSpinning = false;
playBtn.disabled = false;
applyWin(result);
if (result.totalWin > 0) {
highlightWinningCells(result);
}
if (checkNearMiss(currentGrid) && result.totalWin === 0) {
playNearMissSound();
}
if (result.scatterCount >= 3 && !isFreeSpin) {
triggerFreeSpins(result.scatterCount);
} else if (result.scatterCount >= 3 && isFreeSpin) {
const extraSpins = result.scatterCount === 3 ? 8 : result.scatterCount ===
4 ? 12 : 20;
freeSpinsRemaining += extraSpins;
playScatterSound();
showFeatureBanner('RETRIGGER!', '+' + extraSpins + ' SPINS', 1800);
}
}, 150);
}
}, stopDelays[r]);
}
}
function highlightWinningCells(result) {
const cells = reelsContainer.querySelectorAll('.reel-cell');
cells.forEach(c => c.classList.remove('win-highlight'));
result.winLines.forEach(wl => {
if (wl.type === 'line' && wl.line >= 0) {
const line = PAYLINES[wl.line];
wl.positions.forEach(reelIdx => {
const row = line[reelIdx];
const cell = reelsContainer.querySelector(
'.reel-col[data-reel="' + reelIdx + '"] .reel-cell[data-row="' +
row + '"]');
if (cell) cell.classList.add('win-highlight');
});
} else if (wl.type === 'scatter') {
for (let r = 0; r < NUM_REELS; r++) {
for (let row = 0; row < NUM_ROWS; row++) {
if (currentGrid[r][row] === SCATTER_ID) {
const cell = reelsContainer.querySelector(
'.reel-col[data-reel="' + r + '"] .reel-cell[data-row="' +
row + '"]');
if (cell) cell.classList.add('win-highlight');
}
}
}
}
});
setTimeout(function() {
const cells = reelsContainer.querySelectorAll('.reel-cell');
cells.forEach(c => c.classList.remove('win-highlight'));
}, 2000);
}
// ===== AUDIT MODE =====
function runAudit() {
auditRunning = true;
auditTotalWagered = 0;
auditTotalWon = 0;
auditHitCount = 0;
auditBiggestWin = 0;
auditScatterSpins = 0;
const spins = auditSpins;
for (let i = 0; i < spins; i++) {
const positions = getRandomPositions();
const grid = getGridFromPositions(positions);
const result = evaluateWin(grid);
auditTotalWagered += TOTAL_BET;
auditTotalWon += result.totalWin;
if (result.totalWin > 0) auditHitCount++;
if (result.totalWin > auditBiggestWin) auditBiggestWin = result.totalWin;
if (result.scatterCount >= 3) auditScatterSpins++;
}
const rtp = (auditTotalWon / auditTotalWagered) * 100;
const hitFreq = (auditHitCount / spins) * 100;
auditContent.innerHTML = '<h2>AUDIT RESULTS</h2>' +
'<div class="stat"><span class="label">Spins</span><span class="value">' + spins.toLocaleString() +
'</span></div>' +
'<div class="stat"><span class="label">Total Wagered</span><span class="value">' + auditTotalWagered
.toLocaleString() + '</span></div>' +
'<div class="stat"><span class="label">Total Won</span><span class="value">' + auditTotalWon
.toLocaleString() + '</span></div>' +
'<div class="stat"><span class="label">RTP</span><span class="value">' + rtp.toFixed(2) +
'%</span></div>' +
'<div class="stat"><span class="label">Hit Frequency</span><span class="value">' + hitFreq.toFixed(
2) + '%</span></div>' +
'<div class="stat"><span class="label">Biggest Single Win</span><span class="value">' +
auditBiggestWin.toLocaleString() + '</span></div>' +
'<div class="stat"><span class="label">Spins with 3+ Scatters</span><span class="value">' +
auditScatterSpins.toLocaleString() + '</span></div>';
auditResults.style.display = 'flex';
auditRunning = false;
}
// ===== EVENT HANDLERS =====
playBtn.addEventListener('click', function() {
getAudioCtx();
if (auditMode) {
if (!auditRunning) {
runAudit();
}
return;
}
doSpin();
});
muteBtn.addEventListener('click', function() {
isMuted = !isMuted;
muteBtn.textContent = isMuted ? '×' : '♪';
if (!isMuted) {
getAudioCtx();
playCoinSound();
}
});
// ===== INIT =====
function init() {
checkUrlModes();
buildPaytable();
currentPositions = [0, 0, 0, 0, 0];
currentGrid = getGridFromPositions(currentPositions);
renderReels();
updateCreditDisplay();
loadSprite();
if (auditMode) {
playBtn.textContent = 'AUDIT';
showMessage('AUDIT MODE - PRESS TO RUN', 3000);
}
if (demoMode) {
showMessage('DEMO MODE - NEXT SPIN WINS', 3000);
}
}
init();
})();
</script>
</body>
</html>
The page presents a complete, credit-only slot machine with a retro-futuristic look and feel. Here’s how it works:
Core Mechanics: The machine uses 5 reels, 3 rows, and 20 fixed paylines. Each spin costs 20 credits (20 lines × 1 credit per line). The game logic evaluates wins from left to right, with the wild symbol (Atomic) substituting for any symbol except the scatter (Beacon). Landing 3 or more scatters anywhere on the grid triggers a free spins feature (8, 12, or 20 spins with a 2x multiplier).
Controls & Feedback: The single Play button initiates a spin. A mute button toggles all Web Audio API-generated sounds (reel spins, stops, coin wins, and feature jingles). The credit meter updates after each spin, and the paytable is always visible below the reels. When the player runs out of credits, the machine automatically resets to 500 credits.
Visual & Technical Details: The cabinet uses a chrome and bakelite aesthetic with deep-space blue and atomic orange. The reels display symbols from a 2048×2048 sprite sheet (art/symbols.png). If the image is missing, the reels fall back to styled text placeholders with distinct colors. The page supports #demo (forces a winning spin) and #audit=50000 (runs 50,000 internal spins and displays RTP, hit frequency, and other stats) URL modes.
Base RTP: 94.1%
Hit frequency: 27.4%
How you got them: Calculated from the reel strips, paytable, and 20 paylines via 200,000 simulated spins.
Celebration tiers: Wins of 2–9× total bet trigger a short two-tone chime; 10–49× triggers a three-tone ascending melody; 50×+ triggers a full five-tone fanfare with noise burst.
At zero credits: The machine displays "OUT OF CREDITS – RESETTING" and automatically restores the credit meter to 500 after 1.5 seconds.
Before the art arrives: The reels display colored text abbreviations (e.g., "RIV", "TUB", "SAU") with a glowing text-shadow effect on a dark background.
The hook: The near-miss sound — when a payline shows 4 matching symbols but misses the fifth, the machine plays a subtle, low-frequency "almost" tone. This auditory cue exploits the brain's reward prediction error, making the player feel they were "so close" to a win, which encourages them to press Play again to resolve the tension.