Warhammer Dice Action Roller
Build a browser app that rolls dice and looks up unit actions from Excel data.
Introduction
30 Second Summary
Tabletop gaming is full of dice rolls and action tables. Flipping through pages to look up what your unit does on a roll of 4 slows down the fun.
In this project, you will build a browser-based dice roller that reads your custom Excel action table and instantly tells you what happens when you roll.
What You'll Build
A Warhammer-themed web app that parses an uploaded Excel file, populates dropdown menus from the data, and resolves unit actions based on a d6 roll.
By the end of this project, you'll have:
- A working dice roller web app built from a single index.html file with embedded CSS and JavaScript.
- Hands-on experience with DOM manipulation, event handling, and data filtering in the browser.
- A themed, interactive tool you can reuse with any custom Excel action table for your games.
- Secret Mission: Add a Battle Log that records every roll with timestamps, faction, unit, and action taken.
Are there any prerequisites?
Basic familiarity with HTML and CSS is helpful but not required. This project walks you through everything step by step.
Before We Start
You're about to build a web app that rolls dice and looks up unit actions from a real Excel file. Think of it as a digital companion tool for tabletop gaming.
Before we jump into any code, let's set up your project workspace. You'll create a folder and a single HTML file that will hold your entire app.
In this step, get ready to:
- Create a project folder on your Desktop.
- Open the folder in VS Code.
- Create an empty index.html file.
Open VS Code
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
Create your project folder
Your dice roller app will live in a single folder on your Desktop. Let's create that folder using the built-in terminal.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir dice-roller
cd dice-roller
- Confirm the folder was created by running:
ls
The output will be empty since the folder is brand new. That's expected.
Seeing 'no such file or directory'?
Make sure you ran cd ~/Desktop first. If your Desktop has a different path (some Windows setups use OneDrive), try cd ~/OneDrive/Desktop instead.
Help me find my Desktop folder in the terminal.
Open the folder in VS Code
Now that the folder exists, let's open it as a proper VS Code workspace. This gives you the file explorer sidebar and keeps all your work organized.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the dice-roller folder, then click Open (macOS) or Select Folder (Windows).
You should see DICE-ROLLER in the Explorer sidebar on the left. The folder is empty for now.
Trust this folder?
VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors to continue. This is a standard security prompt for newly opened folders.
Create your first file
Your entire dice roller will live in one file. Let's create it now.
- Hover over DICE-ROLLER in the Explorer sidebar.
- Click the New File icon (the page icon with a small plus sign).
- Type index.html and press Enter.
The file opens in the editor area. It's completely empty, and that's perfect. You'll fill it with HTML, CSS, and JavaScript in the next steps.
Don't see the New File icon?
The icons only appear when you hover directly over the folder name in the sidebar. If you still can't find it, right-click anywhere in the Explorer sidebar and select New File from the context menu.
Help me create a new file in VS Code.
Your workspace is ready. Next up, you'll fill that empty file with a complete HTML structure and a dark Warhammer-inspired theme.
Set Up Your Project
Every web project starts with a folder to keep your files organized and an HTML file to hold your code.
Your dice roller lives inside a single index.html file. In this step, you'll create the project folder, open it in VS Code, and create that file.
In this step, get ready to:
- Create a project folder on your Desktop.
- Open the folder in VS Code.
- Create your index.html file.
Create your project folder
You need a dedicated folder to hold your project file. You'll create this on your Desktop so it's easy to find later.
- In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
What is the integrated terminal?
The integrated terminal is a command line built directly into VS Code. It lets you run commands without leaving the editor.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
mkdir dice-roller
- Confirm the folder was created by running:
ls
You should see dice-roller listed in the output.
Don't see the folder?
Make sure you ran cd ~/Desktop first. If your Desktop path is different (some systems use a localized name), try cd ~ and then mkdir dice-roller from there instead.
Help me create a folder from VS Code's terminal
Open the folder in VS Code
Now you'll open the new folder as your VS Code workspace. This tells VS Code to treat dice-roller as your project root.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the dice-roller folder, then click Open.
The VS Code Explorer sidebar on the left should now show DICE-ROLLER as the workspace name with an empty file area below it.
VS Code asking if you trust the authors?
VS Code may display a trust dialog when opening a new folder. Click Yes, I trust the authors to continue. This is your own folder, so it's safe.
Create the HTML file
Your entire dice roller app will live in a single index.html file. This keeps things simple since there are no build tools or additional dependencies to worry about.
- In the Explorer sidebar, hover over the DICE-ROLLER heading to reveal the action icons.
- Click the New File icon (the page icon with a plus sign).
- Type index.html and press Enter.
The file opens automatically in the editor. You should see an empty index.html tab at the top of the editor area.
Don't see the file in the sidebar?
Make sure you opened the dice-roller folder (not a parent folder). The Explorer should show DICE-ROLLER at the top. If it shows your Desktop or home folder instead, repeat the File > Open Folder steps and select the correct folder.
Help me create a file in VS Code
Your project is ready to go. In the next step, you'll fill this empty file with the HTML structure and dark-themed styling for your dice roller.
Build the HTML Structure and Styling
In Step 1, you got VS Code ready with your project folder and an empty index.html file. Now it's time to give that file some life.
In this step, you'll build the visual skeleton of your dice roller. You'll add the HTML elements that make up the interface, load the SheetJS library for reading Excel files, and style everything with a dark, Warhammer-inspired theme.
In this step, get ready to:
- Add the HTML structure with a SheetJS CDN script, file input, dropdowns, and a Roll button.
- Style the page with a dark theme using gold and red accents.
- Preview the page in your browser to confirm it renders correctly.
Add the HTML structure and SheetJS CDN
Your dice roller needs a few key interface elements: a file upload input for the Excel action table, dropdowns to select a Faction and Unit Type, a Roll button, and an area to display results.
- Switch back to index.html in VS Code and paste the following HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warhammer Dice Action Roller</title>
<!-- Load SheetJS library for reading Excel files in the browser -->
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
</head>
<body>
<h1>Warhammer Dice Action Roller</h1>
<div class="container">
<!-- File upload section -->
<div class="section">
<label for="file-input">Upload Action Table (.xlsx)</label>
<input type="file" id="file-input" accept=".xlsx,.xls,.csv">
</div>
<!-- Faction dropdown -->
<div class="section">
<label for="faction-select">Faction</label>
<select id="faction-select" disabled>
<option value="">-- Upload a file first --</option>
</select>
</div>
<!-- Unit Type dropdown -->
<div class="section">
<label for="unit-select">Unit Type</label>
<select id="unit-select" disabled>
<option value="">-- Select a faction first --</option>
</select>
</div>
<!-- Roll button -->
<div class="section">
<button class="roll-btn" id="roll-btn" disabled>Roll D6</button>
</div>
<!-- Result display area -->
<div class="result-area" id="result-area"></div>
</div>
</body>
</html>
What does this code do?
The <script> tag in the <head> loads SheetJS from a CDN (Content Delivery Network). This gives your page access to Excel-reading functions without needing to install anything.
The body contains a container with four sections: a file input that accepts .xlsx, .xls, and .csv files, two <select> dropdowns (both disabled until data is loaded), a Roll button (also disabled), and an empty div where results will appear.
- Save index.html.
- You won't see the final look yet. The next chunk adds the dark theme styling.
Style the page with a dark Warhammer theme
Plain HTML form elements are functional but bland. A dark color scheme with gold and red accents gives your roller the dramatic feel of a tabletop companion app.
- In index.html, find the <script> line that loads SheetJS. Add this <style> block directly below it (before </head>):
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', serif;
background-color: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
h1 {
color: #c9a227;
font-size: 2rem;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 30px;
text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.container {
background-color: #16213e;
border: 2px solid #c9a227;
border-radius: 10px;
padding: 30px;
max-width: 500px;
width: 100%;
}
.section {
margin-bottom: 20px;
}
label {
display: block;
color: #c9a227;
font-weight: bold;
margin-bottom: 8px;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
input[type="file"] {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
cursor: pointer;
}
select {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
font-size: 1rem;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roll-btn {
width: 100%;
padding: 15px;
background-color: #8b0000;
color: #c9a227;
border: 2px solid #c9a227;
border-radius: 5px;
font-size: 1.2rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: background-color 0.3s;
}
.roll-btn:hover:not(:disabled) {
background-color: #a50000;
}
.roll-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
text-align: center;
margin-top: 25px;
min-height: 120px;
}
.dice-display {
width: 80px;
height: 80px;
background-color: #0f3460;
border: 3px solid #c9a227;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: #c9a227;
margin-bottom: 15px;
}
.action-result {
font-size: 1.3rem;
color: #e94560;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.no-match {
color: #888;
font-style: italic;
}
</style>
What does this styling do?
The CSS creates a dark navy background (#1a1a2e) with gold (#c9a227) and deep red (#8b0000) accents. Labels and borders use gold, while the Roll button uses a dark red that brightens on hover.
The .dice-display and .action-result classes style the dice roll output that your JavaScript will create later. Disabled elements appear faded (opacity: 0.5) so the learner knows they can't interact with them yet.
- Save index.html.
- You'll open this in your browser in the next substep to see the full themed layout.
Style tag not in the right place?
Make sure the <style> block sits between the <script> line and the closing </head> tag. If it ends up inside <body>, browsers will still render it, but it's not valid HTML placement.
Help me figure out where to put my style tag.
Preview the page in your browser
Your HTML and CSS are in place. Time to see the result in a real browser window.
- Open the dice-roller folder in Finder (macOS) or File Explorer (Windows).
- Double-click index.html to open it in your default browser.
Use Live Server for auto-refresh
If you have the Live Server extension in VS Code, you can right-click index.html in the Explorer sidebar and select Open with Live Server. This auto-refreshes the browser whenever you save changes.
You should see a dark-themed page with a gold title reading "Warhammer Dice Action Roller" at the top. Below it sits a bordered container with a file upload input, two greyed-out dropdowns (Faction and Unit Type), and a faded red Roll D6 button.
Page looks unstyled or blank?
If you see plain unstyled text, check that your <style> opening and closing tags are both present. A missing </style> causes the browser to treat your HTML as part of the stylesheet.
If the page is completely blank, open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for red error messages.
Help me debug why my dice roller page isn't displaying correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and the page matches the dark-themed layout described above.
ⓧ I'd like to double check the full code
Here is the full index.html as it should look at the end of this step:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warhammer Dice Action Roller</title>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', serif;
background-color: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
h1 {
color: #c9a227;
font-size: 2rem;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 30px;
text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.container {
background-color: #16213e;
border: 2px solid #c9a227;
border-radius: 10px;
padding: 30px;
max-width: 500px;
width: 100%;
}
.section {
margin-bottom: 20px;
}
label {
display: block;
color: #c9a227;
font-weight: bold;
margin-bottom: 8px;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
input[type="file"] {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
cursor: pointer;
}
select {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
font-size: 1rem;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roll-btn {
width: 100%;
padding: 15px;
background-color: #8b0000;
color: #c9a227;
border: 2px solid #c9a227;
border-radius: 5px;
font-size: 1.2rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: background-color 0.3s;
}
.roll-btn:hover:not(:disabled) {
background-color: #a50000;
}
.roll-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
text-align: center;
margin-top: 25px;
min-height: 120px;
}
.dice-display {
width: 80px;
height: 80px;
background-color: #0f3460;
border: 3px solid #c9a227;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: #c9a227;
margin-bottom: 15px;
}
.action-result {
font-size: 1.3rem;
color: #e94560;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.no-match {
color: #888;
font-style: italic;
}
</style>
</head>
<body>
<h1>Warhammer Dice Action Roller</h1>
<div class="container">
<div class="section">
<label for="file-input">Upload Action Table (.xlsx)</label>
<input type="file" id="file-input" accept=".xlsx,.xls,.csv">
</div>
<div class="section">
<label for="faction-select">Faction</label>
<select id="faction-select" disabled>
<option value="">-- Upload a file first --</option>
</select>
</div>
<div class="section">
<label for="unit-select">Unit Type</label>
<select id="unit-select" disabled>
<option value="">-- Select a faction first --</option>
</select>
</div>
<div class="section">
<button class="roll-btn" id="roll-btn" disabled>Roll D6</button>
</div>
<div class="result-area" id="result-area"></div>
</div>
</body>
</html>
Your page looks the part. Next up, you'll add the JavaScript that reads an Excel file and populates those dropdowns with real data.
Parse the Excel File with JavaScript
Your page has the structure and styling in place, but nothing happens when you interact with it. The dropdowns are disabled and the Roll button sits idle.
In this step, you will bring the app to life with JavaScript. You will read an uploaded Excel file, convert it into usable data, and wire up the dropdowns so they respond to the player's choices.
In this step, get ready to:
- Add JavaScript that reads an Excel file and converts it to a JavaScript array.
- Populate the Faction dropdown from the parsed data.
- Wire up the Faction dropdown to filter and populate the Unit Type dropdown.
Add the file input event listener
When a user uploads an Excel file, your app needs to read the raw binary data and convert it into JavaScript objects. The SheetJS library (already loaded via CDN) handles the heavy lifting of parsing Excel formats.
- In index.html, find the closing </div> tag just above </body>.
- Add a <script> element between that </div> and </body> by pasting the following code:
<script>
// Array to store all rows from the Excel file
let actionsData = [];
// Cache references to DOM elements
const fileInput = document.getElementById('file-input');
const factionSelect = document.getElementById('faction-select');
const unitSelect = document.getElementById('unit-select');
const rollBtn = document.getElementById('roll-btn');
const resultArea = document.getElementById('result-area');
// Listen for file uploads
fileInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
// Read the file as raw binary data
const data = await file.arrayBuffer();
// Parse the binary data into a SheetJS workbook
const workbook = XLSX.read(data);
// Get the first worksheet from the workbook
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
// Convert the worksheet rows into an array of objects
actionsData = XLSX.utils.sheet_to_json(firstSheet);
// Extract unique faction names from the data
const factions = [...new Set(actionsData.map(row => row.Faction))];
// Populate the Faction dropdown with options
factionSelect.innerHTML = '<option value="">-- Select a Faction --</option>';
factions.forEach(faction => {
const option = document.createElement('option');
option.value = faction;
option.textContent = faction;
factionSelect.appendChild(option);
});
factionSelect.disabled = false;
// Reset the other controls
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
</script>
What does this code do?
This script sets up the core data pipeline for your app.
- actionsData is an array that will hold every row from your Excel file as a JavaScript object.
- file.arrayBuffer() reads the uploaded file as raw binary bytes.
- XLSX.read(data) parses those bytes into a workbook object that SheetJS understands.
- XLSX.utils.sheet_to_json(firstSheet) converts the first sheet into an array of objects, using your Excel column headers (like Faction, Unit_type, Numerdice) as property names.
- new Set(...) removes duplicate faction names so the dropdown only shows each faction once.
- Save index.html.
- Refresh your browser and upload your Excel file using the file input.
- You should see the Faction dropdown become active and display your faction names.
Dropdown still disabled after uploading?
- Make sure your <script> tag is placed after the closing </div> of the container and before </body>. If it appears inside the <head>, the DOM elements will not exist yet when the script runs.
- Check that your Excel file has a column header named exactly Faction in the first row. SheetJS uses the first row as property names.
- Open your browser's DevTools console (F12) to check for error messages.
Help me debug why my file upload isn't populating the Faction dropdown.
Add the faction change listener
When the player picks a faction, the app needs to filter the data and show only the unit types that belong to that faction. This creates a cascading dropdown effect.
- In index.html, find the closing }); of the file input event listener (the line just above </script>).
- Add the following code below that line, before </script>:
factionSelect.addEventListener('change', function() {
const selectedFaction = factionSelect.value;
// If no faction is selected, reset and disable downstream controls
if (!selectedFaction) {
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
return;
}
// Filter the data for the selected faction and get unique unit types
const units = [...new Set(
actionsData
.filter(row => row.Faction === selectedFaction)
.map(row => row.Unit_type)
)];
// Populate the Unit Type dropdown
unitSelect.innerHTML = '<option value="">-- Select a Unit Type --</option>';
units.forEach(unit => {
const option = document.createElement('option');
option.value = unit;
option.textContent = unit;
unitSelect.appendChild(option);
});
unitSelect.disabled = false;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
What does this code do?
This event listener fires every time the player changes their faction selection.
- It filters actionsData to find only rows where row.Faction matches the selected faction.
- It maps those rows to their Unit_type values and removes duplicates with Set.
- It dynamically creates <option> elements for each unit type and adds them to the Unit Type dropdown.
- Save index.html.
- Refresh your browser and upload your Excel file again.
- Select a faction from the dropdown. You should see the Unit Type dropdown enable and populate with the unit types for that faction.
Unit Type dropdown not populating?
- Check that your Excel file has a column header named exactly Unit_type (with an underscore). JavaScript property names are case-sensitive.
- Make sure the new code is inside the <script> tag, not outside of it. It should appear after the file input listener's closing }); and before </script>.
- Confirm you re-uploaded the file after refreshing. The data resets on page reload since it lives only in memory.
Help me figure out why selecting a faction isn't showing unit types.
Test the file upload and dropdowns
Both listeners are in place. Time to confirm the full flow works end to end.
- Refresh your browser to start fresh.
- Click the file upload input and select your Excel file.
- Confirm the Faction dropdown activates and shows your faction names.
- Select a faction. Confirm the Unit Type dropdown activates and shows the correct unit types for that faction.
- Switch to a different faction. Confirm the Unit Type dropdown updates to show the new faction's unit types.
Why does the data reset on refresh?
The actionsData array lives in browser memory only. When you refresh the page, the JavaScript re-initializes and the array starts empty again.
This is by design for a client-side app. No data leaves your computer and no server is involved.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html file at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warhammer Dice Action Roller</title>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', serif;
background-color: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
h1 {
color: #c9a227;
font-size: 2rem;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 30px;
text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.container {
background-color: #16213e;
border: 2px solid #c9a227;
border-radius: 10px;
padding: 30px;
max-width: 500px;
width: 100%;
}
.section {
margin-bottom: 20px;
}
label {
display: block;
color: #c9a227;
font-weight: bold;
margin-bottom: 8px;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
input[type="file"] {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
cursor: pointer;
}
select {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
font-size: 1rem;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roll-btn {
width: 100%;
padding: 15px;
background-color: #8b0000;
color: #c9a227;
border: 2px solid #c9a227;
border-radius: 5px;
font-size: 1.2rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: background-color 0.3s;
}
.roll-btn:hover:not(:disabled) {
background-color: #a50000;
}
.roll-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
text-align: center;
margin-top: 25px;
min-height: 120px;
}
.dice-display {
width: 80px;
height: 80px;
background-color: #0f3460;
border: 3px solid #c9a227;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: #c9a227;
margin-bottom: 15px;
}
.action-result {
font-size: 1.3rem;
color: #e94560;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.no-match {
color: #888;
font-style: italic;
}
</style>
</head>
<body>
<h1>Warhammer Dice Action Roller</h1>
<div class="container">
<div class="section">
<label for="file-input">Upload Action Table (.xlsx)</label>
<input type="file" id="file-input" accept=".xlsx,.xls,.csv">
</div>
<div class="section">
<label for="faction-select">Faction</label>
<select id="faction-select" disabled>
<option value="">-- Upload a file first --</option>
</select>
</div>
<div class="section">
<label for="unit-select">Unit Type</label>
<select id="unit-select" disabled>
<option value="">-- Select a faction first --</option>
</select>
</div>
<div class="section">
<button class="roll-btn" id="roll-btn" disabled>Roll D6</button>
</div>
<div class="result-area" id="result-area"></div>
</div>
<script>
let actionsData = [];
const fileInput = document.getElementById('file-input');
const factionSelect = document.getElementById('faction-select');
const unitSelect = document.getElementById('unit-select');
const rollBtn = document.getElementById('roll-btn');
const resultArea = document.getElementById('result-area');
fileInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
const data = await file.arrayBuffer();
const workbook = XLSX.read(data);
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
actionsData = XLSX.utils.sheet_to_json(firstSheet);
const factions = [...new Set(actionsData.map(row => row.Faction))];
factionSelect.innerHTML = '<option value="">-- Select a Faction --</option>';
factions.forEach(faction => {
const option = document.createElement('option');
option.value = faction;
option.textContent = faction;
factionSelect.appendChild(option);
});
factionSelect.disabled = false;
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
factionSelect.addEventListener('change', function() {
const selectedFaction = factionSelect.value;
if (!selectedFaction) {
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
return;
}
const units = [...new Set(
actionsData
.filter(row => row.Faction === selectedFaction)
.map(row => row.Unit_type)
)];
unitSelect.innerHTML = '<option value="">-- Select a Unit Type --</option>';
units.forEach(unit => {
const option = document.createElement('option');
option.value = unit;
option.textContent = unit;
unitSelect.appendChild(option);
});
unitSelect.disabled = false;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
</script>
</body>
</html>
Your dropdowns are alive and responding to real data from your Excel file. Next up, you will wire up the Roll button to generate a random d6 result and look up the matching action.
Build the Dice Roll and Action Lookup
Your app can read Excel data and populate dropdowns based on the learner's selections.
Now it is time to make the Roll D6 button actually roll a dice and look up the matching action from your data. The button needs to generate a random number between 1 and 6, then search through your parsed data for the row that matches the current Faction, Unit Type, and dice result.
In this step, get ready to:
- Enable the Roll button only when a Unit Type is selected.
- Generate a random dice result and look up the matching action.
- Display the dice value and action text in the result area.
Enable the Roll button when a unit is selected
The Roll D6 button is currently always disabled. You need a way to enable it only after the learner has picked both a Faction and a Unit Type.
- In index.html, scroll down to the <script> section.
- Find the closing }); of the factionSelect.addEventListener('change' block (the last event listener in your script).
- Add this new event listener below it:
unitSelect.addEventListener('change', function() {
// Enable the Roll button only when a unit type is selected
if (unitSelect.value) {
rollBtn.disabled = false;
} else {
rollBtn.disabled = true;
}
// Clear any previous result when the selection changes
resultArea.innerHTML = '';
});
What does this code do?
This listener fires every time the Unit Type dropdown changes.
- If unitSelect.value is truthy (a unit is selected), it enables the Roll button.
- If the value is empty (the placeholder option), it keeps the button disabled.
- It also clears the result area so stale results from a previous selection do not linger.
- Save index.html.
- Refresh your browser, upload your Excel file, select a Faction, then select a Unit Type.
- You should see the Roll D6 button change from faded (disabled) to fully opaque and clickable.
Button still looks faded?
- Make sure you placed the new listener after the closing }); of the factionSelect listener, not inside it.
- Check that you selected an actual unit from the dropdown, not the placeholder option.
- Open the browser console (F12) and look for any red error messages that might indicate a typo.
Why isn't my Roll button enabling after I select a unit type?
Add the dice roll and action lookup
With the button enabled, you can now wire up the core logic. When clicked, the button needs to generate a random d6 result, search through your data for the matching row, and display the outcome.
- In index.html, add this event listener directly below the unitSelect listener you just wrote:
rollBtn.addEventListener('click', function() {
// Generate a random number between 1 and 6
const diceResult = Math.floor(Math.random() * 6) + 1;
const selectedFaction = factionSelect.value;
const selectedUnit = unitSelect.value;
// Find the row matching all three criteria
const match = actionsData.find(row =>
row.Faction === selectedFaction &&
row.Unit_type === selectedUnit &&
Number(row.Numerdice) === diceResult
);
// Build the result HTML with the dice display
let resultHTML = '<div class="dice-display">' + diceResult + '</div>';
if (match) {
resultHTML += '<div class="action-result">' + match.Action + '</div>';
} else {
resultHTML += '<div class="no-match">No action found for this roll</div>';
}
resultArea.innerHTML = resultHTML;
});
What does this code do?
This is the heart of the dice roller. It does three things when the button is clicked:
- Math.floor(Math.random() * 6) + 1 generates a random integer from 1 to 6, simulating a d6 roll.
- actionsData.find() searches through the parsed Excel data for a row where Faction, Unit_type, and Numerdice all match the current selections and dice result.
- Number(row.Numerdice) ensures the comparison works even if the Excel cell stored the dice number as text instead of a number.
- The result is displayed inside a styled .dice-display box (the gold-bordered square from your CSS) with the action text below it.
- Save index.html.
- Refresh your browser, upload your Excel file, select a Faction and Unit Type, then click Roll D6.
- You should see a large gold number (1-6) in a bordered box, with the action text in red below it.
- Click Roll D6 several more times to confirm different results appear.
Seeing 'No action found for this roll'?
This is normal for some rolls if your Excel file does not have an entry for every combination of Faction, Unit Type, and dice number (1-6).
- Check that your Excel column headers are exactly Faction, Unit_type, Numerdice, and Action (case-sensitive).
- If it says 'No action found' on every single roll, open the browser console (F12) and type actionsData to inspect your parsed data and verify the property names match.
Help me debug why my dice roller always shows 'No action found' for every roll.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html file at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warhammer Dice Action Roller</title>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', serif;
background-color: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
h1 {
color: #c9a227;
font-size: 2rem;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 30px;
text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.container {
background-color: #16213e;
border: 2px solid #c9a227;
border-radius: 10px;
padding: 30px;
max-width: 500px;
width: 100%;
}
.section {
margin-bottom: 20px;
}
label {
display: block;
color: #c9a227;
font-weight: bold;
margin-bottom: 8px;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
input[type="file"] {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
cursor: pointer;
}
select {
width: 100%;
padding: 10px;
background-color: #0f3460;
border: 1px solid #c9a227;
border-radius: 5px;
color: #e0e0e0;
font-size: 1rem;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roll-btn {
width: 100%;
padding: 15px;
background-color: #8b0000;
color: #c9a227;
border: 2px solid #c9a227;
border-radius: 5px;
font-size: 1.2rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: background-color 0.3s;
}
.roll-btn:hover:not(:disabled) {
background-color: #a50000;
}
.roll-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
text-align: center;
margin-top: 25px;
min-height: 120px;
}
.dice-display {
width: 80px;
height: 80px;
background-color: #0f3460;
border: 3px solid #c9a227;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: #c9a227;
margin-bottom: 15px;
}
.action-result {
font-size: 1.3rem;
color: #e94560;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.no-match {
color: #888;
font-style: italic;
}
</style>
</head>
<body>
<h1>Warhammer Dice Action Roller</h1>
<div class="container">
<div class="section">
<label for="file-input">Upload Action Table (.xlsx)</label>
<input type="file" id="file-input" accept=".xlsx,.xls,.csv">
</div>
<div class="section">
<label for="faction-select">Faction</label>
<select id="faction-select" disabled>
<option value="">-- Upload a file first --</option>
</select>
</div>
<div class="section">
<label for="unit-select">Unit Type</label>
<select id="unit-select" disabled>
<option value="">-- Select a faction first --</option>
</select>
</div>
<div class="section">
<button class="roll-btn" id="roll-btn" disabled>Roll D6</button>
</div>
<div class="result-area" id="result-area"></div>
</div>
<script>
let actionsData = [];
const fileInput = document.getElementById('file-input');
const factionSelect = document.getElementById('faction-select');
const unitSelect = document.getElementById('unit-select');
const rollBtn = document.getElementById('roll-btn');
const resultArea = document.getElementById('result-area');
fileInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
const data = await file.arrayBuffer();
const workbook = XLSX.read(data);
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
actionsData = XLSX.utils.sheet_to_json(firstSheet);
const factions = [...new Set(actionsData.map(row => row.Faction))];
factionSelect.innerHTML = '<option value="">-- Select a Faction --</option>';
factions.forEach(faction => {
const option = document.createElement('option');
option.value = faction;
option.textContent = faction;
factionSelect.appendChild(option);
});
factionSelect.disabled = false;
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
factionSelect.addEventListener('change', function() {
const selectedFaction = factionSelect.value;
if (!selectedFaction) {
unitSelect.innerHTML = '<option value="">-- Select a faction first --</option>';
unitSelect.disabled = true;
rollBtn.disabled = true;
return;
}
const units = [...new Set(
actionsData
.filter(row => row.Faction === selectedFaction)
.map(row => row.Unit_type)
)];
unitSelect.innerHTML = '<option value="">-- Select a Unit Type --</option>';
units.forEach(unit => {
const option = document.createElement('option');
option.value = unit;
option.textContent = unit;
unitSelect.appendChild(option);
});
unitSelect.disabled = false;
rollBtn.disabled = true;
resultArea.innerHTML = '';
});
unitSelect.addEventListener('change', function() {
if (unitSelect.value) {
rollBtn.disabled = false;
} else {
rollBtn.disabled = true;
}
resultArea.innerHTML = '';
});
rollBtn.addEventListener('click', function() {
const diceResult = Math.floor(Math.random() * 6) + 1;
const selectedFaction = factionSelect.value;
const selectedUnit = unitSelect.value;
const match = actionsData.find(row =>
row.Faction === selectedFaction &&
row.Unit_type === selectedUnit &&
Number(row.Numerdice) === diceResult
);
let resultHTML = '<div class="dice-display">' + diceResult + '</div>';
if (match) {
resultHTML += '<div class="action-result">' + match.Action + '</div>';
} else {
resultHTML += '<div class="no-match">No action found for this roll</div>';
}
resultArea.innerHTML = resultHTML;
});
</script>
</body>
</html>
Your dice roller is fully functional. You can upload an Excel file, pick your Faction and Unit Type, and roll to see what action your units take. Next up, the Secret Mission challenges you to add a Battle Log that records every roll.
Secret mission
Add a Battle Log
Your dice roller works, but what happened three rolls ago? Add a Battle Log that records every roll with timestamps, faction, unit, result, and action. You will dynamically create HTML elements, manage an array of log entries, and format timestamps.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Your dice-roller folder and its index.html file live on your computer and use no external services.
Resources you used:
- The dice-roller folder on your Desktop containing index.html.
Keep everything running
No action needed. Choose this if you want to keep using your dice roller or plan to extend it further.
- Your index.html file is ready to use anytime. Just double-click it to open in your browser.
- You could host it for free on GitHub Pages if you want to share it with friends.
Pause - I'll come back to this later
Since this is a single local file with no running processes, there is nothing to pause. Your project stays exactly where you left it.
- Close the browser tab showing the app.
- Close VS Code if you are done for now.
- When you want to return, open the dice-roller folder in VS Code again and double-click index.html to reopen the app.
Delete - I don't want to use this again
Remove all project files from your computer entirely.
- Close VS Code and the browser tab showing the app.
- Delete the dice-roller folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/dice-roller
Windows
rmdir /s /q %USERPROFILE%\Desktop\dice-roller
- Confirm the folder is gone by checking your Desktop. You should no longer see a dice-roller folder.
Nice Work!
Nice Work!
Nice work! You just built a fully functional Warhammer dice action roller from a single HTML file.
Your app reads real Excel data, dynamically populates dropdowns, and resolves unit actions with a d6 roll. That is a complete interactive tool powered by JavaScript.
You've learned how to:
- Parse Excel data in the browser using SheetJS and convert spreadsheet rows into JavaScript objects.
- Dynamically populate dropdown menus from data and filter arrays based on multiple criteria.
- Implement dice rolling logic with random number generation and look up matching actions from your data.
- Secret Mission: Added a persistent Battle Log that records every roll with timestamps, faction, unit, and action taken.
Ready to quiz yourself?