Scrollable Nav Bar

Dice Rolling Game Using Python Pygame (With Levels, Target Score, Bonus Tokens)

If you want to practice Python game development with Pygame and build something that feels like a complete mini‑game (not just a small demo), a dice game is a perfect project. It teaches you the real fundamentals of interactive apps: game loop design, button clicks, UI rendering, state management, scoring logic, and level progression.

In this tutorial, you’ll build a Dice Rolling Game in Pygame where the player rolls a dice repeatedly to reach an exact target score. Each level increases the target, and you earn bonus tokens whenever you roll a 6. Bonus tokens can be spent to add +5 points—useful when you’re close to the target. If you go over the target, it’s game over.

This explanation is written for students, professionals, and developers. Students will understand each block step-by-step, while experienced developers will see a clean structure based on a finite state machine (FSM), which is a standard architecture used in real games.


What You Will Build

You will build a windowed Pygame application that has multiple screens:

  • Menu screen with a Start Game button.
  • Playing screen showing Level, Score, Target, Bonus Tokens, and Last Dice.
  • Level Complete screen with a Next Level button.
  • Game Over screen with a Restart button.
  • A Quit button visible on every screen.

The player’s goal is simple but surprisingly addictive: reach the exact target score. The moment you overshoot the target, you lose.


Prerequisites

To build this project smoothly, you should have:

  • Python 3.9+ installed.
  • A code editor like VS Code, PyCharm, or Anaconda (Spyder/Jupyter + scripts). VS Code is a popular choice for Pygame projects because it has excellent Python tooling.
  • Basic knowledge of Python variables, functions, and if/elif conditions.

You also need the Pygame library.

Install Pygame using pip:

pip install pygame

If you’re using Anaconda, you can still use pip inside the environment:

pip install pygame

After installing, you’re ready to create a Python file such as:

  • dice_game.py

and start coding.


Project Idea and Architecture

The easiest mistake beginners make in game development is writing everything in one giant block of code without structure. That works for tiny demos, but the moment you add multiple screens (menu, game, win/lose), it becomes difficult to manage.

So we’ll use a professional approach: a state machine.

The State Machine Concept

A state represents the current mode of your game:

  • menu
  • playing
  • level_complete
  • game_over

Only one state is active at a time. Based on that state, your program:

  1. Handles different input.
  2. Draws different UI.
  3. Switches to another state when conditions are met.

This is exactly how many real games are structured: the “Play” state is different from the “Pause” state, which is different from the “Main Menu,” etc.

Why This Architecture Is Good

  • It keeps the logic clean and scalable.
  • You can add new screens easily (like “settings” or “high score”).
  • Your code becomes easier to read, test, and modify.

Import Modules and Initialize Pygame

At the very top of the file, we import three modules:

import pygame
import sys
import random
Code language: JavaScript (javascript)

This looks small, but it’s doing a lot for our project.

pygame is the library that gives you a complete toolkit for building a real-time interactive window: drawing shapes and text, creating a display, reading keyboard/mouse input, tracking time (FPS), and managing the event queue. When you write professional games, you rarely “print” output—you render it, and Pygame is what makes that possible.

random is used for the dice roll logic. A dice roll is a classic example of controlled randomness: we want unpredictable outcomes, but we want them to always stay within a safe range (1 to 6). That’s why the code uses random.randint(1, 6).

sys is included so we can call sys.exit() at the end. On many systems, this ensures the program quits cleanly after Pygame shuts down. This is a practical habit: you don’t want the Python process to hang in the background.

After imports, we initialize Pygame:

pygame.init()
Code language: CSS (css)

Initialization matters because Pygame is made of multiple subsystems (display, font, sound, etc.). If you forget to initialize, many features will fail silently or throw errors later. By calling pygame.init() once at the beginning, you ensure the environment is ready before you create the window, load fonts, or handle events.


Create the Game Window

Every Pygame game needs a display surface—this is the “canvas” where everything is drawn. In this project, we use a fixed resolution so UI placement stays simple and predictable:

WIDTH, HEIGHT = 600, 400
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Dice Rolling Game")
Code language: JavaScript (javascript)

Here’s what’s happening in real terms. WIDTH and HEIGHT define a coordinate system where (0, 0) is the top-left corner of the window, x increases as you move right, and y increases as you move down. This coordinate system is what you’ll use for buttons, text placement, and UI alignment.

pygame.display.set_mode((WIDTH, HEIGHT)) returns a Surface object, stored in screen. Think of this as the main drawing board for your game. Every frame, you will draw onto this surface, then “flip” it to the monitor.

