Skip to content

Dice Rolling Simulator

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.

  • 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 .py file.
  • No external libraries are needed; random ships with Python.

Before writing code, here is what each piece does conceptually:

  • random module — 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 integer N such that a ≤ 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 a break statement 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.
  1. Create a folder named dice-rolling-simulator.
  2. Open it in your code editor.
  3. Inside, create a file called dicerolling.py.

Add the following to dicerolling.py:

Dice Rolling pch.viewSource
Dice Rolling
"""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:

command
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.

Running the file exactly as it ships takes 0.3 s and prints:

python dicerolling.py
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.

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.

diagram Diagram mermaid
dicerolling.py
import random

import 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.

dicerolling.py
def roll():
    return random.randint(1, 6)
  • def roll(): declares a new function named roll that takes no arguments.
  • The indented body is the function’s code.
  • return hands 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 reading random.randint(1, 6) everywhere.
  • It is easy to change: if you later want a 20-sided die, you change one line.
dicerolling.py
while True:
    print(f"You rolled {roll()}")
    play_again = input("Do you want to roll again? (y/n): ")
    if play_again.lower() != "y":
        break
  • while True: starts a loop that never ends on its own — you exit it manually.
  • print(f"You rolled {roll()}") is an f-string. The f prefix 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 type y (in any case), break immediately exits the loop.
dicerolling.py
print("Thanks for playing!")

This line runs once, after the loop ends.

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:

seeded.py
import random
random.seed(42)
print(random.randint(1, 6))   # Always prints the same number for seed 42

Seeding 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:

python dicerolling.py (tail)
   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 times26x 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.

ProblemFix
NameError: name 'random' is not definedYou forgot import random at the top.
The program crashes after one rollYou 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 TypeErrorrandint needs integers — use random.uniform(1, 6) for floats.
multi_dice.py
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.

any_sides.py
def roll(sides=6):
    return random.randint(1, sides)
 
print(roll())      # six-sided
print(roll(20))    # twenty-sided
print(roll(100))   # percentile die

sides=6 is a default argument — if the caller does not specify a value, Python uses 6.

dnd_notation.py
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.

ascii_dice.py
FACES = {
    1: ("┌─────────┐", "│         │", "│    ●    │", "│         │", "└─────────┘"),
    2: ("┌─────────┐", "│  ●      │", "│         │", "│      ●  │", "└─────────┘"),
    # …add 3, 4, 5, 6
}
 
for line in FACES[roll()]:
    print(line)

A dictionary maps each face to its ASCII art.

stats.py
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.

  • 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.
  • 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.

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.

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:

sketch Roll the dice p5.js
Click to roll two dice. Each die shows 1-6 pips; the total is their sum.

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.

  • 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 and random.randrange(1, 6) does too. randint is 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 random from the OS by default, which is fine here. Setting random.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.
pch.quizTag pch.quizDefaultTitle
  1. `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

  2. 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

  3. 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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading