Game Over State

Objective: Game Over State

Let's begin by reviewing our objective.

Our goal is to create a script with a 'game_state' variable set to 'playing' and a 'lives' variable set to 1. When the ball goes off-screen, set 'lives' to 0. Use an if-statement in the main loop to check the 'game_state'. If lives is 0, change 'game_state' to 'game_over'. If the state is 'game_over', stop the ball's movement and display a large 'GAME OVER' message in the center of the screen.

Check the Specification

Next, we'll run pytest to see the failing tests. This confirms the engineering specification we need to meet.

Run the tests in your terminal.

pytest

Test Results:

  • test_game_over_flow_and_display

Implement: main.py

Now, let's build the solution by following the TODO comments in main.py. Each TODO guides you through implementing a part of the game over state logic.

Step by step checklist:

  1. Initialize a variable called game_state and set its initial value to the string 'playing'.
  2. Initialize a variable called lives and set its initial value to the integer 1.
  3. Create a font object for the 'GAME OVER' message using the GAME_OVER_FONT_SIZE constant from settings.
  4. Render the text 'GAME OVER' using the font object, enabling anti-aliasing, and setting the color to WHITE from settings.
  5. Get the rectangle for the rendered text surface and set its center to the center of the screen using SCREEN_WIDTH and SCREEN_HEIGHT from settings.
  6. Inside the main game loop, check if the lives variable is less than or equal to 0. If this condition is true, set the game_state variable to the string 'game_over'.
  7. Check if the game_state variable is equal to the string 'playing'.
  8. If the state is 'playing', call the move() method on the ball object.
  9. Still within the 'playing' state check, check if the bottom edge of the ball's rectangle (ball.rect.bottom) is greater than the SCREEN_HEIGHT constant.
  10. If the ball has gone off the bottom, set the lives variable to 0.
  11. In the drawing section of the loop, check the current value of the game_state variable.
  12. If the state is 'game_over', draw (blit) the rendered game over text surface onto the screen using its centered rectangle.
  13. Otherwise (if the state is not 'game_over', meaning it's 'playing'), draw the ball object onto the screen.

The following documentation sections are going to be helpful:

  • Displaying Text with Pygame Fonts
  • Updating Game State Variables
  • Handling Events and Conditional Logic
  • Implementing a Simple Game State Machine
  • Integrating Multiple Game Components

Validate the Solution

With the code in place, let's run the tests again to validate our work.

Run the tests in your terminal.

pytest

Test Results:

  • test_game_over_flow_and_display All tests passed!

Documentation

Displaying Text with Pygame Fonts

Pygame allows you to render text onto the screen using fonts. This is essential for displaying information like scores, lives, or game messages.

The process involves:

  1. Initializing the font module (often done with pygame.init(), but pygame.font.init() is specific).
  2. Creating a Font object.
  3. Rendering the desired text onto a Surface.
  4. Getting the Rect for the text surface to position it.
  5. Blitting (drawing) the text surface onto the main screen surface.
import pygame

# Example: Font creation
# Use None for the default font, specify size
font = pygame.font.Font(None, 36)

# Example: Rendering text
# Arguments: text string, anti-aliasing (True/False), color
text_surface = font.render("Hello, Pygame!", True, (255, 255, 255)) # White text

# Example: Positioning text
text_rect = text_surface.get_rect()
text_rect.topleft = (10, 10) # Position at (10, 10)

# Example: Drawing text (inside the game loop)
# screen.fill((0, 0, 0)) # Fill background
# screen.blit(text_surface, text_rect)
# pygame.display.flip()

[Image/Gif: Show a simple Pygame window with static text rendered in the corner.]

Updating Game State Variables

Game state refers to the current condition of the game, such as the player's score, remaining lives, or the game's overall status (playing, paused, game over). These are typically stored in variables.

To update the display of these variables, you need to:

  1. Modify the variable's value based on game events (e.g., increment score on collision, decrement lives on miss).
  2. Re-render the text surface with the new variable value.
  3. Re-blit the updated text surface to the screen in the drawing phase of the game loop.
# Example: Initial state
score = 0
lives = 3

# Example: Updating state based on an event (inside game logic)
# if collision_with_brick:
#     score += 10
# if ball_went_off_bottom:
#     lives -= 1

# Example: Re-rendering text after state change (inside game logic or drawing)
# Assuming 'font' and 'screen' are defined
score_text_surface = font.render(f"Score: {score}", True, (255, 255, 255))
lives_text_surface = font.render(f"Lives: {lives}", True, (255, 255, 255))

# Example: Drawing updated text (inside drawing phase)
# screen.blit(score_text_surface, (10, 10))
# screen.blit(lives_text_surface, (screen_width - lives_text_surface.get_width() - 10, 10))

[Image/Gif: Show a score counter incrementing or a lives counter decrementing.]

Handling Events and Conditional Logic

Game logic often depends on specific conditions being met. For example, a score should only increase when a collision occurs, or a life should only be lost when the ball goes off-screen. if statements are used to check these conditions and execute code accordingly.

Common conditions involve checking:

  • Collisions between sprites (sprite.rect.colliderect(other_sprite.rect)).
  • Position relative to screen boundaries (ball.rect.bottom >= screen_height).
  • The value of game state variables (if lives <= 0).
# Example: Checking for ball going off bottom (inside game logic)
# Assuming 'ball' object and 'screen_height' constant exist
if ball.rect.bottom > screen_height:
    # This condition is true when the ball is below the screen
    print("Ball missed!")
    # Trigger actions like losing a life and resetting the ball
    # lives -= 1
    # ball.reset_position()

# Example: Checking for collision (inside game logic)
# Assuming 'ball' and 'paddle' objects exist
if ball.rect.colliderect(paddle.rect):
    print("Ball hit paddle!")
    # Trigger actions like bouncing the ball
    # ball.speed_y *= -1

[Image/Gif: Show a ball hitting the bottom edge and disappearing, or hitting a paddle and bouncing.]

Implementing a Simple Game State Machine

For games with distinct phases (like a title screen, playing, game over), a simple state machine can manage which logic and drawing code runs. A variable (e.g., game_state) holds the current state, and if/elif/else statements control the flow.

# Example: Initializing state
game_state = 'playing' # Possible states: 'playing', 'game_over'

# Example: State transition (inside game logic)
# if lives <= 0:
#     game_state = 'game_over'

# Example: Logic and Drawing based on state (inside main loop)
# if game_state == 'playing':
#     # Update ball, paddle, check collisions, etc.
#     ball.move()
#     paddle.move(keys)
#     # ... collision checks ...
#     
#     # Draw playing elements
#     screen.fill(BLACK)
#     ball.draw(screen)
#     paddle.draw(screen)
#     # ... draw bricks, score, lives ...
#
# elif game_state == 'game_over':
#     # Stop movement (implicitly done by not calling move() above)
#     
#     # Draw game over screen
#     screen.fill(BLACK)
#     # ... draw 'GAME OVER' text, final score ...

[Image/Gif: Show a transition from the active game screen to a static "GAME OVER" screen.]

Integrating Multiple Game Components

A complete game loop combines all the elements: event handling, updating game state variables, moving objects, checking collisions, managing game state transitions, and drawing everything to the screen. The main loop iterates continuously, performing these steps in order for each frame.

The structure typically looks like:

# Initialize Pygame, screen, clock, game state, objects

# Main game loop
running = True
while running:
    # 1. Event Handling (check for quit, key presses)
    for event in pygame.event.get():
        # ... handle events ...

    # 2. Game Logic (based on current state)
    # if game_state == 'playing':
        # Update object positions (ball.move(), paddle.move())
        # Check collisions (ball-paddle, ball-brick)
        # Update score/lives based on collisions/misses
        # Check for win/lose conditions and update game_state
    # elif game_state == 'game_over':
        # Handle game over screen logic (e.g., wait for restart input)

    # 3. Drawing
    screen.fill(background_color)
    # if game_state == 'playing':
        # Draw all active game objects (paddle, ball, bricks)
        # Draw score and lives text
    # elif game_state == 'game_over':
        # Draw game over message and final score

    # 4. Update Display
    pygame.display.flip()

    # 5. Control Frame Rate
    clock.tick(FPS)

# Quit Pygame

[Image/Gif: Show a full, simple Breakout game loop running.]

Adding Sound Effects with pygame.mixer

Sound effects enhance the game experience by providing audio feedback for events like collisions.

Steps to add sound:

  1. Initialize the mixer module (pygame.mixer.init()). This is separate from pygame.init().
  2. Load a sound file (.wav, .ogg, etc.) into a Sound object (pygame.mixer.Sound("path/to/sound.wav")). It's good practice to handle potential errors if the file is missing.
  3. Play the sound object when the corresponding game event occurs (sound_object.play()).
import pygame

# Example: Initialize mixer (after pygame.init())
# pygame.init()
pygame.mixer.init()

# Example: Load sound file
try:
    bounce_sound = pygame.mixer.Sound("assets/bounce.wav")
except pygame.error as e:
    print(f"Could not load sound file: {e}")
    # Create a dummy object if loading fails to prevent crashes
    class DummySound:
        def play(self): pass
    bounce_sound = DummySound()


# Example: Play sound on event (inside game logic)
# if ball.rect.colliderect(paddle.rect):
#     ball.speed_y *= -1
#     bounce_sound.play() # Play the sound here

[Image/Gif: Show a visual representation of a sound wave playing when a collision happens.]

Code Refactoring: Improving Code Structure

Refactoring is the process of restructuring existing computer code without changing its external behavior. The goal is to improve nonfunctional attributes of the software, such as readability, maintainability, and simplicity.

Common refactoring techniques include:

  • Extracting methods/functions: Turning a block of code into a reusable function with a clear purpose.
  • Renaming variables/functions: Using descriptive names that explain the code's intent.
  • Introducing constants: Replacing "magic numbers" or hardcoded values with named constants.
  • Adding comments and docstrings: Explaining complex logic or the purpose of functions/classes.
# Example: Before Refactoring (simplified)
# def calculate_total(prices, tax, discount_percent):
#     subtotal = 0
#     for p in prices:
#         subtotal += p
#     discount_amount = subtotal * (discount_percent / 100)
#     discounted_total = subtotal - discount_amount
#     tax_amount = discounted_total * tax
#     final_price = discounted_total + tax_amount
#     return final_price

# Example: After Refactoring (using extracted functions and better names)
# DEFAULT_TAX_RATE = 0.08
# DEFAULT_DISCOUNT_PERCENTAGE = 10

# def calculate_subtotal(item_prices):
#     """Calculates the sum of all item prices."""
#     return sum(item_prices)

# def apply_discount(amount, discount_percentage):
#     """Applies a discount to a given amount."""
#     discount_amount = amount * (discount_percentage / 100.0)
#     return amount - discount_amount

# def apply_tax(amount, tax_rate):
#     """Applies tax to a given amount."""
#     tax_amount = amount * tax_rate
#     return amount + tax_amount

# def process_order(item_prices, tax_rate=DEFAULT_TAX_RATE, discount_percentage=DEFAULT_DISCOUNT_PERCENTAGE):
#     """Processes a customer's order."""
#     subtotal = calculate_subtotal(item_prices)
#     discounted_total = apply_discount(subtotal, discount_percentage)
#     final_price = apply_tax(discounted_total, tax_rate)
#     return final_price

Refactoring makes code easier to understand, test, and modify in the future. It's an ongoing process, not a one-time task.