Finally, set_caption() sets the title bar name, which is a small detail—but these details add polish and make your project feel like a real app.


Define Colors and Fonts

Games are visual, so you define a palette early:

WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GRAY = (200, 200, 200)
BLUE = (50, 50, 255)
GREEN = (0, 200, 0)
RED = (200, 0, 0)

Pygame colors are RGB tuples. For example, (255, 255, 255) is white because it’s maximum red, green, and blue intensity. (0, 0, 0) is black because all channels are zero.

Using named variables instead of raw tuples everywhere makes the code easier to read and easier to change later. If you want to redesign your UI theme, you change the color definitions once and the whole UI updates.

Next come fonts:

font = pygame.font.SysFont(None, 36)
small_font = pygame.font.SysFont(None, 24)

Fonts are critical for UI clarity. The font is used for headings and main game stats, while small_font is used for button labels. In professional UI design, this is called typographic hierarchy: important information is visually stronger.

Why None? Because we want a system-default font so the project runs without bundling a .ttf file. Later, if you want a custom font, you can replace this with pygame.font.Font("yourfont.ttf", size).


Create Buttons with Rectangles (UI + Click Detection)

In many Pygame projects, a “button” is not a built-in widget like in Tkinter. Instead, you build it from primitives. The most common approach is a rectangle plus text.

In this project, every button is defined as a pygame.Rect:

start_button_rect = pygame.Rect(250, 150, 100, 50)
roll_button_rect = pygame.Rect(400, 150, 150, 50)
bonus_button_rect = pygame.Rect(400, 220, 150, 50)
next_level_button_rect = pygame.Rect(250, 200, 100, 50)
restart_button_rect = pygame.Rect(250, 200, 100, 50)
quit_button_rect = pygame.Rect(WIDTH - 110, 10, 100, 40)

A Rect gives you two things at the same time:

  1. A location and size where you can draw the button.
  2. A collision box you can test with collidepoint() to detect clicks.

This is why rectangles are used in almost every 2D game framework: one object supports both rendering layout and interaction logic.

The Quit button uses WIDTH - 110 so it stays near the top-right corner. That is a small but developer-friendly pattern: if you later increase width to 800, the quit button doesn’t remain stuck at a hardcoded x position.


Define Game Variables (Rules of the Game)

Now we define the game’s core “model”—the values that represent the current run:

level = 1
score = 0
target = 20 + level * 10  # Target score increases with level
bonus_tokens = 0
state = "menu"  # states: "menu", "playing", "level_complete", "game_over"
last_dice = None
Code language: PHP (php)

Even if you don’t call it that, this is the game state in the data sense. Your rendering reads from these variables, and your click handling modifies them.

The most important design decision here is the state variable. It ensures you don’t accidentally allow “rolling dice” while you’re still in the menu, or allow “next level” clicks during the game over screen. Professional games separate flows using states because it prevents messy conditions and keeps logic predictable.

The target formula is also worth understanding deeply. Instead of hardcoding a different target for each level, you define a mathematical rule: 20 + level * 10. This means difficulty increases automatically, and the code stays small.

Finally, last_dice starts as None so the UI doesn’t show a “Last Dice” value before the player rolls even once. That’s a subtle UI correctness detail.


Write Reusable UI Functions (Clean, Maintainable Code)

Repeated code is the fastest way to make a project hard to maintain. In this dice game, text drawing and button drawing happen in many screens, so you correctly turned them into functions.

draw_text

def draw_text(text, font, color, surface, x, y):
    textobj = font.render(text, True, color)
    surface.blit(textobj, (x, y))
Code language: PHP (php)

This function does two key actions. First, it converts your string into a renderable surface using the selected font. That’s necessary because Pygame cannot draw plain strings directly. Second, it places the rendered text at a specific coordinate using blit().

From a developer perspective, this function is also an “API” you created for yourself. Instead of remembering the exact font.render() arguments every time, you call draw_text() and focus on your UI placement.

draw_button

def draw_button(surface, rect, text, font, bg_color):
    pygame.draw.rect(surface, bg_color, rect)
    text_surface = font.render(text, True, BLACK)
    text_rect = text_surface.get_rect(center=rect.center)
    surface.blit(text_surface, text_rect)
Code language: PHP (php)

This function draws a rectangle first (the button body), then renders the button label, and finally centers it.

The centering part is what makes it feel professional. If you only used fixed text coordinates, the label would look wrong when you change the button width. By computing text_rect with center=rect.center, your text always stays centered. This is the kind of small engineering detail that makes UI stable.


