Three percussive layers running on Euclidean rhythms — kick at 5-of-8, hat at 11-of-16, pitched perc at 7-of-13 (against a 4-feel) — beating polyrhythmically against each other. Densities re-roll every 8 beats. Pad and sub underneath.
plinko-previewimport type { ElementaryPatch } from '@/lib/engine/types'
import { euclidean } from '@/lib/generative'
const plinko: ElementaryPatch = {
id: 'plinko',
title: 'Plinko',
author: 'stuart',
year: 2026,
tags: ['rhythm', 'euclidean', 'polyrhythm'],
description:
'Three percussive layers running on Euclidean rhythms — kick at 5-of-8, hat at 11-of-16, pitched perc at 7-of-13 (against a 4-feel) — beating polyrhythmically against each other. Densities re-roll every 8 beats. Pad and sub underneath.',
engine: 'elementary',
audio: ({ el, rng, schedule, render }) => {
const chords = [
[220, 261.63, 329.63, 392, 493.88], // Am add9
[196, 246.94, 293.66, 369.99, 440], // G6
[174.61, 220, 261.63, 329.63, 392], // Fmaj7
[164.81, 207.65, 246.94, 311.13, 415.3], // E
]
const bpm = 88
const beatMs = 60_000 / bpm
const eighthMs = beatMs / 2
const sixteenthMs = beatMs / 4
const interval = (beatMs * 8) / 1000 // chord change every 8 beats
schedule(interval, (tick) => {
const chord = rng.pick(chords, tick, 0)
const kickHits = rng.int(3, 6, tick, 1) // 3-6 in 8 — varies kick density
const hatHits = rng.int(7, 13, tick, 2) // 7-13 in 16
const percHits = rng.int(4, 8, tick, 3) // 4-8 in 13 — irregular phrase length
// ── Kick — Euclidean(kickHits, 8) at eighth-note resolution ──
const kickStep = el.metro({ name: 'kickStep', interval: eighthMs })
const kickGate = el.seq(
{
key: `kickPat-${tick}`,
seq: euclidean(kickHits, 8),
loop: true,
},
kickStep,
el.const({ key: 'kickReset', value: 0 }),
)
const kickEnv = el.adsr(0.001, 0.18, 0, 0, kickGate)
const kickPitch = el.add(
el.const({ key: 'kFreq', value: 55 }),
el.mul(kickEnv, 45),
)
const kick = el.mul(el.cycle(kickPitch), el.mul(kickEnv, 0.55))
// ── Hat — Euclidean(hatHits, 16) at sixteenth-note resolution ──
const hatStep = el.metro({ name: 'hatStep', interval: sixteenthMs })
const hatGate = el.seq(
{
key: `hatPat-${tick}`,
seq: euclidean(hatHits, 16),
loop: true,
},
hatStep,
el.const({ key: 'hatReset', value: 0 }),
)
const hatEnv = el.adsr(0.001, 0.045, 0, 0, hatGate)
const hat = el.mul(
el.highpass(
el.const({ key: 'hatHpf', value: 6500 }),
0.9,
el.noise(),
),
el.mul(hatEnv, 0.07),
)
// ── Pitched perc — Euclidean(percHits, 13) at sixteenth-note resolution.
// 13 against 16 yields shifting alignment ("rotating" feel).
const percStep = el.metro({ name: 'percStep', interval: sixteenthMs })
const percGate = el.seq(
{
key: `percPat-${tick}`,
seq: euclidean(percHits, 13),
loop: true,
},
percStep,
el.const({ key: 'percReset', value: 0 }),
)
const percEnv = el.adsr(0.001, 0.1, 0, 0, percGate)
const percFreq = chord[2] * 2 // pitched in chord
const percMod = el.mul(
el.cycle(el.const({ key: 'percMod', value: percFreq * 2 })),
30,
)
const perc = el.mul(
el.cycle(
el.add(el.const({ key: 'percCar', value: percFreq }), percMod),
),
el.mul(percEnv, 0.13),
)
// ── Pad chord (held under percussion) ──
const padVoices = chord.map((f, i) =>
el.mul(el.cycle(el.const({ key: `pad-${i}`, value: f })), 0.07),
)
const pad = el.lowpass(
el.const({ key: 'padCut', value: 1500 }),
0.4,
el.add(...padVoices),
)
// ── Sub bass ──
const sub = el.mul(
el.cycle(el.const({ key: 'sub', value: chord[0] / 2 })),
0.18,
)
const mix = el.add(pad, sub, kick, hat, perc)
const out = el.mul(el.tanh(el.mul(mix, 0.5)), 0.2)
render(out, out)
})
},
visual: ({ ctx, rng, t, width, height, mouseX, mouseY, mouseActive, getRms }) => {
// Three rotating rings — one per percussion layer. Cursor accelerates
// ring rotation when it's near the rings; nodes near the cursor brighten.
ctx.fillStyle = 'rgba(12, 12, 12, 0.18)'
ctx.fillRect(0, 0, width, height)
const cx = width / 2
const cy = height / 2
const baseR = Math.min(width, height) * 0.32
const mx = mouseActive ? mouseX * width : cx
const my = mouseActive ? mouseY * height : cy
// Cursor distance from center (normalized) — used to speed up rotation
const dCenter = Math.hypot(mx - cx, my - cy) / baseR
const speedBoost = mouseActive ? 1 + Math.min(dCenter, 1.2) * 1.5 : 1
// Audio RMS adds to overall brightness — quiet beats fade, loud ones flare
const audioRms = getRms()
const audioBoost = Math.min(1, audioRms * 6)
const rings = [
{ steps: 8, hits: 5, radius: baseR, rate: 0.08, color: '232,230,225' },
{ steps: 16, hits: 11, radius: baseR * 0.7, rate: 0.13, color: '180,180,180' },
{ steps: 13, hits: 7, radius: baseR * 0.45, rate: 0.21, color: '239,68,68' },
]
for (const ring of rings) {
const hitMask = new Array(ring.steps).fill(false)
for (let i = 0; i < ring.hits; i++) {
hitMask[Math.floor((i * ring.steps) / ring.hits)] = true
}
const rotation = t * ring.rate * speedBoost
for (let i = 0; i < ring.steps; i++) {
const a = (i / ring.steps) * Math.PI * 2 + rotation
const x = cx + Math.cos(a) * ring.radius
const y = cy + Math.sin(a) * ring.radius
// Distance from cursor → brightness boost (closer = brighter)
const cursorDist = mouseActive
? Math.hypot(x - mx, y - my)
: Number.POSITIVE_INFINITY
const proximity = mouseActive
? Math.max(0, 1 - cursorDist / 120)
: 0
if (hitMask[i]) {
const pulse = 0.5 + 0.5 * Math.sin(t * 4 + i * 0.4)
const alpha = 0.3 + pulse * 0.5 + proximity * 0.4 + audioBoost * 0.2
ctx.fillStyle = `rgba(${ring.color}, ${Math.min(1, alpha)})`
ctx.beginPath()
ctx.arc(x, y, 3 + pulse * 4 + proximity * 3 + audioBoost * 4, 0, Math.PI * 2)
ctx.fill()
} else {
ctx.fillStyle = `rgba(${ring.color}, ${0.12 + proximity * 0.4})`
ctx.beginPath()
ctx.arc(x, y, 1.5 + proximity * 2, 0, Math.PI * 2)
ctx.fill()
}
// Optional cursor "tug" line: nearest node to cursor
if (mouseActive && proximity > 0.5) {
ctx.strokeStyle = `rgba(${ring.color}, ${proximity * 0.3})`
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(mx, my)
ctx.stroke()
}
}
}
},
}
export default plinko