Skip to main content
The gameplay system implements the core combat loop: player movement, aiming, firing, creature AI, collision detection, and progression (XP/levels/perks).

Architecture

Gameplay logic is split across multiple modules:
  • src/crimson/gameplay.py — Player update, movement, firing, reload
  • src/crimson/creatures/ — Creature AI, animations, spawning
  • src/crimson/projectiles/ — Projectile pools, hit detection
  • src/crimson/bonuses/ — Bonus pickups and effects
  • src/crimson/perks/ — Perk selection and runtime effects
  • src/crimson/weapon_runtime.py — Weapon assignment and availability

Player Update

The main player update loop runs each tick:
src/crimson/gameplay.py
def player_update(
    player: PlayerState,
    state: GameplayState,
    dt: float,
    input: PlayerInput,
    *,
    world_size: float,
    # ... other params
) -> None:
    """Update one player for one frame."""
    
    # 1. Movement
    apply_player_movement(
        player, 
        input, 
        dt,
        world_size=world_size,
    )
    
    # 2. Aiming
    apply_player_aim(
        player,
        input,
        aim_scheme=state.aim_scheme,
    )
    
    # 3. Reload
    update_reload_timer(
        player,
        input,
        dt,
        stationary_reloader_active=perk_active(player, PerkId.STATIONARY_RELOADER),
    )
    
    # 4. Firing
    if input.fire and can_fire(player):
        player_fire_weapon(
            player,
            state,
            projectiles=state.projectiles,
        )
    
    # 5. Bonus timers
    update_bonus_timers(player, dt)
    
    # 6. Perk tick hooks
    apply_player_perk_ticks(player, state, dt)

Movement System

def apply_player_movement(
    player: PlayerState,
    input: PlayerInput,
    dt: float,
    *,
    world_size: float,
) -> None:
    """Apply player movement with boundary clamping."""
    
    # Base speed
    base_speed = 100.0
    
    # Speed modifiers
    speed_mult = 1.0
    if player.speed_bonus_timer > 0:
        speed_mult = 1.5  # Speed bonus: +50%
    
    # Movement direction
    dx = 0.0
    dy = 0.0
    if input.up:
        dy -= 1.0
    if input.down:
        dy += 1.0
    if input.left:
        dx -= 1.0
    if input.right:
        dx += 1.0
    
    # Normalize diagonal movement
    if dx != 0.0 and dy != 0.0:
        length = math.sqrt(dx * dx + dy * dy)
        dx /= length
        dy /= length
    
    # Apply movement
    speed = base_speed * speed_mult * dt
    player.pos.x += dx * speed
    player.pos.y += dy * speed
    
    # Clamp to world bounds
    player.pos.x = max(0.0, min(world_size, player.pos.x))
    player.pos.y = max(0.0, min(world_size, player.pos.y))

Movement Perks

  • Speed Bonus — Temporary +50% movement speed
  • Angry Reloader — +25% speed while reloading
  • Slow Motion — Global time scale affects enemies but not player input

Weapon System

Weapon Firing

src/crimson/gameplay.py
def player_fire_weapon(
    player: PlayerState,
    state: GameplayState,
    projectiles: ProjectilePool,
) -> None:
    """Fire the player's weapon."""
    
    weapon = WEAPON_TABLE[player.weapon.weapon_id]
    
    # Check ammo
    if player.weapon.ammo <= 0:
        start_reload(player)
        return
    
    # Check fire rate
    if player.weapon.cooldown > 0:
        return
    
    # Consume ammo
    player.weapon.ammo -= 1
    player.shot_seq += 1
    
    # Reset cooldown
    player.weapon.cooldown = weapon.fire_rate
    
    # Spawn projectiles
    for i in range(weapon.projectiles_per_shot):
        angle = player.aim_angle + calc_spread(
            weapon, 
            i, 
            weapon.projectiles_per_shot
        )
        
        projectiles.spawn(
            type_id=weapon.projectile_type,
            pos=player.pos,
            angle=angle,
            owner=OwnerRef.player(player.index),
        )

Weapon Table

Weapons are defined in src/crimson/weapons.py:
class WeaponEntry(msgspec.Struct):
    weapon_id: WeaponId
    name: str
    ammo_class: int              # Projectile type category
    clip_size: int               # Magazine size
    ammo_count: int              # Total ammo pool
    fire_rate: float             # Cooldown between shots
    projectiles_per_shot: int    # Burst count
    projectile_type: int         # ProjectileTemplateId
    fire_sound: str              # SFX key
    reload_sound: str            # SFX key
Example:
WEAPON_TABLE[WeaponId.SHOTGUN] = WeaponEntry(
    weapon_id=WeaponId.SHOTGUN,
    name="Shotgun",
    clip_size=8,
    ammo_count=80,
    fire_rate=0.6,
    projectiles_per_shot=8,
    projectile_type=ProjectileTemplateId.SHOTGUN_PELLET,
    fire_sound="sfx_shotgun_fire",
    reload_sound="sfx_shotgun_reload",
)

