// Stoopshake: free 3D street dice (classic Cee-lo, 4-5-6) on a night-time stoop. Real-physics dice (dice3d.js), the rules // engine (rules.js), an original soundtrack (audio.js). Modes: Quick 1v1, Table (up to 6, pot), Banker, the Daily Table, // pass-and-play, async challenge links (state in the URL, no server), Crew tables (Crew Pass), Telegram Mini App + group // chats (/dice on the bot, rolled with Telegram's own animated dice). Free play chips only: daily top-up, earned by playing, // never bought, sold or cashed out; no prizes. Paid = looks and crew features only, never odds. (c) 2026 CyberMax. import { count, store, toast, share, download } from './cmx.js'; import * as FP from './funpass.js'; import * as pack from './pack.js'; import * as TGM from './tg.js'; import * as R from './rules.js'; import * as A from './audio.js'; const GAME = 'stoopshake'; const SITE = 'https://cybermaxtools.com'; const PATH = '/games/stoopshake/'; const DATA = 'https://data.cybermaxtools.com'; const BOT_LINK = 'https://t.me/CyberMaxTools_bot'; const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const sleep = (ms) => new Promise((r) => setTimeout(r, ms * (CAP ? 0 : FAST ? 0.15 : 1))); const Q = new URLSearchParams(location.search); const CAP = Q.has('cap'); // trailer / screenshots: frame-stepped from outside (window.__cap) const FAST = Q.has('fast'); // tests: shorter pauses const TODAY = R.dayNumber(); const dz = (d) => (d || []).map(R.face).join(''); // ------------------------------------------------------------------ paid extras (cosmetic / convenience only) pack.capture({ game: GAME, name: 'Stoopshake', title: 'Dice & Table Pack', skins: 4, price: '$1.99', preview: ['#D9DDE3', '#E6B54A', '#E4D6B8', '#3CFFE0'], perks: ['4 dice: Chrome, Gold, Bone and Neon', '3 tables: Brownstone Stoop, Rooftop and Arcade', '5 win emotes for your big rolls'] }); const CREW_PERKS = ['Crew tables: save your crew (up to 6) and seat them in one tap', 'Your crew name and colours on the dice and every share card', 'Stats history: every round, win rate, best rolls', '2 more tables: Night Court and Boardwalk']; // Named exactly as the Stripe product (prod "Stoopshake Crew Pass: crew tables, colours and stats"); the Crew Pass also // turns on every Dice & Table Pack look (ownPack() below), so the sheet says so. Every mode stays free (17 Oct 2026). FP.init({ game: GAME, name: 'Stoopshake', title: 'Stoopshake Crew Pass', short: 'Crew Pass', exact: true, free: 'Every mode, the Daily Table and the daily free chips stay free for everyone.', perks: [...CREW_PERKS, 'Also every Dice & Table Pack look: 4 dice, 3 tables, 5 emotes'], extra: 'The Crew Pass opens crew tables, your crew name and colours, stats history and two more tables. Same dice odds for everyone.' }); const ownPack = () => pack.owned() || FP.isPro(); const ownCrew = () => FP.isPro(); const EMOTES = [{ id: 'none', t: '' }, { id: 'mic', t: '🎀 MIC DROP' }, { id: 'ice', t: '🧊 ICE COLD' }, { id: 'easy', t: '😀 TOO EASY' }, { id: 'crown', t: 'πŸ‘‘ RUN IT BACK' }, { id: 'dance', t: 'πŸ•Ί DANCE BREAK' }]; // ------------------------------------------------------------------ saved on this device only const DEF = { name: '', wallet: null, rules: false, skin: 'classic', theme: 'corner', emote: 'none', daily: {}, hist: [], crew: { name: 'MY CREW', a: '#E23B5A', b: '#FFFFFF', members: [] }, board: false, dev: '', locals: [] }; const S = Object.assign({}, DEF, store('ssk:s') || {}); if (!S.dev) S.dev = Math.random().toString(36).slice(2, 12); if (!S.name) S.name = ['ACE', 'BLAZE', 'DOT', 'KASH', 'NOVA', 'RICO', 'JAY', 'MIKA', 'ZEE', 'LUX'][Math.floor(Math.random() * 10)] + (10 + Math.floor(Math.random() * 89)); const save = () => store('ssk:s', S); let gift = 0; ({ wallet: S.wallet, gift } = R.dailyRefill(S.wallet, TODAY)); save(); // ------------------------------------------------------------------ challenge link from a friend (?c=...) let chal = Q.get('c') ? R.decodeChallenge(Q.get('c')) : null; // ------------------------------------------------------------------ 3D boot let table = null, DT = null; const cv = $('c'); async function boot() { const ok = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })(); if (!ok) { $('loading').hidden = true; $('nogl').hidden = false; count(GAME, 'nogl'); return; } $('lbar').style.width = '35%'; DT = await import('./dice3d.js'); $('lbar').style.width = '75%'; try { await document.fonts.load('800 20px "Barlow Condensed"'); } catch { /* */ } table = new DT.DiceTable(cv, { theme: S.theme, skin: skinFor(), record: CAP, onImpact: (k, v) => A.hit(k, v), onSlow: () => { A.whoosh(0.3); $('app').classList.add('slow'); } }); if (S.skin === 'crew') table.setSkin('crew', S.crew); if (FAST) { table.speed = 3; table.stage.setQuality(0.5); table.stage.q.bloom = false; table.r.shadowMap.enabled = false; } // tests on software GL: same physics, faster clock, cheap frames addEventListener('resize', () => table.resize()); table.resize(); // compile every shader behind the loading screen (parallel where the GPU allows), so the first frame doesn't stall $('lbar').style.width = '90%'; const tc = performance.now(); try { table.stage.setCam(table.camPos, table.camLook); await table.r.compileAsync(table.scene, table.stage.cam); } catch { /* older three: compiles on first frame */ } table.update(1 / 60); // first frame (post-processing programs) window.__sskCompile = Math.round(performance.now() - tc); $('loading').hidden = true; count(GAME, 'view', TGM.app() ? 'telegram' : chal ? 'challenge' : ''); if (!CAP) requestAnimationFrame(loop); window.__ssk = { ready: true, readyAt: Math.round(performance.now()), rolls: 0, state: () => ({ mode: M && M.mode, turn: turnSeat && turnSeat.name, chips: S.wallet.chips, waiting: !!waiting }), table }; renderTop(); if (gift > 0 && S.wallet.played > 0) setTimeout(() => toast(`Daily top-up: +${gift} free chips. Come back tomorrow for more.`, 3500), 600); if (chal) home(); else if (!S.rules) { $('rules').hidden = false; } else home(); } const skinFor = () => { const sk = DT.SKINS[S.skin]; if (!sk || (sk.pack === 'skins' && !ownPack()) || (sk.pack === 'crew' && !ownCrew())) return 'classic'; return S.skin; }; const themeFor = () => { const th = DT.THEMES[S.theme]; if (!th || (th.pack === 'skins' && !ownPack()) || (th.pack === 'crew' && !ownCrew())) return 'corner'; return S.theme; }; let last = performance.now(); function loop(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; if (holding && table.state === 'hand') { table.shake(holdX, holdK); A.rattle(holdK); holdK = Math.max(0.35, holdK * 0.96); } table.update(dt); rec.frame(); if (table.state !== 'rolling') $('app').classList.remove('slow'); requestAnimationFrame(loop); } // frame-stepped capture for the trailer and store shots (no wall clock) window.__cap = { step: (dt = 1 / 30, draw = true) => { if (!table) return; table.noDraw = !draw; table.update(dt); table.noDraw = false; }, ready: () => !!table, // trailer + store shots: drive the real scene and the real HUD from outside (tests/trailer.mjs) hideAll: () => { hideAll(); actions([]); }, seats: (list, turn) => { M = { mode: 'cap', seats: list.map((x) => ({ ...x, last: x.last || null, sc: x.last ? R.score(x.last) : null })), chipsShown: !!list[0].chips, potLabel: () => list.pot || '' }; turnSeat = M.seats[turn ?? -1] || null; renderSeats(); }, pot: (t) => { $('pot').textContent = t; }, banner: (kind, text, sub, emote) => { const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `${esc(sub)}` : ''}${emote ? `${esc(emote)}` : ''}`; b.classList.add('show'); }, unbanner: () => $('banner').classList.remove('show'), talk: (who, text) => { const t = $('talk'); t.innerHTML = `${esc(who)}: ${esc(text)}`; t.classList.add('show'); }, untalk: () => $('talk').classList.remove('show'), hint: (t) => { $('hint').textContent = t || ''; $('hint').classList.toggle('hide', !t); }, roll: (on) => { $('roll').disabled = !on; }, hud: (on) => { $('hud').style.display = on ? '' : 'none'; }, theme: (id) => { table.setTheme(id); table.hand(0); }, skin: (id) => table.setSkin(id, S.crew), find: (want, from = 1, n = 400, power = 0.62) => { for (let s = from; s < from + n; s++) { const r = table.simulate({ seed: s, power }); if (!r.off && !r.cocked && R.score(r.dice).kind === want) return { seed: s, dice: r.dice }; } return null; }, throwSeed: (seed, power = 0.62) => { table.hand(0); table.throw({ seed, power }); }, state: () => table.state, celebrate: (k) => table.celebrate(k), shake: (x, k) => table.shake(x, k), hand: (x) => table.hand(x || 0), read: () => table.read().map((r) => r.val), score: () => R.score(table.read().map((r) => r.val)), }; // ------------------------------------------------------------------ input: hold to shake, flick to throw let waiting = null, holding = false, holdX = 0, holdK = 0, trail = []; const toX = (clientX) => { const r = cv.getBoundingClientRect(); return ((clientX - r.left) / r.width - 0.5) * 8; }; cv.addEventListener('pointerdown', (e) => { A.ensure(); A.startMusic(); if (!waiting || table.state !== 'hand') return; holding = true; holdX = toX(e.clientX); holdK = 1; trail = [{ x: e.clientX, y: e.clientY, t: performance.now() }]; try { cv.setPointerCapture(e.pointerId); } catch { /* */ } $('hint').classList.add('hide'); }); cv.addEventListener('pointermove', (e) => { if (!holding) return; const p = { x: e.clientX, y: e.clientY, t: performance.now() }; const q = trail[trail.length - 1]; holdX = toX(e.clientX); holdK = Math.min(1, holdK + Math.hypot(p.x - q.x, p.y - q.y) / 60); trail.push(p); if (trail.length > 30) trail.shift(); }); const endHold = (e) => { if (!holding) return; holding = false; const now = performance.now(); const pts = trail.filter((p) => now - p.t < 110); const a = pts[0] || trail[0], b = trail[trail.length - 1]; const dt = Math.max(16, b.t - a.t), vx = (b.x - a.x) / dt, vy = (b.y - a.y) / dt; // px per ms const r = cv.getBoundingClientRect(), k = 900 / Math.max(320, r.height); const flick = -vy * k; const power = flick > 0.25 ? Math.min(1, 0.3 + flick * 0.42) : 0.55; // a tap or a sideways drag = a steady toss doThrow({ x: Math.max(-1, Math.min(1, vx * k * 0.6)), power }); }; cv.addEventListener('pointerup', endHold); cv.addEventListener('pointercancel', endHold); addEventListener('keydown', (e) => { if ((e.key === ' ' || e.key === 'Enter') && waiting && document.activeElement === cv) { e.preventDefault(); doThrow({ power: 0.6 + Math.random() * 0.3, x: (Math.random() - 0.5) * 0.4 }); } }); $('roll').onclick = () => { A.ensure(); A.startMusic(); if (waiting) doThrow({ power: 0.55 + Math.random() * 0.4, x: (Math.random() - 0.5) * 0.5 }); }; function doThrow(o) { if (!waiting || table.state !== 'hand') return; if (window.__ssk && !window.__ssk.firstThrow) window.__ssk.firstThrow = Math.round(performance.now()); const w = waiting; waiting = null; $('roll').disabled = true; A.whoosh(o.power); TGM.haptic('medium'); w(o); } // ------------------------------------------------------------------ clip recorder (on this device only, watermark link) const rec = (() => { let mr = null, chunks = [], c2 = null, g = null, type = '', on = false; const ok = () => !CAP && window.MediaRecorder && HTMLCanvasElement.prototype.captureStream; return { start() { if (!ok() || on) return; type = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find((t) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)); if (!type) return; const w = 720, h = Math.round(720 * cv.clientHeight / Math.max(1, cv.clientWidth)); c2 = c2 || document.createElement('canvas'); c2.width = w; c2.height = Math.min(1280, Math.max(480, h)); g = c2.getContext('2d'); try { const s = c2.captureStream(30); const au = A.stream(); if (au) au.getAudioTracks().forEach((t) => s.addTrack(t)); mr = new MediaRecorder(s, { mimeType: type, videoBitsPerSecond: 4_000_000 }); } catch { mr = null; return; } chunks = []; mr.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; mr.start(250); on = true; }, frame() { if (!on || !g) return; const W = c2.width, H = c2.height, sw = cv.width, sh = cv.height, k = Math.max(W / sw, H / sh); g.drawImage(cv, (W - sw * k) / 2, (H - sh * k) / 2, sw * k, sh * k); g.fillStyle = 'rgba(0,0,0,.55)'; g.fillRect(0, H - 54, W, 54); g.font = '800 30px "Barlow Condensed", sans-serif'; g.fillStyle = '#FFC56B'; g.textBaseline = 'middle'; g.fillText('STOOPSHAKE', 16, H - 27); g.fillStyle = '#FFFFFF'; g.textAlign = 'right'; g.font = '700 22px "Barlow Condensed", sans-serif'; g.fillText('cybermaxtools.com/games/stoopshake Β· free play chips', W - 16, H - 27); g.textAlign = 'left'; }, stop() { return new Promise((res) => { if (!on || !mr) { res(null); return; } on = false; mr.onstop = () => res(chunks.length ? { blob: new Blob(chunks, { type: type.split(';')[0] }), ext: type.includes('mp4') ? 'mp4' : 'webm' } : null); try { mr.stop(); } catch { res(null); } }); }, }; })(); let clip = null; // ------------------------------------------------------------------ UI bits function renderTop() { $('chipsN').textContent = S.wallet.chips.toLocaleString('en-US'); const rk = R.rankOf(S.wallet.rep); $('rank').textContent = rk.name.toUpperCase(); $('b-snd').classList.toggle('off', !A.settings().music); document.body.classList.toggle('cmx-pro', ownCrew()); } let bannerT = 0; function banner(kind, text, sub = '', ms = 1700, emote = '') { const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `${esc(sub)}` : ''}${emote ? `${esc(emote)}` : ''}`; requestAnimationFrame(() => b.classList.add('show')); clearTimeout(bannerT); bannerT = setTimeout(() => b.classList.remove('show'), ms * (FAST ? 0.3 : 1)); } let talkT = 0; function talk(who, text, ms = 2600) { const t = $('talk'); t.innerHTML = `${esc(who)}: ${esc(text)}`; t.classList.add('show'); clearTimeout(talkT); talkT = setTimeout(() => t.classList.remove('show'), ms); } function renderSeats() { if (!M) { $('seats').innerHTML = ''; $('pot').textContent = ''; return; } $('seats').innerHTML = M.seats.map((s, i) => `
${M.bank === i ? 'BANK' : ''}${esc(s.name)}` + `${s.last ? dz(s.last) : ' '}${s.sc ? esc(s.sc.short) : s.kind === 'bot' ? esc(s.bot.vibe) : s.kind === 'me' ? 'you' : ' '}${M.chipsShown && s.chips != null ? ` Β· β—‰${s.chips}` : ''}
`).join(''); $('pot').textContent = M.potLabel ? M.potLabel() : ''; } function actions(btns) { $('actions').innerHTML = ''; for (const [label, fn, cls] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'chipb'; b.textContent = label; b.onclick = fn; $('actions').appendChild(b); } } const hideAll = () => { for (const id of ['home', 'rules', 'setup', 'pass', 'end', 'style', 'board', 'me', 'shop']) $(id).hidden = true; }; // ------------------------------------------------------------------ one turn: a seat rolls until it scores let M = null, turnSeat = null; async function rollFor(seat, { label } = {}) { turnSeat = seat; renderSeats(); for (let tries = 0; tries < 40; tries++) { table.hand(seat.kind === 'bot' ? (Math.random() - 0.5) * 3 : 0); let throwOpts; if (seat.kind === 'bot') { $('roll').disabled = true; $('hint').classList.add('hide'); await sleep(450 + Math.random() * 400); if (tries === 0 && Math.random() < 0.45) talk(seat.name, R.line(seat.bot, 'hi')); const t0 = performance.now(); while (performance.now() - t0 < (CAP ? 0 : FAST ? 120 : 700)) { table.shake((Math.random() - 0.5) * 2, 1); A.rattle(0.8); await sleep(50); } throwOpts = { power: seat.bot.power * (0.75 + Math.random() * 0.25), x: (Math.random() - 0.5) * 0.6 }; A.whoosh(throwOpts.power); } else { if (seat.kind === 'local' && M.locals > 1) { $('pass-n').textContent = seat.name.toUpperCase(); $('pass').hidden = false; await new Promise((r) => { $('pass-ok').onclick = () => { $('pass').hidden = true; r(); }; }); } $('roll').disabled = false; $('hint').textContent = `${seat.kind === 'me' ? 'Your roll' : `${seat.name}'s roll`} Β· hold to shake, flick up to throw`; $('hint').classList.remove('hide'); throwOpts = await new Promise((r) => { waiting = r; }); } rec.start(); const res = await table.throw(throwOpts); window.__ssk && (window.__ssk.rolls += 1); if (res.off) { banner('none', 'OFF THE CURB!', 'Free re-roll', 1300); A.sting('none'); await sleep(1000); continue; } if (res.cocked) { banner('none', 'COCKED DIE', 'Tilted against something. Free re-roll', 1400); A.sting('none'); await sleep(1100); continue; } const sc = R.score(res.dice); seat.last = res.dice; seat.sc = R.scores(sc) ? sc : null; renderSeats(); if (!R.scores(sc)) { banner('none', 'NO SCORE', `${dz(res.dice)} Β· roll again`, 1100); A.sting('none'); await sleep(950); continue; } table.celebrate(sc.kind); A.sting(sc.kind); const big = sc.kind === '456' || sc.kind === 'trips' || sc.kind === '123'; banner(sc.kind === '456' ? '456' : sc.kind, sc.kind === '456' ? '4-5-6!' : sc.kind === '123' ? '1-2-3' : sc.kind === 'trips' ? `TRIP ${sc.value}s` : `POINT ${sc.value}`, `${seat.name} Β· ${sc.kind === '456' ? 'instant win' : sc.kind === '123' ? 'instant loss' : sc.kind === 'trips' ? 'beats any point' : 'the odd die'}`, big ? 2300 : 1500); if (seat.kind === 'bot' && big) setTimeout(() => talk(seat.name, R.line(seat.bot, 'big')), 900); if (big && seat.kind !== 'bot') TGM.haptic(sc.kind === '123' ? 'error' : 'success'); await sleep(big ? 1500 : 900); const c = await rec.stop(); if (c && big) { clip = { ...c, label: `${seat.name}-${sc.short}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-') }; } return { dice: res.dice, sc }; } return { dice: [2, 2, 1], sc: R.score([2, 2, 1]) }; } // ------------------------------------------------------------------ seats const ME = () => ({ id: 'me', name: S.name, kind: 'me', color: '#FFC56B' }); const botSeat = (id) => { const b = R.botById(id); return { id, name: b.name, kind: 'bot', bot: b, color: b.color, chips: 1000 }; }; const pickBots = (n) => { const ids = R.BOTS.map((b) => b.id).sort(() => Math.random() - 0.5); return ids.slice(0, n).map(botSeat); }; function settleMe(delta, sc, won) { const before = R.rankOf(S.wallet.rep).index; S.wallet = R.settle(S.wallet, { delta, sc, won }); S.hist.push({ d: TODAY, m: M ? M.mode : '', w: won ? 1 : won === false ? 0 : -1, c: delta, k: sc ? sc.kind : '' }); if (S.hist.length > 400) S.hist.splice(0, S.hist.length - 400); save(); renderTop(); if (delta) A.chips(Math.min(8, 2 + Math.round(Math.abs(delta) / 25))); const after = R.rankOf(S.wallet.rep); if (after.index > before) setTimeout(() => { banner('trips', `RANK UP`, after.name.toUpperCase(), 2400); A.sting('trips'); }, 1200); } const myEmote = () => (ownPack() ? (EMOTES.find((e) => e.id === S.emote) || EMOTES[0]).t : ''); // ------------------------------------------------------------------ modes async function runDuel({ stake, opp, practice = false }) { const me = ME(), them = opp || pickBots(1)[0]; M = { mode: practice ? 'practice' : 'duel', seats: [me, them], chipsShown: false, potLabel: () => (practice ? 'PRACTICE Β· no chips' : `STAKE β—‰${stake} each`) }; let first = 0; count(GAME, 'play', M.mode); for (;;) { M.seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; }); renderSeats(); const a = M.seats[first], b = M.seats[1 - first]; const ra = await rollFor(a); let r = R.duel(ra.sc, null), rb = null; if (r.winner === null) { rb = await rollFor(b); r = R.duel(ra.sc, rb.sc); } if (r.winner === null) { banner('point', 'TIE!', 'Shoot-off', 1300); await sleep(1300); continue; } const winner = r.winner === 0 ? a : b; winner.win = true; renderSeats(); const iWon = winner === me, mySc = a === me ? ra.sc : rb && rb.sc; if (!practice) settleMe(iWon ? stake : -stake, mySc, iWon); if (them.kind === 'bot') setTimeout(() => talk(them.name, R.line(them.bot, iWon ? 'lose' : 'win')), 400); A.sting(iWon ? 'win' : 'lose'); await sleep(500); banner(iWon ? '456' : '123', iWon ? 'YOU WIN' : `${winner.name.toUpperCase()} WINS`, practice ? '' : `${iWon ? '+' : '-'}${stake} chips`, 2000, iWon ? myEmote() : ''); await sleep(1800); const again = await endChoice(iWon, practice ? null : stake); if (again !== 'again') return; if (!practice && S.wallet.chips < stake) { broke(); return; } first = iWon ? 1 : 0; // loser of the last round shoots first } } async function runTable({ stake, seats, mode = 'table', rounds = 0, daily = null }) { M = { mode, seats, chipsShown: true, pot: 0, potLabel: () => `POT β—‰${M.pot}` }; count(GAME, 'play', daily ? 'daily' : mode); const log = []; for (let round = 1; ; round++) { seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; }); M.pot = 0; for (const s of seats) { const st = s.kind === 'me' ? stake : Math.min(stake, s.chips); s.stake = st; s.chips = (s.chips ?? 0) - (s.kind === 'me' ? 0 : st); M.pot += st; } renderSeats(); A.chips(5); if (daily) banner('point', `ROUND ${round}/${rounds}`, daily.label, 1200); await sleep(daily ? 1100 : 300); let live = [...seats], winners = null, mySc = null; for (;;) { const res = []; for (const s of live) { const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (s.kind === 'me') mySc = r.sc; if (r.sc.kind === '456') break; if (r.sc.kind === '123') { s.out = true; renderSeats(); } } const tw = R.tableWinners(res); if (tw.winners.length === 1) { winners = tw.winners; break; } banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); live = tw.winners; live.forEach((s) => { s.last = null; s.sc = null; }); renderSeats(); } const w = winners[0]; w.win = true; if (w.kind !== 'me') w.chips += M.pot; const iWon = w.kind === 'me' || (w.kind === 'local' && false); if (seats.some((s) => s.kind === 'me')) settleMe(iWon ? M.pot - stake : -stake, mySc, iWon); if (w.kind === 'local') { w.chips = (w.chips ?? 500) + M.pot; } renderSeats(); A.sting(iWon ? 'win' : 'lose'); banner(iWon ? '456' : 'point', iWon ? 'YOU TAKE THE POT' : `${w.name.toUpperCase()} TAKES IT`, `β—‰${M.pot}`, 2000, iWon ? myEmote() : ''); if (w.kind === 'bot') setTimeout(() => talk(w.name, R.line(w.bot, 'win')), 500); log.push({ sc: mySc, won: iWon, delta: iWon ? M.pot - stake : -stake }); await sleep(2000); if (daily && round >= rounds) return finishDaily(daily, log); if (daily) { if (S.wallet.chips < stake) { broke(); return; } continue; } const again = await endChoice(iWon, stake, true); if (again !== 'again') return; if (seats.some((s) => s.kind === 'me') && S.wallet.chips < stake) { broke(); return; } seats.push(seats.shift()); // the deal moves round the table } } async function runBanker({ stake }) { const me = ME(); const bots = pickBots(3); const seats = [me, ...bots]; M = { mode: 'banker', seats, chipsShown: true, bank: 1, potLabel: () => `BANK: ${seats[M.bank].name}` }; count(GAME, 'play', 'banker'); for (;;) { seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; s.stake = s.kind === 'me' ? stake : R.botStake(s.bot, s.chips, 10); }); const bank = seats[M.bank]; renderSeats(); banner('point', `${bank.name.toUpperCase()} HAS THE BANK`, bank.kind === 'me' ? 'You cover every bet' : `Your stake β—‰${stake}`, 1500); await sleep(1400); const br = await rollFor(bank); let next = M.bank; let myDelta = 0, mySc = bank.kind === 'me' ? br.sc : null; const players = seats.filter((s) => s !== bank); if (br.sc.kind === '456' || br.sc.kind === '123') { const o = R.bankerSettle(br.sc, null); banner(o === 'lose' ? '456' : '123', o === 'lose' ? 'BANK TAKES ALL' : 'BANK PAYS ALL', '', 1800); await sleep(1600); for (const p of players) { const d = o === 'win' ? p.stake : -p.stake; if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d; } } else { for (const p of players) { const pr = await rollFor(p); if (p.kind === 'me') mySc = pr.sc; const o = R.bankerSettle(br.sc, pr.sc); const d = o === 'win' ? p.stake : o === 'lose' ? -p.stake : 0; if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d; p.win = o === 'win'; renderSeats(); banner(o === 'win' ? 'point' : o === 'push' ? 'none' : '123', o === 'win' ? `${p.name.toUpperCase()} BEATS THE BANK` : o === 'push' ? 'PUSH' : 'BANK WINS', o === 'push' ? 'Stake back' : `β—‰${p.stake}`, 1300); await sleep(1200); if (R.takesBank(br.sc, pr.sc, o) && next === M.bank) next = seats.indexOf(p); } } const won = myDelta > 0 ? true : myDelta < 0 ? false : null; settleMe(myDelta, mySc, won); banner(myDelta >= 0 ? '456' : '123', myDelta > 0 ? 'UP THIS ROUND' : myDelta < 0 ? 'DOWN THIS ROUND' : 'EVEN', `${myDelta >= 0 ? '+' : ''}${myDelta} chips`, 1800, won ? myEmote() : ''); await sleep(1700); if (next !== M.bank) { toast(`${seats[next].name} beat the bank with ${seats[next].sc.short} and takes the bank.`, 2600); M.bank = next; } else if (Math.random() < 0.3) M.bank = (M.bank + 1) % seats.length; const again = await endChoice(won, stake, true); if (again !== 'again') return; if (S.wallet.chips < stake) { broke(); return; } } } // pass-and-play / crew: local players only, session chips (500 each) async function runLocal(names, crew = false) { const seats = names.map((n, i) => ({ id: `l${i}`, name: n, kind: 'local', color: ['#FFC56B', '#2FE6FF', '#FF3D6E', '#7CF7C8', '#C9A2FF', '#FF9A3D'][i % 6], chips: 500 })); if (crew) seats.forEach((s) => { s.color = S.crew.a; }); M = { mode: crew ? 'crew' : 'local', seats, locals: seats.length, chipsShown: true, pot: 0, potLabel: () => `POT β—‰${M.pot}` }; count(GAME, 'play', M.mode); for (;;) { seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; }); M.pot = 0; for (const s of seats) { s.chips -= 25; M.pot += 25; } renderSeats(); let live = [...seats], w = null; for (;;) { const res = []; for (const s of live) { const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (r.sc.kind === '456') break; if (r.sc.kind === '123') { s.out = true; renderSeats(); } } const tw = R.tableWinners(res); if (tw.winners.length === 1) { w = tw.winners[0]; break; } banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); live = tw.winners; } w.win = true; w.chips += M.pot; renderSeats(); A.sting('win'); banner('456', `${w.name.toUpperCase()} TAKES IT`, `β—‰${M.pot}`, 2000); if (crew) { S.crew.stats = S.crew.stats || {}; for (const s of seats) { const k = s.name; S.crew.stats[k] = S.crew.stats[k] || { p: 0, w: 0, best: 0 }; S.crew.stats[k].p++; if (s === w) S.crew.stats[k].w++; if (s.sc && s.sc.rank > S.crew.stats[k].best) S.crew.stats[k].best = s.sc.rank; } save(); } await sleep(2000); const again = await endChoice(null, null, true); if (again !== 'again') return; seats.push(seats.shift()); } } // async challenge link (no server): one turn per link async function runLinkTurn() { const st = chal; const me = st.t; const meName = st.n[me], them = st.n[1 - me]; const seat = { id: 'me', name: meName, kind: 'me', color: '#FFC56B' }, other = { id: 'them', name: them, kind: 'remote', color: '#2FE6FF', last: st.d, sc: st.d ? R.score(st.d) : null }; M = { mode: 'link', seats: me === 0 ? [seat, other] : [other, seat], chipsShown: false, potLabel: () => `${st.n[0]} ${st.s[0]} - ${st.s[1]} ${st.n[1]} Β· first to ${st.w}` }; count(GAME, 'play', 'link'); if (st.d) talk(them, `I rolled ${dz(st.d)} (${R.score(st.d).short}). Beat that.`, 4000); const r = await rollFor(seat); const out = R.challengeTurn(st, r.dice); chal = out.st; const code = R.encodeChallenge(chal); const url = `${SITE}${PATH}?c=${code}`; history.replaceState(null, '', `${location.pathname}?c=${code}`); const over = R.seriesOver(chal); let msg; if (out.round && out.round.tie) msg = `Tie on the point! Round ${chal.k} replays. ${them} rolls first.`; else if (out.round) msg = `${out.round.winner === me ? 'You take' : `${them} takes`} round ${chal.k - 1}. ${chal.n[0]} ${chal.s[0]} - ${chal.s[1]} ${chal.n[1]}.`; else msg = `You set ${r.sc.short}. Now ${them} has to beat it.`; if (over !== null) { msg = `${chal.n[over]} wins the series ${Math.max(...chal.s)}-${Math.min(...chal.s)}!`; if (over === me) settleMe(0, r.sc, true); } const text = over !== null ? `${msg} Rematch on Stoopshake:` : `🎲 ${meName} rolled ${dz(r.dice)} (${r.sc.short}) on Stoopshake. Your turn, ${them}. No real money, just bragging rights:`; const shareUrl = over !== null ? `${SITE}${PATH}?c=${R.encodeChallenge({ n: [chal.n[1], chal.n[0]], s: [0, 0], k: 1, t: 0, d: null, w: chal.w })}` : url; showEnd(`

