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.
You will build a windowed Pygame application that has multiple screens:
The player’s goal is simple but surprisingly addictive: reach the exact target score. The moment you overshoot the target, you lose.
To build this project smoothly, you should have:
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.pyand start coding.
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.
A state represents the current mode of your game:
menuplayinglevel_completegame_overOnly one state is active at a time. Based on that state, your program:
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.
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.
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.
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).
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
if event.type == pygame.QUIT:
running = False
Code language: PHP (php)
This lets the window close button work properly.
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.
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).
Now we connect clicks to actions based on the active 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().
This is where the actual game happens.
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.
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.
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.
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.
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().
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.
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.
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.
In playing state, you render live statistics from your game variables:
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.
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.
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.
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.
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.
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.
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)