Skip to main content

Random Generation for Games

Creating Variety and Surprise

Randomness is the spice of gaming! It creates variety, replayability, and surprise. From loot drops to procedural worlds, understanding controlled randomness is essential for creating engaging games. Let's explore how to harness chaos for fun! ๐ŸŽฒ๐ŸŒŸ

Understanding Randomness in Games

๐ŸŽฐ The Casino Analogy

Think of randomness in games like a casino:

graph TD A["Random Generation"] --> B["Basic Random"] A --> C["Distributions"] A --> D["Noise Functions"] A --> E["Procedural Generation"] B --> F["Uniform Random"] B --> G["Seeded Random"] C --> H["Gaussian/Normal"] C --> I["Weighted"] D --> J["Perlin Noise"] D --> K["Simplex Noise"] E --> L["Terrain"] E --> M["Dungeons"]

Interactive Random Visualizer

Visualize different random distributions and patterns!

Mode: Uniform

Basic Random Number Generation

import random

# Basic random functions
random_float = random.random()           # 0.0 to 1.0
random_int = random.randint(1, 6)       # 1 to 6 (inclusive)
random_range = random.randrange(0, 100, 5)  # 0, 5, 10, ..., 95
random_choice = random.choice(['sword', 'shield', 'potion'])

# Set seed for reproducibility
random.seed(12345)  # Same seed = same sequence

# Random with probability
def weighted_choice(choices):
    """choices = [(item, weight), ...]"""
    total = sum(weight for item, weight in choices)
    r = random.uniform(0, total)
    upto = 0
    for item, weight in choices:
        upto += weight
        if r <= upto:
            return item
    return choices[-1][0]

# Example: Loot table
loot_table = [
    ('common', 60),
    ('uncommon', 30),
    ('rare', 9),
    ('legendary', 1)
]
rarity = weighted_choice(loot_table)

Random Distributions

import random
import math

class RandomDistributions:
    @staticmethod
    def uniform(min_val, max_val):
        """Equal probability for all values"""
        return random.uniform(min_val, max_val)
    
    @staticmethod
    def gaussian(mean, std_dev):
        """Bell curve distribution"""
        return random.gauss(mean, std_dev)
    
    @staticmethod
    def exponential(rate=1.0):
        """Decay distribution - good for spawn timers"""
        return random.expovariate(rate)
    
    @staticmethod
    def triangular(low, high, peak):
        """Triangle-shaped distribution"""
        return random.triangular(low, high, peak)
    
    @staticmethod
    def beta_distribution(alpha, beta):
        """Flexible distribution between 0 and 1"""
        return random.betavariate(alpha, beta)
    
    @staticmethod
    def pareto(alpha):
        """Power law - few large values, many small"""
        return random.paretovariate(alpha)

# Custom distributions
def biased_random(bias=2):
    """Returns 0-1 biased towards 0 or 1"""
    r = random.random()
    if bias > 1:
        # Bias towards 1
        return r ** (1/bias)
    else:
        # Bias towards 0
        return 1 - (1 - r) ** bias

def random_in_circle(radius):
    """Uniform random point in circle"""
    # Simple but wrong: clustering in center
    # angle = random.uniform(0, 2 * math.pi)
    # r = random.uniform(0, radius)
    
    # Correct: uniform distribution
    angle = random.uniform(0, 2 * math.pi)
    r = math.sqrt(random.random()) * radius
    x = r * math.cos(angle)
    y = r * math.sin(angle)
    return (x, y)

def random_on_circle(radius):
    """Random point on circle perimeter"""
    angle = random.uniform(0, 2 * math.pi)
    x = radius * math.cos(angle)
    y = radius * math.sin(angle)
    return (x, y)

Controlled Randomness

class DiceRoller:
    """Dice with guaranteed distribution over time"""
    def __init__(self, sides=6):
        self.sides = sides
        self.history = []
        self.max_history = sides * 3
    
    def roll(self):
        """Roll with bias against recent results"""
        weights = [1.0] * self.sides
        
        # Reduce weight of recent results
        for past_roll in self.history[-self.sides:]:
            weights[past_roll - 1] *= 0.5
        
        # Weighted random choice
        total = sum(weights)
        r = random.uniform(0, total)
        cumsum = 0
        
        for i, weight in enumerate(weights):
            cumsum += weight
            if r <= cumsum:
                result = i + 1
                self.history.append(result)
                if len(self.history) > self.max_history:
                    self.history.pop(0)
                return result
        
        return self.sides

class LootBag:
    """Guaranteed drops without repetition"""
    def __init__(self, items):
        self.items = items[:]
        self.available = items[:]
    
    def get_item(self):
        if not self.available:
            self.available = self.items[:]
            random.shuffle(self.available)
        return self.available.pop()

class PitySystem:
    """Increase chance until success"""
    def __init__(self, base_chance=0.01, increase=0.01):
        self.base_chance = base_chance
        self.increase = increase
        self.current_chance = base_chance
    
    def try_drop(self):
        if random.random() < self.current_chance:
            self.current_chance = self.base_chance
            return True
        else:
            self.current_chance += self.increase
            return False

Noise Functions

import math
import random

class NoiseGenerator:
    def __init__(self, seed=None):
        if seed:
            random.seed(seed)
        
        # Create permutation table
        self.perm = list(range(256))
        random.shuffle(self.perm)
        self.perm = self.perm + self.perm  # Duplicate for wrapping
    
    def fade(self, t):
        """Fade function for smooth interpolation"""
        return t * t * t * (t * (t * 6 - 15) + 10)
    
    def lerp(self, t, a, b):
        """Linear interpolation"""
        return a + t * (b - a)
    
    def grad1d(self, hash_val, x):
        """1D gradient function"""
        return x if hash_val & 1 else -x
    
    def noise1d(self, x):
        """1D Perlin noise"""
        # Find unit interval
        xi = int(math.floor(x)) & 255
        xf = x - math.floor(x)
        
        # Fade for smoothing
        u = self.fade(xf)
        
        # Hash and interpolate
        a = self.perm[xi]
        b = self.perm[xi + 1]
        
        return self.lerp(u, self.grad1d(a, xf), 
                        self.grad1d(b, xf - 1))
    
    def octave_noise1d(self, x, octaves=4, persistence=0.5):
        """Layered noise for more detail"""
        total = 0
        amplitude = 1
        max_value = 0
        frequency = 1
        
        for _ in range(octaves):
            total += self.noise1d(x * frequency) * amplitude
            max_value += amplitude
            amplitude *= persistence
            frequency *= 2
        
        return total / max_value

class ValueNoise2D:
    """Simple 2D value noise"""
    def __init__(self, width, height, scale=20):
        self.width = width
        self.height = height
        self.scale = scale
        self.values = {}
    
    def get_noise(self, x, y):
        """Get noise value at integer coordinates"""
        key = (x, y)
        if key not in self.values:
            random.seed(hash(key))
            self.values[key] = random.random()
        return self.values[key]
    
    def smooth_noise(self, x, y):
        """Interpolated noise value"""
        int_x = int(x)
        int_y = int(y)
        frac_x = x - int_x
        frac_y = y - int_y
        
        # Get corner values
        v1 = self.get_noise(int_x, int_y)
        v2 = self.get_noise(int_x + 1, int_y)
        v3 = self.get_noise(int_x, int_y + 1)
        v4 = self.get_noise(int_x + 1, int_y + 1)
        
        # Interpolate
        i1 = self.cosine_interp(v1, v2, frac_x)
        i2 = self.cosine_interp(v3, v4, frac_x)
        
        return self.cosine_interp(i1, i2, frac_y)
    
    def cosine_interp(self, a, b, x):
        """Smooth interpolation"""
        ft = x * math.pi
        f = (1 - math.cos(ft)) * 0.5
        return a * (1 - f) + b * f
    
    def generate_map(self):
        """Generate full noise map"""
        noise_map = []
        for y in range(self.height):
            row = []
            for x in range(self.width):
                noise_val = self.smooth_noise(x / self.scale, 
                                             y / self.scale)
                row.append(noise_val)
            noise_map.append(row)
        return noise_map

Procedural Generation

class ProceduralDungeon:
    def __init__(self, width, height, seed=None):
        self.width = width
        self.height = height
        if seed:
            random.seed(seed)
        
        # Initialize with walls
        self.tiles = [['#' for _ in range(width)] for _ in range(height)]
        self.rooms = []
    
    def generate(self, num_rooms=10, min_size=4, max_size=10):
        """Generate dungeon with rooms and corridors"""
        
        for _ in range(num_rooms):
            # Random room size and position
            w = random.randint(min_size, max_size)
            h = random.randint(min_size, max_size)
            x = random.randint(1, self.width - w - 1)
            y = random.randint(1, self.height - h - 1)
            
            room = {'x': x, 'y': y, 'w': w, 'h': h}
            
            # Check for overlaps
            if not any(self.rooms_overlap(room, other) for other in self.rooms):
                self.carve_room(room)
                
                # Connect to previous room
                if self.rooms:
                    prev = self.rooms[-1]
                    self.carve_corridor(
                        room['x'] + room['w'] // 2,
                        room['y'] + room['h'] // 2,
                        prev['x'] + prev['w'] // 2,
                        prev['y'] + prev['h'] // 2
                    )
                
                self.rooms.append(room)
    
    def rooms_overlap(self, r1, r2):
        """Check if two rooms overlap"""
        return (r1['x'] < r2['x'] + r2['w'] and
                r1['x'] + r1['w'] > r2['x'] and
                r1['y'] < r2['y'] + r2['h'] and
                r1['y'] + r1['h'] > r2['y'])
    
    def carve_room(self, room):
        """Carve out a room"""
        for y in range(room['y'], room['y'] + room['h']):
            for x in range(room['x'], room['x'] + room['w']):
                self.tiles[y][x] = '.'
    
    def carve_corridor(self, x1, y1, x2, y2):
        """Carve corridor between two points"""
        # Horizontal first, then vertical
        if random.random() < 0.5:
            self.carve_h_tunnel(x1, x2, y1)
            self.carve_v_tunnel(y1, y2, x2)
        else:
            self.carve_v_tunnel(y1, y2, x1)
            self.carve_h_tunnel(x1, x2, y2)
    
    def carve_h_tunnel(self, x1, x2, y):
        """Carve horizontal tunnel"""
        for x in range(min(x1, x2), max(x1, x2) + 1):
            self.tiles[y][x] = '.'
    
    def carve_v_tunnel(self, y1, y2, x):
        """Carve vertical tunnel"""
        for y in range(min(y1, y2), max(y1, y2) + 1):
            self.tiles[y][x] = '.'

class WaveFunction Collapse:
    """Simple WFC for tile-based generation"""
    def __init__(self, tile_rules):
        self.tile_rules = tile_rules  # What can be adjacent
        
    def generate(self, width, height):
        # Simplified WFC implementation
        grid = [[None for _ in range(width)] for _ in range(height)]
        
        # Start with random tile
        start_x = width // 2
        start_y = height // 2
        grid[start_y][start_x] = random.choice(list(self.tile_rules.keys()))
        
        # Propagate constraints
        # (Full WFC is complex - this is simplified)
        for y in range(height):
            for x in range(width):
                if grid[y][x] is None:
                    possible = self.get_valid_tiles(grid, x, y)
                    if possible:
                        grid[y][x] = random.choice(possible)
        
        return grid
    
    def get_valid_tiles(self, grid, x, y):
        """Get tiles that can be placed at position"""
        possible = set(self.tile_rules.keys())
        
        # Check neighbors
        neighbors = [
            (x-1, y), (x+1, y),
            (x, y-1), (x, y+1)
        ]
        
        for nx, ny in neighbors:
            if 0 <= nx < len(grid[0]) and 0 <= ny < len(grid):
                neighbor_tile = grid[ny][nx]
                if neighbor_tile:
                    # Filter based on rules
                    valid = self.tile_rules[neighbor_tile]
                    possible = possible.intersection(valid)
        
        return list(possible)

Random in Game Systems

class CriticalHitSystem:
    """Critical hits with pseudo-random distribution"""
    def __init__(self, base_chance=0.25):
        self.base_chance = base_chance
        self.c = self._calculate_c()
        self.counter = 0
    
    def _calculate_c(self):
        """Calculate PRD constant"""
        # Approximation for PRD
        p = self.base_chance
        if p <= 0:
            return 0
        return p * 0.75  # Simplified
    
    def check_crit(self):
        """Check if attack is critical"""
        self.counter += 1
        chance = min(1.0, self.c * self.counter)
        
        if random.random() < chance:
            self.counter = 0
            return True
        return False

class RandomEncounter:
    """Random encounters with increasing probability"""
    def __init__(self, base_steps=20):
        self.base_steps = base_steps
        self.steps_since_encounter = 0
    
    def step(self):
        """Take a step, check for encounter"""
        self.steps_since_encounter += 1
        
        # Probability increases with steps
        chance = self.steps_since_encounter / self.base_steps
        
        if random.random() < chance:
            self.steps_since_encounter = 0
            return True
        return False

class RandomSpawner:
    """Spawn enemies with controlled randomness"""
    def __init__(self, spawn_points):
        self.spawn_points = spawn_points
        self.last_spawn = None
        self.spawn_cooldown = {}
    
    def get_spawn_point(self):
        """Get random spawn point, avoiding recent ones"""
        available = [sp for sp in self.spawn_points 
                    if sp not in self.spawn_cooldown]
        
        if not available:
            # All on cooldown, use oldest
            oldest = min(self.spawn_cooldown, 
                        key=self.spawn_cooldown.get)
            del self.spawn_cooldown[oldest]
            available = [oldest]
        
        spawn = random.choice(available)
        
        # Add to cooldown
        self.spawn_cooldown[spawn] = 3  # 3 spawns before reuse
        
        # Decrease all cooldowns
        for sp in list(self.spawn_cooldown.keys()):
            self.spawn_cooldown[sp] -= 1
            if self.spawn_cooldown[sp] <= 0:
                del self.spawn_cooldown[sp]
        
        return spawn

Complete Random Generation Demo

import pygame
import random
import math

class RandomDemo:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Random Generation Demo")
        self.clock = pygame.time.Clock()
        
        # Initialize systems
        self.seed = random.randint(0, 999999)
        random.seed(self.seed)
        
        # Generate terrain with noise
        self.terrain = self.generate_terrain()
        
        # Generate dungeon
        self.dungeon = ProceduralDungeon(40, 30, self.seed)
        self.dungeon.generate()
        
        # Particle system with random properties
        self.particles = []
        
        # Loot system
        self.loot_bag = LootBag(['sword', 'shield', 'potion', 
                                'gold', 'gem', 'scroll'])
        
        # Random walker
        self.walker_x = 400
        self.walker_y = 300
        self.walker_trail = []
        
        # Spawn timer
        self.spawn_timer = 0
        self.spawn_interval = RandomDistributions.exponential(0.5)
        
        # Demo state
        self.show_terrain = True
        self.show_dungeon = False
    
    def generate_terrain(self):
        """Generate terrain using layered noise"""
        noise = ValueNoise2D(80, 60, scale=10)
        terrain_map = noise.generate_map()
        
        # Convert to tiles
        terrain = []
        for row in terrain_map:
            terrain_row = []
            for value in row:
                if value < 0.3:
                    terrain_row.append('water')
                elif value < 0.5:
                    terrain_row.append('sand')
                elif value < 0.7:
                    terrain_row.append('grass')
                else:
                    terrain_row.append('mountain')
            terrain.append(terrain_row)
        
        return terrain
    
    def spawn_particle(self):
        """Spawn particle with random properties"""
        # Random position on edge
        edge = random.choice(['top', 'bottom', 'left', 'right'])
        if edge == 'top':
            x = random.randint(0, 800)
            y = 0
        elif edge == 'bottom':
            x = random.randint(0, 800)
            y = 600
        elif edge == 'left':
            x = 0
            y = random.randint(0, 600)
        else:
            x = 800
            y = random.randint(0, 600)
        
        # Random velocity towards center with variation
        center_x = 400 + random.gauss(0, 100)
        center_y = 300 + random.gauss(0, 100)
        
        dx = center_x - x
        dy = center_y - y
        dist = math.sqrt(dx*dx + dy*dy)
        
        if dist > 0:
            speed = random.uniform(1, 4)
            vx = (dx / dist) * speed
            vy = (dy / dist) * speed
        else:
            vx = vy = 0
        
        # Random color
        hue = random.randint(0, 360)
        color = self.hsv_to_rgb(hue, 0.8, 1.0)
        
        # Random lifetime
        lifetime = random.triangular(0.5, 3, 1.5)
        
        self.particles.append({
            'x': x, 'y': y,
            'vx': vx, 'vy': vy,
            'color': color,
            'lifetime': lifetime,
            'max_lifetime': lifetime,
            'size': random.randint(2, 8)
        })
    
    def hsv_to_rgb(self, h, s, v):
        """Convert HSV to RGB"""
        h = h / 360.0
        i = int(h * 6)
        f = h * 6 - i
        p = v * (1 - s)
        q = v * (1 - f * s)
        t = v * (1 - (1 - f) * s)
        
        i = i % 6
        
        if i == 0:
            r, g, b = v, t, p
        elif i == 1:
            r, g, b = q, v, p
        elif i == 2:
            r, g, b = p, v, t
        elif i == 3:
            r, g, b = p, q, v
        elif i == 4:
            r, g, b = t, p, v
        else:
            r, g, b = v, p, q
        
        return (int(r * 255), int(g * 255), int(b * 255))
    
    def update_walker(self):
        """Update random walker"""
        # Biased random walk
        dx = random.gauss(0, 2)
        dy = random.gauss(0, 2)
        
        self.walker_x += dx
        self.walker_y += dy
        
        # Keep on screen
        self.walker_x = max(10, min(790, self.walker_x))
        self.walker_y = max(10, min(590, self.walker_y))
        
        # Add to trail
        self.walker_trail.append((self.walker_x, self.walker_y))
        if len(self.walker_trail) > 100:
            self.walker_trail.pop(0)
    
    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_SPACE:
                    # Get random loot
                    loot = self.loot_bag.get_item()
                    print(f"Got loot: {loot}")
                elif event.key == pygame.K_r:
                    # New seed
                    self.seed = random.randint(0, 999999)
                    random.seed(self.seed)
                    self.terrain = self.generate_terrain()
                    self.dungeon = ProceduralDungeon(40, 30, self.seed)
                    self.dungeon.generate()
                elif event.key == pygame.K_t:
                    self.show_terrain = not self.show_terrain
                elif event.key == pygame.K_d:
                    self.show_dungeon = not self.show_dungeon
        
        return True
    
    def update(self, dt):
        # Update particles
        for particle in self.particles[:]:
            particle['x'] += particle['vx']
            particle['y'] += particle['vy']
            particle['lifetime'] -= dt
            
            if particle['lifetime'] <= 0:
                self.particles.remove(particle)
        
        # Spawn new particles
        self.spawn_timer -= dt
        if self.spawn_timer <= 0:
            self.spawn_particle()
            self.spawn_timer = random.expovariate(2)  # Average 0.5 seconds
        
        # Update walker
        self.update_walker()
    
    def draw(self):
        self.screen.fill((20, 20, 20))
        
        # Draw terrain
        if self.show_terrain:
            tile_size = 10
            colors = {
                'water': (50, 100, 200),
                'sand': (238, 203, 173),
                'grass': (50, 200, 50),
                'mountain': (139, 90, 43)
            }
            
            for y, row in enumerate(self.terrain):
                for x, tile in enumerate(row):
                    color = colors[tile]
                    pygame.draw.rect(self.screen, color,
                                   (x * tile_size, y * tile_size,
                                    tile_size, tile_size))
        
        # Draw dungeon overlay
        if self.show_dungeon:
            tile_size = 15
            for y, row in enumerate(self.dungeon.tiles):
                for x, tile in enumerate(row):
                    if tile == '#':
                        color = (100, 100, 100)
                    else:
                        color = (200, 180, 140)
                    
                    pygame.draw.rect(self.screen, color,
                                   (x * tile_size + 200, 
                                    y * tile_size + 150,
                                    tile_size - 1, tile_size - 1))
        
        # Draw random walker trail
        if len(self.walker_trail) > 1:
            for i in range(1, len(self.walker_trail)):
                alpha = i / len(self.walker_trail)
                color = (int(255 * alpha), int(100 * alpha), int(100 * alpha))
                pygame.draw.line(self.screen, color,
                               self.walker_trail[i-1],
                               self.walker_trail[i], 2)
        
        # Draw walker
        pygame.draw.circle(self.screen, (255, 100, 100),
                         (int(self.walker_x), int(self.walker_y)), 5)
        
        # Draw particles
        for particle in self.particles:
            alpha = particle['lifetime'] / particle['max_lifetime']
            size = int(particle['size'] * alpha)
            if size > 0:
                pygame.draw.circle(self.screen, particle['color'],
                                 (int(particle['x']), int(particle['y'])),
                                 size)
        
        # Draw info
        font = pygame.font.Font(None, 24)
        info = [
            f"Seed: {self.seed}",
            f"Particles: {len(self.particles)}",
            "R: New Seed | T: Terrain | D: Dungeon | Space: Loot"
        ]
        
        for i, text in enumerate(info):
            rendered = font.render(text, True, (255, 255, 255))
            self.screen.blit(rendered, (10, 10 + i * 25))
    
    def run(self):
        running = True
        dt = 0
        
        while running:
            running = self.handle_events()
            self.update(dt)
            self.draw()
            pygame.display.flip()
            dt = self.clock.tick(60) / 1000.0
        
        pygame.quit()

