Session Attendance Probability Calculator

Calculate the probability everyone in the party will show up for a D&D session.

The calculator is embedded at the bottom of the page, or you can open it in a new window.

launch session attendance probability calculatorin a new window

Why make a calculator?

It’s notoriously hard to schedule D&D sessions. Especially with parties of more than three players, or parties that have a player with a consistently full social calendar. I was curious to know: given the individual probability of each player’s likelihood of attending, what is the statistical probability of everyone showing up?

Building the Tool

I chose to co-build this tool with an AI, mostly as an experiment to determine how well it could handle this kind of problem.

The user needed to add and remove players and set their individual probabilities, and have the app update and perform calculations based on those parameters. This meant making a component-based and stateful front-end. The AI wanted to use React for this, but I wanted to try my hand at native HTML Web Components—an interesting way to create complex, reusable custom elements without the need for 3rd-party libraries like React.

HTML Web Components

The Web Components API provides a way to create encapsulated and reusable custom HTML elements with tailored functionality, events, attributes, and methods.

The set of player UI inputs were a perfect candidate for a custom element:

A group of user interface form elements: A “name” text input, an “attendance probability” number slider and number input, and a “delete” button.
Just a screenshot; not meant to be interactive.

I directed the AI to register a new custom <player-element> element using the HTML template I created (in Pug) below:

template#player-template
  fieldset(part='player')
    legend#player-legend(part='legend')
    div(part='name')
      label(part='name-label', for='player-name') Name
      input(part='name-input', type='text', id='player-name', placeholder='Player Name')
    fieldset(part='probability')
      legend#prob-legend(part='probability-legend') Attendance Probability
      input#player-slider(part='probability-slider', type='range', min='1', max='100', aria-labelledby='prob-legend')
      input#player-number(part='probability-number', type='number', min='1', max='100', aria-labelledby='prob-legend')
    button#remove-btn(part='delete', type='button') delete

Accessibility

Accessibility is always important, even for side projects. I wanted to be sure my custom element was properly marked up with standard semantic HTML elements like fieldsets and labels.

Duplicate IDs in Shadow Doms

Normally an HTML document can’t have duplicate ID attributes. But one thing I learned in this process is that IDs inside a shadow root are scoped to that shadow root. So even though there are multiple instances of this component on the page at a time (each with an identical set of ID attributes for associating the “name” input with its label), that’s not an issue.

Outputting as a Table

The statistical probability data that the tool outputs is tabular in nature, and is best expressed in rows and columns. Table elements get a bad rap in HTML due to their ubiquitous misuse for styling in the early days of the web. When used properly for content that is semantically tabular, they’re incredible accessible for assitive technologies like screen readers.

It made sense to use one here.

D&D Should Always Be Accessible

At its core D&D is just text and numbers and theater of the mind. So it’s well-positioned to be an incredibly accessible game. The tools and platforms enhancing it should embrace that.

JavaScript

For those that are interested, the JavaScript for creating and registering the custom element, as well as the global state management and rendering are below. Note: This code was mostly written by an AI, with some minor edits by a human for clarity and maintainability.

JavaScript: Custom PlayerElement class definition and registration
class PlayerElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const template = document.getElementById('player-template');
    const templateContent = template.content.cloneNode(true);
    this.shadowRoot.appendChild(templateContent);

    // References to elements in the shadow DOM.
    this._legend = this.shadowRoot.getElementById('player-legend');
    this._nameInput = this.shadowRoot.getElementById('player-name');
    this._slider = this.shadowRoot.getElementById('player-slider');
    this._numberInput = this.shadowRoot.getElementById('player-number');
    this._removeButton = this.shadowRoot.getElementById('remove-btn');
  }

  connectedCallback() {
    // Initialize properties from attributes or defaults.
    this._name = this.getAttribute('name') || 'Player';
    this._probability = Number(this.getAttribute('probability')) || 50;
    this._updateRendering();

    // When the name changes, update the legend and dispatch an event.
    this._nameInput.addEventListener('input', () => {
      this._name = this._nameInput.value;
      this._legend.textContent = this._name.trim() || 'Player';
      this.setAttribute('name', this._name);
      this._dispatchChange();
    });

    // When the slider changes, update the number input.
    this._slider.addEventListener('input', () => {
      const val = this._slider.value;
      this._probability = Number(val);
      this._numberInput.value = val;
      this.setAttribute('probability', this._probability);
      this._dispatchChange();
    });

    // When the number input changes, update the slider.
    this._numberInput.addEventListener('input', () => {
      const val = this._numberInput.value;
      this._probability = Number(val);
      this._slider.value = val;
      this.setAttribute('probability', this._probability);
      this._dispatchChange();
    });

    // Clamp the number input on blur.
    this._numberInput.addEventListener('blur', () => {
      let val = Number(this._numberInput.value);
      if (isNaN(val) || val < 1) {
        val = 1;
      }
      if (val > 100) {
        val = 100;
      }
      this._probability = val;
      this._numberInput.value = val;
      this._slider.value = val;
      this.setAttribute('probability', this._probability);
      this._dispatchChange();
    });

    // Remove button dispatches an event.
    this._removeButton.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('remove-player', {
        detail: { name: this._name },
        bubbles: true,
        composed: true
      }));
    });
  }

  _updateRendering() {
    this._legend.textContent = this._name;
    this._nameInput.value = this._name;
    this._slider.value = this._probability;
    this._numberInput.value = this._probability;
  }

  _dispatchChange() {
    // Dispatch a custom event so the parent can update the distribution.
    this.dispatchEvent(new CustomEvent('player-change', {
      bubbles: true,
      composed: true
    }));
  }

  static get observedAttributes() {
    return ['name', 'probability'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    if (name === 'name') {
      this._name = newValue;
      if (this._legend) {
        this._legend.textContent = newValue;
      }
      if (this._nameInput) {
        this._nameInput.value = newValue;
      }
    } else if (name === 'probability') {
      this._probability = Number(newValue);
      if (this._slider) {
        this._slider.value = newValue;
      }
      if (this._numberInput) {
        this._numberInput.value = newValue;
      }
    }
  }

  get probability() {
    return this._probability;
  }
}

