Bingalinga!

Make a bingo card

Start from a theme or build your own. Everyone who opens your link gets their own shuffled card.

Start from a theme Custom / blank Card title Things to spot (one per line)
0 itemsneed at least 8 for a 3×3 card
Card size
3×3
4×4
5×5
6×6
Free center space (odd sizes only)
Bingalinga! — make a card, send a card :root{ –bg: #14121f; –paper: #fffcf5; –paper-line: #e8e2d0; –ink: #1b1b2f; –ink-soft: #55516b; –marigold: #f4b400; –coral: #ff5a5f; –teal: #12a594; –violet: #7c5cfc; –sky: #3aa0ff; –radius-card: 22px; –radius-cell: 12px; } * { box-sizing: border-box; } html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at 15% 10%, rgba(124,92,252,0.25), transparent 45%), radial-gradient(circle at 85% 90%, rgba(18,165,148,0.18), transparent 45%), var(–bg); color: var(–ink); font-family: ‘Inter’, sans-serif; -webkit-font-smoothing: antialiased; } body { display: flex; justify-content: center; padding: 40px 16px 80px; } .stage { width: 100%; max-width: 640px; } .brand { text-align: center; margin-bottom: 28px; } .brand .mark { display: inline-flex; align-items: center; gap: 8px; font-family: ‘Fredoka’, sans-serif; font-weight: 700; font-size: 15px; letter-spacing: 0.06em; text-transform: uppercase; color: #d9d5f0; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); padding: 7px 16px 7px 12px; border-radius: 999px; } .brand .mark .dot { width: 8px; height: 8px; border-radius: 50%; background: var(–marigold); box-shadow: 0 0 0 4px rgba(244,180,0,0.18); } .ticket { position: relative; background: var(–paper); border-radius: var(–radius-card); padding: 34px 30px 30px; box-shadow: 0 30px 60px -20px rgba(0,0,0,0.55), 0 2px 0 rgba(255,255,255,0.4) inset; transform: rotate(-0.6deg); } .ticket::before, .ticket::after{ content:””; position:absolute; left:0; right:0; height:14px; background-image: radial-gradient(circle, var(–bg) 6px, transparent 6.5px); background-size: 26px 14px; background-repeat: repeat-x; background-position: 6px center; } .ticket::before{ top:-7px; } .ticket::after{ bottom:-7px; transform: scaleY(-1); } h1.title { font-family: ‘Fredoka’, sans-serif; font-weight: 700; font-size: 30px; line-height: 1.05; margin: 4px 0 4px; letter-spacing: -0.01em; } .subtitle { color: var(–ink-soft); font-size: 14.5px; margin: 0 0 16px; } label.field-label { display:block; font-family:’Fredoka’, sans-serif; font-weight:600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(–ink-soft); margin: 20px 0 8px; } select.text-input, input.text-input, textarea.text-input { width: 100%; font-family: ‘Inter’, sans-serif; font-size: 16px; color: var(–ink); background: #fffef9; border: 2px solid var(–paper-line); border-radius: 12px; padding: 12px 14px; resize: vertical; transition: border-color .15s ease, box-shadow .15s ease; } select.text-input { appearance: none; cursor: pointer; background-image: linear-gradient(45deg, transparent 50%, var(–ink-soft) 50%), linear-gradient(135deg, var(–ink-soft) 50%, transparent 50%); background-position: calc(100% – 20px) center, calc(100% – 15px) center; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; } select.text-input:focus, input.text-input:focus, textarea.text-input:focus { outline: none; border-color: var(–violet); box-shadow: 0 0 0 4px rgba(124,92,252,0.15); } textarea.text-input { min-height: 132px; line-height: 1.55; } .hint { font-size: 12.5px; color: var(–ink-soft); margin-top: 6px; } .counter { font-family:’JetBrains Mono’, monospace; font-weight:600; } .counter.ok { color: var(–teal); } .counter.low { color: var(–coral); } .pill-row { display:flex; gap:10px; flex-wrap:wrap; } .pill { font-family:’Fredoka’, sans-serif; font-weight:600; font-size: 15px; padding: 10px 0; width: 64px; text-align:center; border-radius: 12px; border: 2px solid var(–paper-line); background: #fffef9; cursor:pointer; user-select:none; transition: transform .12s ease, border-color .12s ease, background .12s ease, color .12s ease; color: var(–ink); } .pill:hover{ transform: translateY(-2px); } .pill.active { background: var(–ink); border-color: var(–ink); color: var(–paper); } .toggle-row { display:flex; align-items:center; gap:10px; margin-top: 16px; font-size: 14.5px; color: var(–ink-soft); } .toggle-row input[type=”checkbox”]{ width:18px; height:18px; accent-color: var(–violet); } .toggle-row.disabled { opacity: 0.4; } .overflow-box { margin-top: 16px; background: #f6f2e6; border: 2px dashed #ddd3b0; border-radius: 12px; padding: 12px 14px; font-size: 13.5px; color: var(–ink-soft); } .overflow-box .ob-title { font-weight: 700; color: var(–ink); margin-bottom: 8px; font-family:’Fredoka’, sans-serif; } .overflow-box label { display:flex; align-items:flex-start; gap: 8px; margin-bottom: 6px; cursor:pointer; } .overflow-box input[type=”radio”]{ margin-top: 3px; accent-color: var(–violet); } .btn-primary { margin-top: 24px; width: 100%; font-family:’Fredoka’, sans-serif; font-weight: 700; font-size: 17px; letter-spacing: 0.01em; color: #1a1428; background: linear-gradient(180deg, #ffd23f, var(–marigold)); border: none; border-radius: 14px; padding: 15px 20px; cursor: pointer; box-shadow: 0 6px 0 #b98300, 0 12px 20px -6px rgba(244,180,0,0.5); transition: transform .08s ease, box-shadow .08s ease; } .btn-primary:active { transform: translateY(4px); box-shadow: 0 2px 0 #b98300, 0 6px 12px -6px rgba(244,180,0,0.5); } .error-msg { color: var(–coral); font-size: 13.5px; margin-top: 10px; font-weight: 600; } .play-header { text-align:center; margin-bottom: 6px; } .name-bar { display:flex; gap:8px; margin-top: 6px; align-items:center; } .name-bar input { flex:1; font-size: 14px; padding: 9px 12px; border-radius: 10px; border: 2px solid var(–paper-line); font-family:’Inter’,sans-serif; } .name-bar .name-label { font-size: 12.5px; color: var(–ink-soft); white-space:nowrap; } .share-bar { display:flex; gap: 8px; margin: 14px 0 6px; } .share-bar input { flex:1; font-family:’JetBrains Mono’, monospace; font-size: 13px; background:#f6f2e6; border: 2px solid var(–paper-line); border-radius: 10px; padding: 10px 12px; color: var(–ink-soft); } .btn-small { font-family:’Fredoka’, sans-serif; font-weight:600; font-size:14px; border-radius: 10px; border: 2px solid var(–ink); background: var(–ink); color: var(–paper); padding: 0 16px; cursor:pointer; white-space:nowrap; transition: transform .08s ease; } .btn-small:active{ transform: scale(0.96); } .btn-small.ghost { background: transparent; color: var(–ink); } .actions-row { display:flex; gap:10px; margin-top: 12px; flex-wrap: wrap; } .actions-row .btn-small { flex:1; padding: 10px 10px; font-size:13px; } .card-grid { margin: 24px auto 4px; display: grid; gap: 8px; width: 100%; aspect-ratio: 1 / 1; } .cell { position: relative; background: #fffef9; border: 2px solid var(–paper-line); border-radius: var(–radius-cell); display:flex; align-items:center; justify-content:center; text-align:center; padding: 6px; cursor: pointer; overflow: hidden; user-select:none; transition: border-color .12s ease, transform .08s ease; } .cell:hover { transform: translateY(-2px); border-color: #cfc8ae; } .cell .txt { font-size: clamp(9px, 2.1vw, 13.5px); font-weight: 600; line-height: 1.2; z-index: 2; color: var(–ink); word-break: break-word; } .cell.free .txt { font-family: ‘Fredoka’, sans-serif; font-weight: 700; font-size: clamp(11px, 2.6vw, 15px); color: #8a6d00; } .cell.free { background: #fff6d9; border-color: #f4dd8f; } .cell.marked .txt { color: rgba(27,27,47,0.35); } .daub { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; z-index:1; opacity:0; transform: scale(0.4) rotate(0deg); transition: opacity .18s ease, transform .28s cubic-bezier(.34,1.56,.64,1); pointer-events:none; } .cell.marked .daub { opacity: 0.85; transform: scale(1) rotate(var(–rot,0deg)); } .daub svg { width: 82%; height: 82%; } .win-banner { margin-top: 16px; text-align:center; font-family:’Fredoka’, sans-serif; font-weight:700; font-size: 26px; color: var(–coral); letter-spacing: 0.03em; opacity:0; transform: scale(0.6) rotate(-6deg); transition: opacity .3s ease, transform .4s cubic-bezier(.34,1.56,.64,1); } .win-banner.show { opacity:1; transform: scale(1) rotate(-4deg); } .leaderboard { margin-top: 22px; background: #f6f2e6; border-radius: 14px; padding: 16px 18px; border: 2px solid var(–paper-line); } .leaderboard .lb-head { display:flex; align-items:center; justify-content:space-between; margin-bottom: 10px; } .leaderboard .lb-title { font-family:’Fredoka’, sans-serif; font-weight:700; font-size: 15px; } .leaderboard .lb-refresh { background:none; border:none; cursor:pointer; font-size:16px; color: var(–ink-soft); } .lb-row { display:flex; justify-content:space-between; padding: 6px 0; font-size: 14px; border-top: 1px solid var(–paper-line); } .lb-row:first-child { border-top:none; } .lb-name { font-weight: 600; } .lb-score { font-family:’JetBrains Mono’, monospace; font-weight:700; color: var(–violet); } .lb-empty { font-size: 13px; color: var(–ink-soft); font-style: italic; padding: 6px 0; } .lb-note { font-size: 11.5px; color: var(–ink-soft); margin-top: 10px; } .lb-reset { font-size: 12px; color: var(–coral); background:none; border:none; cursor:pointer; text-decoration: underline; padding:0; margin-top: 8px; } .footer-note { text-align:center; color: #a29cc9; font-size: 12.5px; margin-top: 26px; line-height:1.6; } .hidden { display:none !important; } .confetti-piece { position: fixed; top: -20px; width: 9px; height: 14px; z-index: 999; animation: fall linear forwards; border-radius: 2px; } @keyframes fall { to { transform: translateY(110vh) rotate(540deg); opacity: 0.9; } } @media (max-width: 480px){ .ticket{ padding: 26px 18px 22px; } h1.title{ font-size: 25px; } } @media print { body { background: white !important; padding: 0; } .brand, .name-bar, .share-bar, .actions-row, .win-banner, .leaderboard, .footer-note, #builderView { display: none !important; } .ticket { box-shadow: none !important; transform: none !important; border-radius: 0; padding: 10px; } .ticket::before, .ticket::after { display:none; } .cell { break-inside: avoid; -webkit-print-color-adjust: exact; print-color-adjust: exact; } .cell.free { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } (function(){ const ACCENTS = [‘#ff5a5f’, ‘#12a594’, ‘#7c5cfc’, ‘#3aa0ff’, ‘#f4b400’]; // ———- THEMES ———- const THEMES = { ‘Night Out’: [“Too Drunk”,”Birthday Celebration”,”Skimpy Outfit”,”Bum a Smoke”,”Karaoke”,”A Fall”,”Live Music”,”Trade Clothing”,”Get a Phone Number”,”Group Photo”,”Buy a Drink”,”Buy Someone Else a Drink”,”Make a Bathroom Friend”,”Take a Shot”,”Woo Girls”,”Last Call”,”Heavy Pour”,”Sticky Floor”,”Get Something for Free”,”Compliment Someone New”,”Someone Compliments You”,”Phone-Zoned Table”,”Sloppy Make-Out Session”,”Give Out Your Phone Number”,”Dance”,”Bachelorette / Bachelor Party”,”Spill a Drink”,”Bar Fight Almost Breaks Out”,”Bouncer Checks ID Twice”,”Lost Phone Panic”,”Someone Cries in the Bathroom”,”Bartender Remembers Your Order”,”Someone Falls in Their Heels”,”Group Chant or Toast”,”Rideshare Surge Pricing Complaint”,”Someone Loses Their Jacket”,”Awkward Run-In with an Ex”,”Line for the Bathroom”], ‘Holiday Party’: [“Hang an Ornament”,”Compliment the Chef”,”Ask if You Can Help”,”Drunk Uncle”,”Ugly Sweater Winner”,”Mistletoe Dodging”,”Re-gifting Confession”,”Spill the Eggnog”,”Singing Carols”,”Santa Hat Selfie”,”Stealing the Best White Elephant Gift”,”Mariah Carey Playing”,”Complaining About Traffic”,”Fruitcake Sighting”,”Burned Gingerbread”,”Sneaking a Second Dessert”,”Awkward Co-worker Small Talk”,”Tangled Tree Lights”,”Sparkly Holiday Dress”,”Punch Bowl Refill”,”Festive Socks”,”Comparing Travel Plans”,”Showing off Pet Photos”,”Someone Asleep on the Couch”,”Red and Green Outfit”,”Checking the Football Score”,”Secret Santa Reveal”,”Someone Talks Politics”,”Holiday Playlist Request”,”Christmas Sweater Compliment”,”Plus-One Nobody Knows”,”Photo with Santa”,”Leftover Container Debate”,”Someone Forgets a Gift”,”Kids Opening Presents Early”,”Hot Cocoa Bar”,”Someone Falls Asleep by the Fireplace”,”Holiday Movie Reference”], ‘Road Trip’: [“Cow”,”Out-of-State License Plate”,”Rest Stop Bathroom Break”,”Gas Station Snack Run”,”Someone Falls Asleep”,”\”Are We There Yet?\””,”Road Construction”,”Speed Trap”,”Fast Food Drive-Thru”,”Windmill”,”Water Tower”,”Hitchhiker”,”Roadkill”,”Traffic Jam”,”Radio Station Loses Signal”,”Singing Along to the Radio”,”Someone Gets Carsick”,”Billboard for a Lawyer”,”Farm Animals”,”Train Crossing”,”Argument Over the AUX Cord”,”Wrong Turn”,”Someone Spills a Drink”,”State Welcome Sign”,”Toll Booth”,”Full Tank of Gas”,”Bug Splat on Windshield”,”Motorcycle Gang”,”Scenic Overlook Photo Stop”,”Someone Needs to Pee Immediately”,”Snack Debate”,”Flat Tire”,”GPS Recalculating”,”Classic Car Sighting”,”Funny Bumper Sticker”,”Rainbow”,”Someone Complains About the Temperature”,”Nap in the Backseat”], ‘Work Meeting’: [“\”Let’s Circle Back\””,”Someone’s on Mute”,”Awkward Silence”,”\”Can You See My Screen?\””,”Dog Barking in the Background”,”Someone Joins Late”,”\”Sorry, Go Ahead\””,”Meeting Runs Over Time”,”\”Let’s Take This Offline\””,”Buzzword (\”Synergy\”)”,”Someone Eating on Camera”,”Bad Wifi Freeze”,”\”Can Everyone Hear Me?\””,”Rescheduled Meeting”,”Small Talk About the Weather”,”Someone Multitasking (Obviously)”,”\”I’ll Send a Follow-Up Email\””,”Kid or Pet Interruption”,”Phone Rings Mid-Meeting”,”\”No Questions? Great.\””,”Screen Share Fumble”,”Someone Forgets to Unmute”,”Coffee Mug Visible”,”\”Per My Last Email\””,”Meeting Could’ve Been an Email”,”Someone Joins from Their Car”,”Awkward Laugh”,”\”Let’s Take a Step Back\””,”Someone’s Camera Is Off”,”Virtual Background Glitch”,”Long-Winded Story”,”\”Just to Piggyback on That\””,”Calendar Invite with No Agenda”,”Someone Checking Their Phone”,”\”Can We Push This to Next Week?\””,”Interrupted by a Notification Ding”,”\”Quick Question\” That Isn’t Quick”,”Meeting Ends Early (Miracle)”], ‘Wedding Reception’: [“Someone Cries During Vows”,”Best Man Forgets the Rings”,”Awkward Bouquet Toss”,”Open Bar Line”,”Kids Running Around”,”Someone Trips on the Dance Floor”,”Emotional Father-Daughter Dance”,”DJ Plays a Song Nobody Requested”,”Speech Runs Too Long”,”Someone Catches the Garter”,”Photo Booth Line”,”Awkward Plus-One”,”Complaints About the Seating Chart”,”Cake Smash”,”Electric Slide or Cha Cha Slide”,”Someone Gets Too Drunk”,”Wardrobe Malfunction”,”Flower Girl Meltdown”,”Toast Glass Clinking”,”Sneaky Selfie with the Couple”,”Late Arrival”,”Guest Book Signing”,”\”When’s the Next Wedding?\””,”Grandparents Dancing”,”Bridesmaid Dress Color Debate”,”Someone Loses a Shoe”,”First Dance Song Surprise”,”Centerpiece Compliment”,”Someone Sneaks Out Early”,”Overheard Ex Drama”,”Confetti or Sparkler Send-Off”,”Unsolicited Marriage Tip”,”Bartender Runs Out of Something”,”Wedding Hashtag Sighting”,”Officiant Cracks a Joke”,”Someone Asks About the Honeymoon”,”Couple’s Dog in the Wedding Party”,”Late-Night Snack Table Raid”] }; const themeSelect = document.getElementById(‘themeSelect’); Object.keys(THEMES).forEach(name => { const opt = document.createElement(‘option’); opt.value = name; opt.textContent = name; themeSelect.appendChild(opt); }); const builderView = document.getElementById(‘builderView’); const playView = document.getElementById(‘playView’); const cardTitleInput = document.getElementById(‘cardTitle’); const itemsInput = document.getElementById(‘itemsInput’); const itemCounter = document.getElementById(‘itemCounter’); const neededHint = document.getElementById(‘neededHint’); const sizeRow = document.getElementById(‘sizeRow’); const freeRow = document.getElementById(‘freeRow’); const freeSpaceCheckbox = document.getElementById(‘freeSpace’); const generateBtn = document.getElementById(‘generateBtn’); const errorMsg = document.getElementById(‘errorMsg’); const overflowBox = document.getElementById(‘overflowBox’); let size = 4; themeSelect.addEventListener(‘change’, () => { const name = themeSelect.value; if(!name){ cardTitleInput.value=”; itemsInput.value=”; } else { cardTitleInput.value = name + ‘ Bingo’; itemsInput.value = THEMES[name].join(‘\n’); } updateHints(); }); function neededCount(){ const odd = size % 2 === 1; const useFree = odd && freeSpaceCheckbox.checked; return size*size – (useFree ? 1 : 0); } function parseItems(){ return itemsInput.value.split(‘\n’).map(s => s.trim()).filter(Boolean); } function updateHints(){ const items = parseItems(); const need = neededCount(); itemCounter.textContent = items.length + ‘ item’ + (items.length===1?”:’s’); itemCounter.className = ‘counter ‘ + (items.length >= need ? ‘ok’ : ‘low’); neededHint.textContent = ‘need at least ‘ + need + ‘ for a ‘ + size + ‘×’ + size + ‘ card’; if(items.length > need){ overflowBox.classList.remove(‘hidden’); document.getElementById(‘ofNeed1’).textContent = need; document.getElementById(‘ofNeed2’).textContent = need; document.getElementById(‘ofTotal’).textContent = items.length; } else { overflowBox.classList.add(‘hidden’); } } sizeRow.addEventListener(‘click’, (e) => { const pill = e.target.closest(‘.pill’); if(!pill) return; […sizeRow.children].forEach(p => p.classList.remove(‘active’)); pill.classList.add(‘active’); size = parseInt(pill.dataset.size, 10); const odd = size % 2 === 1; freeRow.classList.toggle(‘disabled’, !odd); if(!odd) freeSpaceCheckbox.checked = false; freeSpaceCheckbox.disabled = !odd; updateHints(); }); freeSpaceCheckbox.addEventListener(‘change’, updateHints); itemsInput.addEventListener(‘input’, updateHints); freeSpaceCheckbox.disabled = true; function shuffle(arr){ const a = arr.slice(); for(let i=a.length-1;i>0;i–){ const j = Math.floor(Math.random()*(i+1)); [a[i],a[j]] = [a[j],a[i]]; } return a; } function encodeState(state){ const json = JSON.stringify(state); let b64 = btoa(encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(‘0x’+p1))); return b64.replace(/\+/g,’-‘).replace(/\//g,’_’).replace(/=+$/,”); } function decodeState(hash){ try{ let b64 = hash.replace(/-/g,’+’).replace(/_/g,’/’); while(b64.length % 4) b64 += ‘=’; const bin = atob(b64); const json = decodeURIComponent(Array.prototype.map.call(bin, c => ‘%’ + (’00’ + c.charCodeAt(0).toString(16)).slice(-2)).join(”)); return JSON.parse(json); }catch(e){ return null; } } function hashStr(str){ let hash = 5381; for(let i=0;i>> 0; } return hash.toString(36); } generateBtn.addEventListener(‘click’, () => { const title = cardTitleInput.value.trim() || ‘Bingo Card’; const items = parseItems(); const need = neededCount(); errorMsg.classList.add(‘hidden’); if(items.length < need){ errorMsg.textContent = 'Add ' + (need – items.length) + ' more item' + ((need-items.length)===1?'':'s') + ' to fill a ' + size + '×' + size + ' card.'; errorMsg.classList.remove('hidden'); return; } const overflowMode = (document.querySelector('input[name="overflow"]:checked') || {}).value || 'cycle'; const state = { t: title, i: items, s: size, f: freeSpaceCheckbox.checked && size % 2 === 1, o: overflowMode }; location.hash = 'c=' + encodeState(state); loadFromHash(); }); // ———- PLAY VIEW ———- const playTitle = document.getElementById('playTitle'); const playSubtitle = document.getElementById('playSubtitle'); const shareLinkInput = document.getElementById('shareLink'); const copyBtn = document.getElementById('copyBtn'); const shuffleBtn = document.getElementById('shuffleBtn'); const printBtn = document.getElementById('printBtn'); const resetBtn = document.getElementById('resetBtn'); const cardGrid = document.getElementById('cardGrid'); const winBanner = document.getElementById('winBanner'); const playerNameInput = document.getElementById('playerName'); const lbBody = document.getElementById('lbBody'); const lbRefresh = document.getElementById('lbRefresh'); const lbReset = document.getElementById('lbReset'); let currentState = null, marked = new Set(), cellItems = [], currentSize = 4, freeIndex = -1, won = false, gameId = null, pollId = null; const DAUB_SHAPES = [ '’, ”, ” ]; function pickItemsForCard(state){ const need = state.f ? state.s*state.s – 1 : state.s*state.s; const pool = state.i.slice(); if(pool.length <= need){ const shuffledPool = shuffle(pool); const chosen = []; let ptr = 0; while(chosen.length < need){ chosen.push(shuffledPool[ptr % shuffledPool.length]); ptr++; } return chosen; } if(state.o === 'first'){ return shuffle(pool.slice(0, need)); } return shuffle(pool).slice(0, need); } function buildCard(state){ currentState = state; currentSize = state.s; const n = currentSize * currentSize; freeIndex = state.f ? Math.floor(n/2) : -1; const chosen = pickItemsForCard(state); cellItems = []; let ptr = 0; for(let idx=0; idx= 0 ? [freeIndex] : []); won = false; winBanner.classList.remove(‘show’); renderCard(); gameId = hashStr(JSON.stringify({t:state.t, i:state.i, s:state.s, f:state.f, o:state.o})); refreshLeaderboard(); if(pollId) clearInterval(pollId); pollId = setInterval(refreshLeaderboard, 7000); } function renderCard(){ playTitle.textContent = currentState.t; playSubtitle.textContent = ‘Tap a square when you spot it — ‘ + currentSize + ‘×’ + currentSize; cardGrid.style.gridTemplateColumns = ‘repeat(‘ + currentSize + ‘, 1fr)’; cardGrid.innerHTML = ”; cellItems.forEach((label, idx) => { const cell = document.createElement(‘div’); cell.className = ‘cell’ + (idx===freeIndex ? ‘ free’ : ”) + (marked.has(idx) ? ‘ marked’ : ”); cell.dataset.idx = idx; const shapeIdx = idx % DAUB_SHAPES.length; const color = ACCENTS[idx % ACCENTS.length]; const rot = (idx * 37) % 40 – 20; cell.style.setProperty(‘–rot’, rot + ‘deg’); cell.innerHTML = ‘
‘ + DAUB_SHAPES[shapeIdx] + ‘
‘ + escapeHtml(label) + ‘‘; if(idx !== freeIndex){ cell.addEventListener(‘click’, () => toggleCell(idx)); } cardGrid.appendChild(cell); }); updateShareLink(); } function escapeHtml(s){ const d = document.createElement(‘div’); d.textContent = s; return d.innerHTML; } function toggleCell(idx){ if(marked.has(idx)) marked.delete(idx); else marked.add(idx); cardGrid.querySelector(‘[data-idx=”‘+idx+'”]’).classList.toggle(‘marked’, marked.has(idx)); checkWin(); } function checkWin(){ const n = currentSize; const lines = []; for(let r=0;rr*n+c)); } for(let c=0;cr*n+c)); } lines.push(Array.from({length:n},(_,i)=>i*n+i)); lines.push(Array.from({length:n},(_,i)=>i*n+(n-1-i))); const hasWin = lines.some(line => line.every(i => marked.has(i))); if(hasWin && !won){ won = true; celebrate(); recordWin(); } else if(!hasWin){ won = false; winBanner.classList.remove(‘show’); } } function celebrate(){ winBanner.classList.add(‘show’); for(let i=0;i p.remove(), 4000); } } function updateShareLink(){ const url = location.origin + location.pathname + ‘#c=’ + encodeState(currentState); shareLinkInput.value = url; } copyBtn.addEventListener(‘click’, () => { shareLinkInput.select(); if(navigator.clipboard){ navigator.clipboard.writeText(shareLinkInput.value).then(showCopied).catch(()=>{document.execCommand(‘copy’); showCopied();}); } else { document.execCommand(‘copy’); showCopied(); } }); function showCopied(){ const orig = copyBtn.textContent; copyBtn.textContent = ‘Copied ✓’; setTimeout(() => copyBtn.textContent = orig, 1400); } shuffleBtn.addEventListener(‘click’, () => buildCard(currentState)); printBtn.addEventListener(‘click’, () => window.print()); resetBtn.addEventListener(‘click’, () => { if(pollId) clearInterval(pollId); location.hash = ”; playView.classList.add(‘hidden’); builderView.classList.remove(‘hidden’); }); // ———- persistent name + group scores ———- async function loadPlayerName(){ try{ const res = await window.storage.get(‘player-name’, false); return res && res.value ? res.value : ”; }catch(e){ return ”; } } async function savePlayerName(name){ try{ await window.storage.set(‘player-name’, name, false); }catch(e){ console.error(‘name save failed’, e); } } playerNameInput.addEventListener(‘change’, () => savePlayerName(playerNameInput.value.trim())); async function loadScores(){ try{ const res = await window.storage.get(‘scores:’+gameId, true); return res && res.value ? JSON.parse(res.value) : {}; }catch(e){ return {}; } } async function saveScores(scores){ try{ await window.storage.set(‘scores:’+gameId, JSON.stringify(scores), true); }catch(e){ console.error(‘score save failed’, e); } } async function recordWin(){ const name = (playerNameInput.value.trim() || ‘Guest’).slice(0,24); const scores = await loadScores(); scores[name] = (scores[name] || 0) + 1; await saveScores(scores); renderLeaderboard(scores); } async function refreshLeaderboard(){ const scores = await loadScores(); renderLeaderboard(scores); } function renderLeaderboard(scores){ const entries = Object.entries(scores).sort((a,b) => b[1]-a[1]); if(!entries.length){ lbBody.innerHTML = ‘
No wins yet — first BINGO gets the top spot.
‘; return; } const medals = [‘🥇’,’🥈’,’🥉’]; lbBody.innerHTML = entries.map(([name, score], i) => ‘
‘ + (medals[i] || ”) + ‘ ‘ + escapeHtml(name) + ‘‘ + score + ‘
‘ ).join(”); } lbRefresh.addEventListener(‘click’, refreshLeaderboard); lbReset.addEventListener(‘click’, async () => { if(!confirm(‘Reset the shared scoreboard for this card? This affects everyone using this link.’)) return; await saveScores({}); renderLeaderboard({}); }); function loadFromHash(){ const hash = location.hash.replace(/^#/, ”); if(hash.startsWith(‘c=’)){ const state = decodeState(hash.slice(2)); if(state && Array.isArray(state.i) && state.i.length){ builderView.classList.add(‘hidden’); playView.classList.remove(‘hidden’); buildCard(state); loadPlayerName().then(n => { if(n) playerNameInput.value = n; }); return; } } builderView.classList.remove(‘hidden’); playView.classList.add(‘hidden’); } window.addEventListener(‘hashchange’, loadFromHash); updateHints(); loadFromHash(); })();