CHALLENGE Β· FIRST TO ${chal.w}

${esc(chal.n[0])} ${chal.s[0]}-${chal.s[1]} ${esc(chal.n[1])}

${esc(msg)}

${dz(r.dice)}
` + `

${over !== null ? 'Send a rematch link:' : `Send this link to ${esc(them)} so they can take their turn (anywhere, any time):`}

`, [['πŸ“€ SEND TO ' + them.toUpperCase().slice(0, 12), () => shareLink(text, shareUrl), 'btn'], ['COPY LINK', () => copy(shareUrl), 'ghost'], ['HOME', () => { chal = null; history.replaceState(null, '', location.pathname); home(); }, 'ghost']]); } function shareLink(text, url) { count(GAME, 'share', 'challenge'); if (TGM.app()) { TGM.challenge(text, url); return; } share(GAME, text, url.replace(SITE, ''), null, null); } async function copy(t) { try { await navigator.clipboard.writeText(t); toast('Link copied.'); } catch { prompt('Copy this link', t); } } // daily table async function runDaily() { const d = R.dailyTable(TODAY); const seats = [ME(), ...d.lineup.map(botSeat)]; if (S.daily[TODAY]) toast('You already played today\'s table. This run is for fun and won\'t change your daily card.', 3500); if (themeFor() !== d.scene && DT.THEMES[d.scene] && !DT.THEMES[d.scene].pack) table.setTheme(d.scene); await runTable({ stake: Math.min(d.stake, Math.max(10, S.wallet.chips)), seats, mode: 'daily', rounds: d.rounds, daily: d }); } function finishDaily(d, log) { const net = log.reduce((a, x) => a + x.delta, 0); const first = !S.daily[TODAY]; const text = R.dailyCard(d.day, log, net); if (first) { S.daily[TODAY] = { net, em: text.split('\n')[1] }; save(); count(GAME, 'done', 'daily'); } table.setTheme(themeFor()); showEnd(`