customElements.define('player-element', PlayerElement);
JavaScript: Global state management and rendering
// Global state: manage the list of player elements.
let nextPlayerId = 1;
const playersContainer = document.getElementById('players-container');
const addPlayerBtn = document.getElementById('add-player-btn');
const distributionContainer = document.getElementById('distribution-container');

// Add a new player custom element.
function addPlayer(name = 'Player ' + nextPlayerId, probability = 80) {
  const playerEl = document.createElement('player-element');
  playerEl.setAttribute('name', name);
  playerEl.setAttribute('probability', probability);
  playerEl.setAttribute('data-id', nextPlayerId);
  nextPlayerId++;
  playersContainer.appendChild(playerEl);
  updateDistribution();
}

// Remove a player element and update distribution.
function removePlayer(playerEl) {
  playersContainer.removeChild(playerEl);
  updateDistribution();
}

// Calculate the probability distribution using dynamic programming.
// dp[k] is the probability that exactly k players attend.
function calculateDistribution() {
  const playerElements = playersContainer.querySelectorAll('player-element');
  const probs = Array.from(playerElements).map(el => Number(el.getAttribute('probability')) / 100);
  const n = probs.length;
  let dp = new Array(n + 1).fill(0);
  dp[0] = 1;
  probs.forEach(p => {
    let newdp = new Array(n + 1).fill(0);
    for (let k = 0; k <= n; k++) {
      newdp[k] += dp[k] * (1 - p);
      if (k > 0) newdp[k] += dp[k - 1] * p;
    }
    dp = newdp;
  });
  return dp;
}

// Update the distribution table.
function updateDistribution() {
  const distribution = calculateDistribution();
  let html = '';
  distribution.forEach((prob, k) => {
    html += ``;
  });
  html += '
AttendeesProbability
${k}${(prob * 100).toFixed(2)}%
'; distributionContainer.innerHTML = html; } // Listen for events from player elements to update distribution. document.body.addEventListener('player-change', () => { updateDistribution(); }); // Listen for remove-player events to remove a player element. document.body.addEventListener('remove-player', (e) => { // e.target is the custom element that dispatched the event. const playerEl = e.target; removePlayer(playerEl); }); addPlayerBtn.addEventListener('click', () => { addPlayer(); }); // Initialize with two players. addPlayer('Player 1', 80); addPlayer('Player 2', 80);

Bar Graphs

Even though a table was the semantically correct way to mark up the results, a table of percentages is both boring to look at and difficult to quickly scan. I wanted to add bar charts to visualize the different probabilites and the bell curve they produced.

N Attendees Probability
0 4.00%
1 32.00%
2 64.00%
Visually dull.

At this point in the process, I was pretty frustrated with constantly wrestling with the AI. (This was back in early 2025 when the models were much more unweildy). I didn’t want to use it to add the bar graphs, and also didn’t have the patience to select, understand, and implement a new 3rd-party graphing library.

So I took a shortcut.

Rather than making a separate graph, it’s possible to turn the table itself into a graph using CSS.

Graphing with Background Gradients

I was already outputting the probability as a percentage in the JavaScript rendering loop for each row:

html += `<td>${(prob * 100).toFixed(2)}%</td>`

It would be easy to also add that number as an inline CSS variable declaration to each table cell:

const percentage = (prob * 100).toFixed(2)
<td style="--percentage: ${percentage};">${percentage}%</td>`

And because color stops in CSS background gradients can be defined in percentages, it would be trivial to construct a color gradient that started from the left as a solid color before transitioning to transparent at --prob percent of the way across the element:

td {
  background: linear-gradient(
    to right,
    #007bff99 calc(var(--prob) * 1%),
    transparent calc(var(--prob) * 1%)
  );
}

Voilà ! We’ve made a budget graph with just CSS which is arguably better than adding a whole new set of elements for a chart. Think of it as progressive enhancement for the table element.

N Attendees Probability
0 4.00%
1 32.00%
2 64.00%
Visually helpful.

Probabilities Compound

It turns out that if you have five very reliable people, each with a 95% chance of showing up for a session, there's only a 77% chance that everyone be able to make it on a given day:

0.95 5 = 0.774 = 77.4%

This obviously greatly oversimplifies things, and determining individual players’ reliability is entirely subjective. There’s not much of a real-world use-case for the tool, but it’s helpful math to keep in mind the next time you have to reschedule D&D! Your party might not be flakey. It might just be statistics.

Try it out

Interact with the tool below. Try adding new players and changing the probability they will attend.

Embedded Session Probability Calculator