Two Days, Fifteen Commits: Building for the Person You Love

Two Days, Fifteen Commits: Building for the Person You Love

“Chaos is found in greatest abundance wherever order is being sought. It always defeats order, because it is better organised.” — Ly Tin Wheedle

My wife Erica and I play D&D together every other week. A few months ago, we created twin Wild Magic characters as alternates—my Wild Magic Barbarian and her Wild Magic Sorcerer. The Wild Magic Sorcerer is thematically delightful: every spell you cast has a chance to trigger chaotic magical effects that range from “you turn blue” to “a unicorn appears within 60 feet of you” to “you cast fireball centered on yourself.” And if you really lean into the chaos, you can use Tides of Chaos to essentially guarantee a surge on every single spell you cast.

The problem is the UX.

I could already picture how it would go. Every surge would grind gameplay to a halt. Roll the d100. Flip to the surge table. Find the result. Oh, that one summons a creature—open the Monster Manual. Wait, it also requires a secondary d4 roll. What’s the creature’s stat block again? Five minutes later, we’d have the answer. The joy would be gone.

I’ve been at tables where this happens. The apologetic “sorry, just one more minute” to the other players. The mechanical joy being drained from something that should be fun.

So before our first session with these characters, I built her a tool.


Building for One

There’s a design philosophy I’ve been thinking about lately: when you build for one specific person, you make different choices than when you build for “everyone.”

Building for everyone means feature flags, configurability, edge case handling, documentation. Building for Erica meant: what does she actually need? What would make her smile?

The answer was simple: roll a button, get a complete answer. No lookups. No page-flipping. No secondary references. Just the surge effect, fully resolved, with creature stats and spell descriptions inline.


The Data Challenge

The first problem was getting the data. The 2024 Player’s Handbook has a Wild Magic Surge table with 25 base effects across the d100 range. But four of those effects have secondary dice rolls—a d4 for summoned creatures, a d6 for powerful benefits, a d8 for weird transformations, and a d10 for random spells—bringing the total to 49 distinct possible outcomes. Many reference spells. Some summon creatures.

I used Claude Code’s Chrome connector—the Claude-in-Chrome MCP server—to extract content from Wizards of the Coast’s digital rulebooks. Once I pointed it at the surge table and told it to read the page content (not take screenshots) and follow links for spells, creatures, and status effects, it did a decent job of pulling everything together.

But here’s the important part: I can build tools with this content for personal use. I can’t distribute them. So this tool lives on my site, unlisted, for an audience of one. The screenshots in this post are fair use for commentary—but I won’t be sharing the tool itself.

The data ended up in three YAML files:

FileEntriesPurpose
table.yml49 effectsThe surge outcomes with secondary roll definitions
spells.yml12 spellsComplete spell descriptions for inline display
creatures.yml5 creaturesStat blocks for summoned creatures

Day 1: 3D Dice and Core Logic

I started January 6th with a clear goal: rolling dice should feel magical. Not a random number generator with a pretty skin—actual physics-based 3D dice that tumble and settle.

I found dice-box-threejs , a Three.js-based dice roller. The API is straightforward:

const diceBox = new DiceBox('#dice-container', {
  framerate: 1/60,
  sounds: true,
  assetPath: '/js/dice-box-threejs/assets/',
  shadows: true,
  theme_surface: 'green-felt',
  theme_material: 'glass',
  gravity_multiplier: 400
});

await diceBox.initialize();
const results = await diceBox.roll('1d100+1d10');

The d100 mechanic in D&D uses two dice: a percentile die (00-90) and a units die (0-9). Reading them correctly is its own logic:

function parseD100Results(results) {
  let d100Die = 0;
  let d10Die = 0;

  for (const set of results.sets) {
    if (set.type === 'd100') d100Die = set.rolls[0].value;
    else if (set.type === 'd10') d10Die = set.rolls[0].value;
  }

  // 00 + 0 = 100, not 0
  if (d100Die === 100 && d10Die === 10) return 100;

  const tens = d100Die === 100 ? 0 : d100Die;
  const ones = d10Die === 10 ? 0 : d10Die;
  return tens + ones || 100;
}