${esc(d.label)}

${net >= 0 ? '+' : ''}${net}

chips over ${d.rounds} rounds

${esc(text)}
`, [['πŸ“€ SHARE', () => { count(GAME, 'share', 'daily'); if (TGM.app()) TGM.share(text, `${SITE}${PATH}`); else share(GAME, text, PATH, null, null); }, 'btn'], ['πŸ† BOARD', () => openBoard(), 'ghost'], ['HOME', home, 'ghost']]); } // ------------------------------------------------------------------ round end / home / setup function endChoice(won, stake, table_ = false) { return new Promise((res) => { const b = [['RUN IT BACK', () => { actions([]); res('again'); }, 'btn']]; if (clip) b.push(['🎬 SAVE CLIP', () => { download(clip.blob, `stoopshake-${clip.label}.${clip.ext}`); count(GAME, 'share', 'clip'); toast('Clip saved on this device. Post it anywhere.'); }, 'chipb']); if (won && S.wallet.winStreak >= 2) b.push([`πŸ”₯ STREAK ${S.wallet.winStreak} CARD`, () => streakCard(), 'chipb']); b.push(['HOME', () => { actions([]); res('home'); home(); }, 'chipb']); actions(b); $('hint').classList.add('hide'); }); } function broke() { M = null; renderSeats(); showEnd(`
OUT OF CHIPS

