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.
ATOMIC BANDIT: A chrome-plated, bakelite-trimmed 1950s sci-fi slot machine that hums with vacuum-tube warmth, its amber indicators glowing and reels spinning with the optimistic promise of cosmic jackpots.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATOMIC BANDIT</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
body {
overflow: hidden;
width: 100vw;
height: 100vh;
background: #0a1119;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Impact', 'Arial Black', sans-serif;
}
.cabinet {
aspect-ratio: 9/16;
max-width: 100vw;
max-height: 100vh;
width: auto;
height: auto;
background: linear-gradient(180deg, #1a2a3a 0%, #0f1923 100%);
border: 8px solid #e8e8e8;
border-radius: 12px;
position: relative;
box-shadow: 0 0 40px rgba(255, 179, 71, 0.3), inset 0 0 20px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
padding: 12px;
}
/* Rivet decoration */
.cabinet::before, .cabinet::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: #b0b0b0;
box-shadow: inset 0 2px 2px rgba(255,255,255,0.5), inset 0 -2px 2px rgba(0,0,0,0.3);
}
.cabinet::before { top: 8px; left: 8px; }
.cabinet::after { top: 8px; right: 8px; }
/* Topper */
.topper {
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #5d3a1a;
margin-bottom: 8px;
}
.topper h1 {
font-size: clamp(1.5rem, 5vw, 2.5rem);
color: #ff6b35;
text-shadow: 0 0 10px rgba(255, 107, 53, 0.8), 2px 2px 0 #5d3a1a;
letter-spacing: 4px;
}
.topper p {
font-size: clamp(0.7rem, 2vw, 0.9rem);
color: #ffb347;
letter-spacing: 2px;
}
/* Meters */
.meters {
display: flex;
justify-content: space-between;
padding: 8px;
background: #0a1119;
border: 2px solid #5d3a1a;
border-radius: 4px;
margin-bottom: 8px;
}
.meter {
text-align: center;
}
.meter-label {
font-size: clamp(0.6rem, 1.8vw, 0.7rem);
color: #888;
letter-spacing: 1px;
}
.meter-value {
font-size: clamp(1rem, 3vw, 1.3rem);
color: #ffb347;
font-family: 'Courier New', monospace;
text-shadow: 0 0 5px rgba(255, 179, 71, 0.5);
}
/* Reel Window */
.reel-window {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
background: #000;
border: 4px solid #e8e8e8;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
position: relative;
overflow: hidden;
}
.reel {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.symbol {
aspect-ratio: 1/1;
background: #0a1119;
border: 1px solid #333;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.symbol.has-art {
background-image: url('art/symbols.png');
background-size: 400% 400%;
}
/* Fallback symbol styling */
.symbol-text {
text-align: center;
font-size: clamp(0.5rem, 1.5vw, 0.7rem);
padding: 2px;
word-break: break-word;
}
.symbol-text.wild { color: #ff6b35; text-shadow: 0 0 8px #ffb347; }
.symbol-text.scatter { color: #ffb347; animation: pulse 1s infinite; }
.symbol-text.low { color: #e8e8e8; }
.symbol-text.mid { color: #ff6b35; }
.symbol-text.high { color: #ffb347; }
/* Win Display */
.win-display {
text-align: center;
min-height: clamp(1.5rem, 4vw, 2rem);
margin-bottom: 8px;
}
.win-display .win-amount {
font-size: clamp(1.2rem, 4vw, 1.8rem);
color: #ff6b35;
text-shadow: 0 0 10px rgba(255, 107, 53, 0.8);
opacity: 0;
transition: opacity 0.2s;
}
.win-display .win-amount.active { opacity: 1; }
.win-display .message {
font-size: clamp(0.8rem, 2.5vw, 1.1rem);
color: #ffb347;
text-shadow: 0 0 5px rgba(255, 179, 71, 0.5);
}
/* Paytable */
.paytable {
background: #0a1119;
border: 2px solid #5d3a1a;
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.paytable h3 {
color: #ff6b35;
text-align: center;
font-size: clamp(0.8rem, 2.5vw, 1rem);
margin-bottom: 4px;
letter-spacing: 2px;
}
.paytable-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2px;
font-size: clamp(0.6rem, 1.8vw, 0.7rem);
}
.payline-entry {
display: flex;
justify-content: space-between;
color: #e8e8e8;
padding: 2px 4px;
border-bottom: 1px solid #222;
}
.payline-entry .name { color: #ffb347; }
/* Controls */
.controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 8px 0;
border-top: 2px solid #5d3a1a;
}
.btn {
border: none;
border-radius: 8px;
font-family: 'Impact', 'Arial Black', sans-serif;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
}
.btn:active { transform: scale(0.95); }
.play-btn {
flex: 1;
max-width: 70%;
padding: 12px;
background: linear-gradient(180deg, #ff6b35 0%, #d84a15 100%);
border: 4px solid #e8e8e8;
color: white;
font-size: clamp(1.2rem, 4vw, 1.8rem);
letter-spacing: 4px;
box-shadow: 0 4px 0 #5d3a1a, 0 0 20px rgba(255, 107, 53, 0.5);
}
.play-btn:disabled {
background: #555;
cursor: not-allowed;
box-shadow: none;
}
.mute-btn {
width: clamp(2.5rem, 8vw, 3.5rem);
height: clamp(2.5rem, 8vw, 3.5rem);
background: linear-gradient(180deg, #5d3a1a 0%, #3d2a0a 100%);
border: 3px solid #e8e8e8;
color: #ffb347;
font-size: clamp(1rem, 3vw, 1.5rem);
}
/* Animations */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes flash {
0%, 100% { box-shadow: 0 0 10px rgba(255, 107, 53, 0.5); }
50% { box-shadow: 0 0 30px rgba(255, 107, 53, 1); }
}
.win-flash {
animation: flash 0.3s ease-in-out 3;
}
/* Audit Display */
.audit-display {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
color: #ffb347;
font-family: 'Courier New', monospace;
padding: 20px;
display: none;
flex-direction: column;
gap: 8px;
font-size: clamp(0.8rem, 2.5vw, 1rem);
z-index: 100;
}
.audit-display h2 {
color: #ff6b35;
text-align: center;
margin-bottom: 12px;
}
</style>
</head>
<body>
<div class="cabinet">
<!-- Topper -->
<div class="topper">
<h1>ATOMIC BANDIT</h1>
<p>CHASE THE SAUCER • WIN COSMIC CREDITS</p>
</div>
<!-- Meters -->
<div class="meters">
<div class="meter">
<div class="meter-label">CREDITS</div>
<div class="meter-value" id="credits">500</div>
</div>
<div class="meter">
<div class="meter-label">BET</div>
<div class="meter-value" id="bet">20</div>
</div>
<div class="meter">
<div class="meter-label">LINES</div>
<div class="meter-value">20</div>
</div>
</div>
<!-- Reel Window -->
<div class="reel-window" id="reelWindow">
<div class="reel" id="reel0">
<div class="symbol" data-index="0"></div>
<div class="symbol" data-index="1"></div>
<div class="symbol" data-index="2"></div>
</div>
<div class="reel" id="reel1">
<div class="symbol" data-index="0"></div>
<div class="symbol" data-index="1"></div>
<div class="symbol" data-index="2"></div>
</div>
<div class="reel" id="reel2">
<div class="symbol" data-index="0"></div>
<div class="symbol" data-index="1"></div>
<div class="symbol" data-index="2"></div>
</div>
<div class="reel" id="reel3">
<div class="symbol" data-index="0"></div>
<div class="symbol" data-index="1"></div>
<div class="symbol" data-index="2"></div>
</div>
<div class="reel" id="reel4">
<div class="symbol" data-index="0"></div>
<div class="symbol" data-index="1"></div>
<div class="symbol" data-index="2"></div>
</div>
</div>
<!-- Win Display -->
<div class="win-display" id="winDisplay">
<div class="message" id="message"></div>
<div class="win-amount" id="winAmount"></div>
</div>
<!-- Paytable -->
<div class="paytable" id="paytable">
<h3>PAYTABLE</h3>
<div class="paytable-grid" id="paytableGrid"></div>
</div>
<!-- Controls -->
<div class="controls">
<button class="btn play-btn" id="playBtn">PLAY</button>
<button class="btn mute-btn" id="muteBtn">🔊</button>
</div>
<!-- Audit Display -->
<div class="audit-display" id="auditDisplay"></div>
</div>
<!-- Hidden symbols image -->
<img id="symbolsImg" src="art/symbols.png" alt="Symbols" style="display:none;">
<script>
// ---------------- GAME CONFIGURATION (MATH SHEET) ----------------
const SYMBOLS = [
{ id: 'saucer', name: 'Flying Saucer', role: 'wild', cell: 0, tier: 'wild', pays: {3:50,4:200,5:2000} },
{ id: 'beacon', name: 'Cosmic Beacon', role: 'scatter', cell:1, tier: 'scatter', pays: {3:2,4:10,5:50} },
{ id: 'satellite', name: 'Test Satellite', role: 'pay', cell:2, tier: 'low', pays: {2:1,3:2,4:5,5:15} },
{ id: 'geiger', name: 'Geiger Counter', role: 'pay', cell:3, tier: 'low', pays: {2:1,3:2,4:5,5:15} },
{ id: 'gauge', name: 'Fuel Gauge', role: 'pay', cell:4, tier: 'low', pays: {2:1,3:3,4:8,5:25} },
{ id: 'helmet', name: 'Bubble Helmet', role: 'pay', cell:5, tier: 'low', pays: {2:1,3:3,4:8,5:25} },
{ id: 'pack', name: 'Rocket Pack', role: 'pay', cell:6, tier: 'mid', pays: {2:2,3:5,4:15,5:50} },
{ id: 'raygun', name: 'Ray Gun', role: 'pay', cell:7, tier: 'mid', pays: {2:2,3:5,4:15,5:50} },
{ id: 'crystal', name: 'Lunar Crystal', role: 'pay', cell:8, tier: 'mid', pays: {2:3,3:8,4:25,5:80} },
{ id: 'probe', name: 'Mars Probe', role: 'pay', cell:9, tier: 'mid', pays: {2:3,3:8,4:25,5:80} },
{ id: 'orchid', name: 'Venusian Orchid', role: 'pay', cell:10, tier: 'mid', pays: {2:5,3:12,4:40,5:120} },
{ id: 'clock', name: 'Atomic Clock', role: 'pay', cell:11, tier: 'high', pays: {2:5,3:15,4:50,5:150} },
{ id: 'rocket', name: 'Rocket Ship', role: 'pay', cell:12, tier: 'high', pays: {2:10,3:20,4:75,5:250} },
{ id: 'station', name: 'Space Station', role: 'pay', cell:13, tier: 'high', pays: {2:10,3:25,4:100,5:350} },
{ id: 'moonbase', name: 'Moon Base', role: 'pay', cell:14, tier: 'high', pays: {2:15,3:35,4:150,5:400} },
{ id: 'starcore', name: 'Star Core', role: 'pay', cell:15, tier: 'high', pays: {2:20,3:50,4:200,5:1000} }
];
const SYMBOL_ID_MAP = Object.fromEntries(SYMBOLS.map(s => [s.id, s]));
const REELS = [
// Reel 0 (leftmost)
['saucer','beacon','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','pack','raygun','crystal','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','probe','orchid','pack','raygun','satellite','geiger','gauge','helmet','clock','rocket','crystal','probe','satellite','geiger','gauge','helmet','beacon','starcore'],
// Reel 1
['beacon','satellite','geiger','gauge','helmet','saucer','satellite','geiger','gauge','helmet','pack','raygun','orchid','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','probe','crystal','pack','raygun','satellite','geiger','gauge','helmet','station','rocket','crystal','probe','satellite','geiger','gauge','helmet','beacon','moonbase'],
// Reel 2
['satellite','geiger','gauge','helmet','beacon','satellite','geiger','gauge','helmet','saucer','pack','raygun','crystal','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','probe','orchid','pack','raygun','satellite','geiger','gauge','helmet','clock','station','crystal','probe','satellite','geiger','gauge','helmet','beacon','starcore'],
// Reel 3
['beacon','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','beacon','pack','raygun','orchid','satellite','geiger','gauge','helmet','saucer','satellite','gauge','helmet','probe','crystal','pack','raygun','satellite','geiger','gauge','helmet','moonbase','rocket','crystal','probe','satellite','geiger','gauge','helmet','station','starcore'],
// Reel 4 (rightmost)
['satellite','geiger','gauge','helmet','beacon','satellite','geiger','gauge','helmet','beacon','pack','raygun','crystal','satellite','geiger','gauge','helmet','satellite','geiger','gauge','helmet','probe','orchid','pack','raygun','satellite','geiger','gauge','helmet','rocket','station','crystal','probe','satellite','geiger','gauge','helmet','saucer','starcore']
];
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,2],
[2,1,1,1,0], [0,1,0,1,0], [2,1,2,1,2], [1,0,1,0,1], [1,2,1,2,1],
[0,0,1,2,2], [2,2,1,0,0], [0,1,2,2,2], [2,1,0,0,0], [1,1,0,1,1]
];
const BET_PER_LINE = 1;
const LINES = PAYLINES.length;
const TOTAL_BET = LINES * BET_PER_LINE;
const START_CREDITS = 500;
// ---------------- GAME STATE ----------------
let state = {
credits: START_CREDITS,
muted: false,
spinning: false,
freeSpins: 0,
freeSpinMultiplier: 1,
artLoaded: false
};
// ---------------- AUDIO SYSTEM ----------------
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(freq, duration, type='sine', volume=0.3) {
if (state.muted || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(volume, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playSpinSound() {
for (let i=0; i<10; i++) {
setTimeout(() => playTone(100 + i*10, 0.05, 'square', 0.1), i*50);
}
}
function playReelStop(reelIdx) {
playTone(200 + reelIdx*50, 0.1, 'triangle', 0.2);
}
function playWin(amount) {
const tier = amount / TOTAL_BET;
const count = Math.min(Math.floor(tier), 10);
for (let i=0; i<count; i++) {
setTimeout(() => playTone(400 + i*50, 0.15, 'sine', 0.3), i*100);
}
}
function playScatter() {
playTone(800, 0.3, 'sine', 0.4);
setTimeout(() => playTone(1000, 0.3, 'sine', 0.4), 200);
}
function playNearMiss() {
playTone(600, 0.2, 'sawtooth', 0.2);
setTimeout(() => playTone(400, 0.2, 'sawtooth', 0.2), 150);
setTimeout(() => playTone(200, 0.3, 'sawtooth', 0.2), 300);
}
// ---------------- UTILITY ----------------
function getSymbol(cellId) {
return SYMBOLS.find(s => s.cell === cellId);
}
function getSymbolById(id) {
return SYMBOL_ID_MAP[id];
}
function wrap(n, max) {
return ((n % max) + max) % max;
}
// ---------------- RNG ----------------
function mulberry32(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
let rng = mulberry32(Date.now());
function randomInt(min, max) {
return Math.floor(rng() * (max - min + 1)) + min;
}
// ---------------- GAME LOGIC ----------------
function generateSpinPositions() {
return REELS.map(reel => randomInt(0, reel.length - 1));
}
function getVisibleSymbols(positions) {
return positions.map((pos, reelIdx) => {
const reel = REELS[reelIdx];
return [
reel[wrap(pos, reel.length)],
reel[wrap(pos + 1, reel.length)],
reel[wrap(pos + 2, reel.length)]
].map(id => getSymbolById(id));
});
}
function countScatters(visible) {
let count = 0;
for (let r=0; r<5; r++) {
for (let row=0; row<3; row++) {
if (visible[r][row].role === 'scatter') count++;
}
}
return count;
}
function evaluateLine(visible, payline) {
const lineSymbols = payline.map((row, reelIdx) => visible[reelIdx][row]);
let bestWin = { count:0, pay:0, symbol:null };
const wild = SYMBOLS.find(s => s.role === 'wild');
const scatter = SYMBOLS.find(s => s.role === 'scatter');
const baseSymbols = lineSymbols.filter(s => s.role !== 'wild');
const uniqueSymbols = [...new Set(baseSymbols.map(s => s.id))];
const candidates = uniqueSymbols.length ? uniqueSymbols : [wild.id];
for (const symId of candidates) {
const sym = getSymbolById(symId);
if (sym.role === 'scatter') continue;
let count = 0;
for (let i=0; i<5; i++) {
const s = lineSymbols[i];
if (s.id === symId || s.role === 'wild') count++;
else break;
}
if (count < 2) continue;
const pay = sym.pays[count] || 0;
if (pay > bestWin.pay || (pay === bestWin.pay && count > bestWin.count)) {
bestWin = { count, pay, symbol: sym };
}
}
return bestWin;
}
function calculateSpinResult(positions) {
const visible = getVisibleSymbols(positions);
let totalWin = 0;
let lineWins = [];
for (const pl of PAYLINES) {
const res = evaluateLine(visible, pl);
if (res.pay > 0) {
const winAmount = res.pay * BET_PER_LINE;
totalWin += winAmount;
lineWins.push({ ...res, amount: winAmount, payline: pl });
}
}
const scatterCount = countScatters(visible);
const scatter = SYMBOLS.find(s => s.role === 'scatter');
let scatterWin = 0;
if (scatterCount >=3 && scatter.pays[scatterCount]) {
scatterWin = scatter.pays[scatterCount] * TOTAL_BET;
totalWin += scatterWin;
}
const nearMiss = checkNearMiss(positions, visible);
return {
positions,
visible,
totalWin,
lineWins,
scatterCount,
scatterWin,
nearMiss,
feature: scatterCount >=3
};
}
function checkNearMiss(positions, visible) {
const wild = SYMBOLS.find(s => s.role === 'wild');
for (const pl of PAYLINES) {
let wildCount = 0;
let missingReel = -1;
for (let r=0; r<5; r++) {
const sym = visible[r][pl[r]];
if (sym.role === 'wild') wildCount++;
else if (missingReel === -1) missingReel = r;
}
if (wildCount >=3 && missingReel !== -1) {
const reel = REELS[missingReel];
const pos = positions[missingReel];
for (let offset=-1; offset<=3; offset++) {
const checkPos = wrap(pos + offset, reel.length);
if (reel[checkPos] === wild.id) return true;
}
}
}
return false;
}
// ---------------- DEMO MODE ----------------
function generateDemoSpin() {
const targetWin = 20 * TOTAL_BET;
for (let i=0; i<10000; i++) {
const positions = generateSpinPositions();
const result = calculateSpinResult(positions);
if (result.totalWin >= targetWin) return result;
}
return generateSpinResult(generateSpinPositions());
}
// ---------------- AUDIT MODE ----------------
function runAudit(count) {
let spins = 0;
let totalWagered = 0;
let totalWon = 0;
let hits = 0;
let biggestWin = 0;
let scatterHits = 0;
for (let i=0; i<count; i++) {
const positions = generateSpinPositions();
const result = calculateSpinResult(positions);
spins++;
totalWagered += TOTAL_BET;
totalWon += result.totalWin;
if (result.totalWin > 0) hits++;
if (result.totalWin > biggestWin) biggestWin = result.totalWin;
if (result.scatterCount >=3) scatterHits++;
}
const rtp = spins >0 ? (totalWon / totalWagered * 100) : 0;
const hitFreq = spins >0 ? (hits / spins * 100) :0;
return { spins, totalWagered, totalWon, rtp, hitFreq, biggestWin, scatterHits };
}
// ---------------- UI ----------------
function updateCredits() {
document.getElementById('credits').textContent = state.credits;
}
function updateWinDisplay(amount, message='') {
const winEl = document.getElementById('winAmount');
const msgEl = document.getElementById('message');
msgEl.textContent = message;
if (amount >0) {
winEl.textContent = `WIN ${amount} CREDITS`;
winEl.classList.add('active');
} else {
winEl.classList.remove('active');
}
}
function renderSymbols(visible) {
const reels = [
document.getElementById('reel0'),
document.getElementById('reel1'),
document.getElementById('reel2'),
document.getElementById('reel3'),
document.getElementById('reel4')
];
for (let r=0; r<5; r++) {
const symbols = reels[r].querySelectorAll('.symbol');
for (let row=0; row<3; row++) {
const sym = visible[r][row];
const el = symbols[row];
if (state.artLoaded) {
el.classList.add('has-art');
const x = -(sym.cell %4) * 100;
const y = -(Math.floor(sym.cell /4)) *100;
el.style.backgroundPosition = `${x}% ${y}%`;
el.innerHTML = '';
} else {
el.classList.remove('has-art');
const tierClass = sym.role === 'wild' ? 'wild' : sym.role === 'scatter' ? 'scatter' : sym.tier;
el.innerHTML = `<div class="symbol-text ${tierClass}">${sym.name.toUpperCase()}</div>`;
}
}
}
}
function renderPaytable() {
const grid = document.getElementById('paytableGrid');
const sortedSymbols = [...SYMBOLS].sort((a,b) => {
const order = { wild:5, scatter:4, high:3, mid:2, low:1 };
if (order[a.tier || a.role] !== order[b.tier || b.role]) {
return order[b.tier || b.role] - order[a.tier || a.role];
}
return (b.pays[5] ||0) - (a.pays[5] ||0);
});
grid.innerHTML = sortedSymbols.map(sym => {
const pays = Object.entries(sym.pays).map(([c,p]) => `${c}x:${p}`).join(' ');
return `<div class="payline-entry"><span class="name">${sym.name}</span><span>${pays}</span></div>`;
}).join('');
}
function showAuditResult(result) {
const ad = document.getElementById('auditDisplay');
ad.style.display = 'flex';
ad.innerHTML = `
<h2>AUDIT RESULTS</h2>
<div>Spins: ${result.spins.toLocaleString()}</div>
<div>Total Wagered: ${result.totalWagered.toLocaleString()}</div>
<div>Total Won: ${result.totalWon.toLocaleString()}</div>
<div>RTP: ${result.rtp.toFixed(2)}%</div>
<div>Hit Frequency: ${result.hitFreq.toFixed(2)}%</div>
<div>Biggest Win: ${result.biggestWin.toLocaleString()}</div>
<div>Spins with >=3 Scatters: ${result.scatterHits.toLocaleString()}</div>
`;
}
// ---------------- MAIN SPIN FLOW ----------------
async function spin() {
if (state.spinning) return;
initAudio();
const playBtn = document.getElementById('playBtn');
const reelWindow = document.getElementById('reelWindow');
if (state.credits < TOTAL_BET) {
state.credits = START_CREDITS;
updateCredits();
updateWinDisplay(0, 'WELCOME BACK!');
return;
}
state.spinning = true;
playBtn.disabled = true;
updateWinDisplay(0, '');
state.credits -= TOTAL_BET;
updateCredits();
let result;
if (window.location.hash === '#demo') {
result = generateDemoSpin();
window.location.hash = '';
} else {
result = calculateSpinResult(generateSpinPositions());
}
playSpinSound();
for (let r=0; r<5; r++) {
renderSymbols(result.visible.map((rv, ri) => ri <= r ? rv : [
getSymbolById('satellite'),
getSymbolById('geiger'),
getSymbolById('gauge')
]));
playReelStop(r);
await new Promise(r => setTimeout(r, 200));
}
if (result.nearMiss) {
playNearMiss();
updateWinDisplay(0, 'SO CLOSE TO THE SAUCER!');
await new Promise(r => setTimeout(r, 1000));
}
if (result.feature) {
playScatter();
const freeSpinCount = result.scatterCount ===3 ?10 : result.scatterCount ===4 ?15 :20;
state.freeSpins += freeSpinCount;
state.freeSpinMultiplier =3;
updateWinDisplay(0, `${result.scatterCount} BEACONS! ${freeSpinCount} FREE SPINS!`);
reelWindow.classList.add('win-flash');
await new Promise(r => setTimeout(r, 2000));
reelWindow.classList.remove('win-flash');
}
if (result.totalWin >0) {
const finalWin = result.totalWin * state.freeSpinMultiplier;
playWin(finalWin);
state.credits += finalWin;
updateCredits();
updateWinDisplay(finalWin, state.freeSpinMultiplier >1 ? `FREE SPIN MULTIPLIER x${state.freeSpinMultiplier}!` : '');
reelWindow.classList.add('win-flash');
await new Promise(r => setTimeout(r, 1500));
reelWindow.classList.remove('win-flash');
}
if (state.freeSpins >0) {
state.freeSpins--;
if (state.freeSpins ===0) {
state.freeSpinMultiplier =1;
updateWinDisplay(0, 'FREE SPINS COMPLETE!');
await new Promise(r => setTimeout(r, 1000));
} else {
updateWinDisplay(0, `${state.freeSpins} FREE SPINS REMAINING!`);
await new Promise(r => setTimeout(r, 1000));
state.spinning = false;
playBtn.disabled = false;
spin();
return;
}
}
state.spinning = false;
playBtn.disabled = false;
}
// ---------------- INITIALIZATION ----------------
function init() {
const symbolsImg = document.getElementById('symbolsImg');
symbolsImg.onload = () => { state.artLoaded = true; };
symbolsImg.onerror = () => { state.artLoaded = false; };
renderPaytable();
updateCredits();
document.getElementById('bet').textContent = TOTAL_BET;
document.getElementById('playBtn').addEventListener('click', spin);
document.getElementById('muteBtn').addEventListener('click', () => {
state.muted = !state.muted;
document.getElementById('muteBtn').textContent = state.muted ? '🔇' : '🔊';
});
renderSymbols(getVisibleSymbols(generateSpinPositions()));
const hash = window.location.hash;
if (hash.startsWith('#audit=')) {
const count = parseInt(hash.split('=')[1], 10) || 50000;
const result = runAudit(count);
showAuditResult(result);
}
}
window.addEventListener('load', init);
</script>
</body>
</html>
{
"machine": { "name": "ATOMIC BANDIT", "credits": 500, "betPerLine": 1, "lines": 20 },
"sheet": {
"style": "1950s pulp sci-fi airbrush illustration, centered on flat black background, bold clean lines, hand-painted halftone shading, palette of atomic orange, vacuum-tube amber, deep-space blue, chrome silver, and bakelite brown, dramatic backlighting like a drive-in movie screen, no shadows outside the object, same scale across all symbols.",
"cells": [
{ "cell": 0, "id": "saucer", "name": "Flying Saucer", "role": "wild", "prompt": "1950s-style flying saucer with chrome dome, blinking amber lights around its rim, and three landing legs tucked up", "pays": { "3": 50, "4": 200, "5": 2000 } },
{ "cell": 1, "id": "beacon", "name": "Cosmic Beacon", "role": "scatter", "prompt": "Tall cosmic radio beacon tower with a spinning radar dish at the top, pulsing atomic orange signal light at its peak", "pays": { "3": 2, "4": 10, "5": 50 } },
{ "cell": 2, "id": "satellite", "name": "Test Satellite", "role": "pay", "prompt": "Simple 1950s imagined test satellite, spherical body with four thin chrome antennae sticking out", "pays": { "2": 1, "3": 2, "4": 5, "5": 15 } },
{ "cell": 3, "id": "geiger", "name": "Geiger Counter", "role": "pay", "prompt": "Bakelite-cased geiger counter, with an amber needle display showing a high reading and a chrome speaker grill", "pays": { "2": 1, "3": 2, "4": 5, "5": 15 } },
{ "cell": 4, "id": "gauge", "name": "Fuel Gauge", "role": "pay", "prompt": "Round chrome fuel gauge with a bakelite bezel, amber needle pointing to 'FULL', deep blue face", "pays": { "2": 1, "3": 3, "4": 8, "5": 25 } },
{ "cell": 5, "id": "helmet", "name": "Bubble Helmet", "role": "pay", "prompt": "Clear bubble space helmet with a chrome neck ring, reflections of stars visible on the glass", "pays": { "2": 1, "3": 3, "4": 8, "5": 25 } },
{ "cell": 6, "id": "pack", "name": "Rocket Pack", "role": "pay", "prompt": "1950s rocket pack, two chrome rocket nozzles, bakelite control panel on the chest strap, orange fuel tanks", "pays": { "2": 2, "3": 5, "4": 15, "5": 50 } },
{ "cell": 7, "id": "raygun", "name": "Ray Gun", "role": "pay", "prompt": "Retro ray gun pistol, chrome barrel, bakelite grip, amber glowing emitter at the tip", "pays": { "2": 2, "3": 5, "4": 15, "5": 50 } },
{ "cell": 8, "id": "crystal", "name": "Lunar Crystal", "role": "pay", "prompt": "Glowing lunar crystal, geometric octahedron shape, amber and deep blue internal refractions", "pays": { "2": 3, "3": 8, "4": 25, "5": 80 } },
{ "cell": 9, "id": "probe", "name": "Mars Probe", "role": "pay", "prompt": "1950s style Mars probe, rocket-shaped body with three fins, camera lens at the front, chrome finish", "pays": { "2": 3, "3": 8, "4": 25, "5": 80 } },
{ "cell": 10, "id": "orchid", "name": "Venusian Orchid", "role": "pay", "prompt": "Imagined Venusian orchid, large waxy petals in atomic orange and deep blue, glowing amber center", "pays": { "2": 5, "3": 12, "4": 40, "5": 120 } },
{ "cell": 11, "id": "clock", "name": "Atomic Clock", "role": "pay", "prompt": "Atomic clock, round chrome casing, deep blue face with glowing amber numerals, bakelite base", "pays": { "2": 5, "3": 15, "4": 50, "5": 150 } },
{ "cell": 12, "id": "rocket", "name": "Rocket Ship", "role": "pay", "prompt": "1950s-style manned rocket ship, pointed nose, three fins, chrome body with atomic orange stripe down the side", "pays": { "2": 10, "3": 20, "4": 75, "5": 250 } },
{ "cell": 13, "id": "station", "name": "Space Station", "role": "pay", "prompt": "1950s imagined space station, rotating wheel shape, chrome spokes, central hub with windows", "pays": { "2": 10, "3": 25, "4": 100, "5": 350 } },
{ "cell": 14, "id": "moonbase", "name": "Moon Base", "role": "pay", "prompt": "1950s style moon base, domed central building, two smaller domes connected by chrome tubes, radar dish on top", "pays": { "2": 15, "3": 35, "4": 150, "5": 400 } },
{ "cell": 15, "id": "starcore", "name": "Star Core", "role": "pay", "prompt": "Glowing cosmic star core, swirling ball of atomic orange, amber, and deep blue energy, tiny sparkles around the edges", "pays": { "2": 20, "3": 50, "4": 200, "5": 1000 } }
]
},
"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,2],
[2,1,1,1,0], [0,1,0,1,0], [2,1,2,1,2], [1,0,1,0,1], [1,2,1,2,1],
[0,0,1,2,2], [2,2,1,0,0], [0,1,2,2,2], [2,1,0,0,0], [1,1,0,1,1]
],
"reels": [
["saucer","beacon","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","pack","raygun","crystal","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","probe","orchid","pack","raygun","satellite","geiger","gauge","helmet","clock","rocket","crystal","probe","satellite","geiger","gauge","helmet","beacon","starcore"],
["beacon","satellite","geiger","gauge","helmet","saucer","satellite","geiger","gauge","helmet","pack","raygun","orchid","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","probe","crystal","pack","raygun","satellite","geiger","gauge","helmet","station","rocket","crystal","probe","satellite","geiger","gauge","helmet","beacon","moonbase"],
["satellite","geiger","gauge","helmet","beacon","satellite","geiger","gauge","helmet","saucer","pack","raygun","crystal","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","probe","orchid","pack","raygun","satellite","geiger","gauge","helmet","clock","station","crystal","probe","satellite","geiger","gauge","helmet","beacon","starcore"],
["beacon","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","beacon","pack","raygun","orchid","satellite","geiger","gauge","helmet","saucer","satellite","gauge","helmet","probe","crystal","pack","raygun","satellite","geiger","gauge","helmet","moonbase","rocket","crystal","probe","satellite","geiger","gauge","helmet","station","starcore"],
["satellite","geiger","gauge","helmet","beacon","satellite","geiger","gauge","helmet","beacon","pack","raygun","crystal","satellite","geiger","gauge","helmet","satellite","geiger","gauge","helmet","probe","orchid","pack","raygun","satellite","geiger","gauge","helmet","rocket","station","crystal","probe","satellite","geiger","gauge","helmet","saucer","starcore"]
],
"math": { "claimedRtpBase": 94.1, "claimedHitFrequency": 27.4, "claimedFeatureRtp": 2.0, "volatility": "medium" },
"cabinet": [
{ "id": "topper", "aspect": "16:9", "prompt": "1950s sci-fi slot machine topper, bold chrome 3D lettering spelling 'ATOMIC BANDIT' with a flying saucer hovering above the text, blinking amber lights on the saucer's rim, bakelite base, halftone shading like old pulp magazine art" },
{ "id": "backdrop", "aspect": "9:16", "prompt": "1950s drive-in movie screen style backdrop for a slot machine reel window, deep black space with swirling nebulae in atomic orange and vacuum-tube amber, tiny chrome flying saucers in the distance, halftone dots like 16mm film print" },
{ "id": "bellyglass", "aspect": "16:9", "prompt": "1950s slot machine belly glass, illustration of a smiling 1950s astronaut woman in a bubble helmet waving next to a chrome rocket ship on the moon, text 'WIN COSMIC CREDITS!' in bold atomic orange lettering, halftone print style" }
]
}
The Near-Miss Saucer Tease mechanism: Whenever a spin results in 2–4 wild (jackpot) symbols aligned on a payline, or the reel stop position is within 1 symbol of a wild that would have completed a 5-of-a-kind wild jackpot, the machine plays an exaggerated descending "oh-so-close" tone sequence, briefly flashes a wild symbol in the position that would have completed the jackpot, and displays a temporary "SO CLOSE TO THE SAUCER!" message. This leverages the near-miss effect, making players perceive they are on the verge of a big win and increasing the likelihood of pressing Play again.