Picture this: It's 7 p.m. on a Friday night. You and the nerds are gathered around the table. Snacks have been dispensed. Miniatures are laid out. Maps are drawn. Maybe someone is wearing a cloak. It's about to get weird. But these are modern times with modern problems. Gaming isn't the same as it was in decades past. It's not just paper, pencils, and dice. Sometimes it's virtual tabletops, digital libraries, and expensive subscription systems. Those dollars can add up. And so can the minutes spent waiting for the buffer screens. Yes, I know. I'm offending some old school sensibilities here. You old school D&D players are gnashing your teeth, saying all those modern technical impurities pollute the game. It goes against tradition! You're supposed to churn your own butter!
Fine. You're welcome to throw yourself upon the loom of progress, John Henry. Some of us don't like wearing a character sheet ragged with our pencil and erasers. Some of us don't like math. Let's let a formula do it. That's why God invented code.
Snark aside, digital applications in the tabletop role playing game space are a tremendous time saver. The rules can be dense, especially to new players, and when they run into calculations, it can bring the game to a screeching halt. (Is there such a thing as a quick round of combat in Dungeons and Dragons 5E? Is that even possible?)
What if we built a character sheet web app that works offline, lives in a single html file, and runs on any phone without installation? The official SRD–the system reference document–is Dungeon and Dragons' open-license rulebook, released under Creative Commons. We'll encode these rules as pure JavaScript functions, build a reactive UI that derives everything from a single state object, and save everything to the browser's local storage. No server. No framework. No build pipeline. One file.
What We're Building
First, we have to nail down the scope. This is a weekend project, but from the jump, the bells and whistles will start piling up. Stay on target! Our character sheet needs to do just five things:
- Display and edit the six core ability scores with live modifier calculation
- Auto-calculate every skill bonus, saving throw, initiative, and passive perception derived from those scores
- Track hit points with damage and healing buttons, plus death saving throw pips
- Handle weapons and attack rolls—attack bonus, damage dice, damage type
- Save everything automatically, with an export/import system so characters survive a phone switch or cache wipe
The Runes Behind the Magic: Encoding the D&D Math
Like any good project, we want to keep the code separated from the UI. Before we dive into the HTML or CSS, we'll focus on the JavaScript functions that encode the math for the SRD. These functions will take numerical inputs and return numerical outputs. Easy peasy. No side effects, no DOM access, no knowledge of what a button is. Easy to test, easy to reason about, completely reusable.
One of foundations of your character's mathematical progression is the ability modifier. Every ability score produces a modifier that gets added to dice rolls. The formula is pretty straightforward.
function getMod(score) {
return Math.floor((score - 10) / 2);
}
Ability modifiers break down very simply. A score of 10 gives the player +0. A score of 16 provides +3, but a low score of 8 is a −1. That Math.floor is load-bearing. Without it, a score of 11 would return +0.5. I'm sure some uber-dorks would love that, but that's a hard pass from me, thank you very much.
The next big one to consider is how we integrate the calculation of the Proficiency Bonus. As characters gain levels, they get better at things they're trained in. It's kind of the whole point. The bonus starts at +2 at level 1 and increases every four levels, capping at +6. In the SRD this is available as a single table, but it can be compressed into a single function:
function profBonus(level) {
return Math.floor((level - 1) / 4) + 2;
}
Let's test it:
- Level 1 Math.floor(0/4) + 2 = 2
- Level 5 Math.floor(4/4) + 2 = 3
- Level 17 Math.floor(16/4) + 2 = 6
We've built a simple formula that calculates that one fundamental table for us.
The ability and proficiency modifier are the two fundamental building blocks. With those, everything else falls into place. If you're unfamiliar, here's a quick lesson that glosses over the high points:
- A skill bonus is the relevant ability modifier plus the Proficiency Bonus if the character is trained in that skill.
- Saving throws work the same way.
- Initiative is just the Dexterity modifier added to a D20 roll.
- Passive Perception is 10 plus the Perception skill bonus.
For dice rolling, JavaScript's built-in Math.random() works just fine:
function d20() {
return Math.floor(Math.random() * 20) + 1;
}
function rollDice(count, sides) {
const results = [];
for (let i = 0; i < count; i++) {
results.push(Math.floor(Math.random() * sides)
+ 1);
}
return results;
}
Take a look at our Skills tab in action in **Figure 2.
**The Data Model: Defining a Character
Now that we have our rules engine built, we need to decide what a character looks like on the inside. Not when they're hurt in battle. We're not there yet. I mean as data. Everything displayed in the UI comes from this object.
let S = {
name: '', cls: 'Fighter', level: 1, race: '', bg: '',
ab: { str:10, dex:10, con:10, int:10, wis:10,
cha:10 },
sp: [], // skill proficiencies
svp: ['str', 'con'], // save proficiencies
hp: { cur: 10, max: 10 },
ac: 10, spd: 30,
ds: { s: [0,0,0], f: [0,0,0] }, // death saves
wpn: [], notes: '', rolls: []
};
Let's take note as to what's included here. We have the six ability scores—Strength (str), Dexterity (dex), Constitution (con), Intelligence (int), Wisdom (wis), and Charisma (cha). But you'll see that items like the modifiers, saving throw totals, and initiative are not included. These are not stored but calculated on the fly whenever the UI needs to display them.
User Interface: Putting the Math on the Screen
Our character sheet is built around five core tabs. These tabs collect the primary domains of knowledge players must keep track of—Stats, Skills, Combat, Rolls, and Notes. There's a lot more we could tack onto that, but that's the foundation.
The UI is structured around five tabs with those five domains, mapping to how players actually use a character sheet at the table. Throughout this, the render function is key. Instead of updating individual DOM elements when pieces of data change, it re-renders entire sections from the state object.
function renderSkills() {
const list = el('skillsList');
list.innerHTML = '';
SKILLS.forEach(sk => {
const bonus = getMod(S.ab[sk.a])
+ (S.sp.includes(sk.n) ? profBonus(S.level) : 0);
const row = mkSkillRow(
sk.n, sk.a.toUpperCase(), bonus,
S.sp.includes(sk.n),
() => toggleSP(sk.n), // pip tap toggles
proficiency
() => rollSkill(sk.n) // row tap triggers a roll
);
list.appendChild(row);
});
}
function onAb(key, val) {
S.ab[key] = Math.max(1, Math.min(30,
parseInt(val) || 10));
const m = el('m-' + key);
if (m) m.textContent = fmt(getMod(S.ab[key]));
renderDerived();
renderSkills();
renderSaves();
renderWpn();
save();
}
Roll to Hit: The Combat Tab
Attack rolls follow the same pattern as skill checks—roll a d20, add a modifier to increase your chances to hit your enemy. There's a second stage, though—a hit triggers a damage roll, depending on the specific weapon's damage dice. Each weapon is treated as a simple object. For example:
{ n: 'Longsword', dmg: '1d8', dt: 'slashing',
a: 'str', prof: true }
Below we have our attack roll function. It parses the damage string for the weapon, generates the rolls, and adds the ability modifier to the total.
Each roll pops up in the Rolls tab. We'll make the outcomes color-coded. A natural 20 (a good, good thing, for those of you who don't know) is highlighted in gold. A natural 1 (a bad, bad thing) is greyed out. We break up everything else into categories based on the modified total:
- Strong (15+)
- Moderate (10–14)
- Low (below 10)
The Roll History is clean and elegant, as you can see in Figure 3.
Persistence: Setting Up Efficient Local Storage
Saving to local storage is simple. It's a key-value store built into every browser, persistent across sessions, accessible with two methods:
function save() {
collectDOM();
try {
localStorage.setItem('srd3', JSON.stringify(S));
el('saveIndicator').textContent = 'Saved ✓';
} catch(e) {
el('saveIndicator').textContent = 'Storage full';
}
}
function load() {
const raw = localStorage.getItem('srd3');
if (raw) S = JSON.parse(raw);
}
Call save() at the end of every event handler. Call load() once on page init before the first render. The character is always there when the player reopens the app, even when the phone is rebooted. For you neophytes and non-coders, this is only on that specific device and browser. The character you save on Chrome isn't available on Brave. The character you save on your Android isn't available on your iPhone, and so on. When a player switches devices, they'll need to export the system.
Cross-Platform Mobile: iOS and Android Tweaks
This is where I ran into the most amount of trouble. It took a surprising amount of elbow grease to get it working on both of the big two platforms. (I think I got everything, but maybe you'll find some improvements we can make.)
Offline Capability and Home Screen Installation
Both iOS Safari and Android Chrome support adding a page to the home screen as a standalone app. When launched this way, the address bar and browser navigation buttons are hidden. The app fills the screen like any native application would. iOS requires specific meta tags. Android Chrome reads a PWA manifest—a small JSON file that tells the browser the app's name, icon, and display preferences, and signals that it's installable as a standalone home screen app.
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable"
content="yes">
<meta name="apple-mobile-web-app-status-bar-style"
content="black-translucent">
<meta name="apple-mobile-web-app-title"
content="SRD Sheet">
<!-- Android Chrome -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f0e0c">
<!-- Inline manifest keeps the file self-contained -->
<link rel="manifest" href="data:application/json,...">
System Fonts—Eliminating Dependencies
Using Google Fonts could break things at first. It's an external font service, so the app could look broken the first time it opens offline. The device's own font stack has the fix, however.
:root {
--font-ui: -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
}
- -apple-system resolves to San Francisco on Apple devices.
- BlinkMacSystemFont handles older macOS Chrome.
- Segoe UI covers Windows.
- Roboto covers Android.
Touch Targets and Tap Delay
It just keeps getting messier and fiddlier! This is the kind of minute UI work that turns my brain into lumpy stew. Apple's Human Interface Guidelines and Google's Material Design both specify a minimum tap target of 44×44 pixels. Every interactive element in our character sheet meets this minimum. It's also worth noting that Android Chrome added a 300ms delay before registering taps. One CSS property eliminates it:
.nav-btn {
touch-action: manipulation;
}
touch-action: manipulation tells the browser this element responds to taps and drags but not zoom, so the delay can be skipped.
Safe Area Insets for Notched Devices
iPhones from the series X and beyond, along with many Androids, have notches or home indicator bars that can obscure content on the page or app. CSS environment variables handle this elegantly:
:root {
--sat: env(safe-area-inset-top, 0px);
--sab: env(safe-area-inset-bottom, 0px);
}
.header { padding-top: calc(12px + var(--sat)); }
.app { padding-bottom: calc(80px + var(--sab)); }
On devices without notches, this just zeroes out. On devices with notches, the content is pushed into the visible area. The viewport meta tag must also opt in, as seen below.
<meta name="viewport"
content="width=device-width, initial-scale=1.0,
viewport-fit=cover">
Dynamic Viewport Height
One last minor tweak to our UI is a subtle iOS issue. The “browser chrome”—address bar, tab bar—is counted in the standard 100vh measurement, making a full-height element taller than the visible screen. A newer unit fixes this:
body { min-height: 100dvh; }
100dvh adjusts dynamically and will hide that unnecessary browser information. This is supported in iOS Safari 16+ and Android Chrome 108+, so it will cover most modern mobile devices.
Export and Import: Surviving a Cache Wipe
localStorage is persistent but vulnerable. A browser wipe can destroy your character or maybe you got a new phone. Naturally, we must be able to make our character sheet transferable. An easy way to do that is to encode the entire state as a portable text string.
function exportChar() {
collectDOM();
// JSON -> UTF-8 -> base64
const code = btoa(unescape(
encodeURIComponent(JSON.stringify(S))));
if (navigator.share) {
// Opens native share sheet on iOS and Android
navigator.share({ title: S.name + ' - SRD Backup',
text: code });
} else if (navigator.clipboard) {
navigator.clipboard.writeText(code);
}
}
navigator.share is the Web Share API. On iOS it opens the native share sheet: AirDrop, Messages, Notes, Mail. When using Android it opens the system share dialog. If neither is available, the clipboard is the fallback. Import runs the process in reverse: paste the code, decode the base64, parse the JSON, rebuild state.
We've placed the Export/Import buttons on the Notes tab, right below Notes and Features. You can see its simplicity in Figure 4.
The Journey Ahead
Here's where the real fun starts. As we play with our character sheets, we'll start to see all sorts of things we want to expand upon. The options are nearly infinite:
**Spell slot tracker—**This one is a must. We'll definitely need a section showing spell slots by level (1st through 9th) with toggles for each. The SRD provides the slot table for each character class. The rules engine is unchanged, of course. This feature will require a UI and state addition.
**Multiple characters—Who just has one character? New players, that's who. To rectify that limitation for after you've played a few weeks or months, we can **replace the single state object with an array stored under different localStorage keys. We'll add a character select screen at launch. The same getMod() and profBonus() functions work for every character unchanged.
**Export to PDF—**The browser's print dialog renders a clean version of the sheet to PDF if you add a print stylesheet. The jsPDF library can generate properly formatted PDFs entirely in the browser, still with no central server, all on the device.
**AI narration—**This one is the flash. This turns our practical little app into something sassy. Imagine—a player rolls a skill check. That result, along with the character's pertinent information, is passed over to an LLM's API. A one sentence narration for the action is returned. A level 3 Fighter with a +5 Athletics bonus rolling a 17 might see:
“Your boots find purchase on the slick stone. You haul yourself up over the wall with finesse.”
Our simple rules engine determines the math. The AI will provide some vague story flavor. Those are two different types of architectural problems in the same result, so it will certainly result in some interesting obstacles.
**Homebrew rules—**Every table has its own house rules, little nudges to the SRD that makes it more fun or convenient for you or your players. And one of the best things about building your own tool is that you can modify it to your heart's content. If you change getMod(), every derived stat in the app updates instantly. There's no expensive subscription required.