Free chips top up every day. Come back tomorrow, or keep playing the Practice table (no chips).

`, [['PRACTICE', () => { hideAll(); runDuel({ stake: 0, practice: true }); }, 'btn'], ['HOME', home, 'ghost']]); } function showEnd(html, btns) { hideAll(); actions([]); $('end-c').innerHTML = html + '

No real money. Just for fun.

'; for (const [l, f, c] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = c; b.textContent = l; b.onclick = f; $('end-b').appendChild(b); } $('end').hidden = false; } function home() { hideAll(); actions([]); M = null; turnSeat = null; renderSeats(); waiting = null; $('roll').disabled = true; if (table && table.state !== 'rolling') { if (table.themeId !== themeFor()) table.setTheme(themeFor()); table.hand(0); } const d = R.dailyTable(TODAY), done = S.daily[TODAY]; $('h-daily').textContent = `DAILY TABLE #${TODAY}${done ? ' βœ“' : ''}`; $('h-daily-s').textContent = done ? `Played: ${done.em} ${done.net >= 0 ? '+' : ''}${done.net}` : `${d.lineup.map((id) => R.botById(id).name).join(', ')} Β· ${d.rounds} rounds`; $('crew-lock').hidden = ownCrew(); const hc = $('h-chal'); if (chal) { hc.hidden = false; const them = chal.n[1 - chal.t]; hc.innerHTML = `${esc(them)} challenged you${chal.d ? ` and rolled ${dz(chal.d)} (${esc(R.score(chal.d).short)})` : ''}. Series ${chal.s[0]}-${chal.s[1]}, first to ${chal.w}.
`; $('h-go').onclick = () => { hideAll(); runLinkTurn(); }; } else hc.hidden = true; $('home').hidden = false; } function setup(mode) { if (mode === 'daily') { hideAll(); runDaily(); return; } if (mode === 'tg') { count(GAME, 'cta', 'tg-group'); window.open(`${BOT_LINK}?startgroup=dice`, '_blank', 'noopener'); return; } if (mode === 'crew' && !ownCrew()) { openCrew('crew-mode'); return; } const c = $('setup-c'); const stakes = R.STAKES.filter((s) => s <= Math.max(10, S.wallet.chips)); let stake = stakes.includes(25) ? 25 : stakes[0]; let n = mode === 'table' ? 4 : 2; const draw = () => { const stakeRow = `STAKE (free play chips Β· you have β—‰${S.wallet.chips})
${stakes.map((s) => ``).join('')}
`; if (mode === 'duel' || mode === 'banker') c.innerHTML = `