Creature System

Creature AI

Creatures use simple heuristic AI:
src/crimson/creatures/ai.py
def creature_ai_update(
    creature: Creature,
    target_pos: Vec2,
    dt: float,
) -> None:
    """Update creature AI and movement."""
    
    # Calculate direction to target
    dx = target_pos.x - creature.pos.x
    dy = target_pos.y - creature.pos.y
    distance = math.sqrt(dx * dx + dy * dy)
    
    if distance < 1.0:
        return  # Already at target
    
    # Turn toward target
    target_angle = math.atan2(dy, dx)
    creature.heading = angle_approach(
        creature.heading,
        target_angle,
        turn_rate=creature.turn_rate * dt,
    )
    
    # Move forward
    speed = creature.base_speed * dt
    creature.pos.x += math.cos(creature.heading) * speed
    creature.pos.y += math.sin(creature.heading) * speed

Creature Types

  • Zombie — Slow, weak, basic melee
  • Lizard — Fast, moderate health
  • Alien — Flying, ranged attacks
  • Spider — Very fast, low health
  • Trooper — Ranged, high health

Spawn System

src/crimson/creatures/spawn.py
def spawn_creature(
    pool: CreaturePool,
    type_id: CreatureTypeId,
    pos: Vec2,
) -> Creature | None:
    """Spawn a creature at position."""
    
    # Find free slot
    slot = pool.find_free_slot()
    if slot is None:
        return None
    
    # Get template
    template = CREATURE_TEMPLATES[type_id]
    
    # Create creature
    creature = Creature(
        type_id=type_id,
        pos=pos,
        heading=random_angle(),
        health=template.max_health,
        base_speed=template.speed,
        turn_rate=template.turn_rate,
        active=True,
    )
    
    pool.creatures[slot] = creature
    return creature

Combat System

Hit Detection

src/crimson/projectiles/runtime.py
def projectile_hit_test(
    projectile: Projectile,
    creatures: CreaturePool,
) -> Creature | None:
    """Test if projectile hits any creature."""
    
    for creature in creatures.active_creatures():
        # Radius-based collision
        dx = creature.pos.x - projectile.pos.x
        dy = creature.pos.y - projectile.pos.y
        dist_sq = dx * dx + dy * dy
        
        hit_radius = creature.collision_radius + projectile.collision_radius
        
        if dist_sq < hit_radius * hit_radius:
            return creature
    
    return None

Damage Application

src/crimson/creatures/damage.py
def creature_apply_damage(
    creature: Creature,
    damage: float,
    damage_type: DamageType,
) -> bool:
    """Apply damage to creature. Returns True if killed."""
    
    # Apply damage modifiers
    modified_damage = damage
    
    # Headshot multiplier (random chance)
    if random.random() < HEADSHOT_CHANCE:
        modified_damage *= 2.0
    
    # Type effectiveness
    if damage_type == DamageType.FIRE:
        modified_damage *= creature.fire_resistance
    
    # Apply damage
    creature.health -= modified_damage
    
    # Check death
    if creature.health <= 0:
        creature.active = False
        return True
    
    return False

Progression System

Experience and Levels

src/crimson/gameplay.py
def award_experience(
    state: GameplayState,
    amount: int,
) -> bool:
    """Award XP and check for level up. Returns True if leveled."""
    
    state.experience += amount
    
    # Check level up
    next_level_xp = calc_level_threshold(state.level + 1)
    
    if state.experience >= next_level_xp:
        state.level += 1
        state.perk_selection.pending_count += 1
        return True
    
    return False

def calc_level_threshold(level: int) -> int:
    """Calculate XP needed for level."""
    # Formula from original: 50 * level * (level + 1) / 2
    return 50 * level * (level + 1) // 2
XP Sources:
  • Creature kills (base XP per type)
  • Double Experience bonus (2x multiplier)
  • Lean Mean XP Machine perk (extra XP per kill)

Perk Selection

See Perks Module for details.

Bonus System

Bonuses drop from creatures and provide temporary effects:
src/crimson/bonuses/apply.py
def apply_bonus(
    bonus_id: BonusId,
    player: PlayerState,
    state: GameplayState,
) -> None:
    """Apply bonus effect to player."""
    
    if bonus_id == BonusId.MEDIKIT:
        player.health = min(100, player.health + 50)
    
    elif bonus_id == BonusId.SPEED:
        player.speed_bonus_timer = 8.0
    
    elif bonus_id == BonusId.FIRE_BULLETS:
        player.fire_bullets_timer = 4.0
    
    elif bonus_id == BonusId.FREEZE:
        state.effects.freeze_timer = 5.0
    
    # ... more bonuses

Next Steps

Rendering System

Learn about graphics rendering

Audio System

Explore audio routing

Perks Module

Deep dive into perks

Replay Module

Understand replay system

Build docs developers (and LLMs) love