Engineering · April 30, 2026 · 7 min read

The drum sandbox grew up: from grid to instrument

The drum sandbox started as a 16-step grid with three cells: kick, snare, hat. Twelve iterations later it has chord overlays, walking bass, kit swap, swing, save, random, shift, mute, and copy-share-link. The URL hash format from Round 106 still loads in the Round 141 sandbox without a single migration.

Round 106: the bones

The first build was a Web Audio look-ahead scheduler driving three rows of toggle cells. A 25ms tick scanned 100ms into the future and queued any step whose start time fell in the window. Each cell was a click-to-toggle bit; the kick was a sine with a pitch envelope, the snare was high-passed noise plus a 200Hz triangle, the hat was 7kHz high-passed noise.

Three rows. Sixteen columns. One BPM input. The hash format was fixed from the first commit: bpm=90&p=... where p packed the kick/snare/hat bits into hex. Every later round added a new key to the same query string instead of reshaping it. That choice did most of the work.

Round 107: chord overlay

A chord progression dropdown was added below the grid. Eight progressions — pop, lo-fi, sad, R&B, jazz, gospel, drill, ambient — each one a list of chord pitch-class sets the scheduler would trigger every four steps. A single new query key: prog=pop.

Round 108: walking bass

Bass was the next layer: same trigger times as the chord overlay, but with a four-style toggle (root, walking, octaves, syncopated). The bass synth was a sine with a 60Hz floor and a slow attack. One more query key: bass=walking.

Round 109: drum kits

The synthesis parameters that defined the kick/snare/hat sound were lifted into a parameter table, and a kit dropdown swapped between four named kits — default, eight08, lofi, tight. The grid bits didn't change; the synthesis params did.

const DRUM_KITS = {
  default: { kick: { f0: 60,  decay: 0.45 }, snare: { hpfHz: 1800, decay: 0.18 }, hat: { hpfHz: 7000, decay: 0.05 } },
  eight08: { kick: { f0: 50,  decay: 0.80 }, snare: { hpfHz: 1500, decay: 0.22 }, hat: { hpfHz: 8500, decay: 0.04 } },
  lofi:    { kick: { f0: 70,  decay: 0.30 }, snare: { hpfHz: 1200, decay: 0.30 }, hat: { hpfHz: 5000, decay: 0.08 } },
  tight:   { kick: { f0: 65,  decay: 0.20 }, snare: { hpfHz: 2200, decay: 0.10 }, hat: { hpfHz: 9000, decay: 0.03 } },
};

One more query key: kit=eight08. The grid pattern was already kit-independent, so a 2024-era pattern in the lofi kit became the same pattern in the tight kit with one tap.

Round 117: save library

Up to twelve named patterns went into localStorage keyed by name. Save was a button that snapshotted the current hash; load was a click that pushed the saved hash back into location.hash and re-parsed. Persistence was free because the hash was already canonical state.

Round 125: swing pocket

Swing reshaped the gap between consecutive 16th-step trigger times. A four-pill row — 50, 54, 58, 66 — let the user move between straight 16ths and triplet shuffle. The scheduler interpreted the swing percent as s = (swing - 50) / 48 and split each pair of steps into asymmetric halves:

// Swing-aware advance
// After an on-beat 16th: gap = (1 + s) * stepDur
// After an off-beat 16th: gap = (1 - s) * stepDur
// Pair sums to 2 * stepDur so bar length is invariant.

Bar length stayed fixed, so the chord and bass overlays stayed anchored on steps 0 and 8. One more query key: swing=58. Old hashes without a swing key defaulted to 50% (straight) and played identically to before.

Round 135: random pattern

A "Random" button below the grid generated a fresh kick/snare/hat pattern using genre-aware densities — kicks weighted toward beats 1 and 3, snares on 2 and 4, hats roughly 70% density. The result replaced the current bits and the URL hash updated in place. Any generated pattern that the user liked could be saved like a hand-built one; nothing about save-load needed to know it was generated.

Round 139: pattern shift

Two buttons — ← Shift / Shift → — rotated the current pattern by one step in either direction. Useful for sliding a snare from beat 2 to the "and of 2" without retyping the bits. The implementation was three lines per row, using the safe-negative-index modulo idiom:

function shift(bits, d) {
  const N = bits.length;
  return bits.map((_, i) => bits[((i - d) % N + N) % N]);
}

The hash updated in place, so a shifted pattern was instantly shareable. Save-load and copy-link still worked unchanged.

Round 140: per-track mute

Three small M labels, one per row. Tap to mute that row in the audio path without touching its bits. The mute state was deliberately kept out of the URL hash — it's a monitoring affordance, not a song state. Mute the kick, audition the snare and hat, mute the hat, audition the kick alone. Unmute and the pattern is exactly what was on the screen before.

Round 141: copy-share-link

Each saved-library row got a icon next to its Load button. Tap it and the saved pattern's full URL gets copied to the clipboard, ready to paste into a message.

if (e.target.classList.contains('saved-copy')) {
  const ci = parseInt(e.target.dataset.copy, 10);
  const item = readSaved()[ci];
  if (!item) return;
  const url = `${location.origin}${location.pathname}#${item.hash}`;
  try {
    await navigator.clipboard.writeText(url);
    showToast('Link copied');
  } catch {
    window.open(url, '_blank');
  }
  return;
}

A saved pattern wasn't in the URL — it was in localStorage. But because every saved record kept its full hash, regenerating the share link was a string concatenation. No serialization, no migration, no parallel format.

Twelve iterations. The hash format never broke. Old shared links from Round 106 still load in the Round 141 sandbox.

12
Iterations
4
Drum kits
8
Progressions
12
Local saves

The compounding pattern

Every layer added a new key to the URL hash and read it on page load. Save/load was free: the hash was already canonical, so localStorage just stored hash strings. Copy-link was free: take the saved hash, prepend location.origin + location.pathname + '#', copy. Random pattern was free: the generator just wrote new bits into the same hash field the user was about to share.

This is the opposite of how the sandbox could have grown. If every feature had owned its own state object, save/load would have been per-feature serialization, copy-link would have been a parallel format, and an upgrade to swing in Round 125 would have meant migrating saved Round 117 patterns. None of that happened because the hash was the source of truth from Round 106 onward.

What this isn't

The drum sandbox isn't a DAW. It doesn't have per-step velocity, per-step pitch, per-row send effects, multi-bar arrangement, or tempo automation. It's a pocket-sized 16-step playground built to teach kick / snare / hat / chord / bass / kit / swing as one coherent system, and to make any pattern shareable as a URL. The constraints are the point.

Each constraint is also where the next round will start. The hash format has room for a velocity layer (probably encoded as a parallel v key), and the chord overlay has room for the diatonic palette from the piano roll. Every new feature so far has cost less than the one before it because the layer it sat on already understood the hash.

Open the drum sandbox

Twelve rounds of features. One URL hash. Build, save, copy, share.

Open the drum sandbox →

What's next

Per-step velocity. The current sandbox treats every cell as a bit (on / off); Round 142+ wants three states (off / soft / loud) so the kick on beat 1 can hit harder than the kick on the "and." The visual lift is small (a darker shade for soft, full color for loud); the hash key is a new v field that older hashes silently default to all-loud. About 50 lines, same shape as every other round.

© 2026 StudioMode · Twelve iterations, one hash format.