${mode === 'duel' ? 'QUICK 1v1' : 'BANKER'}

${stakeRow}
`; else if (mode === 'table') c.innerHTML = `

TABLE

SEATS (you + bots)
${[2, 3, 4, 5, 6].map((k) => ``).join('')}
${stakeRow}
`; else if (mode === 'local' || mode === 'crew') { const names = mode === 'crew' ? (S.crew.members.length ? S.crew.members : [S.name, '', '']) : (S.locals.length ? S.locals : [S.name, 'Player 2']); c.innerHTML = `

${mode === 'crew' ? esc(S.crew.name) : 'PASS & PLAY'}

${mode === 'crew' ? 'Your saved crew. Everyone shoots on this phone; crew stats are kept.' : 'Everyone on one phone. Each round: 25 table chips in the pot.'}

` + `
${[0, 1, 2, 3, 4, 5].map((i) => ``).join('')}
` + `
`; } else if (mode === 'link') c.innerHTML = `

CHALLENGE A FRIEND

You roll first, then send the link. Your friend rolls on their phone and sends it back. No account, no server, first to 3.

` + `YOUR NAMEFRIEND'S NAME
`; for (const b of c.querySelectorAll('[data-s]')) b.onclick = () => { stake = Number(b.dataset.s); A.tap(); draw(); }; for (const b of c.querySelectorAll('[data-n]')) b.onclick = () => { n = Number(b.dataset.n); A.tap(); draw(); }; $('back').onclick = home; $('go').onclick = () => { A.ensure(); A.startMusic(); hideAll(); if (mode === 'duel') runDuel({ stake }); else if (mode === 'banker') runBanker({ stake }); else if (mode === 'table') runTable({ stake, seats: [ME(), ...pickBots(n - 1)] }); else if (mode === 'local' || mode === 'crew') { const names = [...c.querySelectorAll('#nm input')].map((i) => R.cleanName(i.value)).filter((v, i, a) => v && v !== 'Player' || i < 2).slice(0, 6); const list = names.length >= 2 ? names : [S.name, 'Player 2']; if (mode === 'crew') S.crew.members = list; else S.locals = list; save(); runLocal(list, mode === 'crew'); } else if (mode === 'link') { S.name = R.cleanName($('n1').value) || S.name; save(); chal = { n: [S.name, R.cleanName($('n2').value || 'Friend')], s: [0, 0], k: 1, t: 0, d: null, w: 3 }; runLinkTurn(); } }; }; hideAll(); draw(); $('setup').hidden = false; } // ------------------------------------------------------------------ style, profile, board, shop function openStyle() { const c = $('style-c'); const lockP = (p) => (p === 'skins' && !ownPack()) || (p === 'crew' && !ownCrew()); const tagP = (p) => (p === 'skins' ? 'PACK' : p === 'crew' ? 'CREW' : ''); c.innerHTML = `

