№ 011
Cartridge Club
A pixel music console where every song is a cartridge — changing tracks ejects one and drops in the next.
- Version
- 1.0.0
- Download
- 16 KB · 4 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- music player, pixel, web audio
Usage
Copy cartridge-club.css and cartridge-club.js (and assets/, if there is one) into your project, then:
new CartridgeClub(element, {
tracks: [{ id: 'CC-001', title: 'AFTER HOURS', artist: 'MONO CLUB', duration: 222,
cart: '#EB674C', print: '#212B39', decal: 'assets/decal-sunset.png', song: 0, src? }],
sound: true, // generated chiptune and mechanical clicks (Web Audio)
onChange(track, index) {},
})
A pixel music console where every song is a cartridge. Changing tracks ejects the current cartridge,
swaps it for the next one and drops that into the slot. The track changes the moment the button is
pressed; the 900 ms swap is only feedback, and a tap during a swap replays it at double speed.
`decal` is an image URL used as a mask (any opaque pixels print), or one of the built-in pixel
decals: 'moon', 'bloom', 'invader'. Without `src`, each track plays a generated chiptune (`song`).Source
/* Cartridge Club — a pixel music console. Everything is laid out on a 256-unit grid (--u),
so the console scales as one piece with the width of its container. */
.cc {
--cc-shell: #e9e2d2;
--cc-shell-lt: #f4efe4;
--cc-shell-dk: #d3cab6;
--cc-shell-dk2: #bfb39c;
--cc-ink: #212b39;
--cc-slot: #141b25;
--cc-coral: #eb674c;
--cc-coral-dk: #c24f37;
--cc-screen: #a7bbac;
--cc-screen-dk: #92a797;
--cc-shadow: rgba(33, 43, 57, 0.18);
--cc-bitmap: "Silkscreen", "Press Start 2P", ui-monospace, monospace;
--cc-mono: "VT323", ui-monospace, monospace;
container-type: inline-size;
position: relative;
width: 100%;
color: var(--cc-ink);
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.cc *,
.cc *::before,
.cc *::after {
box-sizing: border-box;
}
.cc-stage {
--u: calc(100cqw / 256);
position: relative;
width: 100%;
height: calc(var(--u) * 232);
}
/* stepped (pixel) corners; --st is the step size */
.cc-stage::before,
.cc-body,
.cc-body-side,
.cc-body-face,
.cc-slot,
.cc-screen,
.cc-screen-glass,
.cc-key,
.cc-key-face,
.cc-cart,
.cc-cart-face {
clip-path: polygon(
0 calc(var(--st) * 2), var(--st) calc(var(--st) * 2), var(--st) var(--st), calc(var(--st) * 2) var(--st), calc(var(--st) * 2) 0,
calc(100% - var(--st) * 2) 0, calc(100% - var(--st) * 2) var(--st), calc(100% - var(--st)) var(--st), calc(100% - var(--st)) calc(var(--st) * 2), 100% calc(var(--st) * 2),
100% calc(100% - var(--st) * 2), calc(100% - var(--st)) calc(100% - var(--st) * 2), calc(100% - var(--st)) calc(100% - var(--st)), calc(100% - var(--st) * 2) calc(100% - var(--st)), calc(100% - var(--st) * 2) 100%,
calc(var(--st) * 2) 100%, calc(var(--st) * 2) calc(100% - var(--st)), var(--st) calc(100% - var(--st)), var(--st) calc(100% - var(--st) * 2), 0 calc(100% - var(--st) * 2)
);
}
/* ── console body ── */
.cc-body {
--st: calc(var(--u) * 3);
position: absolute;
left: calc(var(--u) * 12);
top: calc(var(--u) * 76);
width: calc(var(--u) * 232);
height: calc(var(--u) * 150);
background: var(--cc-ink);
}
/* hard shadow: a clip-path would cut off a drop-shadow filter, so the shadow is its own layer */
.cc-stage::before {
content: "";
position: absolute;
left: calc(var(--u) * 17);
top: calc(var(--u) * 81);
width: calc(var(--u) * 232);
height: calc(var(--u) * 150);
background: var(--cc-shadow);
--st: calc(var(--u) * 3);
}
.cc-body-side {
--st: calc(var(--u) * 3);
position: absolute;
inset: calc(var(--u) * 3);
background: var(--cc-shell-dk);
}
.cc-body-face {
--st: calc(var(--u) * 3);
position: absolute;
inset: calc(var(--u) * 3) calc(var(--u) * 3) calc(var(--u) * 9);
background: var(--cc-shell);
/* a one-unit highlight along the top and left edges */
box-shadow: inset calc(var(--u) * 2) calc(var(--u) * 2) 0 var(--cc-shell-lt);
}
.cc-slot {
--st: calc(var(--u) * 2);
position: absolute;
left: calc(var(--u) * 28);
top: calc(var(--u) * 1);
width: calc(var(--u) * 176);
height: calc(var(--u) * 9);
background: var(--cc-slot);
box-shadow: inset 0 calc(var(--u) * -2) 0 #2c3747;
}
.cc-brand {
position: absolute;
left: calc(var(--u) * 26);
right: calc(var(--u) * 26);
top: calc(var(--u) * 13);
display: flex;
align-items: center;
gap: calc(var(--u) * 8);
}
.cc-brand::before,
.cc-brand::after {
content: "";
flex: 1;
height: calc(var(--u) * 1.5);
background: var(--cc-shell-dk2);
}
.cc-brand span {
font: 700 calc(var(--u) * 7) / 1 var(--cc-bitmap);
letter-spacing: 0.16em;
margin-right: -0.16em;
white-space: nowrap;
}
/* ── screen ── */
.cc-screen {
--st: calc(var(--u) * 2);
position: absolute;
left: calc(var(--u) * 18);
top: calc(var(--u) * 25);
width: calc(var(--u) * 196);
height: calc(var(--u) * 64);
background: var(--cc-ink);
}
.cc-screen-glass {
--st: calc(var(--u) * 1.5);
position: absolute;
inset: calc(var(--u) * 3);
background: var(--cc-screen);
box-shadow: inset calc(var(--u) * 2) calc(var(--u) * 2) 0 var(--cc-screen-dk);
overflow: hidden;
}
.cc-count,
.cc-time,
.cc-status {
position: absolute;
font: 400 calc(var(--u) * 10) / 1 var(--cc-mono);
letter-spacing: 0.04em;
white-space: nowrap;
}
.cc-count {
right: calc(var(--u) * 8);
top: calc(var(--u) * 5);
}
.cc-info {
position: absolute;
inset: 0;
}
.cc-title {
position: absolute;
left: calc(var(--u) * 8);
top: calc(var(--u) * 6);
margin: 0;
font: 700 calc(var(--u) * 12) / 1 var(--cc-bitmap);
letter-spacing: 0.02em;
white-space: nowrap;
}
.cc-artist {
position: absolute;
left: calc(var(--u) * 8);
top: calc(var(--u) * 21);
margin: 0;
font: 400 calc(var(--u) * 7) / 1 var(--cc-bitmap);
letter-spacing: 0.06em;
white-space: nowrap;
}
/* 14 columns of 6 blocks; unlit blocks ghost faintly, as on an LCD */
.cc-vis {
position: absolute;
right: calc(var(--u) * 8);
top: calc(var(--u) * 19);
display: flex;
align-items: flex-end;
gap: calc(var(--u) * 1);
height: calc(var(--u) * 17);
}
.cc-col {
--h: 1;
position: relative;
width: calc(var(--u) * 3);
height: 100%;
background: repeating-linear-gradient(to top, rgba(33, 43, 57, 0.1) 0 calc(var(--u) * 2), transparent 0 calc(var(--u) * 3));
}
.cc-col::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: calc(var(--u) * (var(--h) * 3 - 1));
background: repeating-linear-gradient(to top, var(--cc-ink) 0 calc(var(--u) * 2), transparent 0 calc(var(--u) * 3));
}
.cc-seek {
position: absolute;
left: calc(var(--u) * 8);
bottom: calc(var(--u) * 7);
width: calc(var(--u) * 104);
height: calc(var(--u) * 6);
border: calc(var(--u) * 1.2) solid var(--cc-ink);
cursor: pointer;
touch-action: none;
}
/* generous invisible hit area */
.cc-seek::before {
content: "";
position: absolute;
inset: calc(var(--u) * -6) calc(var(--u) * -3);
}
.cc-seek-fill {
position: absolute;
left: calc(var(--u) * 0.8);
top: calc(var(--u) * 0.8);
bottom: calc(var(--u) * 0.8);
max-width: calc(100% - var(--u) * 1.6);
background: var(--cc-ink);
}
.cc-seek:focus-visible {
outline: calc(var(--u) * 1) dashed var(--cc-ink);
outline-offset: calc(var(--u) * 2);
}
.cc-time {
right: calc(var(--u) * 8);
bottom: calc(var(--u) * 5);
}
.cc-status {
inset: 0;
display: grid;
place-items: center;
font-size: calc(var(--u) * 13);
}
.cc-status[hidden] {
display: none;
}
/* ── keys ── */
.cc-controls {
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 96);
height: calc(var(--u) * 40);
}
.cc-btn {
--press: 0;
--face: var(--cc-shell-lt);
--depth: var(--cc-shell-dk2);
position: absolute;
bottom: 0;
padding: 0;
border: 0;
background: none;
color: var(--cc-ink);
cursor: pointer;
filter: drop-shadow(calc(var(--u) * 2) calc(var(--u) * (3 - var(--press))) 0 var(--cc-shell-dk2));
}
/* at least 44 px to press, whatever the scale */
.cc-btn::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: max(100%, 44px);
height: max(100%, 44px);
transform: translate(-50%, -50%);
}
.cc-prev,
.cc-next {
width: calc(var(--u) * 32);
height: calc(var(--u) * 32);
}
.cc-prev { left: calc(var(--u) * 42); }
.cc-next { left: calc(var(--u) * 158); }
.cc-play {
--face: var(--cc-coral);
--depth: var(--cc-coral-dk);
left: calc(var(--u) * 94);
width: calc(var(--u) * 44);
height: calc(var(--u) * 40);
}
.cc-key {
--st: calc(var(--u) * 2);
position: absolute;
inset: 0;
background: var(--cc-ink);
}
/* the depth band shows below the face; pressing slides the face down over it */
.cc-key::before {
content: "";
position: absolute;
inset: calc(var(--u) * 2);
background: var(--depth);
}
.cc-key-face {
--st: calc(var(--u) * 1.5);
position: absolute;
inset: calc(var(--u) * 2) calc(var(--u) * 2) calc(var(--u) * 6);
display: grid;
place-items: center;
background: var(--face);
box-shadow: inset calc(var(--u) * 1.5) calc(var(--u) * 1.5) 0 rgba(255, 255, 255, 0.35);
transform: translateY(calc(var(--u) * var(--press) * 2));
}
.cc-key-face svg {
display: block;
height: calc(var(--u) * 10.5);
fill: var(--cc-ink);
}
.cc-play .cc-key-face svg {
height: calc(var(--u) * 16.5);
}
.cc-btn:focus-visible {
outline: calc(var(--u) * 1.2) dashed var(--cc-ink);
outline-offset: calc(var(--u) * 3);
}
/* ── cartridges ── */
.cc-well {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: calc(var(--u) * 81); /* the slot line: cartridges disappear below it */
clip-path: inset(-100% -200% 0 -200%); /* free to slide out sideways, cut only at the slot */
pointer-events: none;
}
.cc-cart {
--st: calc(var(--u) * 3);
--cart: var(--cc-coral);
--print: var(--cc-ink);
--cart-dk: color-mix(in srgb, var(--cart) 84%, #000);
--cart-lt: color-mix(in srgb, var(--cart) 82%, #fff);
position: absolute;
left: calc(var(--u) * 44);
top: calc(var(--u) * 33);
width: calc(var(--u) * 168);
height: calc(var(--u) * 84);
background: var(--cc-ink);
}
.cc-cart[hidden] {
display: none;
}
.cc-cart-face {
--st: calc(var(--u) * 2);
position: absolute;
inset: calc(var(--u) * 2);
background: var(--cart);
box-shadow: inset 0 calc(var(--u) * 2) 0 var(--cart-lt);
}
/* side grips: a darker column with three notches on the outer edge */
.cc-grip {
position: absolute;
top: 0;
bottom: 0;
width: calc(var(--u) * 13);
background:
repeating-linear-gradient(to bottom, var(--cc-ink) 0 calc(var(--u) * 2), transparent 0 calc(var(--u) * 6))
0 calc(var(--u) * 13) / calc(var(--u) * 3) calc(var(--u) * 16) no-repeat,
var(--cart-dk);
}
.cc-grip-l {
left: 0;
border-right: calc(var(--u) * 1.5) solid var(--cc-ink);
}
.cc-grip-r {
right: 0;
border-left: calc(var(--u) * 1.5) solid var(--cc-ink);
transform: scaleX(-1);
}
.cc-label {
position: absolute;
left: calc(var(--u) * 19);
right: calc(var(--u) * 19);
top: calc(var(--u) * 5);
height: calc(var(--u) * 40);
border: calc(var(--u) * 1.5) solid var(--cart-dk);
color: var(--print);
}
.cc-cart-id,
.cc-cart-title {
position: absolute;
left: calc(var(--u) * 5);
font: 400 calc(var(--u) * 6) / 1 var(--cc-bitmap);
letter-spacing: 0.08em;
white-space: nowrap;
}
.cc-cart-id { top: calc(var(--u) * 4); }
.cc-cart-rule {
position: absolute;
left: calc(var(--u) * 5);
top: calc(var(--u) * 12);
width: calc(var(--u) * 50);
height: calc(var(--u) * 1.5);
background: var(--print);
}
.cc-cart-title {
top: calc(var(--u) * 16);
font-weight: 700;
}
.cc-decal {
position: absolute;
left: calc(var(--u) * 3);
right: calc(var(--u) * 3);
bottom: calc(var(--u) * 3);
aspect-ratio: 96 / 25;
background: var(--print);
-webkit-mask: var(--decal) center / 100% 100% no-repeat;
mask: var(--decal) center / 100% 100% no-repeat;
}
/* grip ridges on the part that lives inside the slot */
.cc-ridges {
position: absolute;
left: calc(var(--u) * 22);
right: calc(var(--u) * 22);
top: calc(var(--u) * 52);
height: calc(var(--u) * 18);
background: repeating-linear-gradient(to bottom, var(--cart-dk) 0 calc(var(--u) * 2), transparent 0 calc(var(--u) * 5));
} /* Cartridge Club — usage:
* new CartridgeClub(element, {
* tracks: [{ id: 'CC-001', title: 'AFTER HOURS', artist: 'MONO CLUB', duration: 222,
* cart: '#EB674C', print: '#212B39', decal: 'assets/decal-sunset.png', song: 0, src? }],
* sound: true, // generated chiptune and mechanical clicks (Web Audio)
* onChange(track, index) {},
* })
*
* A pixel music console where every song is a cartridge. Changing tracks ejects the current cartridge,
* swaps it for the next one and drops that into the slot. The track changes the moment the button is
* pressed; the 900 ms swap is only feedback, and a tap during a swap replays it at double speed.
* `decal` is an image URL used as a mask (any opaque pixels print), or one of the built-in pixel
* decals: 'moon', 'bloom', 'invader'. Without `src`, each track plays a generated chiptune (`song`).
*/
(function () {
const PRESS = 0.09; // button compression (s)
const RISE = 25; // eject height in grid units, ~30% of the cartridge
const EXIT = 212; // sideways travel that takes a cartridge fully out of view (units)
const QUICK = 0.5; // a tap during a swap plays the next one at this share of the duration
// swap timeline (s): press, eject, exchange, insert, display reveal
const T = { eject: 0.09, exchange: 0.29, insert: 0.53, contact: 0.72, reveal: 0.75, done: 0.9 };
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const unit = (t, from, dur) => clamp((t - from) / dur, 0, 1);
const ease = {
outBack: (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2, // a small spring-loaded overshoot
inCubic: (t) => t ** 3,
outCubic: (t) => 1 - (1 - t) ** 3,
inQuad: (t) => t * t,
};
const mmss = (s) => {
s = Math.max(0, Math.floor(s));
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
const pad2 = (n) => String(n).padStart(2, '0');
/* ── pixel art: icons and built-in decals, drawn on a grid and turned into crisp SVG ── */
const svgPath = (rows) => {
let d = '';
rows.forEach((row, y) => {
for (let x = 0; x < row.length; ) {
if (!row[x]) { x++; continue; }
const s = x;
while (x < row.length && row[x]) x++;
d += `M${s} ${y}h${x - s}v1h${s - x}z`;
}
});
return d;
};
const sprite = (art) => art.trim().split('\n').map((r) => [...r.trim()].map((c) => c === 'X'));
const iconSvg = (art) => {
const rows = sprite(art);
return `<svg viewBox="0 0 ${rows[0].length} ${rows.length}" shape-rendering="crispEdges" aria-hidden="true"><path d="${svgPath(rows)}"/></svg>`;
};
const ICON = {
play: iconSvg(`
X.....
XX....
XXX...
XXXX..
XXXXX.
XXXXXX
XXXXX.
XXXX..
XXX...
XX....
X.....`),
pause: iconSvg(`
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX
XX..XX`),
next: iconSvg(`
X...X...
XX..XX..
XXX.XXX.
XXXXXXXX
XXX.XXX.
XX..XX..
X...X...`),
prev: iconSvg(`
...X...X
..XX..XX
.XXX.XXX
XXXXXXXX
.XXX.XXX
..XX..XX
...X...X`),
};
// Decals share the supplied sunset's 96 × 25 grid and its three speed lines on the left.
const DW = 96, DH = 25;
const decal = (draw) => {
const g = Array.from({ length: DH }, () => new Array(DW).fill(false));
const set = (x, y, on = true) => { if (x >= 0 && x < DW && y >= 0 && y < DH) g[y][x] = on; };
const rect = (x, y, w, h) => { for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) set(i, j); };
const art = (a, x, y, s = 1) => sprite(a).forEach((r, j) => r.forEach((on, i) => on && rect(x + i * s, y + j * s, s, s)));
rect(0, 17, 46, 2);
rect(0, 20, 54, 2);
rect(0, 23, 60, 2);
draw({ set, rect, art });
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${DW} ${DH}" shape-rendering="crispEdges"><path d="${svgPath(g)}"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
};
const star = (set, x, y) => [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([i, j]) => set(x + i, y + j));
const DECALS = {
// crescent moon over a mountain ridge
moon: () => decal(({ set }) => {
for (let y = 0; y < DH; y++) for (let x = 60; x < DW; x++) {
const inMoon = (x - 82) ** 2 + (y - 7) ** 2 <= 6.6 ** 2;
const inCut = (x - 85) ** 2 + (y - 5) ** 2 <= 5.6 ** 2;
if (inMoon && !inCut) set(x, y);
}
const peaks = [[48, 25], [58, 17], [64, 20], [72, 13], [80, 19], [88, 15], [96, 21]];
for (let x = 48; x < DW; x++) {
const k = peaks.findIndex(([px]) => px > x);
const [x0, y0] = peaks[k - 1], [x1, y1] = peaks[k];
const top = Math.round(lerp(y0, y1, (x - x0) / (x1 - x0)));
for (let y = top; y < DH; y++) set(x, y);
}
star(set, 66, 5);
[[57, 3], [62, 9], [72, 2], [93, 13], [53, 12]].forEach(([x, y]) => set(x, y));
}),
// three pixel tulips on a strip of ground
bloom: () => decal(({ rect, art }) => {
const tulip = `
.X.X.X.
.XXXXX.
.XXXXX.
..XXX..
...X...
X..X..X
XX.X.XX
.XXXXX.
...X...
...X...`;
art(tulip, 64, 3, 2);
art(tulip, 54, 13);
art(tulip, 84, 11);
rect(48, 23, 48, 2);
[[80, 3], [90, 6], [58, 7]].forEach(([x, y]) => rect(x, y, 2, 2));
}),
// an arcade invader above two bunkers
invader: () => decal(({ set, rect, art }) => {
art(`
..X.....X..
...X...X...
..XXXXXXX..
.XX.XXX.XX.
XXXXXXXXXXX
X.XXXXXXX.X
X.X.....X.X
...XX.XX...`, 64, 1, 2);
art(`
.XXXXXX.
XXXXXXXX
XXX..XXX
XX....XX`, 56, 21);
art(`
.XXXXXX.
XXXXXXXX
XXX..XXX
XX....XX`, 84, 21);
star(set, 92, 4);
[[56, 4], [60, 12], [88, 13], [95, 9]].forEach(([x, y]) => set(x, y));
rect(74, 19, 1, 2);
}),
};
/* ── chiptune: pulse lead and arpeggio, triangle bass, noise drums ── */
const hz = (m) => 440 * 2 ** ((m - 69) / 12);
// Four bars per song, one chord per bar. Melody notes are [step, midi, length in sixteenths].
const SONGS = [
{ // After Hours: night-time synthwave in F
bpm: 104, lead: 0.25, arp: { duty: 0.125, every: 1, vol: 0.035 }, bass: 'octaves',
drums: { kick: [0, 8], snare: [4, 12], hat: [2, 6, 10, 14] },
chords: [[65, 69, 72, 76], [62, 65, 69, 72], [58, 62, 65, 69], [60, 64, 67, 70]],
melody: [
[[0, 77, 3], [3, 76, 3], [6, 72, 2], [8, 74, 4], [12, 72, 4]],
[[0, 74, 3], [3, 72, 3], [6, 69, 2], [8, 72, 6], [14, 74, 2]],
[[0, 70, 3], [3, 69, 3], [6, 65, 2], [8, 69, 4], [12, 72, 4]],
[[0, 72, 6], [6, 70, 2], [8, 67, 4], [12, 64, 4]],
],
},
{ // Night Drive: driving A minor
bpm: 118, lead: 0.125, arp: null, bass: 'drive',
drums: { kick: [0, 4, 8, 12], snare: [4, 12], hat: [2, 6, 10, 14] },
chords: [[57, 60, 64], [53, 57, 60], [60, 64, 67], [55, 59, 62]],
melody: [
[[0, 76, 2], [2, 72, 2], [4, 69, 4], [8, 72, 2], [10, 76, 2], [12, 79, 4]],
[[0, 77, 4], [4, 76, 2], [6, 72, 2], [8, 69, 8]],
[[0, 72, 2], [2, 76, 2], [4, 79, 4], [8, 84, 4], [12, 79, 4]],
[[0, 83, 4], [4, 79, 4], [8, 74, 4], [12, 71, 4]],
],
},
{ // Slow Bloom: gentle C major
bpm: 84, lead: 0.5, arp: { duty: 0.5, every: 2, vol: 0.03 }, bass: 'long',
drums: { kick: [0, 10], snare: [8], hat: [4, 12] },
chords: [[60, 64, 67, 71], [57, 60, 64, 67], [53, 57, 60, 64], [55, 59, 62, 64]],
melody: [
[[0, 76, 6], [6, 74, 2], [8, 72, 8]],
[[0, 72, 4], [4, 74, 4], [8, 76, 4], [12, 79, 4]],
[[0, 77, 6], [6, 76, 2], [8, 72, 8]],
[[0, 74, 6], [6, 72, 2], [8, 71, 4], [12, 74, 4]],
],
},
{ // High Score: bouncy G major
bpm: 144, lead: 0.5, arp: { duty: 0.25, every: 1, vol: 0.03 }, bass: 'bounce',
drums: { kick: [0, 6, 8], snare: [4, 12], hat: [0, 2, 4, 6, 8, 10, 12, 14] },
chords: [[55, 59, 62], [52, 55, 59], [48, 52, 55], [50, 54, 57]],
melody: [
[[0, 79, 2], [2, 83, 2], [4, 86, 2], [6, 83, 2], [8, 79, 4], [12, 74, 4]],
[[0, 76, 2], [2, 79, 2], [4, 83, 4], [8, 79, 2], [10, 76, 2], [12, 71, 4]],
[[0, 72, 2], [2, 76, 2], [4, 79, 2], [6, 84, 2], [8, 83, 4], [12, 79, 4]],
[[0, 81, 2], [2, 78, 2], [4, 74, 4], [8, 78, 2], [10, 81, 2], [12, 86, 4]],
],
},
];
const BASS = {
octaves: (s, r) => (s % 2 === 0 ? [{ midi: r - (s % 4 ? 12 : 24), len: 2 }] : []),
drive: (s, r) => [{ midi: r - (s % 4 === 2 ? 12 : 24), len: 1 }],
long: (s, r) => (s === 0 || s === 8 ? [{ midi: r - 24, len: 7 }] : []),
bounce: (s, r) => ({ 0: [{ midi: r - 24, len: 2 }], 3: [{ midi: r - 17, len: 2 }], 6: [{ midi: r - 12, len: 2 }],
8: [{ midi: r - 24, len: 2 }], 11: [{ midi: r - 17, len: 2 }], 14: [{ midi: r - 12, len: 2 }] }[s] || []),
};
// Everything that sounds on a sixteenth-note step. Pure, so the visualiser can read the same score.
const events = (song, step) => {
const s = step % 16, bar = Math.floor(step / 16) % 4, chord = song.chords[bar];
const ev = [];
if (song.drums.kick.includes(s)) ev.push({ kind: 'kick' });
if (song.drums.snare.includes(s)) ev.push({ kind: 'snare' });
if (song.drums.hat.includes(s)) ev.push({ kind: 'hat', accent: s % 4 === 2 });
BASS[song.bass](s, chord[0]).forEach((b) => ev.push({ kind: 'bass', ...b }));
if (song.arp && s % song.arp.every === 0) {
const k = s / song.arp.every;
ev.push({ kind: 'arp', midi: chord[k % chord.length] + 12 * (Math.floor(k / chord.length) % 2) });
}
song.melody[bar].forEach(([at, midi, len]) => at === s && ev.push({ kind: 'lead', midi, len }));
return ev;
};
// 14 visualiser columns, 1–6 blocks each, from what was played in the last few steps.
const COLS = 14, ROWS = 6;
const levels = (songIndex, elapsed) => {
const song = SONGS[songIndex % SONGS.length];
const sd = 60 / song.bpm / 4;
const cur = Math.floor(elapsed / sd);
const lv = new Array(COLS).fill(0);
const add = (c, v) => { if (c >= 0 && c < COLS) lv[c] = Math.max(lv[c], v); };
const pitchCol = (m) => 4 + clamp(Math.round((m - 64) / 4), 0, 5);
for (let s = Math.max(0, cur - 12); s <= cur; s++) {
const age = elapsed - s * sd;
const fade = (tau) => Math.exp(-age / tau);
for (const e of events(song, s)) {
const held = e.len ? (age < e.len * sd ? 1 : Math.exp(-(age - e.len * sd) / 0.08)) : 0;
if (e.kind === 'kick') { const a = fade(0.13); add(0, 6 * a); add(1, 5 * a); add(2, 2.5 * a); }
if (e.kind === 'bass') { add(2, 4.2 * held); add(3, 3.4 * held); }
if (e.kind === 'lead') { const c = pitchCol(e.midi), a = held * (0.75 + 0.25 * fade(0.2)); add(c, 5.6 * a); add(c - 1, 3.4 * a); add(c + 1, 3 * a); }
if (e.kind === 'arp') { const c = pitchCol(e.midi), a = fade(0.11); add(c, 3.4 * a); add(c + 1, 2 * a); }
if (e.kind === 'snare') { const a = fade(0.11); add(10, 5.2 * a); add(11, 4.4 * a); add(9, 2.6 * a); }
if (e.kind === 'hat') { const a = fade(0.06); add(12, (e.accent ? 4 : 3) * a); add(13, (e.accent ? 4.6 : 3.4) * a); }
}
}
return lv.map((v) => clamp(Math.round(v), 1, ROWS));
};
const IDLE_LEVELS = [2, 3, 4, 3, 5, 4, 2, 3, 2, 4, 3, 2, 3, 2]; // static graphic before first play
class Chip {
constructor(ctx = new (window.AudioContext || window.webkitAudioContext)()) {
this.ctx = ctx; // an OfflineAudioContext works too (used to bounce the music for the reel)
const comp = ctx.createDynamicsCompressor();
this.out = ctx.createGain();
this.out.gain.value = 0.85;
this.out.connect(comp).connect(ctx.destination);
this.music = ctx.createGain();
this.music.gain.value = 0;
this.music.connect(this.out);
// short chip echo on the melodic voices
this.echo = ctx.createDelay(1);
const fb = ctx.createGain(), wet = ctx.createGain();
fb.gain.value = 0.28;
wet.gain.value = 0.22;
this.echo.connect(fb).connect(this.echo);
this.echo.connect(wet).connect(this.music);
this.noise = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const d = this.noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
this.waves = {};
this.song = SONGS[0];
this.timer = null;
}
setSong(i) {
this.song = SONGS[i % SONGS.length];
this.echo.delayTime.value = this.stepDur * 3;
}
get stepDur() {
return 60 / this.song.bpm / 4;
}
start(elapsed) {
const { ctx } = this;
ctx.resume();
const t = ctx.currentTime;
this.music.gain.cancelScheduledValues(t);
this.music.gain.setTargetAtTime(0.6, t, 0.02);
this.seek(elapsed);
clearInterval(this.timer);
this.timer = setInterval(() => this.schedule(), 25);
}
stop() {
clearInterval(this.timer);
this.timer = null;
const t = this.ctx.currentTime;
this.music.gain.cancelScheduledValues(t);
this.music.gain.setTargetAtTime(0, t, 0.025);
}
seek(elapsed) {
this.step = Math.ceil(elapsed / this.stepDur - 1e-6);
this.next = this.ctx.currentTime + 0.03 + (this.step * this.stepDur - elapsed);
}
schedule() {
while (this.next < this.ctx.currentTime + 0.12) {
this.playStep(this.step, this.next);
this.step++;
this.next += this.stepDur;
}
}
playStep(step, t) {
const sd = this.stepDur;
for (const e of events(this.song, step)) {
if (e.kind === 'kick') this.kick(t);
if (e.kind === 'snare') this.snare(t);
if (e.kind === 'hat') this.hat(t, e.accent ? 0.07 : 0.045);
if (e.kind === 'bass') this.tone('triangle', e.midi, t, e.len * sd * 0.9, 0.3);
if (e.kind === 'arp') this.tone(this.song.arp.duty, e.midi, t, sd * 0.7, this.song.arp.vol, true);
if (e.kind === 'lead') this.tone(this.song.lead, e.midi, t, e.len * sd * 0.88, 0.1, true);
}
}
// pulse wave of a given duty cycle, from its Fourier series
wave(duty) {
if (!this.waves[duty]) {
const n = 48, re = new Float32Array(n), im = new Float32Array(n);
for (let k = 1; k < n; k++) re[k] = (2 / (k * Math.PI)) * Math.sin(k * Math.PI * duty);
this.waves[duty] = this.ctx.createPeriodicWave(re, im);
}
return this.waves[duty];
}
tone(type, midi, t, dur, vol, echo = false) {
const { ctx } = this;
const o = ctx.createOscillator(), g = ctx.createGain();
if (typeof type === 'number') o.setPeriodicWave(this.wave(type));
else o.type = type;
o.frequency.value = hz(midi);
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.004);
g.gain.setValueAtTime(vol * 0.8, t + Math.max(0.01, dur - 0.02));
g.gain.linearRampToValueAtTime(0, t + dur + 0.02);
o.connect(g).connect(this.music);
if (echo) g.connect(this.echo);
o.start(t);
o.stop(t + dur + 0.05);
}
noiseHit(t, type, freq, vol, decay, dest = this.music) {
const { ctx } = this;
const src = ctx.createBufferSource(), f = ctx.createBiquadFilter(), g = ctx.createGain();
src.buffer = this.noise;
f.type = type;
f.frequency.value = freq;
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
src.connect(f).connect(g).connect(dest);
src.start(t, Math.random() * 0.5);
src.stop(t + decay + 0.02);
}
kick(t) {
const { ctx } = this;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'triangle';
o.frequency.setValueAtTime(150, t);
o.frequency.exponentialRampToValueAtTime(45, t + 0.1);
g.gain.setValueAtTime(0.7, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.2);
o.connect(g).connect(this.music);
o.start(t);
o.stop(t + 0.22);
}
snare(t) {
this.noiseHit(t, 'bandpass', 2400, 0.32, 0.14);
this.noiseHit(t, 'highpass', 5000, 0.12, 0.08);
}
hat(t, vol) {
this.noiseHit(t, 'highpass', 9000, vol, 0.035);
}
/* mechanical sounds, independent of the music */
fx(kind, t = this.ctx.currentTime) {
const { ctx } = this;
ctx.resume();
if (kind === 'button') this.noiseHit(t, 'bandpass', 3200, 0.12, 0.025, this.out);
if (kind === 'eject') {
this.noiseHit(t, 'bandpass', 1400, 0.28, 0.06, this.out);
const o = ctx.createOscillator(), g = ctx.createGain();
o.setPeriodicWave(this.wave(0.5));
o.frequency.setValueAtTime(220, t);
o.frequency.exponentialRampToValueAtTime(440, t + 0.08);
g.gain.setValueAtTime(0.06, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.1);
o.connect(g).connect(this.out);
o.start(t);
o.stop(t + 0.12);
}
if (kind === 'insert') {
this.noiseHit(t, 'lowpass', 900, 0.5, 0.07, this.out);
this.noiseHit(t + 0.012, 'bandpass', 2600, 0.22, 0.03, this.out);
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'triangle';
o.frequency.setValueAtTime(120, t);
o.frequency.exponentialRampToValueAtTime(60, t + 0.06);
g.gain.setValueAtTime(0.4, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
o.connect(g).connect(this.out);
o.start(t);
o.stop(t + 0.1);
}
}
}
/* ── the console ── */
const CART_DEFAULTS = [
{ cart: '#EB674C', print: '#212B39' },
{ cart: '#212B39', print: '#E9E2D2' },
{ cart: '#A7BBAC', print: '#212B39' },
{ cart: '#E9E2D2', print: '#EB674C' },
];
class CartridgeClub {
constructor(root, { tracks, sound = true, onChange } = {}) {
this.root = root;
this.tracks = tracks.map((t, i) => ({ id: `CC-${String(i + 1).padStart(3, '0')}`, ...CART_DEFAULTS[i % CART_DEFAULTS.length], song: i, ...t }));
this.sound = sound;
this.onChange = onChange;
this.reset();
this.build();
this.render();
}
// Playback and animation state. Everything visual is derived from these and a timestamp,
// so the reel can rebuild any moment by replaying the taps.
reset() {
this.index = 0;
this.playing = false;
this.base = 0; // elapsed seconds when playback last started, paused or seeked
this.since = 0; // clock time of that moment
this.started = false; // the static idle graphic shows until the first play
this.anim = null; // current cartridge swap
this.pressed = { play: -1, prev: -1, next: -1 };
this.lastToggle = -1;
}
clock() {
return performance.now() / 1000;
}
get track() {
return this.tracks[this.index];
}
elapsed(now = this.clock()) {
if (this.audio) return this.audio.currentTime || 0;
return this.playing ? this.base + (now - this.since) : this.base;
}
cartMarkup(i) {
const t = this.tracks[i];
const src = DECALS[t.decal] ? DECALS[t.decal]() : t.decal;
return `
<div class="cc-cart-face">
<span class="cc-grip cc-grip-l"></span><span class="cc-grip cc-grip-r"></span>
<div class="cc-label">
<span class="cc-cart-id">${t.id}</span>
<span class="cc-cart-rule"></span>
<span class="cc-cart-title">${t.title}</span>
${src ? `<span class="cc-decal" style="--decal: url("${src}")"></span>` : ''}
</div>
<span class="cc-ridges"></span>
</div>`;
}
build() {
this.root.classList.add('cc');
const col = '<span class="cc-col"></span>'.repeat(COLS);
this.root.innerHTML = `
<div class="cc-stage">
<div class="cc-body">
<div class="cc-body-side"></div>
<div class="cc-body-face"></div>
<div class="cc-slot"></div>
<div class="cc-brand"><span>CARTRIDGE CLUB</span></div>
<div class="cc-screen" aria-live="polite">
<div class="cc-screen-glass">
<span class="cc-count"></span>
<div class="cc-info">
<h2 class="cc-title"></h2>
<p class="cc-artist"></p>
<div class="cc-vis" aria-hidden="true">${col}</div>
<div class="cc-seek" role="slider" tabindex="0" aria-label="Seek"><span class="cc-seek-fill"></span></div>
<span class="cc-time"></span>
</div>
<div class="cc-status" aria-hidden="true"></div>
</div>
</div>
<div class="cc-controls">
<button class="cc-btn cc-prev" type="button" aria-label="Previous track"><span class="cc-key"><span class="cc-key-face">${ICON.prev}</span></span></button>
<button class="cc-btn cc-play" type="button" aria-label="Play"><span class="cc-key"><span class="cc-key-face">${ICON.play}</span></span></button>
<button class="cc-btn cc-next" type="button" aria-label="Next track"><span class="cc-key"><span class="cc-key-face">${ICON.next}</span></span></button>
</div>
</div>
<div class="cc-well" aria-hidden="true">
<div class="cc-cart"></div>
<div class="cc-cart"></div>
</div>
</div>`;
const q = (s) => this.root.querySelector(s);
this.stage = q('.cc-stage');
this.carts = [...this.root.querySelectorAll('.cc-cart')];
this.count = q('.cc-count');
this.info = q('.cc-info');
this.status = q('.cc-status');
this.titleEl = q('.cc-title');
this.artistEl = q('.cc-artist');
this.cols = [...this.root.querySelectorAll('.cc-col')];
this.seekEl = q('.cc-seek');
this.fill = q('.cc-seek-fill');
this.time = q('.cc-time');
this.btn = { play: q('.cc-play'), prev: q('.cc-prev'), next: q('.cc-next') };
this.playFace = q('.cc-play .cc-key-face');
this.btn.play.addEventListener('click', () => this.toggle());
this.btn.prev.addEventListener('click', () => this.go(-1));
this.btn.next.addEventListener('click', () => this.go(1));
this.root.addEventListener('keydown', (e) => {
if (e.target === this.seekEl) return;
if (e.key === 'ArrowRight') { e.preventDefault(); this.go(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); this.go(-1); }
});
// seeking: press/drag along the progress bar, or arrow keys while it has focus
const seekTo = (e) => {
const r = this.seekEl.getBoundingClientRect();
this.seek(clamp((e.clientX - r.left) / r.width, 0, 1) * this.track.duration);
};
this.seekEl.addEventListener('pointerdown', (e) => {
this.seekEl.setPointerCapture(e.pointerId);
seekTo(e);
});
this.seekEl.addEventListener('pointermove', (e) => {
if (this.seekEl.hasPointerCapture(e.pointerId)) seekTo(e);
});
this.seekEl.addEventListener('keydown', (e) => {
const step = { ArrowRight: 5, ArrowLeft: -5, Home: -Infinity, End: Infinity }[e.key];
if (step === undefined) return;
e.preventDefault();
this.seek(clamp(this.elapsed() + step, 0, this.track.duration - 0.5));
});
}
/* ── actions ── */
press(name, now) {
this.pressed[name] = now;
if (this.sound) this.chip()?.fx('button');
}
chip() {
try {
this.synth ??= new Chip();
} catch {
this.sound = false;
}
return this.synth;
}
toggle() {
const now = this.clock();
this.press('play', now);
this.lastToggle = now;
if (this.playing) this.pause(now);
else this.play(now);
}
play(now = this.clock()) {
this.playing = true;
this.started = true;
this.since = now;
if (this.audio) this.audio.play().catch(() => {});
else if (this.sound) {
this.chip()?.setSong(this.track.song);
this.synth?.start(this.base);
}
this.loop();
}
pause(now = this.clock()) {
this.base = this.elapsed(now);
this.playing = false;
this.audio?.pause();
this.synth?.stop();
this.loop();
}
go(step) {
this.select((this.index + step + this.tracks.length) % this.tracks.length, step);
}
// The track changes immediately; the cartridge swap follows as feedback.
select(i, dir = i > this.index ? 1 : -1) {
if (i === this.index) return;
const now = this.clock();
this.press(dir > 0 ? 'next' : 'prev', now);
this.startSwap(i, dir, now);
this.index = i;
this.base = 0;
this.since = now;
this.setAudio();
if (this.playing && !this.audio && this.sound) {
this.synth?.setSong(this.track.song);
this.synth?.seek(0);
}
this.onChange?.(this.track, i);
this.loop();
}
startSwap(to, dir, now) {
if (reducedMotion.matches) {
this.anim = null;
return;
}
const prev = this.anim && (now - this.anim.start) / this.anim.k < T.done;
if (!prev) {
this.anim = { start: now, k: 1, dir, out: this.index, to, x0: 0, y0: 0, xi: dir * EXIT, yi: -RISE };
} else {
// a tap mid-swap: carry on from where the cartridges are now, at double speed
const carts = this.cartsAt(now);
const inc = carts.find((c) => c.i === to);
const out = carts.filter((c) => c !== inc).sort((a, b) => Math.abs(a.x) - Math.abs(b.x))[0];
const raised = out.y <= -RISE + 0.5;
this.anim = {
start: now - (raised ? T.exchange : T.eject) * QUICK, k: QUICK, dir, out: out.i, to,
x0: out.x, y0: out.y, xi: inc ? inc.x : dir * EXIT, yi: inc ? inc.y : -RISE,
};
}
if (this.sound) {
const a = this.anim, c = this.chip();
if (c && a.y0 > -RISE + 0.5) c.fx('eject', c.ctx.currentTime + Math.max(0, a.start + T.eject * a.k - now));
if (c) c.fx('insert', c.ctx.currentTime + (a.start + T.contact * a.k - now));
}
}
seek(s) {
const now = this.clock();
this.base = clamp(s, 0, this.track.duration);
this.since = now;
if (this.audio) this.audio.currentTime = this.base;
else if (this.playing) this.synth?.seek(this.base);
this.render(now);
}
setAudio() {
const { src } = this.track;
if (!src) {
if (this.audio) {
this.audio.pause();
this.audio = null;
if (this.playing && this.sound) {
this.chip()?.setSong(this.track.song);
this.synth?.start(0);
}
}
return;
}
this.synth?.stop();
if (!this.audio) {
this.audio = new Audio();
this.audio.addEventListener('ended', () => this.go(1));
}
this.audio.src = src;
if (this.playing) this.audio.play().catch(() => {});
}
/* ── what things look like at a given moment ── */
// Cartridges in view: index and offset from the seated position, in grid units.
cartsAt(now) {
const a = this.anim;
if (!a) return [{ i: this.index, x: 0, y: 0 }];
const t = (now - a.start) / a.k;
const out = {
i: a.out,
x: lerp(a.x0, -a.dir * EXIT, ease.inCubic(unit(t, T.exchange, 0.16))),
y: lerp(a.y0, -RISE, ease.outBack(unit(t, T.eject, T.exchange - T.eject))),
};
const enter = unit(t, T.exchange + 0.08, 0.16);
const inc = {
i: a.to,
x: lerp(a.xi, 0, ease.outCubic(enter)),
y: t < T.insert ? lerp(a.yi, -RISE, enter) : lerp(-RISE, 0, ease.inQuad(unit(t, T.insert, T.contact - T.insert))),
};
return [out, inc];
}
render(now = this.clock()) {
const a = this.anim;
if (a && (now - a.start) / a.k >= T.done) this.anim = null;
const t = this.anim ? (now - this.anim.start) / this.anim.k : Infinity;
// cartridges, snapped to the pixel grid
const carts = this.cartsAt(now);
this.carts.forEach((el, n) => {
const c = carts[n];
el.hidden = !c || Math.abs(c.x) >= EXIT - 0.5; // waiting off to the side, or gone
if (el.hidden) return;
if (el.dataset.i !== String(c.i)) {
const tr = this.tracks[c.i];
el.dataset.i = c.i;
el.style.setProperty('--cart', tr.cart);
el.style.setProperty('--print', tr.print);
el.innerHTML = this.cartMarkup(c.i);
}
el.style.transform = `translate(calc(var(--u) * ${Math.round(c.x)}), calc(var(--u) * ${Math.round(c.y)}))`;
});
// the console takes the cartridge's firm stop
const jolt = t >= T.contact && t < T.contact + 0.06;
this.stage.style.transform = jolt ? 'translateY(calc(var(--u) * 1))' : '';
// keys: one grid step down while pressed; play stays half a step down while playing
for (const [name, el] of Object.entries(this.btn)) {
const down = now - this.pressed[name] < PRESS;
el.style.setProperty('--press', down ? 2 : name === 'play' && this.playing ? 1 : 0);
}
const iconPlaying = now - this.lastToggle < PRESS ? !this.playing : this.playing;
if (this.playFace.dataset.state !== String(iconPlaying)) {
this.playFace.dataset.state = String(iconPlaying);
this.playFace.innerHTML = iconPlaying ? ICON.pause : ICON.play;
this.btn.play.setAttribute('aria-label', iconPlaying ? 'Pause' : 'Play');
}
// screen: the counter answers at once; the rest reloads while the cartridge swaps
this.count.textContent = `${pad2(this.index + 1)} / ${pad2(this.tracks.length)}`;
if (this.titleEl.dataset.i !== String(this.index)) {
this.titleEl.dataset.i = this.index;
this.titleEl.textContent = this.track.title;
this.artistEl.textContent = this.track.artist;
this.seekEl.setAttribute('aria-valuemax', String(this.track.duration));
}
if (t < T.reveal) {
this.info.style.visibility = 'hidden';
this.status.hidden = false;
const dots = '.'.repeat(1 + (Math.floor(t / 0.1) % 3));
this.status.textContent = t < T.exchange ? '▲ EJECT' : `LOADING ${this.track.id}${dots}`;
} else {
this.info.style.visibility = '';
this.status.hidden = true;
// line-by-line reveal in five hard steps
const p = t === Infinity ? 1 : Math.ceil(unit(t, T.reveal, T.done - T.reveal) * 5) / 5;
this.info.style.clipPath = p < 1 ? `inset(0 0 ${((1 - p) * 100).toFixed(0)}% 0)` : '';
}
// progress, time and visualiser
const d = this.track.duration;
let el = this.elapsed(now);
if (el >= d && this.playing && !this.audio) {
this.go(1);
return;
}
el = clamp(el, 0, d);
const blocks = 50; // the bar fills in whole pixel blocks
this.fill.style.width = `${(Math.floor((el / d) * blocks) / blocks) * 100}%`;
this.time.textContent = `${mmss(el)} / ${mmss(d)}`;
this.seekEl.setAttribute('aria-valuenow', String(Math.round(el)));
this.seekEl.setAttribute('aria-valuetext', `${mmss(el)} of ${mmss(d)}`);
const lv = this.started ? levels(this.track.song, el) : IDLE_LEVELS;
this.cols.forEach((c, n) => c.style.setProperty('--h', lv[n]));
}
/* ── animation: only runs while something is moving ── */
loop() {
if (this.running) return;
this.running = true;
const tick = () => {
const now = this.clock();
this.render(now);
const busy = this.playing || this.anim || Object.values(this.pressed).some((p) => now - p < PRESS * 2);
if (!busy) {
this.running = false;
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
CartridgeClub.Chip = Chip;
CartridgeClub.SONGS = SONGS;
CartridgeClub.T = T;
CartridgeClub.levels = levels;
window.CartridgeClub = CartridgeClub;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cartridge Club</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="cartridge-club.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
/* muted warm beige, a little darker than the console shell */
background: #ddd3c0;
font-family: "Silkscreen", ui-monospace, monospace;
color: #212b39;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 560px;
gap: 40px;
}
.eyebrow,
.footer {
margin: 0;
font-size: 12px;
letter-spacing: 0.5em;
margin-right: -0.5em;
}
.footer { color: #6d6555; }
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">CARTRIDGE CLUB</p>
<div id="player"></div>
<p class="footer">INSERT YOUR SOUND.</p>
</main>
<script src="cartridge-club.js"></script>
<script>
new CartridgeClub(document.getElementById('player'), {
tracks: [
{ title: 'AFTER HOURS', artist: 'MONO CLUB', duration: 222, decal: 'assets/decal-sunset.png' },
{ title: 'NIGHT DRIVE', artist: 'MONO CLUB', duration: 245, decal: 'moon' },
{ title: 'SLOW BLOOM', artist: 'MONO CLUB', duration: 198, decal: 'bloom' },
{ title: 'HIGH SCORE', artist: 'MONO CLUB', duration: 176, decal: 'invader' },
],
});
</script>
</body>
</html> All 4 files in the zip
-
assets/decal-sunset.png -
cartridge-club.css -
cartridge-club.js -
index.html