By end of day one, I had working 3D dice, the complete surge table, and inline spell/creature display. Ten commits. 2,857 lines of code.

A surge result showing the Grease spell with its full description expanded inline

When a surge references a spell, the full description is available inline—no PHB lookup required.


The Safari Bug

Day two started with a bug report from my QA department (Erica, testing on her iPhone).

“The dice don’t work.”

They worked perfectly on desktop. Chrome, Firefox, Safari—all fine. But iOS Safari? The 3D dice box initialized but never rendered the roll. The dice just… didn’t appear.

I spent an embarrassing amount of time in Safari’s remote debugger before finding the culprit: audio preload constraints.

iOS Safari has aggressive restrictions on audio. You can’t play sounds until the user has interacted with the page—and the way dice-box-threejs handles audio initialization was hitting those restrictions in a way that silently broke the entire WebGL render loop.

The fix was almost anticlimactic:

state.diceBox = new DiceBox('#wms-dice-board', {
  sounds: !isMobile(),  // Disable sounds on mobile
  // ... other config
});

// Also reduce audio callback frequency
state.diceBox.soundDelay = 50;

One line. sounds: !isMobile(). Hours of debugging for a boolean.

The lesson I keep relearning: always test on actual devices. iOS Safari in responsive mode on desktop is not the same as iOS Safari on an iPhone. The audio security model behaves differently. The WebGL context behaves differently. The memory constraints are different.


The Fork Decision

While debugging the Safari issue, I found another bug: the .add() method for rolling secondary dice wasn’t working correctly. The dice would appear but the results wouldn’t resolve properly.

This was an upstream bug in dice-box-threejs. The old calculus used to be: file an issue, hope for a fix, work around it in the meantime. But with AI-assisted development, the calculus has shifted. Fork it, have Claude fix it, submit a PR upstream.

The bug was subtle—it took Claude talking to me and Codex for a while to track it down. But we got it, and the PR has been submitted .

A creature summon result showing a Flumph with its full stat block

When a surge summons a creature, the full stat block appears inline—no Monster Manual required.

The secondary dice appear in a different color so you know which roll is which.


Mobile UX Choreography

The devil is in the scroll behavior.

On desktop, users see the roll button, the dice area, and the result all at once. On mobile, these are stacked vertically. The user taps “Roll Surge,” then needs to see the dice, then needs to see the result.

Getting this right required what I think of as “scroll choreography”:

function scrollToElement(element) {
  if (!element) return;

  if (isMobile()) {
    // Mobile: navbar is hidden, simple scroll
    element.scrollIntoView({ behavior: 'smooth', block: 'start' });
    return;
  }

  // Desktop: account for fixed navbar
  const navbar = document.querySelector('.navbar');
  const navbarHeight = navbar ? navbar.offsetHeight : 0;
  const offset = navbarHeight + 10;
  const rect = element.getBoundingClientRect();
  const scrollTop = window.pageYOffset + rect.top - offset;
  window.scrollTo({ top: scrollTop, behavior: 'smooth' });
}

The flow on mobile:

  1. User taps “Roll Surge”
  2. Hide previous result (stabilizes layout)
  3. requestAnimationFrame → scroll to dice board
  4. Dice roll and settle
  5. Scroll to result display

Each transition is smooth. Nothing jumps. The user’s eye follows the action naturally.

I also hide the navbar entirely on mobile for this tool—it’s a full-screen experience:

body:has(.wild-magic-surge) {
  @include mobile {
    .navbar {
      display: none !important;
    }
  }
}

Progressive Enhancement

Not everyone has WebGL. Not everyone wants animations. The tool should work for everyone, but delight those who can handle it.