STYLE

DICE
${Object.entries(DT.SKINS).map(([id, s]) => ``).join('')}
` + `TABLE
${Object.entries(DT.THEMES).map(([id, t]) => ``).join('')}
` + `WIN EMOTE
${EMOTES.map((e) => ``).join('')}
` + (ownCrew() ? `CREW NAME AND COLOURS
` : '

Crew colours, crew tables and stats history come with the Crew Pass.

') + `

Looks only: every die rolls with exactly the same physics and odds.

`; for (const b of c.querySelectorAll('[data-k]')) b.onclick = () => { const s = DT.SKINS[b.dataset.k]; if (lockP(s.pack)) { s.pack === 'crew' ? openCrew('style-dice') : openPack('style-dice'); return; } S.skin = b.dataset.k; save(); table.setSkin(S.skin, S.crew); A.tap(); openStyle(); }; for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { const t = DT.THEMES[b.dataset.t]; if (lockP(t.pack)) { t.pack === 'crew' ? openCrew('style-table') : openPack('style-table'); return; } S.theme = b.dataset.t; save(); table.setTheme(S.theme); table.hand(0); A.tap(); openStyle(); }; for (const b of c.querySelectorAll('[data-e]')) b.onclick = () => { if (b.dataset.e !== 'none' && !ownPack()) { openPack('style-emote'); return; } S.emote = b.dataset.e; save(); A.tap(); openStyle(); }; if ($('csave')) $('csave').onclick = () => { S.crew.name = R.cleanName($('cn').value).toUpperCase(); S.crew.a = $('ca').value; S.crew.b = $('cb').value; save(); if (S.skin === 'crew') table.setSkin('crew', S.crew); toast('Crew saved.'); }; if ($('s-crew')) $('s-crew').onclick = (e) => { e.preventDefault(); openCrew('style'); }; $('s-done').onclick = home; hideAll(); $('style').hidden = false; } function openMe() { const w = S.wallet, rk = R.rankOf(w.rep), hist = ownCrew() ? S.hist : S.hist.slice(-10); const wr = w.played ? Math.round((w.won / w.played) * 100) : 0; $('me-c').innerHTML = `