Reset and Next Level Functions (Controlled State Transitions)

Games are full of transitions. You start a new game, you finish a level, you restart after a loss. If you write this logic in many places, you will eventually forget to reset something and create bugs.

That’s why your code uses two dedicated functions.

reset_game

def reset_game():
    global level, score, target, bonus_tokens, state, last_dice
    level = 1
    score = 0
    target = 20 + level * 10
    bonus_tokens = 0
    last_dice = None
    state = "playing"
Code language: PHP (php)

The global line matters here. Because these variables are defined outside the function, Python would otherwise treat assignments as local variables. For beginners, this is a common confusion: you can read a global variable inside a function, but if you assign to it, Python assumes it’s local unless declared global.

The job of reset_game() is to guarantee a clean starting point. Notice that it sets state to playing. That means “start button” is not directly entering gameplay logic; it is calling a function that prepares everything and then transitions states.

new_level

def new_level():
    global level, score, target, bonus_tokens, state, last_dice
    level += 1
    score = 0
    target = 20 + level * 10
    bonus_tokens = 0
    last_dice = None
    state = "playing"
Code language: PHP (php)

This function increments the level, recalculates the target, and resets other gameplay values.

Why reset score and bonus_tokens? Because each level is designed as a new mini-round. This makes each level feel fair and keeps difficulty scaling meaningful.


Build the Main Game Loop (Frame-by-Frame Execution)

Pygame games run in a loop because a game is not a single calculation. It is a real-time program that keeps listening for input, updating values, and redrawing.

Your loop setup:

clock = pygame.time.Clock()
running = True

while running:
    screen.fill(WHITE)
Code language: PHP (php)

clock helps control how fast the loop runs. Without it, your loop would run as fast as your CPU allows, which can cause high CPU usage and inconsistent gameplay.

screen.fill(WHITE) clears the screen every frame. This is a key concept in real-time rendering: you redraw the entire scene repeatedly. If you don’t clear the screen, shapes and text from previous frames remain visible and create a “trail” effect.

A good way to think of this is like animation frames: each pass through the loop is one frame.


Handle Events (Quit, Mouse Clicks, and UI Actions)

In Pygame, you don’t continuously poll “is the mouse clicked?” in a simple way like some GUI toolkits. Instead, Pygame pushes events into a queue, and you process them.

for event in pygame.event.get():
Code language: CSS (css)

That line retrieves events that happened since the last frame.

Window close event

if event.type == pygame.QUIT:
    running = False
Code language: PHP (php)

This lets the window close button work properly.

Mouse click event

if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
    mouse_pos = pygame.mouse.get_pos()

We only react to left-click (button == 1). The mouse_pos tuple is the click location.

Global Quit button (works on every screen)

if quit_button_rect.collidepoint(mouse_pos):
    running = False
    continue
Code language: PHP (php)

This design is excellent: you check Quit before state logic. That guarantees quitting is consistent and doesn’t depend on which screen you’re on. The continue prevents accidental double-processing (for example, quitting and also triggering Start Game in the same click event).


Write Game Logic Using States (Finite State Machine in Practice)

Now we connect clicks to actions based on the active state.

Menu State

if state == "menu":
    if start_button_rect.collidepoint(mouse_pos):
        reset_game()
Code language: JavaScript (javascript)

In the menu, clicking anywhere should not roll dice or use bonuses. The only action is Start Game, so the code checks that button and calls reset_game().

Playing State

This is where the actual game happens.

Rolling the dice

if roll_button_rect.collidepoint(mouse_pos):
    dice = random.randint(1, 6)
    last_dice = dice
    score += dice

This is the main mechanic. The dice roll result is saved as last_dice so the UI can show it, and then added to score.

Bonus token reward on rolling a 6

if dice == 6:
    bonus_tokens += 1

This is a smart micro-design. Rolling a 6 already gives you 6 points, but you also earn a token, which adds excitement and makes 6 feel special.

Checking win vs game over

if score == target:
    state = "level_complete"
elif score > target:
    state = "game_over"
Code language: JavaScript (javascript)

The game’s identity is here: you must match the target exactly. Overshooting triggers game over, which adds tension and makes the player think before pressing Roll.

Using the bonus button

elif bonus_button_rect.collidepoint(mouse_pos):
    if bonus_tokens > 0:
        bonus_tokens -= 1
        score += 5

This adds strategy. Instead of relying only on randomness, the player can control score growth in steps of 5—if they earned tokens.

Right after adding the bonus, the same win/lose check happens again because score changed.

Level Complete State