function hasWebGLSupport() {
  try {
    const canvas = document.createElement('canvas');
    return !!(window.WebGLRenderingContext &&
      (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')));
  } catch (e) {
    return false;
  }
}

function prefersReducedMotion() {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

// Initialize appropriately
state.use3D = !prefersReducedMotion() && hasWebGLSupport();
if (state.use3D) {
  await initDiceBox();
} else {
  init2DFallback();
}

The 2D fallback animates simple dice faces with randomized intermediate values before settling on the final result. It’s not as satisfying as watching physics-based dice tumble, but it works everywhere.


The Playtest

Saturday night. A side quest to our main campaign—four level 3 players running scaling content designed for six level 13 players. Mostly puzzles and skill challenges (Tides of Chaos advantage on the d20s was clutch). Then came the final fight: a ballroom full of partygoers and a Big Boss.

We were underpowered and underequipped. A bard, barbarian, sorcerer, and wizard. But sure, let’s do this!

The wizard opens up with… Magic Missile? One missile each at three non-hostile partygoers. I guess evening the odds since there’s four of us against one boss—better to make it 4v4? Not sure what the wizard-brain logic was, but now it’s 4v4 and three angry partygoers immediately swarm and wreck the wizard.

The bard tries to CC the Big Boss. Boss makes the Charisma save, gets angry, focus-fires. She’s down in a couple of rounds.

My barbarian rushes in swinging. Does some damage, but this boss has nearly 100 HP—it’s going to take a while.

The three partygoers have moved on to the sorcerer, who’s been casting damage spells at the boss. No major surge effects yet. Luckily, a racial feature gives her resistance to bludgeoning (all the partygoers deal), but with a rinky-dink sorcerer health pool she’s soon very low.

And lo! She procs “you regain 2d10 hit points”—back to full health. Still taking whacks from the partygoers, but buying time.

Another surge! “Up to three creatures of your choice within 30 feet take 4d10 Lightning damage.”

Zap. All three partygoers are dead.

Meanwhile, my chaos barbarian (species abilities: teleport up to 30 feet as a bonus action, and stick to walls and ceilings) has teleported to the ceiling and is raining hand axes down on the boss, who’s cursing and trying to use his “duel” ability to teleport me back down. But now he’s low on health, the sorcerer no longer has partygoers in her face—we both unload and end up winning the fight.

Chaos turned the tide. Wheeeee.


The Commit Log

The whole project in git:

DateCommitsFocus
Jan 6, morning1Initial implementation (2,857 lines)
Jan 6, afternoon6Polish, formatting, forked dice library
Jan 6, evening4Mobile UX, secondary rolls, scroll behavior
Jan 73Sound effects, visual improvements, button states
Jan 101iOS Safari audio fix

Fifteen commits. Two days of focused work. One very happy user.


What Building for One Person Taught Me

The best UX comes from knowing your user. I know what Erica’s phone is. I know how she holds it. I know she’ll be excited, not patient. I know she needs the answer fast so she can get back to roleplaying. That knowledge shaped every decision.

AI-assisted development enables rapid response to real needs. Two days from “this is frustrating” to “wheeeee” isn’t possible without Claude Code handling the boilerplate, catching my typos, and helping me debug Safari’s audio quirks. I focused on the what and why; Claude helped with the how.

Mobile-first is hard. It’s not enough to make it responsive. You have to test on real devices. You have to think about audio policies, scroll behavior, touch targets, and the choreography of attention.

When to fork is a judgment call. For personal projects, the answer is usually “fork and fix.” For production software, “contribute upstream” is usually better. Know which game you’re playing.

Love languages include code. Seeing someone you care about frustrated with a bad experience, and having the skills to build them something better? That’s a gift I’m grateful to be able to give.


Postscript

We built twin chaos characters, and now they play like it.

My barbarian has Reckless Attack—advantage on every attack, all the time. Erica’s sorcerer now has what I think of as Reckless Magic Surge: Tides of Chaos for advantage, proc the surge to reset it, use it again. Constant advantage, constant chaos.

The difference is she can resolve those surges in seconds now. No friction. No apologies. Just wheeeee.