Dice Rolling Simulator
Abstract
Section titled “Abstract”A Dice Rolling Simulator is one of the most rewarding beginner projects because it does something visibly random — every run produces a different result. Under the hood it teaches you four core skills you will use forever: importing modules from the standard library, defining your own functions, looping until a condition changes, and parsing user input.
In this project we build a dice roller that:
- Rolls a virtual six-sided die and prints the result.
- Lets the user roll again or quit at any time.
- Is easy to extend to support multiple dice, dice of any number of sides (d4, d20, d100), and even Dungeons & Dragons style notation like
3d6+2.
By the end you will understand random.randint, function definitions with def, the while True loop, and how a tiny script grows into a real tool.
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above (download).
- A code editor or IDE (VS Code, PyCharm, Sublime, etc.).
- Completed the Hello World project — or general comfort running a
.pyfile. - No external libraries are needed;
randomships with Python.
Concepts You Will Use
Section titled “Concepts You Will Use”Before writing code, here is what each piece does conceptually:
randommodule — Python’s standard library tool for pseudo-random numbers. It can pick integers, floats, items from a list, shuffle a sequence, and more.random.randint(a, b)— returns a random integerNsuch thata ≤ N ≤ b. Both endpoints are inclusive, which is exactly what you want for dice (1 and 6 are valid rolls).def— defines a function. Functions let you give a name to a block of code so you can reuse it.while True:— an infinite loop. You leave it with abreakstatement when the user wants to quit.input(prompt)— pauses the program, prints a prompt, waits for the user to press Enter, returns whatever they typed as a string..lower()— converts a string to lowercase, so"Y"and"y"both behave the same way.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
dice-rolling-simulator. - Open it in your code editor.
- Inside, create a file called
dicerolling.py.
Write the code
Section titled “Write the code”Add the following to dicerolling.py:
Dice Rolling
pch.viewSource"""Dice roller — one die, many dice, and D&D notation like `3d6+2`.
The interesting part is not the rolling, it is the shape of the results.
One d6 is flat: every face equally likely. Three d6 is not, and the histogram
printed at the end shows why -- there is one way to make 3 and twenty-seven
ways to make 10.
python dicerolling.py # rolls, notation, and a measured histogram
python dicerolling.py --test # unit tests
"""
import collections
import random
import re
import sys
def roll(sides: int = 6) -> int:
"""One die with any number of faces."""
return random.randint(1, sides)
def roll_dice(count: int, sides: int = 6) -> list[int]:
"""`count` dice at once, returned individually so the caller can total."""
return [random.randint(1, sides) for _ in range(count)]
def parse_and_roll(notation: str) -> tuple[list[int], int]:
"""Roll standard dice notation: `3d6`, `1d20+5`, `2d10-1`.
Returning the individual rolls alongside the total is deliberate. A
player who is told only "13" cannot see whether that was three average
rolls or one critical and two disasters, and at a table that matters.
"""
match = re.fullmatch(r"(\d+)d(\d+)([+-]\d+)?", notation.replace(" ", ""))
if not match:
raise ValueError(f"Bad notation: {notation}")
count, sides, modifier = match.groups()
if int(count) == 0 or int(sides) < 2:
raise ValueError(f"Bad notation: {notation}")
rolls = [random.randint(1, int(sides)) for _ in range(int(count))]
total = sum(rolls) + (int(modifier) if modifier else 0)
return rolls, total
def histogram(notation: str = "3d6", trials: int = 60_000) -> dict:
"""Roll `notation` many times and print how often each total came up."""
counts = collections.Counter(parse_and_roll(notation)[1]
for _ in range(trials))
peak = max(counts.values())
print(f"\n{notation}, {trials:,} rolls:\n")
for total in sorted(counts):
share = counts[total] / trials
bar = "#" * round(share / (peak / trials) * 40)
print(f"{total:>4} {share * 100:5.2f}% {bar}")
return dict(counts)
def main():
random.seed(20260809) # so the printed numbers are reproducible
print(f"one d6: {roll()}")
print(f"one d20: {roll(20)}")
print(f"one d100: {roll(100)}")
results = roll_dice(3)
print(f"\nthree d6: {results} total: {sum(results)}")
for notation in ("3d6+2", "1d20+5", "2d10-1"):
rolls, total = parse_and_roll(notation)
print(f"{notation:>8} -> {rolls} = {total}")
counts = histogram("3d6")
trials = sum(counts.values())
print(f"\n3 came up {counts.get(3, 0)} times, "
f"10 came up {counts.get(10, 0)} times "
f"-- {counts.get(10, 0) / max(counts.get(3, 1), 1):.0f}x more often, "
f"against the {27 / 1:.0f}x the combinatorics predict "
f"(27 ways to make 10, 1 way to make 3, out of 216).")
print(f"mean {sum(k * v for k, v in counts.items()) / trials:.3f} "
f"(exactly 10.5 in the limit)")
if __name__ == "__main__":
if "--test" in sys.argv:
import unittest
class TestDice(unittest.TestCase):
def test_roll_in_range(self):
for sides in (2, 6, 20, 100):
for _ in range(500):
self.assertIn(roll(sides), range(1, sides + 1))
def test_roll_dice_count(self):
self.assertEqual(len(roll_dice(7)), 7)
def test_notation(self):
rolls, total = parse_and_roll("3d6+2")
self.assertEqual(len(rolls), 3)
self.assertEqual(total, sum(rolls) + 2)
def test_negative_modifier(self):
rolls, total = parse_and_roll("2d10-1")
self.assertEqual(total, sum(rolls) - 1)
def test_bad_notation_rejected(self):
for bad in ("d6", "3x6", "3d", "0d6", "3d1", ""):
with self.assertRaises(ValueError):
parse_and_roll(bad)
unittest.main(argv=sys.argv[:1], exit=False)
else:
main() Save the file. Open the integrated terminal and run:
C:\Users\username\PythonCentralHub\projects\beginners\dice-rolling-simulator> python dicerolling.py
You rolled 4
Do you want to roll again? (y/n): y
You rolled 5
Do you want to roll again? (y/n): y
You rolled 5
Do you want to roll again? (y/n): n
Thanks for playing!Run it several times. Notice the numbers change each run — that is the random module doing its job.
What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.3 s and prints:
one d6: 1
one d20: 13
one d100: 4
three d6: [1, 4, 4] total: 9
3d6+2 -> [5, 1, 2] = 10
1d20+5 -> [12] = 17
2d10-1 -> [6, 4] = 9
3d6, 60,000 rolls:
3 0.49% ##
4 1.31% ####
5 2.88% #########
6 4.57% ##############
7 6.74% #####################
8 9.63% ##############################
9 11.57% ####################################
10 12.71% ########################################
11 12.63% ########################################
...The first 20 of 30 lines are shown; the run continues past this point.
How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD
RUN(["python dicerolling.py"])
ask("ask")
roll("roll")
RUN --> ask
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Import the random module
Section titled “1. Import the random module”import randomimport makes another module’s code available in yours. After this line, anything inside the random module is reachable as random.something. The module itself lives somewhere in your Python installation; you do not need to know where.
2. Define the roll function
Section titled “2. Define the roll function”def roll():
return random.randint(1, 6)def roll():declares a new function namedrollthat takes no arguments.- The indented body is the function’s code.
returnhands a value back to whoever called the function. Here we return a fresh random integer between 1 and 6.- Calling
roll()now feels like rolling a real die — you do not care how it gets a number, only that you get one.
Why bother with a function for one line? Because:
- It documents intent: reading
roll()is clearer than readingrandom.randint(1, 6)everywhere. - It is easy to change: if you later want a 20-sided die, you change one line.
3. Loop until the user stops
Section titled “3. Loop until the user stops”while True:
print(f"You rolled {roll()}")
play_again = input("Do you want to roll again? (y/n): ")
if play_again.lower() != "y":
breakwhile True:starts a loop that never ends on its own — you exit it manually.print(f"You rolled {roll()}")is an f-string. Thefprefix lets you embed expressions inside{ }.roll()is called, the result is converted to text, and inserted into the message.input(...)shows the prompt and waits.play_again.lower() != "y"is the check: if the user did not typey(in any case),breakimmediately exits the loop.
4. Say goodbye
Section titled “4. Say goodbye”print("Thanks for playing!")This line runs once, after the loop ends.
Pseudo-Random, Not Truly Random
Section titled “Pseudo-Random, Not Truly Random”random.randint does not produce truly random numbers — computers cannot. It produces pseudo-random numbers from a deterministic algorithm seeded by the current time. For dice this is more than fine. For cryptography, banking, or anything security-sensitive, use the secrets module instead.
You can demonstrate the determinism yourself:
import random
random.seed(42)
print(random.randint(1, 6)) # Always prints the same number for seed 42Seeding is useful for reproducible test runs.
The other thing worth measuring is what happens when dice are added together. One d6 is flat — each face turns up about a sixth of the time — but 3d6 is not, and the file rolls it 60,000 times to show the shape:
3 0.49% ##
...
10 12.71% ########################################
11 12.63% ########################################
...
18 0.49% ##Totals of 3 came up 292 times and totals of 10 came up 7,628 times — 26x more often, against the 27x the combinatorics predict (there are 27 ways to roll 10 with three dice and exactly one way to roll 3, out of 216 outcomes). The measured mean was 10.509, against a true value of 10.5.
That is the entire reason tabletop games roll 3d6 for ability scores instead of 1d18. The range is the same; the distribution is not, and averages become overwhelmingly more likely than extremes as soon as dice are summed.
Common Mistakes
Section titled “Common Mistakes”| Problem | Fix |
|---|---|
NameError: name 'random' is not defined | You forgot import random at the top. |
| The program crashes after one roll | You used if play_again == "y": break — backwards logic. Use != "y" to break. |
| The program never quits no matter what you type | "Y" is not equal to "y". Use .lower() to normalize. |
random.randint(1, 6.0) errors with TypeError | randint needs integers — use random.uniform(1, 6) for floats. |
Variations to Try
Section titled “Variations to Try”1. Roll multiple dice at once
Section titled “1. Roll multiple dice at once”def roll_dice(count):
return [random.randint(1, 6) for _ in range(count)]
results = roll_dice(3)
print(f"You rolled: {results} total: {sum(results)}")A list comprehension generates count rolls in one line, and sum() totals them.
2. Dice with any number of sides
Section titled “2. Dice with any number of sides”def roll(sides=6):
return random.randint(1, sides)
print(roll()) # six-sided
print(roll(20)) # twenty-sided
print(roll(100)) # percentile diesides=6 is a default argument — if the caller does not specify a value, Python uses 6.
3. Parse D&D-style notation (3d6+2)
Section titled “3. Parse D&D-style notation (3d6+2)”import re, random
def parse_and_roll(notation):
match = re.fullmatch(r"(\d+)d(\d+)([+-]\d+)?", notation.replace(" ", ""))
if not match:
raise ValueError(f"Bad notation: {notation}")
count, sides, modifier = match.groups()
rolls = [random.randint(1, int(sides)) for _ in range(int(count))]
total = sum(rolls) + (int(modifier) if modifier else 0)
return rolls, total
print(parse_and_roll("3d6+2"))
# Example output: ([4, 2, 5], 13)This shows how a simple project naturally grows into using regular expressions and error handling.
4. ASCII-art dice faces
Section titled “4. ASCII-art dice faces”FACES = {
1: ("┌─────────┐", "│ │", "│ ● │", "│ │", "└─────────┘"),
2: ("┌─────────┐", "│ ● │", "│ │", "│ ● │", "└─────────┘"),
# …add 3, 4, 5, 6
}
for line in FACES[roll()]:
print(line)A dictionary maps each face to its ASCII art.
5. Statistics over many rolls
Section titled “5. Statistics over many rolls”from collections import Counter
rolls = [random.randint(1, 6) for _ in range(10_000)]
print(Counter(rolls))You should see roughly 1666 occurrences of each face — proof the distribution is uniform.
Real-World Applications
Section titled “Real-World Applications”- Tabletop games — replace lost dice, run probability experiments before a game session.
- Monte Carlo simulations — repeated random sampling is the core of physics and finance simulations.
- Game development — every roguelike, RPG, or loot system relies on randomness.
- Teaching probability — let students see the law of large numbers by rolling 100,000 dice.
Features
Section titled “Features”- Uses Python’s standard library only — no installs.
- Demonstrates functions, loops, conditionals, and input parsing in under 15 lines.
- Easy to extend: any-sided dice, multiple dice, parsed notation, GUI.
Next Steps
Section titled “Next Steps”You now have a working dice simulator. Push yourself with one of these challenges:
- Add an average that updates after each roll.
- Add a history of the last 10 rolls.
- Wrap it in a Tkinter GUI with a big “Roll!” button.
- Build a web version with Flask that lets a remote player roll dice in a shared room.
- Turn it into a probability tool that calculates the chance of beating a target with N dice of S sides.
Try it here
Section titled “Try it here”Click to roll two dice — each shows 1–6 pips and the total is their sum, exactly what your
random.randint(1, 6) calls produce:
Conclusion
Section titled “Conclusion”In this project you used Python’s random module to simulate dice, learned to encapsulate behavior in a function, looped until the user wanted to stop, and saw several directions to extend the program. The dice roller is small but it touches every fundamental you need for bigger projects. Find more beginner projects on Python Central Hub and keep that momentum going.
Pitfalls
Section titled “Pitfalls”- Assuming a small sample looks fair. Six rolls of a fair die will almost never give one of each face. Fairness is a statement about the limit, and the exercise below shows how slowly the observed frequencies actually settle.
random.randint(1, 6)includes both ends. That is unusual:range(1, 6)stops at 5 andrandom.randrange(1, 6)does too.randintis the exception, and mixing them up quietly produces a five-sided die.- Reading the loop as ‘ask, then roll’. It rolls first and asks afterwards, so the die is always thrown at least once — including when the program runs unattended, which is why the output above has a roll in it.
- Seeding with the clock for anything that matters. Python seeds
randomfrom the OS by default, which is fine here. Settingrandom.seed(42)makes a run reproducible, which is useful for a test and disastrous for a game.
random.randint(1, 6)is inclusive at both ends — six outcomes, not five.- The loop rolls before it asks, so a run always produces at least one result.
ask()answers ‘n’ when nothing is typed, so the loop terminates unattended instead of raising EOFError.- A fair die is a claim about long-run frequency; six rolls cannot demonstrate it, and the exercise shows how many can.
Try it yourself
Section titled “Try it yourself”-
`random.randint(1, 6)` and `random.randrange(1, 6)` differ. How?
pch.quizShowAnswer
B — `randint` includes both endpoints and can return 6; `randrange` excludes the upper one and stops at 5
-
The program rolled once and stopped when no answer was typed. Why did it roll at all?
pch.quizShowAnswer
B — Because the loop rolls first and asks afterwards, so the first throw happens before any answer is needed
-
You roll a fair die six times and get no 4. What does that tell you about the die?
pch.quizShowAnswer
B — Almost nothing — missing at least one face in six rolls is the common outcome, not the surprising one
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading