Maple Evergreen  ·  Field Organizer at COWINS

How I automated
my D&D games.

I wanted music and lights that change with one button press. A Stream Deck does that, but not with enough buttons or control, so I built my own. Same thing at work: when the mail merge tool wasn't available, I built one.

Bulb
Waiting for input

Page loaded. Soundboard online.

js • ready

What I built.

The Launchpad controller with a small screen mounted above it showing the soundboard grid

Hardware.

My 15-button Stream Deck ran out of buttons and couldn't show me what was playing. I bought a used 64-pad Launchpad for $25 and a small screen for $46, then designed a bracket to hold them together. Six printed versions to get it right.

More details
  • Novation Launchpad S from eBay and an ELECROW 5″ 1024×600 HDMI touchscreen. $70.59 total. A Stream Deck XL is $249.99.
  • Bracket designed in SolidWorks. Five test prints, then the final.
  • The screen mirrors Ableton's Session View, so every pad has a readable name.
Every version of the 3D-printed mount side by side in the slicer
Every version of the mount in the slicer.
Ableton Live session with eight tracks of clips

Software.

Ableton Live is music software built for playing sounds live. Its Session View is a grid: each column is a track, each cell holds a clip, and the Launchpad's 64 pads map onto that grid one to one. Press a pad and its clip plays. 44 clips on eight tracks: boss music, tavern songs, thunder, screams. The eighth track's clips are silent and named after colors.

The game table lit blue by the smart bulbs, with the Launchpad and its screen in the foreground

Lighting.

Ableton can't talk to the internet. I wrote a small program that runs inside it, watches every track, and when a color clip plays, sends a message that turns the bulbs that color. It takes about a second.

A colorful hex map of the game world

An online world.

With the soundboard done and a big game coming up, I built the rest as web apps: a map that generates what the players find, a character builder, a session planner. Everything saves between sessions.

How the lights work.

You press a pad
Ableton plays the clip
My script notices
It sends a webhookretries if the Wi-Fi drops
IFTTT gets it
The bulbs change
Sends "red" through every hop.
See the code

Two files inside a Max for Live device. The comments say what each part does.

trackmonitorn.jsMax for Live · js
// Runs inside Ableton through Max for Live.
// Watches every track and reports which clip just started.

var observers = [];

// Runs once when the device loads.
function bang() {
    // Clear watchers from any earlier run, so nothing
    // gets reported twice after a reload.
    for (var i = 0; i < observers.length; i++) {
        observers[i].property = "";
    }
    observers = [];

    // Count the tracks in the open set.
    var liveSet = new LiveAPI("live_set");
    var count = liveSet.getcount("tracks");
    post("Watching " + count + " tracks\n");

    // Put a watcher on every track. Ableton calls it
    // whenever the playing clip on that track changes.
    for (var i = 0; i < count; i++) {
        var api = new LiveAPI(makeCallback(i), "live_set tracks " + i);
        api.property = "playing_slot_index";
        observers.push(api);
    }
}

// Builds the function that runs when a track changes.
function makeCallback(trackIndex) {
    return function(args) {
        // args[1] is the slot number. -1 means the track
        // stopped, so that case is ignored.
        if (args[0] === "playing_slot_index" && args[1] >= 0) {
            var slotIndex = args[1];

            // Find the clip in that slot and read its name.
            var clip = new LiveAPI("live_set tracks " + trackIndex + " clip_slots " + slotIndex + " clip");
            var name = clip.get("name");
            post("Now playing: " + name + "\n");

            // Hand the name to webhook.js.
            outlet(0, "clip", name);
        }
    };
}
webhook.jsMax for Live · node.script
// Runs in Node inside the same device. Gets a clip name
// from trackmonitorn.js. If it's a lights clip, it sends
// a web request to IFTTT, and IFTTT sets the bulbs.

const Max = require('max-api');
const https = require('https');

// One IFTTT address per color. The key is my IFTTT account.
const KEY    = "<ifttt-maker-key>";
const red    = "https://maker.ifttt.com/trigger/RED/with/key/"    + KEY;
const blue   = "https://maker.ifttt.com/trigger/BLUE/with/key/"   + KEY;
const green  = "https://maker.ifttt.com/trigger/GREEN/with/key/"  + KEY;
const off    = "https://maker.ifttt.com/trigger/OFF/with/key/"    + KEY;
const on     = "https://maker.ifttt.com/trigger/WHITE/with/key/"  + KEY;
const normal = "https://maker.ifttt.com/trigger/NORMAL/with/key/" + KEY;
const rain   = "https://maker.ifttt.com/trigger/rain/with/key/"   + KEY;
const purple = "https://maker.ifttt.com/trigger/pink/with/key/"   + KEY;

// Which clip names mean what. Any name not in this
// list is ignored. Adding a color is one new line here
// and one new rule in IFTTT.
const clipMap = {
    "Start game": { url: normal, color: "normal" },
    "green":  { url: green,  color: "green"  },
    "red":    { url: red,    color: "red"    },
    "blue":   { url: blue,   color: "blue"   },
    "normal": { url: normal, color: "normal" },
    "off":    { url: off,    color: "off"    },
    "white":  { url: on,     color: "white"  },
    "purple": { url: purple, color: "purple" },
    "rain":   { url: rain,   color: "rain"   }
};

// Send the request. If it fails, wait and try again,
// up to four times.
function sendWebhook(entry, attempt) {
    attempt = attempt || 1;

    // agent: false opens a fresh connection every time
    // instead of reusing an old one.
    https.get(entry.url, { agent: false }, (res) => {
        res.resume(); // read the reply so the connection closes cleanly
        Max.post("Sent" + (attempt > 1 ? " (attempt " + attempt + ")" : "") + " > " + entry.color);
    }).on('error', (err) => {
        if (attempt < 4) {
            // Wait a bit longer each time: 0.5 s, 1 s, 1.5 s.
            setTimeout(() => sendWebhook(entry, attempt + 1), attempt * 500);
        } else {
            Max.post("FAILED after 4 tries: " + entry.color + " (" + err.message + ")");
        }
    });
}

// Runs every time trackmonitorn.js reports a clip.
Max.addHandler("clip", (clipName) => {
    const entry = clipMap[clipName];
    if (entry) {
        sendWebhook(entry);
    } else {
        // A music or sound clip. Nothing to send.
        Max.post("No webhook mapped for: " + clipName);
    }
});
The Max for Live patch wiring the two scripts together
The Max patch. A delayed loadbang starts the observer, which feeds the Node script.
IFTTT rule: if a web request arrives, then turn the lights on
One IFTTT rule per color.

Last Light.

My campaign is called Last Light. I built five web apps for it, all linked from one home page. The three big ones are below.

Inhabit a Body

A character in seven steps

Players build a character step by step. The app does the math as they click and prints a finished sheet.

  • Seven steps: lineage, path, ability scores, origin, gear, body, sheet.
  • Works out armor, hit points, saves, skills and a full inventory with weights.
  • One file, no server. Saves in the browser and prints to PDF.
Open it
Chart the Wilds

A map that generates the world

Five stacked maps, from the sky to the deepest underground. When players enter a hex, the map decides what's there and keeps it.

  • 151 explored hexes and 28 creatures that roam, hunt and hold territory, with seasons and weather.
  • Saves on the device first, then to the cloud, with undo. Open it on a phone at the table and it's the same world.
Open it
Inside a hex

One click, one dungeon

Ask a hex what's in it and you get a landmark, then a dungeon: a vampire den with five rooms, drawn as a map, each room with lighting, loot, traps and monsters.

  • Dozens of dungeon types, each limited to the environments where it makes sense.
  • Every number the generator uses lives in one file, so it can be tuned without guessing.
  • A test builds every dungeon type in every environment and blocks the release if a rule breaks.
Plan a Session

Build a Session

Drafts the beats of a night: who's playing, what they might find, which items and spells are in play. The item and spell catalog is baked into the page, so it runs with no server.

Open it
Reference tool

Magic Item Lookup

Pulls 237 magic items from a public D&D data source, cleans them into one format, and serves a page where you can search them or roll a random one.

Under the hood

Storage. The map writes to IndexedDB first, with a localStorage mirror in case IndexedDB fails, then syncs to Supabase (Postgres). One shared campaign row holds the world as JSON, owned by a lead account. Row-level security lets anyone read the map and only the lead write. A regression test proves a rolled hex is never lost, even if IndexedDB is unavailable.

Sync. Sign-in reconciles cloud-wins. Pushes are skipped when nothing changed. The creature ecosystem changes every tick, so it saves locally every tick and commits to the cloud at checkpoints. That rule fixed a realtime echo cascade where every client re-broadcast what it received and the egress bill spiked. I found it in the bill first.

Deploys. All five apps run on Cloudflare Workers. Two smaller apps bake their JSON into the page with a build script.

Supabase project settings for the hex grid generator
The Supabase project behind the map.
GitHub profile showing 219 contributions in the last year
219 commits in the last year.

The payoff.

Turn your sound on
Sound on
The trailer.
The game room with the table, screen and the lights the system controls
The room. Three lights in here change with the music.
Behind the screen: Launchpad, notes, and the map on a tablet
Behind the screen: Launchpad, notes, map on a tablet.

I've already automated things at work.

Three years ago, when I started at COWINS, I didn't have access to the mail merge tool, and I had hundreds of emails to send. So I built my own in Google Sheets.

The mail merge spreadsheet: two recipient columns, the instructions in the first cell, and a Dead Emails tab
The sheet. Two recipient columns, the instructions in the first cell, and a Dead Emails tab.
  • Write it onceWrite the email as a normal Gmail draft. Everyone in the sheet gets their own copy, formatting, pictures and attachments included.
  • Knows their nameType {{Name}} in the draft and each email fills it in from that person's row.
  • Finds the dead addressesWhen an email bounces, the script reads Gmail's failure notice, adds the address to a Dead Emails tab, and marks it in the sheet.
  • Doesn't stopIf one row has a problem, it's logged and skipped. The rest still go out.
See the code

Google Apps Script, which is JavaScript that runs inside Google Sheets and Gmail.

mailmerge.gsGoogle Apps Script
// A mail merge for Google Sheets.
// Column "Non BCC Recipient": who the email is addressed to.
// Column "BCC Recipient": who gets a private copy.
// The email itself is a Gmail draft, picked by its subject line.

const NON_BCC_COL = "Non BCC Recipient";
const BCC_COL = "BCC Recipient";
const BOUNCE_BACK_SUBJECT = 'Delivery Status Notification (Failure)';
const BOUNCE_BACK_SENDER = 'mailer-daemon@googlemail.com';

// Adds a "Mail Merge" menu to the sheet when it opens.
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('Mail Merge')
      .addItem('Send Emails', 'sendEmails')
      .addToUi();
}

// The main job: read the sheet, find the draft, send one email per row.
function sendEmails() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const data = sheet.getDataRange().getValues();
  const headers = data[0];
  const nonBccIndex = headers.indexOf(NON_BCC_COL);
  const bccIndex = headers.indexOf(BCC_COL);

  // Ask which draft to send, by subject line.
  const subjectLine = Browser.inputBox("Mail Merge",
                                       "Type or copy/paste the subject line of the Gmail draft message you'd like to use:",
                                       Browser.Buttons.OK_CANCEL);
  if (subjectLine === "cancel" || subjectLine === "") {
    return;
  }

  const emailTemplate = getGmailTemplateFromDrafts_(subjectLine);

  // One email per row. A bad row is logged and skipped, not fatal.
  for (let i = 1; i < data.length; i++) {
    try {
      const msgObj = fillInTemplateFromObject_(emailTemplate.message, headers, data[i]);

      GmailApp.sendEmail(data[i][nonBccIndex], msgObj.subject, msgObj.text, {
        bcc: data[i][bccIndex],
        htmlBody: msgObj.html,
        attachments: emailTemplate.attachments,
        inlineImages: emailTemplate.inlineImages
      });

    } catch(e) {
      Logger.log("Error sending email for row " + i + ": " + e.message);
    }
  }

  // When the send is done, look for addresses that bounced.
  logBouncedEmails();
}

// Finds the Gmail draft with that subject and pulls out its text,
// formatting, attachments and inline pictures.
function getGmailTemplateFromDrafts_(subjectLine) {
  const drafts = GmailApp.getDrafts();
  const draft = drafts.filter(d => d.getMessage().getSubject() === subjectLine)[0];
  if (!draft) throw new Error("Can't find Gmail draft with subject: " + subjectLine);
  const msg = draft.getMessage();

  const allInlineImages = msg.getAttachments({includeInlineImages: true, includeAttachments: false});
  const attachments = msg.getAttachments({includeInlineImages: false});

  const inlineImages = {};
  allInlineImages.forEach(img => {
    inlineImages[img.getName()] = img;
  });

  return {
    message: {
      subject: subjectLine,
      text: msg.getPlainBody(),
      html: msg.getBody()
    },
    attachments: attachments,
    inlineImages: inlineImages
  };
}

// Swaps placeholders like {{Name}} for the value in that row's column.
function fillInTemplateFromObject_(template, headers, row) {
  let templateString = JSON.stringify(template);

  headers.forEach((header, index) => {
    templateString = templateString.replace(new RegExp('{{' + header + '}}', 'g'), row[index]);
  });

  return JSON.parse(templateString);
}

// Reads Gmail's unread bounce notices, pulls the dead address out of each one,
// writes them to a "Dead Emails" tab, and marks them in the main sheet.
function logBouncedEmails() {
    const threads = GmailApp.search(`from:${BOUNCE_BACK_SENDER} subject:${BOUNCE_BACK_SUBJECT} is:unread`);
    const deadEmails = [];

    for (let thread of threads) {
        let messages = thread.getMessages();
        for (let message of messages) {
            if (message.isUnread()) {
                let body = message.getPlainBody();
                const match = body.match(/Your message wasn't delivered to (.*?) because/);
                if (match) {
                    deadEmails.push(match[1]);
                } else {
                    Logger.log("Failed to extract email from: " + body);
                }
            }
        }
    }

    Logger.log('Dead emails extracted: ' + deadEmails);

    // Make the "Dead Emails" tab if it doesn't exist yet, then add to it.
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    let deadEmailSheet = ss.getSheetByName("Dead Emails");
    if (!deadEmailSheet) {
        deadEmailSheet = ss.insertSheet("Dead Emails");
    }

    if (deadEmails.length > 0) {
        const lastRow = deadEmailSheet.getLastRow();
        deadEmailSheet.getRange(lastRow + 1, 1, deadEmails.length, 1).setValues(deadEmails.map(email => [email]));
    }

    // Flag dead addresses in the main sheet so they're skipped next time.
    const mainSheet = ss.getActiveSheet();
    const emailColumnData = mainSheet.getRange(2, 1, mainSheet.getLastRow()).getValues();
    for (let i = 0; i < emailColumnData.length; i++) {
        if (deadEmails.includes(emailColumnData[i][0])) {
            mainSheet.getRange(i + 2, 2).setValue("email dead");
        }
    }
}

Tools and skills for this project.

Click one.