if __name__ == "__main__":
    demo = RandomDemo()
    demo.run()

Best Practices

โšก Random Generation Tips

Practice Exercises

๐ŸŽฏ Random Generation Challenges!

  1. Infinite Runner: Procedurally generate endless level
  2. Loot System: Tiered drops with rarity and affixes
  3. Name Generator: Random names from syllable parts
  4. Galaxy Generator: Star systems with planets
  5. Music Generator: Procedural melodies with rules
  6. Roguelike Dungeon: Complete dungeon with rooms, items, enemies

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise: Loot Drops with a Loaded Deck

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: Loot Drop Generator โ€” Weighted Choices on a Seeded RNG

Objective: Build a small Pygame loot-drop demo that exercises three core randomness ideas in one program: (1) seeded pseudo-random via random.seed(42) at startup so the same SPACE-press sequence reproduces the same drop sequence on every run โ€” reproducibility for debugging and replays, the lesson's Best Practice #1 'Always Use Seeds'; (2) weighted distribution via random.choices(items, weights=[70, 20, 8, 2]) for a Diablo-style rarity table (Common 70% / Uncommon 20% / Rare 8% / Epic 2%) โ€” lesson Best Practice 'Control Distribution: Don't use uniform for everything'; (3) running tally displayed on screen so the empirical distribution converges visibly toward the declared weights as the player presses SPACE many times โ€” makes the abstract weight values concrete. Press R to re-seed with a different value (e.g. 7) to verify the seed actually matters โ€” different seed โ†’ different sequence; same seed โ†’ same sequence every run.