${esc(S.name)}

${rk.name.toUpperCase()}${rk.next ? ` Β· ${rk.toNext} rep to ${rk.next.name}` : ' Β· top rank'}

` + `
` + `
${w.played}rounds
${wr}%won
${w.bestStreak || 0}best streak
${w.big.r456}4-5-6s
${w.big.trips}trips
${w.best ? esc(w.best.short) : '-'}best roll
` + `${ownCrew() ? `STATS HISTORY (last ${hist.length} rounds)` : 'LAST 10 ROUNDS (full history with the Crew Pass)'}
${hist.map((h) => ``).join('')}
` + (ownCrew() && S.crew.stats ? `${esc(S.crew.name)}${Object.entries(S.crew.stats).sort((a, b) => b[1].w - a[1].w).map(([n, s]) => ``).join('')}
${esc(n)}${s.w}/${s.p} won
` : '') + `NAME` + `
`; $('me-save').onclick = () => { S.name = R.cleanName($('me-n').value); save(); toast('Saved.'); }; $('me-card').onclick = () => streakCard(); $('me-done').onclick = home; hideAll(); $('me').hidden = false; } async function streakCard() { const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; const g = c.getContext('2d'); const w = S.wallet, rk = R.rankOf(w.rep); const bg = g.createLinearGradient(0, 0, 0, 1350); bg.addColorStop(0, '#1D1430'); bg.addColorStop(1, '#07060C'); g.fillStyle = bg; g.fillRect(0, 0, 1080, 1350); try { g.drawImage(cv, 0, 0, cv.width, cv.height, 0, 260, 1080, 1080 * cv.height / cv.width); } catch { /* */ } g.fillStyle = 'rgba(7,6,12,.55)'; g.fillRect(0, 0, 1080, 1350); g.textAlign = 'center'; g.fillStyle = '#FFC56B'; g.font = '120px Spray, "Barlow Condensed", sans-serif'; g.fillText('STOOPSHAKE', 540, 190); g.fillStyle = '#fff'; g.font = '800 110px "Barlow Condensed", sans-serif'; g.fillText(`πŸ”₯ ${w.winStreak || w.bestStreak || 0} WIN STREAK`, 540, 520); g.font = '800 64px "Barlow Condensed", sans-serif'; g.fillStyle = '#2FE6FF'; g.fillText(`${(ownCrew() ? S.crew.name + ' Β· ' : '')}${S.name} Β· ${rk.name.toUpperCase()}`, 540, 640); g.fillStyle = '#fff'; g.font = '800 54px "Barlow Condensed", sans-serif'; g.fillText(`Best roll: ${w.best ? w.best.short : '-'} Β· 4-5-6s: ${w.big.r456}`, 540, 760); g.font = '700 46px "Barlow Condensed", sans-serif'; g.fillStyle = '#B9B1C9'; g.fillText('Free 3D street dice Β· No real money. Just for fun.', 540, 1170); g.fillStyle = '#FFC56B'; g.fillText('cybermaxtools.com/games/stoopshake', 540, 1250); const blob = await new Promise((r) => c.toBlob(r, 'image/png')); count(GAME, 'share', 'streak'); const text = `πŸ”₯ ${w.winStreak || w.bestStreak || 0} win streak on Stoopshake (${rk.name}). Come take it from me:`; if (TGM.app()) { TGM.share(text, `${SITE}${PATH}`); return; } share(GAME, text, PATH, blob, 'stoopshake-streak.png'); } async function openBoard() { const reg = R.regionOf(Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.language); const c = $('board-c'); hideAll(); $('board').hidden = false; const top = `