elif state == "level_complete":
    if next_level_button_rect.collidepoint(mouse_pos):
        new_level()
Code language: JavaScript (javascript)

At this point, dice rolling should not be possible. The only action is moving forward, so the button simply triggers new_level().

Game Over State

elif state == "game_over":
    if restart_button_rect.collidepoint(mouse_pos):
        state = "menu"
Code language: JavaScript (javascript)

Game Over returns the player to the menu rather than auto-restarting. This is a good UX choice because it clearly separates a finished run from a new one.


Render Screens Based on State (UI as a Reflection of Data)

After handling input and logic for the frame, the game draws the correct UI. This is an important pattern: your UI should reflect your variables, not fight them.

Menu screen

draw_text("Dice Rolling Game", font, BLUE, screen, 170, 50)
draw_button(screen, start_button_rect, "Start Game", small_font, GREEN)
Code language: JavaScript (javascript)

The menu keeps the player focused. Too many options at the beginning can confuse beginners. This screen gives one clear call-to-action.

Playing screen

In playing state, you render live statistics from your game variables:

  • Level shows progression.
  • Score shows current total.
  • Target shows the goal.
  • Bonus Tokens shows how many “power moves” you have.
  • Last Dice gives instant feedback about the most recent roll.

The conditional part matters:

if last_dice is not None:
    draw_text(f"Last Dice: {last_dice}", font, BLACK, screen, 20, 180)
Code language: JavaScript (javascript)

This ensures the “Last Dice” line doesn’t show nonsense before the first roll.

Then you render the interactive buttons:

draw_button(screen, roll_button_rect, "Roll Dice", small_font, GRAY)
draw_button(screen, bonus_button_rect, "Use Bonus (+5)", small_font, GRAY)
Code language: JavaScript (javascript)

Even though these are simple rectangles, the layout communicates a clear game flow: stats on the left, actions on the right.

Level complete screen

draw_text("Level Complete!", font, GREEN, screen, 200, 50)
draw_text(f"You reached {target} points.", font, BLACK, screen, 150, 100)
draw_button(screen, next_level_button_rect, "Next Level", small_font, GREEN)
Code language: JavaScript (javascript)

This confirms success, tells the player what happened, and offers a single next action.

Game over screen

draw_text("Game Over!", font, RED, screen, 220, 50)
draw_text(f"You exceeded {target} points.", font, BLACK, screen, 150, 100)
draw_button(screen, restart_button_rect, "Restart", small_font, GREEN)
Code language: JavaScript (javascript)

The message clearly explains why the player lost, which is important feedback in games. A “Game Over” without reason can feel unfair.

Quit button (always visible)

draw_button(screen, quit_button_rect, "Quit", small_font, RED)
Code language: JavaScript (javascript)

Drawing Quit on every screen improves usability and makes the project feel complete.


Update the Display, Control FPS, and Exit Cleanly

At the end of every frame:

pygame.display.flip()
clock.tick(30)
Code language: CSS (css)

flip() pushes your newly drawn frame to the screen. Without it, your drawings remain in memory and you won’t see updates.

tick(30) limits your loop to around 30 frames per second. This keeps the game stable across machines and prevents CPU overuse.

When the loop ends, you shut down:

pygame.quit()
sys.exit()
Code language: CSS (css)

Calling pygame.quit() releases Pygame resources. sys.exit() ends the program process. Together, they form a clean and professional shutdown.


Why This Dice Rolling Game Is a Strong Pygame Project

This project is valuable because it teaches the real structure behind most Pygame applications: a dependable main loop, event-driven input, and rendering that depends on a controlled state machine. You’re also building a game mechanic that mixes luck (dice roll) with strategy (bonus tokens), which is a core principle behind engaging gameplay.

As you grow, you can extend this exact codebase in many directions without rewriting everything. You can add hover effects for buttons, dice images instead of text, sound effects when rolling, a high score tracker, or a difficulty selector that changes the target formula. The architecture you already have—states + reusable drawing functions—makes these upgrades straightforward.


Complete Code

import pygame
import sys
import random

# Initialize pygame
pygame.init()

# Screen dimensions
WIDTH, HEIGHT = 600, 400
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Dice Rolling Game")

# Colors and Fonts
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GRAY = (200, 200, 200)
BLUE = (50, 50, 255)
GREEN = (0, 200, 0)
RED = (200, 0, 0)

font = pygame.font.SysFont(None, 36)
small_font = pygame.font.SysFont(None, 24)

# Button Rectangles
start_button_rect = pygame.Rect(250, 150, 100, 50)
roll_button_rect = pygame.Rect(400, 150, 150, 50)
bonus_button_rect = pygame.Rect(400, 220, 150, 50)
next_level_button_rect = pygame.Rect(250, 200, 100, 50)
restart_button_rect = pygame.Rect(250, 200, 100, 50)
# Quit button placed at top right corner
quit_button_rect = pygame.Rect(WIDTH - 110, 10, 100, 40)

# Game Variables
level = 1
score = 0
target = 20 + level * 10  # Target score increases with level
bonus_tokens = 0
state = "menu"  # states: "menu", "playing", "level_complete", "game_over"
last_dice = None

# Utility function to draw text
def draw_text(text, font, color, surface, x, y):
    textobj = font.render(text, True, color)
    surface.blit(textobj, (x, y))

# Utility function to draw a button
def draw_button(surface, rect, text, font, bg_color):
    pygame.draw.rect(surface, bg_color, rect)
    text_surface = font.render(text, True, BLACK)
    text_rect = text_surface.get_rect(center=rect.center)
    surface.blit(text_surface, text_rect)

# Reset the game for a new game start (from menu)
def reset_game():
    global level, score, target, bonus_tokens, state, last_dice
    level = 1
    score = 0
    target = 20 + level * 10
    bonus_tokens = 0
    last_dice = None
    state = "playing"

# Setup a new level after completing one
def new_level():
    global level, score, target, bonus_tokens, state, last_dice
    level += 1
    score = 0
    target = 20 + level * 10
    bonus_tokens = 0
    last_dice = None
    state = "playing"

# Main game loop
clock = pygame.time.Clock()
running = True

while running:
    screen.fill(WHITE)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_pos = pygame.mouse.get_pos()

            # Global Quit button check
            if quit_button_rect.collidepoint(mouse_pos):
                running = False
                continue

            if state == "menu":
                if start_button_rect.collidepoint(mouse_pos):
                    reset_game()

            elif state == "playing":
                if roll_button_rect.collidepoint(mouse_pos):
                    # Roll the dice (value between 1 and 6)
                    dice = random.randint(1, 6)
                    last_dice = dice
                    score += dice
                    # Award bonus token if a 6 is rolled
                    if dice == 6:
                        bonus_tokens += 1
                    # Check for win or game over
                    if score == target:
                        state = "level_complete"
                    elif score > target:
                        state = "game_over"

                elif bonus_button_rect.collidepoint(mouse_pos):
                    if bonus_tokens > 0:
                        bonus_tokens -= 1
                        score += 5  # Bonus adds 5 points
                        if score == target:
                            state = "level_complete"
                        elif score > target:
                            state = "game_over"

            elif state == "level_complete":
                if next_level_button_rect.collidepoint(mouse_pos):
                    new_level()

            elif state == "game_over":
                if restart_button_rect.collidepoint(mouse_pos):
                    state = "menu"

    # Draw different screens based on game state
    if state == "menu":
        draw_text("Dice Rolling Game", font, BLUE, screen, 170, 50)
        draw_button(screen, start_button_rect, "Start Game", small_font, GREEN)

    elif state == "playing":
        draw_text(f"Level: {level}", font, BLACK, screen, 20, 20)
        draw_text(f"Score: {score}", font, BLACK, screen, 20, 60)
        draw_text(f"Target: {target}", font, BLACK, screen, 20, 100)
        draw_text(f"Bonus Tokens: {bonus_tokens}", font, BLACK, screen, 20, 140)
        if last_dice is not None:
            draw_text(f"Last Dice: {last_dice}", font, BLACK, screen, 20, 180)

        draw_button(screen, roll_button_rect, "Roll Dice", small_font, GRAY)
        draw_button(screen, bonus_button_rect, "Use Bonus (+5)", small_font, GRAY)

    elif state == "level_complete":
        draw_text("Level Complete!", font, GREEN, screen, 200, 50)
        draw_text(f"You reached {target} points.", font, BLACK, screen, 150, 100)
        draw_button(screen, next_level_button_rect, "Next Level", small_font, GREEN)

    elif state == "game_over":
        draw_text("Game Over!", font, RED, screen, 220, 50)
        draw_text(f"You exceeded {target} points.", font, BLACK, screen, 150, 100)
        draw_button(screen, restart_button_rect, "Restart", small_font, GREEN)

    # Draw the Quit button on every screen
    draw_button(screen, quit_button_rect, "Quit", small_font, RED)

    pygame.display.flip()
    clock.tick(30)

pygame.quit()
sys.exit()
Code language: PHP (php)