Instructions:

  1. Open an 800ร—600 window and start a clean game loop. Import random and call random.seed(42) ONCE at startup before any other random calls.
  2. Define rarity tiers as parallel lists: RARITIES = ["Common", "Uncommon", "Rare", "Epic"] and WEIGHTS = [70, 20, 8, 2] (the weights don't have to sum to 100; they're relative).
  3. Maintain a running tally dict tally = {r: 0 for r in RARITIES} and a list of recent drops (the last ~8 to display visually, oldest fading out).
  4. On KEYDOWN for K_SPACE: roll one drop with rarity = random.choices(RARITIES, weights=WEIGHTS, k=1)[0]; increment tally[rarity] and append to the recent-drops list (drop the head if >8 items).
  5. On KEYDOWN for K_R: re-seed with a different value (e.g. random.seed(7)), reset the tally and recent-drops list. The next press of SPACE should produce a DIFFERENT sequence than the seed-42 sequence โ€” visible proof that the seed drives the output.
  6. Render the recent drops as a horizontal row of colored chips (Common gray, Uncommon green, Rare blue, Epic purple) along with the item name; render the tally as a bar chart on the right side of the screen so the empirical distribution converges visibly toward 70/20/8/2 over many presses.
  7. Render a HUD with the current seed value and total drop count so the seeded-reproducibility claim is verifiable: close the program, restart it with seed 42, press SPACE the same number of times โ€” you should see the IDENTICAL sequence of drops.
๐Ÿ’ก Hint

The two big traps: (1) random.seed() seeds the global random module, but if you instead do rng = random.Random(); rng.seed(42) you get an isolated generator โ€” either is fine but DON'T mix them, and DON'T re-seed mid-loop unless you intentionally want the sequence to restart. The lesson's framing is to seed ONCE at startup. (2) random.choices (with the 's', plural) returns a LIST of size k, even when k=1. Don't confuse with random.choice (singular, no weights, returns the item directly). The canonical idiom for one weighted pick is random.choices(items, weights=W, k=1)[0] โ€” the [0] unwraps the single-item list. The weights are RELATIVE, not absolute probabilities, so [70, 20, 8, 2] and [7, 2, 0.8, 0.2] produce identical sampling.

โœ… Example Solution
import pygame, random

pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Loot Drops")
font = pygame.font.SysFont(None, 28)
clock = pygame.time.Clock()

RARITIES = ["Common", "Uncommon", "Rare", "Epic"]
WEIGHTS = [70, 20, 8, 2]
COLORS = {
    "Common":   (180, 180, 180),
    "Uncommon": (60, 200, 60),
    "Rare":     (60, 120, 230),
    "Epic":     (180, 80, 220),
}

seed = 42
random.seed(seed)
tally = {r: 0 for r in RARITIES}
recent = []

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_SPACE:
                rarity = random.choices(RARITIES, weights=WEIGHTS, k=1)[0]
                tally[rarity] += 1
                recent.append(rarity)
                if len(recent) > 8:
                    recent.pop(0)
            elif event.key == pygame.K_r:
                seed = 7 if seed == 42 else 42
                random.seed(seed)
                tally = {r: 0 for r in RARITIES}
                recent = []

    screen.fill((25, 25, 35))
    total = sum(tally.values())
    screen.blit(font.render(f"Seed: {seed}   Drops: {total}   (SPACE drop, R reseed)",
                            True, (240, 240, 240)), (10, 10))
    for i, r in enumerate(recent):
        pygame.draw.rect(screen, COLORS[r], (40 + i * 70, 100, 60, 60))
    for i, r in enumerate(RARITIES):
        bar_w = tally[r] * 4
        pygame.draw.rect(screen, COLORS[r], (450, 220 + i * 50, bar_w, 36))
        screen.blit(font.render(f"{r}: {tally[r]}", True, COLORS[r]), (460, 226 + i * 50))
    pygame.display.flip()
    clock.tick(60)

pygame.quit()

๐ŸŽฏ Quick Quiz

Question 1: What does calling `random.seed(42)` at the start of your game accomplish, and why does the lesson recommend always seeding?

Question 2: You want to pick a loot rarity from `["Common", "Uncommon", "Rare", "Epic"]` with rarity weights `[70, 20, 8, 2]`. Which call does this correctly?

Question 3: The lesson's Best Practices include 'Player Psychology: Fair โ‰  Fun (use controlled random).' What does this mean in practice for game design?

What's Next?

Congratulations! You've completed the Game Mathematics section! You now have the mathematical tools to create smooth movement, realistic physics, and infinite variety. Next, we'll move into the Intermediate Module where you'll learn about sprite animation, scrolling backgrounds, and more!