// Slot machine definitions — pure data, no Phaser imports. // // Reel strips are written compressed: each entry is either a symbol id string // (one copy) or [symbolId, count]. expandStrip() flattens to the real strip. // Symbol weighting comes entirely from repetition counts, which keeps the // paytable math verifiable by Monte-Carlo (server/scripts/verifySlots.js). // // Money model: // - betLevels are TOTAL bet per spin in credits (shared casino bankroll). // - 'lines' machines: paytable values are multipliers of the LINE bet // (total bet / paylines.length). Per-win credits = round(pay * wildMult * bet / nLines). // - 'scatterPays' machines: paytable tiers are multipliers of the TOTAL bet. // - scatter.pays and pickBonus/holdSpin coin values are multipliers of the TOTAL bet. export function expandStrip(compressed) { const out = []; for (const entry of compressed) { if (Array.isArray(entry)) { const [sym, count] = entry; for (let i = 0; i < count; i++) out.push(sym); } else out.push(entry); } return out; } // Spread each symbol's copies evenly around the strip (deterministic, no rng) // so spinning reels never show cheap-looking blocks of identical symbols — // EXCEPT where an entry asks for blocks: [sym, count, blockSize] places the // copies as runs of blockSize consecutive slots (stacked coins / candy clumps), // which is what lets a 3-row window show multiple copies on one reel. export function interleaveStrip(compressed) { const entries = compressed.map((e) => (Array.isArray(e) ? e : [e, 1])); const len = entries.reduce((s, [, c]) => s + c, 0); const strip = new Array(len).fill(null); const sorted = entries.slice().sort((a, b) => b[1] - a[1]); sorted.forEach(([sym, count, block = 1], si) => { const nChunks = Math.ceil(count / block); const step = len / nChunks; let remaining = count; for (let i = 0; i < nChunks; i++) { let idx = Math.floor(i * step + step / 2 + si) % len; const size = Math.min(block, remaining); remaining -= size; for (let j = 0; j < size; j++) { while (strip[idx] !== null) idx = (idx + 1) % len; strip[idx] = sym; idx = (idx + 1) % len; } } }); return strip; } // Standard 20-payline set for 5×3 video slots (row index per reel, 0 = top). export const LINES_20 = [ [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], [1, 0, 0, 0, 1], [1, 2, 2, 2, 1], [0, 0, 1, 2, 2], [2, 2, 1, 0, 0], [1, 2, 1, 0, 1], [1, 0, 1, 2, 1], [0, 1, 1, 1, 0], [2, 1, 1, 1, 2], [0, 1, 0, 1, 0], [2, 1, 2, 1, 2], [1, 1, 0, 1, 1], [1, 1, 2, 1, 1], [0, 2, 0, 2, 0], [2, 0, 2, 0, 2], [0, 2, 2, 2, 0], ]; // 5 paylines for a 3×3 window: three rows + two diagonals. export const LINES_5 = [ [1, 1, 1], [0, 0, 0], [2, 2, 2], [0, 1, 2], [2, 1, 0], ]; export const MACHINES = [ // ── 1. Liberty Belle — 1899 mechanical, pull lever, single line ───────────── { id: 'liberty-belle', name: 'Liberty Belle', tagline: '1899 Original', era: 'vintage', featureLabel: 'PULL THE LEVER', layout: { reels: 3, rows: 1 }, evaluation: 'lines', betLevels: [1, 2, 5, 10, 25], symbols: ['bell', 'horseshoe', 'star', 'heart', 'diamond', 'spade'], reelStrips: [ ['spade', 'diamond', 'star', 'heart', 'diamond', 'spade', 'horseshoe', 'heart', 'diamond', 'spade', 'star', 'heart', 'bell', 'diamond', 'spade', 'horseshoe', 'heart', 'diamond', 'star', 'spade'], ['diamond', 'spade', 'heart', 'star', 'spade', 'diamond', 'heart', 'horseshoe', 'spade', 'diamond', 'heart', 'star', 'diamond', 'bell', 'spade', 'heart', 'horseshoe', 'diamond', 'spade', 'star'], ['heart', 'spade', 'diamond', 'star', 'diamond', 'heart', 'spade', 'horseshoe', 'diamond', 'spade', 'star', 'heart', 'spade', 'diamond', 'bell', 'heart', 'horseshoe', 'spade', 'diamond', 'star'], ], paylines: [[0, 0, 0]], paytable: { bell: { 3: 150, 2: 12 }, horseshoe: { 3: 60, 2: 6 }, star: { 3: 30, 2: 3 }, heart: { 3: 18, 2: 2 }, diamond: { 3: 12, 2: 1 }, spade: { 3: 8, 2: 1 }, }, wild: null, scatter: null, features: { lever: true, steppedStops: true }, theme: { cabinet: 0x5a3a1e, cabinetHi: 0x7a5430, trim: 0xc8a84b, glow: 0xffd98a, screenTop: 0x2b1d10, screenBot: 0x191009, accentHex: '#ffd98a', reelBg: 0xf3ead2, }, }, // ── 2. Fruit Frenzy — 1960s fruit machine, HOLD & NUDGE ───────────────────── { id: 'fruit-frenzy', name: 'Fruit Frenzy', tagline: '1965 Electro-Mechanical', era: 'vintage', featureLabel: 'HOLD & NUDGE', layout: { reels: 3, rows: 3 }, evaluation: 'lines', betLevels: [5, 10, 25, 50], symbols: ['bar', 'bell', 'melon', 'plum', 'orange', 'lemon', 'cherry'], reelStrips: [ ['cherry', 'lemon', 'plum', 'orange', 'cherry', 'melon', 'lemon', 'plum', 'bell', 'orange', 'cherry', 'lemon', 'melon', 'plum', 'orange', 'cherry', 'bell', 'lemon', 'bar', 'plum', 'orange', 'cherry', 'melon', 'bell'], ['lemon', 'cherry', 'orange', 'plum', 'melon', 'cherry', 'lemon', 'bell', 'plum', 'orange', 'cherry', 'melon', 'lemon', 'plum', 'bar', 'orange', 'cherry', 'bell', 'lemon', 'plum', 'cherry', 'orange', 'melon', 'bell'], ['plum', 'cherry', 'lemon', 'orange', 'cherry', 'melon', 'plum', 'bell', 'lemon', 'orange', 'cherry', 'plum', 'melon', 'lemon', 'orange', 'bar', 'cherry', 'bell', 'plum', 'lemon', 'orange', 'cherry', 'melon', 'bell'], ], paylines: LINES_5, paytable: { bar: { 3: 150 }, bell: { 3: 80 }, melon: { 3: 50 }, plum: { 3: 30 }, orange: { 3: 20 }, lemon: { 3: 14 }, cherry: { 3: 10, 2: 3 }, }, wild: null, scatter: null, features: { holdNudge: { holdChance: 0.12, // offered after a losing spin (not twice in a row) nudgeChance: 0.10, // awarded after a losing spin when holds weren't offered nudgeWeights: [[1, 50], [2, 30], [3, 20]], }, }, theme: { cabinet: 0x8c1d2f, cabinetHi: 0xb53247, trim: 0xe8e4da, glow: 0xff7da0, screenTop: 0x241016, screenBot: 0x14080c, accentHex: '#ffb3c7', reelBg: 0xfdf6e6, }, }, // ── 3. Lucky Sevens — 1970s Vegas, doubling wild diamonds ─────────────────── { id: 'lucky-sevens', name: 'Lucky Sevens', tagline: '1975 Vegas Strip', era: 'vintage', featureLabel: 'WILDS DOUBLE PAYS', layout: { reels: 3, rows: 1 }, evaluation: 'lines', betLevels: [1, 2, 5, 10, 25], symbols: ['wild', 'seven-red', 'seven-white', 'seven-blue', 'bar3', 'bar2', 'bar1'], reelStrips: [ ['blank', 'bar1', 'blank', 'seven-white', 'blank', 'bar2', 'blank', 'bar1', 'seven-blue', 'blank', 'bar3', 'blank', 'bar2', 'blank', 'seven-red', 'blank', 'bar1', 'blank', 'seven-blue', 'blank', 'bar1', 'blank', 'wild', 'blank'], ['bar1', 'blank', 'seven-blue', 'blank', 'bar2', 'blank', 'bar1', 'blank', 'seven-white', 'blank', 'bar3', 'blank', 'bar2', 'blank', 'seven-red', 'blank', 'bar1', 'blank', 'wild', 'blank', 'bar1', 'blank', 'seven-blue', 'blank'], ['blank', 'bar2', 'blank', 'seven-white', 'blank', 'bar1', 'blank', 'seven-blue', 'blank', 'bar3', 'blank', 'bar1', 'blank', 'seven-red', 'blank', 'bar2', 'blank', 'wild', 'blank', 'bar1', 'blank', 'seven-blue', 'blank', 'bar1'], ], paylines: [[0, 0, 0]], paytable: { 'wild': { 3: 300 }, 'seven-red': { 3: 100 }, 'seven-white': { 3: 80 }, 'seven-blue': { 3: 50 }, 'any-seven': { 3: 12 }, 'bar3': { 3: 25 }, 'bar2': { 3: 15 }, 'bar1': { 3: 10 }, 'any-bar': { 3: 4 }, }, symbolGroups: { 'any-seven': ['seven-red', 'seven-white', 'seven-blue'], 'any-bar': ['bar1', 'bar2', 'bar3'], }, wild: { symbol: 'wild', multipliers: { 1: 2, 2: 4 } }, scatter: null, features: {}, theme: { cabinet: 0x1b2440, cabinetHi: 0x2c3a66, trim: 0xd4313f, glow: 0x6fa8ff, screenTop: 0x10162b, screenBot: 0x080b16, accentHex: '#8fb8ff', reelBg: 0xf2f2f2, }, }, // ── 4. Pharaoh's Fortune — free spins + expanding wild ────────────────────── { id: 'pharaohs-fortune', name: "Pharaoh's Fortune", tagline: 'Free Spins of the Nile', era: 'modern', featureLabel: 'FREE SPINS', layout: { reels: 5, rows: 3 }, evaluation: 'lines', betLevels: [10, 25, 50, 100, 250], symbols: ['pharaoh', 'pyramid', 'sphinx', 'ankh', 'eye', 'cat', 'ace', 'king', 'queen', 'jack'], reelStrips: [ [['sphinx', 3], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'pyramid'], [['pharaoh', 2], ['sphinx', 3], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 5], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], [['pharaoh', 2], ['sphinx', 3], ['ankh', 3], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], [['pharaoh', 2], ['sphinx', 3], ['ankh', 4], ['eye', 3], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], [['sphinx', 4], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 5], ['jack', 6], 'pyramid'], ], paylines: LINES_20, paytable: { pharaoh: { 3: 85, 4: 340, 5: 1700 }, sphinx: { 3: 50, 4: 170, 5: 680 }, ankh: { 3: 40, 4: 135, 5: 500 }, eye: { 3: 34, 4: 100, 5: 400 }, cat: { 3: 27, 4: 85, 5: 340 }, ace: { 3: 17, 4: 50, 5: 200 }, king: { 3: 14, 4: 40, 5: 170 }, queen: { 3: 10, 4: 34, 5: 135 }, jack: { 3: 10, 4: 27, 5: 100 }, }, wild: { symbol: 'pharaoh' }, scatter: { symbol: 'pyramid', pays: { 3: 2, 4: 10, 5: 50 }, freeSpins: { 3: 8, 4: 12, 5: 20 }, retrigger: true, expandingWildReel: 2, }, features: {}, theme: { cabinet: 0x223047, cabinetHi: 0x32486b, trim: 0xe6b94d, glow: 0xffd86b, screenTop: 0x2c2410, screenBot: 0x120d05, accentHex: '#ffd86b', reelBg: 0x1c1507, }, }, // ── 5. Abyssal Treasures — cascading reels + multiplier ladder ────────────── { id: 'abyssal-treasures', name: 'Abyssal Treasures', tagline: 'Cascades of the Deep', era: 'modern', featureLabel: 'CASCADING REELS', layout: { reels: 5, rows: 3 }, evaluation: 'lines', betLevels: [10, 25, 50, 100, 250], symbols: ['pearl', 'mermaid', 'octopus', 'seahorse', 'angler', 'starfish', 'shell', 'coral', 'bubble'], reelStrips: [ [['mermaid', 3], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 7], ['bubble', 6]], [['pearl', 2], ['mermaid', 3], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 5]], [['pearl', 2], ['mermaid', 3], ['octopus', 3], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], [['pearl', 2], ['mermaid', 3], ['octopus', 4], ['seahorse', 3], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], [['mermaid', 4], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], ], paylines: LINES_20, paytable: { pearl: { 3: 50, 4: 200, 5: 750 }, mermaid: { 3: 30, 4: 100, 5: 375 }, octopus: { 3: 22, 4: 75, 5: 250 }, seahorse: { 3: 18, 4: 50, 5: 200 }, angler: { 3: 15, 4: 38, 5: 150 }, starfish: { 3: 10, 4: 25, 5: 100 }, shell: { 3: 8, 4: 20, 5: 75 }, coral: { 3: 6, 4: 15, 5: 62 }, bubble: { 3: 6, 4: 12, 5: 50 }, }, wild: { symbol: 'pearl' }, scatter: null, features: { cascade: { multipliers: [1, 2, 4, 6] } }, theme: { cabinet: 0x0d2f3d, cabinetHi: 0x15485e, trim: 0x3fd6c0, glow: 0x59e7ff, screenTop: 0x06222e, screenBot: 0x020d13, accentHex: '#59e7ff', reelBg: 0x04161f, }, }, // ── 6. Dragon's Hoard — hold-and-spin coins + progressive jackpot ─────────── { id: 'dragons-hoard', name: "Dragon's Hoard", tagline: 'Hold & Spin Jackpots', era: 'modern', featureLabel: 'JACKPOT COINS', layout: { reels: 5, rows: 3 }, evaluation: 'lines', betLevels: [10, 25, 50, 100, 250], symbols: ['dragon', 'coin', 'knight', 'castle', 'chest', 'gem', 'ace', 'king', 'queen', 'jack'], reelStrips: [ [['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 6], ['jack', 7]], [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], [['coin', 4, 2], ['knight', 3], ['castle', 4], ['chest', 4], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], ], paylines: LINES_20, paytable: { dragon: { 3: 60, 4: 240, 5: 1200 }, knight: { 3: 36, 4: 120, 5: 450 }, castle: { 3: 30, 4: 90, 5: 360 }, chest: { 3: 24, 4: 75, 5: 300 }, gem: { 3: 18, 4: 60, 5: 240 }, ace: { 3: 12, 4: 36, 5: 150 }, king: { 3: 12, 4: 30, 5: 120 }, queen: { 3: 9, 4: 24, 5: 90 }, jack: { 3: 6, 4: 18, 5: 75 }, }, wild: { symbol: 'dragon' }, scatter: null, features: { holdSpin: { trigger: 6, respins: 3, coinSymbol: 'coin', coinChance: 0.085, // per empty cell, per respin // [value-or-tier, weight]; numeric values are ×total-bet coinValues: [[1, 40], [2, 28], [3, 15], [5, 10], [10, 5], ['mini', 1.5], ['minor', 0.4], ['major', 0.1]], jackpots: { mini: 20, minor: 60, major: 250 }, // ×total-bet grand: { seed: 5000, sliver: 0.01 }, // progressive, localStorage-backed }, }, theme: { cabinet: 0x33121c, cabinetHi: 0x55202e, trim: 0xe8483f, glow: 0xff8c3b, screenTop: 0x2a0f08, screenBot: 0x120503, accentHex: '#ff9d4d', reelBg: 0x190a06, }, }, // ── 7. Gold Rush Gulch — sticky wilds + pick-a-cart bonus ─────────────────── { id: 'gold-rush', name: 'Gold Rush Gulch', tagline: 'Sticky Bandits & Mine Carts', era: 'modern', featureLabel: 'PICK BONUS', layout: { reels: 5, rows: 3 }, evaluation: 'lines', betLevels: [10, 25, 50, 100, 250], symbols: ['bandit', 'dynamite', 'sheriff', 'saloon', 'wagon', 'pickaxe', 'ace', 'king', 'queen', 'jack'], reelStrips: [ [['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 7], ['jack', 6], 'dynamite'], ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], [['sheriff', 3], ['saloon', 4], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], ], paylines: LINES_20, paytable: { sheriff: { 3: 30, 4: 100, 5: 380 }, saloon: { 3: 25, 4: 75, 5: 300 }, wagon: { 3: 20, 4: 64, 5: 255 }, pickaxe: { 3: 15, 4: 46, 5: 180 }, ace: { 3: 10, 4: 30, 5: 128 }, king: { 3: 10, 4: 26, 5: 100 }, queen: { 3: 8, 4: 20, 5: 75 }, jack: { 3: 5, 4: 15, 5: 64 }, }, wild: { symbol: 'bandit' }, scatter: { symbol: 'dynamite', pays: { 3: 2, 4: 8, 5: 30 } }, features: { stickyWilds: { symbol: 'bandit', spins: 3 }, pickBonus: { scatter: 'dynamite', min: 3, picks: 3, carts: 9, prizes: [[2, 30], [3, 25], [5, 20], [8, 12], [12, 7], [20, 4], [50, 2]], // ×total-bet }, }, theme: { cabinet: 0x4a3217, cabinetHi: 0x6e4c24, trim: 0xf0c441, glow: 0xffe27a, screenTop: 0x271c0c, screenBot: 0x110c05, accentHex: '#ffe27a', reelBg: 0x1c1409, }, }, // ── 8. Sugar Spin — 6×5 scatter-pays tumble + rainbow multipliers ─────────── { id: 'sugar-spin', name: 'Sugar Spin', tagline: 'Tumbling Candy Mountain', era: 'modern', featureLabel: 'TUMBLE WINS', layout: { reels: 6, rows: 5 }, evaluation: 'scatterPays', betLevels: [10, 25, 50, 100, 250], symbols: ['rainbow', 'chocolate', 'candy-cane', 'gummy', 'lollipop', 'jelly', 'mint', 'berry', 'sugar'], reelStrips: [ [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], ], paylines: null, // scatterPays tiers: count → ×total-bet, highest qualifying tier wins. paytable: { 'chocolate': [{ min: 12, pay: 16 }, { min: 10, pay: 8 }, { min: 8, pay: 3 }], 'candy-cane': [{ min: 12, pay: 8 }, { min: 10, pay: 4 }, { min: 8, pay: 1.6 }], 'gummy': [{ min: 12, pay: 5 }, { min: 10, pay: 2.5 }, { min: 8, pay: 1 }], 'lollipop': [{ min: 12, pay: 4 }, { min: 10, pay: 1.6 }, { min: 8, pay: 0.6 }], 'jelly': [{ min: 12, pay: 3 }, { min: 10, pay: 1.2 }, { min: 8, pay: 0.5 }], 'mint': [{ min: 12, pay: 2.5 }, { min: 10, pay: 0.8 }, { min: 8, pay: 0.3 }], 'berry': [{ min: 12, pay: 2 }, { min: 10, pay: 0.6 }, { min: 8, pay: 0.25 }], 'sugar': [{ min: 12, pay: 1.5 }, { min: 10, pay: 0.5 }, { min: 8, pay: 0.15 }], }, wild: null, scatter: null, features: { scatterPays: { min: 8 }, tumble: true, rainbow: { symbol: 'rainbow', chance: 0.015, // per refilled cell during tumbles values: [[2, 55], [3, 25], [5, 15], [10, 5]], }, }, theme: { cabinet: 0x6e2160, cabinetHi: 0x933582, trim: 0xff8ad2, glow: 0xffa3e0, screenTop: 0x35103a, screenBot: 0x16071d, accentHex: '#ffa3e0', reelBg: 0x230b2a, }, }, ]; export const MACHINE_BY_ID = Object.fromEntries(MACHINES.map((m) => [m.id, m])); // Resolve strips once; consumers should use machine.strips (expanded). // Hand-authored strips (all single entries) keep their written order; // compressed strips are interleaved so identical symbols never clump. for (const m of MACHINES) { m.strips = m.reelStrips.map((s) => (s.some(Array.isArray) ? interleaveStrip(s) : expandStrip(s))); }