BOARD

Ranked by rep (wins and big rolls). Region comes from your device's time zone (${esc(reg.label)}), never your exact location.

`; if (!S.board) { c.innerHTML = top + `

Show ${esc(S.name)} on the ${esc(reg.label)} and world boards? Only your nickname, rank and rep are sent, once a day.

`; $('bd-on').onclick = () => { S.board = true; save(); count(GAME, 'cta', 'board-join'); openBoard(); }; $('bd-x').onclick = home; return; } c.innerHTML = top + '

Loading…

'; let j = null; try { if (S.boardDay !== TODAY || S.boardRep !== S.wallet.rep) { const r = await fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, name: S.name, region: reg.id, label: reg.label, rep: S.wallet.rep, wins: S.wallet.won }) }); j = await r.json(); S.boardDay = TODAY; S.boardRep = S.wallet.rep; save(); } else { const r = await fetch(`${DATA}/dice/board?region=${encodeURIComponent(reg.id)}`); j = await r.json(); } } catch { j = null; } if (!j || !j.world) { c.innerHTML = top + '

The board is resting right now. Try again in a bit.

'; $('bd-b').onclick = home; return; } const rows = (list) => `${list.map((x, i) => ``).join('') || ''}
${i + 1}. ${esc(x.name)} ${esc(x.label || '')}${esc(R.rankOf(x.rep).name)} Β· ${x.rep}
Be the first.
`; let tab = 'near'; const draw = () => { c.innerHTML = top + `
` + (tab === 'near' ? rows(j.region || []) : tab === 'world' ? rows(j.world || []) : `${(j.regions || []).map((x, i) => ``).join('')}
${i + 1}. ${esc(x.label)}${x.players} players Β· ${x.rep}
`) + `
`; for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { tab = b.dataset.t; draw(); }; $('bd-b').onclick = home; $('bd-off').onclick = () => { S.board = false; save(); fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, leave: true }) }).catch(() => {}); home(); }; }; draw(); } function openShop() { const c = $('shop-c'); const tg = !!TGM.app(); c.innerHTML = `

EXTRAS

The game, every mode and the daily chips are free. Extras are looks and crew features, paid once. They never change the dice.

` + `

Stoopshake Crew Pass ${tg ? '235 ⭐' : '$2.99 once'}

` + `

Stoopshake Dice & Table Pack ${tg ? '155 ⭐' : '$1.99 once'}

` + (tg ? '' : `

Prices in USD; tax for your country is added at checkout. Play more than one CyberMax game? The CyberMax Fun Pass is a separate pass that covers every CyberMax game, both Stoopshake extras included. Chips can never be bought.

`) + `
`; $('sh-p').onclick = () => { if (!ownPack()) openPack('shop'); }; $('sh-c').onclick = () => { if (!ownCrew()) openCrew('shop'); }; $('sh-b').onclick = home; hideAll(); $('shop').hidden = false; count(GAME, 'cta', 'shop-open'); } // ------------------------------------------------------------------ Telegram Mini App let tgInfo = {}; function openCrew(from) { if (TGM.app()) TGM.sheet('stoopshake-full', { perks: CREW_PERKS, onPaid: claimTG }); else FP.open(from); } function openPack(from) { if (TGM.app()) TGM.sheet('stoopshake-skins', { perks: ['4 dice: Chrome, Gold, Bone, Neon', '3 tables: Stoop, Rooftop, Arcade', '5 win emotes', 'Looks only: same dice odds'], onPaid: claimTG }); else pack.open(from); } async function claimTG() { tgInfo = { ...tgInfo, ...(await TGM.status()) }; const codes = tgInfo.codes || {}; if (codes['stoopshake-full'] && !FP.ownsGame()) await FP.unlockGame(codes['stoopshake-full']); if (codes['stoopshake-skins'] && !pack.owned()) await pack.unlock(codes['stoopshake-skins']); renderTop(); } FP.onChange(() => renderTop()); pack.onChange(() => renderTop()); // ------------------------------------------------------------------ wiring for (const b of document.querySelectorAll('.mode')) b.onclick = () => { A.tap(); setup(b.dataset.m); }; $('rules-ok').onclick = () => { S.rules = true; save(); A.ensure(); A.startMusic(); hideAll(); if (chal) home(); else runDuel({ stake: Math.min(25, S.wallet.chips) || 0, practice: S.wallet.chips < 10 }); }; $('b-rules').onclick = () => { hideAll(); $('rules').hidden = false; $('rules-ok').textContent = 'BACK'; $('rules-ok').onclick = home; }; $('b-style').onclick = () => openStyle(); $('b-me').onclick = () => openMe(); $('b-board').onclick = () => openBoard(); $('b-shop').onclick = (e) => { e.preventDefault(); openShop(); }; $('b-extras').onclick = () => { A.tap(); openShop(); }; if (TGM.app()) $('b-extras').textContent = '✨ EXTRAS: CREW PASS · DICE & TABLE PACK'; $('b-menu').onclick = () => { A.tap(); if (M && !confirm('Leave this table? Chips already settled stay settled.')) return; location.hash = ''; if (M) { location.href = location.pathname + (chal ? `?c=${R.encodeChallenge(chal)}` : ''); return; } home(); }; $('b-snd').onclick = () => { A.ensure(); const on = !A.settings().music; A.setMusic(on); if (!on) A.stopMusic(); renderTop(); }; TGM.init({ onResize: () => table && table.resize(), onBack: () => home() }).then(async (W) => { if (!W) return; count(GAME, 'tg-open'); tgInfo = await TGM.hello(); claimTG(); }); boot();