Initial build: fullhouse, live music bingo from a real Spotify playlist
All checks were successful
Docker / build-and-push (push) Successful in 1m18s

Based on bingo-bango by f8al (MIT) — same core card-generation idea
("both facets": a square is either an exact song title or an artist,
and an artist square lights up on any of their tracks), reworked with
a real host/guest split, live multiplayer sessions, and cover-art
squares. f8al credited in the footer, README, and PROPOSAL.md.

Three ways to play: host a live session (server-side Spotify
connection, join code + QR, caller screen, auto-call from real
playback), join someone else's session (no login needed), or solo
(guest PKCE login or a pasted public playlist URL, one-off card).

Genuinely verified against a real Spotify account throughout, not
just built and assumed working — including two real bugs found and
fixed along the way (Spotify's playlist-tracks endpoint quietly
renamed to /items with a reshaped response; reading playlist tracks
needs real user auth even for public playlists, so the "no guest
login" public-playlist path routes through the host's connection
instead of a dead-end app-only token). Both Docker images built and
run together on a real network with the nginx proxy verified working
end-to-end, and auto-call confirmed detecting an actual track playing
live through Spotify Connect within one poll tick.
This commit is contained in:
Fredrik Johansson
2026-07-18 22:05:37 +02:00
commit d99c44cb9d
26 changed files with 3778 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
// Card generation engine. The "both facets" idea — a square can hold
// either a track title or an artist name, and playing a track marks
// both the title square and any artist squares for that artist — is
// f8al's design from bingo-bango (https://github.com/f8al/bingo-bango,
// MIT licensed). This module is a fresh implementation of that shape,
// not copied code, extended with a cover-art render mode the original
// doesn't have.
//
// Pure, dependency-free, deterministic given a seed — same property
// the original's engine has, load-bearing here too: a guest's card
// has to reproduce identically from a shareable seed/URL.
export type PoolTrack = {
id: string;
title: string;
artist: string;
album: string;
image: string | null;
};
export type SquareFacet = 'title' | 'artist' | 'free';
export type CardSquare = {
facet: SquareFacet;
text: string;
image: string | null;
/** Track ids that mark this square when called. A title square has
* exactly one; an artist square can have several if the artist
* appears more than once in the pool. */
matchIds: string[];
};
export type BingoCard = {
seed: number;
size: number;
squares: CardSquare[]; // row-major, length size*size
};
// Small, fast, deterministic PRNG (mulberry32) — no crypto needed, the
// point is reproducibility from a seed, not unpredictability.
function mulberry32(seed: number) {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle<T>(arr: T[], rand: () => number): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export type CardOptions = {
size: 3 | 4 | 5;
freeSpace: boolean;
/** Roughly how much of the grid is artist squares vs title squares —
* 0.5 matches the original's ~50/50 split. */
artistRatio?: number;
};
export function generateCard(pool: PoolTrack[], seed: number, opts: CardOptions): BingoCard {
const { size, freeSpace, artistRatio = 0.5 } = opts;
const cellCount = size * size;
const needed = cellCount - (freeSpace ? 1 : 0);
if (pool.length < needed) {
throw new Error(`pool has ${pool.length} tracks, need at least ${needed} for a ${size}x${size} card`);
}
const rand = mulberry32(seed);
const chosen = shuffle(pool, rand).slice(0, needed);
// Group by artist so an artist square can list every matching track id.
const byArtist = new Map<string, PoolTrack[]>();
for (const t of pool) {
const list = byArtist.get(t.artist) ?? [];
list.push(t);
byArtist.set(t.artist, list);
}
const squares: CardSquare[] = chosen.map((track) => {
const useArtist = rand() < artistRatio;
if (useArtist) {
const matches = byArtist.get(track.artist) ?? [track];
return {
facet: 'artist',
text: track.artist,
image: track.image,
matchIds: matches.map((m) => m.id),
};
}
return {
facet: 'title',
text: track.title,
image: track.image,
matchIds: [track.id],
};
});
if (freeSpace) {
const center = Math.floor(cellCount / 2);
squares.splice(center, 0, { facet: 'free', text: 'FREE', image: null, matchIds: [] });
}
return { seed, size, squares };
}
/** Which squares light up when a track is called. */
export function markedIndices(card: BingoCard, calledTrackId: string): number[] {
const out: number[] = [];
card.squares.forEach((sq, i) => {
if (sq.matchIds.includes(calledTrackId)) out.push(i);
});
return out;
}
/** True if any full row, column, or diagonal is fully covered. */
export function hasBingo(card: BingoCard, markedSet: Set<number>): boolean {
const { size } = card;
const at = (r: number, c: number) => r * size + c;
const isMarked = (r: number, c: number) => card.squares[at(r, c)].facet === 'free' || markedSet.has(at(r, c));
for (let r = 0; r < size; r++) {
if (Array.from({ length: size }, (_, c) => isMarked(r, c)).every(Boolean)) return true;
}
for (let c = 0; c < size; c++) {
if (Array.from({ length: size }, (_, r) => isMarked(r, c)).every(Boolean)) return true;
}
if (Array.from({ length: size }, (_, i) => isMarked(i, i)).every(Boolean)) return true;
if (Array.from({ length: size }, (_, i) => isMarked(i, size - 1 - i)).every(Boolean)) return true;
return false;
}