How Do You Create Lighting That Actually Helps Players Win?

Matthew HarwoodMatthew Harwood
31 min read

Table of contents

Your Learning Journey Map

Time Investment: 2-3 hours reading + 4-6 hours implementation

Prerequisites: Basic Rust, introductory Bevy experience

Learning Outcome: Build a professional lighting system that enhances gameplay while maintaining 60fps across 8 arenas

What You'll Master

  • The "Lighting as Communication" Mental Model: How to think about light as a gameplay information system

  • The 3-Layer Architecture Pattern: A scalable approach that separates functional from atmospheric lighting

  • The Performance-First Design Process: Building systems that maintain quality while gracefully degrading

  • The Emergency Visibility Framework: Ensuring critical information is never lost in visual noise


The Mental Model: Lighting as a Communication System

Core Analogy: Think of your lighting system like a traffic control system for a busy intersection. Just as traffic lights use color, intensity, and timing to safely guide vehicles through complex scenarios, your game lighting uses the same principles to guide players through complex gameplay decisions.

The Traffic Light Mental Model in Action

๐Ÿšฆ Traffic Light System     โ†’    ๐ŸŽฎ Game Lighting System
Red = Stop/Danger           โ†’    Red = Boss attacks, low health
Yellow = Caution/Prepare    โ†’    Orange = Telegraphs, warnings
Green = Go/Safe             โ†’    Blue/Green = Friendly abilities, safe zones
Flashing = Urgent           โ†’    Pulsing = Time-sensitive actions
Brightness = Priority       โ†’    Intensity = Information importance

Why This Analogy Works: Both systems must communicate critical information instantly, work under stress, and never fail when lives (or characters) are at stake.

Building Your Core Schema: The 3-Layer Architecture

Before we dive into implementation, establish this mental framework:

Layer 1: Information (What players must know)

  • Character health and selection status

  • Boss attack telegraphs and danger zones

  • Critical timing windows

Layer 2: Navigation (Where players should look)

  • Arena boundaries and focal points

  • Movement paths and safe zones

  • Resource and objective locations

Layer 3: Atmosphere (How players should feel)

  • Arena personality and theme

  • Tension building and release

  • Emotional context and mood


The Arenic Challenge: Why Normal Lighting Fails

Active Recall Check #1

Before reading further, pause and consider: What makes Arenic's lighting requirements different from a typical game?

Think about:

  • How many characters can be on screen simultaneously?

  • What happens when recordings overlap?

  • How fast do players need to make decisions?

๐Ÿง  Compare Your Answer

Arenic's Unique Constraints:

  • 40+ simultaneous characters in a 320ร—180 grid (most games handle 5-10)

  • Overlapping ghost recordings with multiply-blend effects creating visual noise

  • Frame-perfect timing requirements (16ms decision windows)

  • 8 simultaneous arenas competing for GPU resources

  • 2-minute deterministic cycles requiring pattern recognition support

Why Standard Approaches Fail:

  • Traditional RPG lighting assumes 4-6 party members maximum

  • Most lighting systems prioritize atmosphere over functional communication

  • Standard performance optimizations break down with Arenic's scale

The Visual Chaos Problem

Imagine trying to read 40 overlapping transparent sheets of paper, each with different text, while someone shines colored lights through them randomly. That's what players face without proper lighting design.

The Solution: Systematic visual hierarchy that automatically prioritizes information by gameplay importance.


Building Schema: The Emergency Visibility Framework

Core Principle: Critical Information Always Wins

Your lighting system needs an "emergency broadcast system" that can override everything else when lives are at stake.

Priority Hierarchy (Memorize This):

  1. Immediate Death Threats (Boss telegraphs) - Always maximum visibility

  2. Character Health Emergencies - Cuts through all visual noise

  3. Player Selection State - Must remain visible for control clarity

  4. Strategic Information - Visible when screen isn't chaotic

  5. Atmospheric Elements - Suppressed during intense moments

Mental Model: The Spotlight Metaphor

Think of your lighting system as a theater director with a spotlight:

  • Spotlight follows the most important actor (critical information)

  • Stage lights provide context (general illumination)

  • Background lighting sets mood (atmosphere)

  • Emergency lights override everything (health crises, boss attacks)


Implementation Phase 1: Building the Foundation

The Lighting Manager: Your System's Brain

This is your traffic control center. Every light decision flows through this system.

use bevy::prelude::*;
use std::collections::VecDeque;
use crate::recording::ArenaIndex; // Import from the existing recording module

/// The central nervous system for all lighting decisions
#[derive(Resource)]
pub struct LightingManager {
    /// Current performance level - automatically adjusts based on frame rate
    pub performance_level: PerformanceLevel,
    /// Which arena gets full lighting attention - uses existing ArenaIndex type
    pub focused_arena: Option<ArenaIndex>,
    /// Emergency override - forces maximum visibility for critical situations
    pub emergency_mode: bool,
    /// Performance tracking for auto-optimization
    pub frame_time_history: VecDeque<f32>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PerformanceLevel {
    Ultra,   // All effects, all arenas lit
    High,    // 5-6 arenas, reduced particle effects
    Medium,  // 3-4 arenas, essential lighting only
    Low,     // Active arena + emergency visibility
    Emergency, // Absolute minimum for playability
}

impl Default for LightingManager {
    fn default() -> Self {
        Self {
            performance_level: PerformanceLevel::Ultra,
            focused_arena: None,
            emergency_mode: false,
            frame_time_history: VecDeque::with_capacity(60), // 1 second of history
        }
    }
}

Active Recall Challenge #1: Test Your Understanding

Without looking back, explain what each PerformanceLevel means and when the system would use each one.

๐ŸŽฏ Solution + Explanation

  • Ultra: All 8 arenas fully lit, every character has lighting, all effects enabled

  • High: 5-6 arenas get lighting, particle effects reduced, distant characters dimmed

  • Medium: 3-4 arenas lit, only essential character lighting, boss telegraphs prioritized

  • Low: Only the active arena gets full lighting, other arenas get ambient only

  • Emergency: Absolute minimum - selected characters + health warnings + boss telegraphs only

Auto-switching Logic: System monitors frame time. If average drops below 16.6ms (60fps), it steps down one level. If frame time improves and stays stable, it steps back up.

The Arena Lighting Component: Personality + Function

Each arena needs both functional lighting (gameplay) and personality lighting (theme).

use crate::arena::{Bastion, Casino, Crucible, Gala, Labyrinth, Mountain, Pawnshop, Sanctum}; // Import existing arena types

#[derive(Component)]
pub struct ArenaLighting {
    pub theme: ArenaTheme,
    pub functional_priority: bool, // True = gameplay over atmosphere
    pub ambient_lights: Vec<Entity>,
    pub emergency_override: bool,
}

/// Arena theme enum that mirrors the existing arena structure
/// Note: This maps to the actual arena components in the codebase
#[derive(Debug, Clone, Copy)]
pub enum ArenaTheme {
    Bastion,    // Index 4 - Military precision - cool blues, sharp edges
    Casino,     // Index 7 - Luxury excess - warm golds, glittering
    Crucible,   // Index 2 - Industrial danger - orange-red heat, harsh shadows
    Gala,       // Index 8 - Elegant sophistication - soft whites, refined
    Labyrinth,  // Index 5 - Mystery - purple-teal, shifting shadows
    Mountain,   // Index 6 - Natural power - earth tones, storm effects
    Pawnshop,   // Index 1 - Cluttered chaos - mixed colors, item highlights
    Sanctum,    // Index 3 - Sacred solemnity - divine golds, pillar lighting
    GuildHouse, // Index 0 - Additional arena type from the codebase
}

impl ArenaTheme {
    /// Returns the base color that defines this arena's personality
    pub fn ambient_color(&self) -> Color {
        match self {
            ArenaTheme::Bastion =>    Color::srgb(0.4, 0.6, 0.8),   // Cool military blue
            ArenaTheme::Casino =>     Color::srgb(1.0, 0.8, 0.4),   // Rich gold
            ArenaTheme::Crucible =>   Color::srgb(0.8, 0.4, 0.2),   // Industrial orange-red
            ArenaTheme::Gala =>       Color::srgb(0.9, 0.9, 0.95),  // Elegant white
            ArenaTheme::Labyrinth =>  Color::srgb(0.6, 0.4, 0.8),   // Mysterious purple
            ArenaTheme::Mountain =>   Color::srgb(0.6, 0.5, 0.4),   // Earth brown
            ArenaTheme::Pawnshop =>   Color::srgb(0.7, 0.7, 0.6),   // Cluttered gray
            ArenaTheme::Sanctum =>    Color::srgb(1.0, 0.95, 0.8),  // Divine gold
            ArenaTheme::GuildHouse => Color::srgb(0.5, 0.7, 0.5),   // Balanced green
        }
    }

    /// How much should this theme's atmosphere be suppressed during intense gameplay?
    pub fn suppression_factor(&self) -> f32 {
        match self {
            // Crucible and Bastion themes complement danger, so suppress less
            ArenaTheme::Crucible | ArenaTheme::Bastion => 0.3,
            // Casino and Gala can be distracting, suppress more
            ArenaTheme::Casino | ArenaTheme::Gala => 0.7,
            // GuildHouse is neutral training ground
            ArenaTheme::GuildHouse => 0.4,
            // Others are neutral
            _ => 0.5,
        }
    }

    /// Convert from the arena index to theme type (based on codebase arena indices)
    pub fn from_arena_index(index: u8) -> Option<Self> {
        match index {
            0 => Some(ArenaTheme::GuildHouse),
            1 => Some(ArenaTheme::Pawnshop),
            2 => Some(ArenaTheme::Crucible),
            3 => Some(ArenaTheme::Sanctum),
            4 => Some(ArenaTheme::Bastion),
            5 => Some(ArenaTheme::Labyrinth),
            6 => Some(ArenaTheme::Mountain),
            7 => Some(ArenaTheme::Casino),
            8 => Some(ArenaTheme::Gala),
            _ => None,
        }
    }
}

Setting Up Your First Arena

Let's implement the system that creates lighting when a new arena is loaded:

Rust

use crate::arena::Arena; // Import the existing Arena component

/// System that automatically sets up lighting when arenas are created
/// This system detects newly spawned arenas and adds appropriate lighting
pub fn setup_arena_lighting(
    mut commands: Commands,
    // Find arenas that were just created but don't have lighting yet
    new_arenas: Query<(Entity, &ArenaIndex), (Added<Arena>, Without<ArenaLighting>)>,
) {
    for (arena_entity, arena_index) in new_arenas.iter() {
        // Get the theme for this arena based on its index
        let theme = ArenaTheme::from_arena_index(arena_index.get_index())
            .unwrap_or(ArenaTheme::GuildHouse);

        // Create the main ambient light for this arena
        let ambient_light = commands.spawn(PointLightBundle {
            point_light: PointLight {
                intensity: 500.0,
                range: 200.0,
                color: theme.ambient_color(),
                shadows_enabled: false, // 2D doesn't need shadows
            },
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, 5.0)),
            ..default()
        }).id();

        // Create theme-specific accent lighting
        let accent_lights = create_theme_accent_lights(&mut commands, &theme);

        // Combine ambient + accent lights
        let mut all_lights = vec![ambient_light];
        all_lights.extend(accent_lights);

        // Attach the lighting component to the arena
        commands.entity(arena_entity).insert(ArenaLighting {
            theme,
            functional_priority: false, // Start with atmosphere, switch during combat
            ambient_lights: all_lights,
            emergency_override: false,
        });
    }
}

/// Creates additional lights that give each arena its unique personality
fn create_theme_accent_lights(commands: &mut Commands, theme: &ArenaTheme) -> Vec<Entity> {
    match theme {
        ArenaTheme::Casino => {
            // Glittering accent lights for luxury feel
            let mut lights = Vec::new();
            for i in 0..6 {
                let angle = (i as f32 / 6.0) * std::f32::consts::TAU;
                let radius = 150.0;
                let pos = Vec2::new(radius * angle.cos(), radius * angle.sin());

                let light = commands.spawn(PointLightBundle {
                    point_light: PointLight {
                        intensity: 200.0,
                        range: 50.0,
                        color: Color::srgb(1.0, 0.9, 0.6), // Warm gold
                        shadows_enabled: false,
                    },
                    transform: Transform::from_translation(Vec3::new(pos.x, pos.y, 3.0)),
                    ..default()
                }).id();
                lights.push(light);
            }
            lights
        },
        ArenaTheme::Crucible => {
            // Industrial heat sources
            vec![
                commands.spawn(PointLightBundle {
                    point_light: PointLight {
                        intensity: 800.0,
                        range: 100.0,
                        color: Color::srgb(1.0, 0.3, 0.1), // Hot orange-red
                        shadows_enabled: false,
                    },
                    transform: Transform::from_translation(Vec3::new(-100.0, 0.0, 4.0)),
                    ..default()
                }).id(),
                commands.spawn(PointLightBundle {
                    point_light: PointLight {
                        intensity: 800.0,
                        range: 100.0,
                        color: Color::srgb(1.0, 0.3, 0.1),
                        shadows_enabled: false,
                    },
                    transform: Transform::from_translation(Vec3::new(100.0, 0.0, 4.0)),
                    ..default()
                }).id(),
            ]
        },
        // For this tutorial, we'll implement Casino and Crucible as examples
        // Other themes follow similar patterns
        _ => Vec::new(),
    }
}

What's the Output? Challenge #1

Run the code above with a new Bastion arena. Before looking at the answer, predict:

  1. How many lights will be created?

  2. What color will the ambient light be?

  3. Where will the lights be positioned in 3D space?

๐ŸŽฏ Verify Your Prediction

Results for Bastion Arena:

  1. 1 light total - Bastion uses default case, so only ambient light created

  2. Cool blue color - Color::srgb(0.4, 0.6, 0.8) from the ambient_color() method

  3. Position: Ambient light at (0, 0, 5) - centered on arena, elevated above characters

If it were Casino Arena:

  1. 7 lights total - 1 ambient + 6 glittering accent lights

  2. Gold base color with warm gold accents

  3. Positions: Ambient at center, accents in hexagonal pattern 150 units from center


Character Lighting: The Heart of Player Communication

The Selection Highlight System

Players need to instantly identify which character they've selected, even when 40 characters overlap. This is your highest-priority functional lighting.

#[derive(Component)]
pub struct SelectionHighlight {
    pub light_entity: Entity,
    pub pulse_timer: Timer,
    pub base_intensity: f32,
    pub emergency_boost: f32, // Extra brightness when health is critical
}

#[derive(Component)]
pub struct Selected(pub bool);

/// Creates selection highlighting for characters that don't have it yet
pub fn add_selection_highlighting(
    mut commands: Commands,
    // Find characters without selection highlighting
    characters_needing_lights: Query<Entity, (With<Character>, Without<SelectionHighlight>)>,
) {
    for character_entity in characters_needing_lights.iter() {
        // Create a bright white light that will pulse when selected
        let light_entity = commands.spawn(PointLightBundle {
            point_light: PointLight {
                intensity: 0.0, // Start dim, will be controlled by selection state
                range: 35.0,    // Slightly larger than character sprite
                color: Color::WHITE,
                shadows_enabled: false,
            },
            // Position slightly above character for proper layering
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, 8.0)),
            ..default()
        }).id();

        // Attach the selection highlight to the character
        commands.entity(character_entity).insert(SelectionHighlight {
            light_entity,
            pulse_timer: Timer::from_seconds(1.2, TimerMode::Repeating),
            base_intensity: 400.0,
            emergency_boost: 0.0,
        });

        // Also add the selection state component
        commands.entity(character_entity).insert(Selected(false));
    }
}

The Selection Animation System

This system creates the pulsing effect that makes selected characters unmistakable:

/// Updates selection highlighting - runs every frame to create smooth pulsing
/// Note: This system would need to be adapted based on your character health system
pub fn update_character_selection_lighting(
    mut highlights: Query<(&mut SelectionHighlight, &Selected, &Character)>,
    mut lights: Query<&mut PointLight>,
    time: Res<Time>,
) {
    for (mut highlight, selected, character) in highlights.iter_mut() {
        // Update the pulse timer
        highlight.pulse_timer.tick(time.delta());

        // Calculate emergency boost for low health characters
        // NOTE: The current Character struct only has health (u32), not max_health
        // You'll need to either:
        // 1. Add max_health field to Character struct, or
        // 2. Define a constant MAX_HEALTH, or 
        // 3. Create a separate HealthStats component
        const DEFAULT_MAX_HEALTH: u32 = 100; // Placeholder - adjust based on your game design
        let health_ratio = character.health as f32 / DEFAULT_MAX_HEALTH as f32;
        if health_ratio < 0.25 {
            highlight.emergency_boost = (0.25 - health_ratio) * 4.0; // 0.0 to 1.0 boost
        } else {
            highlight.emergency_boost = 0.0;
        }

        // Update the actual light
        if let Ok(mut light) = lights.get_mut(highlight.light_entity) {
            if selected.0 {
                // Selected: Pulsing bright white with emergency boost
                let pulse_factor = (highlight.pulse_timer.elapsed_secs() * 3.0).sin() * 0.3 + 0.7;
                let base_intensity = highlight.base_intensity * (1.0 + highlight.emergency_boost);
                light.intensity = base_intensity * pulse_factor;
                light.color = Color::WHITE;
            } else if highlight.emergency_boost > 0.0 {
                // Not selected but low health: Pulsing red emergency light
                let pulse_factor = (highlight.pulse_timer.elapsed_secs() * 5.0).sin().abs();
                light.intensity = 300.0 * pulse_factor * highlight.emergency_boost;
                light.color = Color::srgb(1.0, 0.2, 0.2); // Emergency red
            } else {
                // Normal state: Subtle ambient glow for character identity
                light.intensity = 100.0;
                light.color = Color::srgb(0.8, 0.8, 0.9); // Subtle blue-white
            }
        }
    }
}

Active Recall Challenge #2: Trace the Logic

A character has 15 health out of 60 maximum, and is currently selected. The pulse timer shows 0.5 seconds elapsed.

Calculate (don't look ahead):

  1. What's the health ratio?

  2. What's the emergency boost value?

  3. What's the pulse factor?

  4. What's the final light intensity?

  5. What color will the light be?

๐Ÿงฎ Work Through the Math

Step-by-step calculation:

  1. Health ratio: 15/60 = 0.25 (exactly at the emergency threshold)

  2. Emergency boost: Since health_ratio == 0.25, boost = 0.0 (no emergency boost)

  3. Pulse factor: sin(0.5 3.0) 0.3 + 0.7 = sin(1.5) 0.3 + 0.7 = 0.997 0.3 + 0.7 โ‰ˆ 0.999

  4. Final intensity: 400.0 (1.0 + 0.0) 0.999 โ‰ˆ 399.6

  5. Color: WHITE (because selected.0 is true)

Key insight: The emergency threshold is exactly 0.25, so a character at 25% health doesn't get emergency lighting yet. This prevents flickering at the boundary.


Boss Telegraph System: Dramatic Danger Communication

The Telegraph Mental Model: Movie Lighting

Think of boss telegraphs like movie lighting for dramatic scenes:

  • Build-up phase: Subtle color shift (like storm clouds gathering)

  • Warning phase: Clear geometric shapes (like spotlights on stage)

  • Danger phase: Urgent pulsing (like emergency alarms)

  • Execution phase: Bright flash + aftermath (like lightning strike)

The Telegraph Component Architecture

#[derive(Component)]
pub struct BossTelegraph {
    pub attack_type: AttackType,
    pub current_phase: TelegraphPhase,
    pub phase_timer: Timer,
    pub light_entities: Vec<Entity>,
    pub audio_sync_offset: f32, // Keeps visuals synchronized with audio cues
}

#[derive(Debug, Clone)]
pub enum AttackType {
    SingleTarget { 
        target_pos: Vec2,
        damage_type: DamageType,
    },
    AoeCircle { 
        center: Vec2, 
        radius: f32,
        damage_type: DamageType,
    },
    AoeLine { 
        start: Vec2, 
        end: Vec2, 
        width: f32,
        damage_type: DamageType,
    },
    Environmental { 
        affected_tiles: Vec<Vec2>,
        damage_type: DamageType,
    },
}

#[derive(Debug, Clone, Copy)]
pub enum TelegraphPhase {
    Buildup(f32),   // 0.0 to 1.0 progress - subtle environmental shift
    Warning(f32),   // 0.0 to 1.0 progress - clear geometric telegraph
    Danger(f32),    // 0.0 to 1.0 progress - urgent pulsing
    Execution(f32), // 0.0 to 1.0 progress - bright flash + aftermath
    Idle,           // No active telegraph
}

#[derive(Debug, Clone, Copy)]
pub enum DamageType {
    Physical,   // Orange-red colors
    Magical,    // Blue-purple colors
    Fire,       // Bright red-orange
    Ice,        // Cyan-blue
    Poison,     // Toxic green
    Death,      // Dark magenta/purple
}

impl DamageType {
    pub fn telegraph_color(&self) -> Color {
        match self {
            DamageType::Physical => Color::srgb(1.0, 0.4, 0.2),  // Orange-red
            DamageType::Magical =>  Color::srgb(0.4, 0.2, 1.0),  // Blue-purple
            DamageType::Fire =>     Color::srgb(1.0, 0.2, 0.0),  // Bright red
            DamageType::Ice =>      Color::srgb(0.2, 0.8, 1.0),  // Cyan
            DamageType::Poison =>   Color::srgb(0.4, 1.0, 0.2),  // Toxic green
            DamageType::Death =>    Color::srgb(0.8, 0.0, 0.8),  // Dark magenta
        }
    }
}

Creating Circle AoE Telegraphs

This is the most common boss attack pattern - let's build it step by step:

impl AttackType {
    /// Creates the light entities that visualize this attack's danger zone
    pub fn create_telegraph_lights(&self, commands: &mut Commands) -> Vec<Entity> {
        match self {
            AttackType::AoeCircle { center, radius, damage_type } => {
                let mut lights = Vec::new();

                // Create a ring of lights to show the blast radius
                const LIGHTS_IN_RING: u32 = 16;
                let color = damage_type.telegraph_color();

                for i in 0..LIGHTS_IN_RING {
                    let angle = (i as f32 / LIGHTS_IN_RING as f32) * std::f32::consts::TAU;
                    let light_pos = Vec2::new(
                        center.x + radius * angle.cos(),
                        center.y + radius * angle.sin(),
                    );

                    let light = commands.spawn(PointLightBundle {
                        point_light: PointLight {
                            intensity: 0.0, // Will be animated by telegraph system
                            range: 30.0,
                            color,
                            shadows_enabled: false,
                        },
                        transform: Transform::from_translation(
                            Vec3::new(light_pos.x, light_pos.y, 12.0) // High Z for visibility
                        ),
                        ..default()
                    }).id();

                    lights.push(light);
                }

                // Add a center warning light for extra clarity
                let center_light = commands.spawn(PointLightBundle {
                    point_light: PointLight {
                        intensity: 0.0,
                        range: radius * 0.8, // Covers most of the danger zone
                        color,
                        shadows_enabled: false,
                    },
                    transform: Transform::from_translation(
                        Vec3::new(center.x, center.y, 10.0)
                    ),
                    ..default()
                }).id();

                lights.push(center_light);
                lights
            },

            AttackType::SingleTarget { target_pos, damage_type } => {
                // Single spotlight pointing at the target
                vec![commands.spawn(SpotLightBundle {
                    spot_light: SpotLight {
                        intensity: 0.0,
                        range: 80.0,
                        color: damage_type.telegraph_color(),
                        outer_angle: 0.3,  // Focused beam
                        inner_angle: 0.15,
                        shadows_enabled: false,
                    },
                    transform: Transform::from_translation(
                        Vec3::new(target_pos.x, target_pos.y, 15.0)
                    ),
                    ..default()
                }).id()]
            },

            // Line and environmental attacks would follow similar patterns
            _ => Vec::new(),
        }
    }
}

The Telegraph Animation System

This system brings the telegraphs to life with proper timing and visual escalation:

/// Animates boss telegraphs through their phases with dramatic lighting changes
pub fn animate_boss_telegraphs(
    mut telegraphs: Query<&mut BossTelegraph>,
    mut point_lights: Query<&mut PointLight, Without<SpotLight>>,
    mut spot_lights: Query<&mut SpotLight>,
    time: Res<Time>,
    lighting_manager: Res<LightingManager>,
) {
    for mut telegraph in telegraphs.iter_mut() {
        // Update the phase timer
        telegraph.phase_timer.tick(time.delta());

        // Determine current phase and progress
        let (intensity_base, pulse_frequency, color_saturation) = match telegraph.current_phase {
            TelegraphPhase::Buildup(progress) => {
                // Subtle buildup - players should notice something is coming
                (200.0 * progress, 0.0, 0.7) // No pulsing, slightly desaturated
            },
            TelegraphPhase::Warning(progress) => {
                // Clear visibility - players must see the exact danger zone
                (400.0 + (200.0 * progress), 1.0, 1.0) // Gentle pulse, full color
            },
            TelegraphPhase::Danger(progress) => {
                // Urgent warning - attack is imminent
                let urgency = 1.0 + progress * 2.0; // Increasing urgency
                (600.0 + (400.0 * progress), 3.0 * urgency, 1.0) // Fast pulsing
            },
            TelegraphPhase::Execution(progress) => {
                // Flash and aftermath
                if progress < 0.1 {
                    (2000.0, 0.0, 1.2) // Bright flash, slightly overexposed
                } else {
                    // Fade to dim aftermath
                    let fade = 1.0 - ((progress - 0.1) / 0.9);
                    (300.0 * fade, 0.0, 0.6)
                }
            },
            TelegraphPhase::Idle => (0.0, 0.0, 1.0), // Lights off
        };

        // Apply performance scaling
        let performance_scale = match lighting_manager.performance_level {
            PerformanceLevel::Ultra => 1.0,
            PerformanceLevel::High => 0.8,
            PerformanceLevel::Medium => 0.6,
            PerformanceLevel::Low => 0.4,
            PerformanceLevel::Emergency => 0.2,
        };

        let final_intensity = intensity_base * performance_scale;

        // Calculate pulsing effect
        let pulse_factor = if pulse_frequency > 0.0 {
            1.0 + (0.4 * (time.elapsed_seconds() * pulse_frequency).sin().abs())
        } else {
            1.0
        };

        // Update all lights for this telegraph
        for &light_entity in &telegraph.light_entities {
            // Try point light first
            if let Ok(mut light) = point_lights.get_mut(light_entity) {
                light.intensity = final_intensity * pulse_factor;

                // Adjust color saturation for different phases
                let base_color = telegraph.attack_type.damage_type().telegraph_color();
                light.color = adjust_color_saturation(base_color, color_saturation);
            }
            // Then try spot light
            else if let Ok(mut light) = spot_lights.get_mut(light_entity) {
                light.intensity = final_intensity * pulse_factor;
                let base_color = telegraph.attack_type.damage_type().telegraph_color();
                light.color = adjust_color_saturation(base_color, color_saturation);
            }
        }
    }
}

/// Helper function to adjust color saturation for different telegraph phases
fn adjust_color_saturation(base_color: Color, saturation: f32) -> Color {
    let [r, g, b, a] = base_color.to_srgba().to_f32_array();

    // Convert to grayscale for desaturation
    let gray = 0.299 * r + 0.587 * g + 0.114 * b;

    // Mix between grayscale and original color based on saturation
    Color::srgba(
        gray + (r - gray) * saturation,
        gray + (g - gray) * saturation,
        gray + (b - gray) * saturation,
        a,
    )
}

Active Recall Challenge #3: Telegraph Timing

A Fire-type AoE Circle attack is in the Danger phase at 0.6 progress. The current time is 10.5 seconds, and we're at High performance level.

Calculate:

  1. What's the intensity_base?

  2. What's the pulse_frequency?

  3. What's the performance_scale?

  4. What's the pulse_factor? (assume sin(31.5) โ‰ˆ -0.5)

  5. What's the final light intensity?

๐ŸŽฏ Work Through the Telegraph Math

Step-by-step:

  1. intensity_base: 600.0 + (400.0 * 0.6) = 600.0 + 240.0 = 840.0

  2. pulse_frequency: 3.0 (1.0 + 0.6 2.0) = 3.0 * 2.2 = 6.6

  3. performance_scale: High level = 0.8

  4. pulse_factor: 1.0 + (0.4 abs(sin(10.5 6.6))) = 1.0 + (0.4 * abs(-0.5)) = 1.0 + 0.2 = 1.2

  5. final intensity: 840.0 0.8 1.2 = 806.4

Color: Fire damage = bright red Color::srgb(1.0, 0.2, 0.0) at full saturation


Performance Optimization: The Graceful Degradation System

The Performance Monitoring Brain

Your lighting system needs to automatically detect when it's causing performance problems and gracefully reduce quality to maintain 60fps.

#[derive(Resource)]
pub struct PerformanceMonitor {
    /// Rolling average of frame times in milliseconds
    pub frame_time_samples: VecDeque<f32>,
    /// How many lights are currently active
    pub active_light_count: u32,
    /// Target frame time in milliseconds (16.67ms = 60fps)
    pub target_frame_time: f32,
    /// When we last adjusted performance level
    pub last_adjustment: f32,
    /// Minimum time between performance adjustments (prevents oscillation)
    pub adjustment_cooldown: f32,
}

impl Default for PerformanceMonitor {
    fn default() -> Self {
        Self {
            frame_time_samples: VecDeque::with_capacity(120), // 2 seconds at 60fps
            active_light_count: 0,
            target_frame_time: 16.67, // 60fps
            last_adjustment: 0.0,
            adjustment_cooldown: 2.0, // 2 second cooldown
        }
    }
}

/// Monitors performance and triggers automatic optimization
pub fn monitor_lighting_performance(
    mut performance_monitor: ResMut<PerformanceMonitor>,
    mut lighting_manager: ResMut<LightingManager>,
    diagnostics: Res<Diagnostics>,
    time: Res<Time>,
    active_lights: Query<&PointLight>,
) {
    // Update light count
    performance_monitor.active_light_count = active_lights.iter().count() as u32;

    // Sample current frame time
    if let Some(frame_time_diag) = diagnostics.get(FrameTimeDiagnosticsPlugin::FRAME_TIME) {
        if let Some(current_frame_time) = frame_time_diag.average() {
            performance_monitor.frame_time_samples.push_back(current_frame_time as f32);

            // Keep only recent samples
            if performance_monitor.frame_time_samples.len() > 120 {
                performance_monitor.frame_time_samples.pop_front();
            }
        }
    }

    // Check if it's time to adjust performance
    let current_time = time.elapsed_seconds();
    if current_time - performance_monitor.last_adjustment > performance_monitor.adjustment_cooldown {
        if should_adjust_performance(&performance_monitor) {
            adjust_performance_level(&mut lighting_manager, &performance_monitor);
            performance_monitor.last_adjustment = current_time;
        }
    }
}

/// Determines if performance adjustment is needed based on frame time trends
fn should_adjust_performance(monitor: &PerformanceMonitor) -> bool {
    if monitor.frame_time_samples.len() < 60 {
        return false; // Need enough samples for reliable measurement
    }

    let avg_frame_time: f32 = monitor.frame_time_samples.iter().sum::<f32>() 
        / monitor.frame_time_samples.len() as f32;

    // Adjust if consistently above or below target with some hysteresis
    avg_frame_time > monitor.target_frame_time * 1.15 || // 15% over target
    avg_frame_time < monitor.target_frame_time * 0.85    // 15% under target (can upgrade)
}

/// Adjusts performance level up or down based on current performance
fn adjust_performance_level(
    lighting_manager: &mut LightingManager,
    monitor: &PerformanceMonitor,
) {
    let avg_frame_time: f32 = monitor.frame_time_samples.iter().sum::<f32>() 
        / monitor.frame_time_samples.len() as f32;

    if avg_frame_time > monitor.target_frame_time * 1.15 {
        // Performance is poor, reduce quality
        lighting_manager.performance_level = match lighting_manager.performance_level {
            PerformanceLevel::Ultra => PerformanceLevel::High,
            PerformanceLevel::High => PerformanceLevel::Medium,
            PerformanceLevel::Medium => PerformanceLevel::Low,
            PerformanceLevel::Low => PerformanceLevel::Emergency,
            PerformanceLevel::Emergency => PerformanceLevel::Emergency, // Can't go lower
        };
        info!("Lighting performance reduced to {:?} (frame time: {:.2}ms)", 
              lighting_manager.performance_level, avg_frame_time);
    } else if avg_frame_time < monitor.target_frame_time * 0.85 {
        // Performance is good, can increase quality
        lighting_manager.performance_level = match lighting_manager.performance_level {
            PerformanceLevel::Emergency => PerformanceLevel::Low,
            PerformanceLevel::Low => PerformanceLevel::Medium,
            PerformanceLevel::Medium => PerformanceLevel::High,
            PerformanceLevel::High => PerformanceLevel::Ultra,
            PerformanceLevel::Ultra => PerformanceLevel::Ultra, // Already at max
        };
        info!("Lighting performance increased to {:?} (frame time: {:.2}ms)", 
              lighting_manager.performance_level, avg_frame_time);
    }
}

The Arena Culling System

When performance drops, the first optimization is to stop lighting arenas that players can't see:

Rust

/// Disables lighting for arenas that are off-screen or too distant to matter
pub fn cull_distant_arena_lighting(
    mut arena_lighting: Query<(&mut ArenaLighting, &Transform, &ArenaIndex)>,
    camera: Query<&Transform, (With<Camera>, Without<ArenaLighting>)>,
    lighting_manager: Res<LightingManager>,
) {
    if let Ok(camera_transform) = camera.get_single() {
        for (mut lighting, arena_transform, arena_index) in arena_lighting.iter_mut() {
            // Calculate distance from camera to arena center
            let distance = camera_transform.translation.distance(arena_transform.translation);

            // Determine if this arena should have lighting based on performance level
            let should_be_lit = match lighting_manager.performance_level {
                PerformanceLevel::Ultra => true, // Light everything
                PerformanceLevel::High => distance < 1000.0 || arena_index.0 == lighting_manager.focused_arena.unwrap_or(255),
                PerformanceLevel::Medium => distance < 500.0 || arena_index.0 == lighting_manager.focused_arena.unwrap_or(255),
                PerformanceLevel::Low => arena_index.0 == lighting_manager.focused_arena.unwrap_or(255),
                PerformanceLevel::Emergency => false, // No arena ambient lighting in emergency mode
            };

            // Only change lighting state if it needs to change (prevents unnecessary work)
            if lighting.functional_priority != should_be_lit {
                lighting.functional_priority = should_be_lit;

                // TODO: Enable/disable the actual light entities
                // This would involve iterating through lighting.ambient_lights
                // and setting their Visibility component
            }
        }
    }
}

Emergency Mode: When All Else Fails

In Emergency mode, only the most critical information gets lighting:

Rust

/// Emergency lighting system - only essential information gets lit
pub fn apply_emergency_lighting(
    mut character_lights: Query<(&mut SelectionHighlight, &Selected, &Character)>,
    mut telegraph_lights: Query<&mut BossTelegraph>,
    mut point_lights: Query<&mut PointLight>,
    lighting_manager: Res<LightingManager>,
) {
    if lighting_manager.performance_level != PerformanceLevel::Emergency {
        return; // Only run in emergency mode
    }

    // Only light selected characters and critical health characters
    for (highlight, selected, character) in character_lights.iter_mut() {
        if let Ok(mut light) = point_lights.get_mut(highlight.light_entity) {
            let health_ratio = character.health as f32 / character.max_health as f32;

            if selected.0 {
                // Selected character: minimal lighting for control
                light.intensity = 200.0;
                light.color = Color::WHITE;
            } else if health_ratio < 0.15 {
                // Critical health: emergency red
                light.intensity = 150.0;
                light.color = Color::srgb(1.0, 0.0, 0.0);
            } else {
                // Everything else: no lighting
                light.intensity = 0.0;
            }
        }
    }

    // Boss telegraphs still work but at minimum intensity
    for mut telegraph in telegraph_lights.iter_mut() {
        for &light_entity in &telegraph.light_entities {
            if let Ok(mut light) = point_lights.get_mut(light_entity) {
                // Reduce to 20% intensity but still visible
                if light.intensity > 0.0 {
                    light.intensity = (light.intensity * 0.2).max(50.0);
                }
            }
        }
    }
}

Advanced Feature: Ghost Overlap Handling

The Multiply Blend Problem

When multiple ghost recordings overlap, traditional lighting creates unreadable visual soup. The solution: depth-coded lighting intensity.

Rust

#[derive(Component)]
pub struct GhostLighting {
    pub recording_age: u32,        // How many cycles old this recording is
    pub base_light: Entity,        // The character's base lighting
    pub depth_factor: f32,         // 0.0 = oldest, 1.0 = current recording
    pub emergency_override: bool,  // Forces visibility regardless of age
}

/// Updates ghost lighting intensity based on recording age and health status
pub fn update_ghost_lighting_depth(
    mut ghost_lighting: Query<(&mut GhostLighting, &Character, &Selected)>,
    mut lights: Query<&mut PointLight>,
    time: Res<Time>,
) {
    for (mut ghost, character, selected) in ghost_lighting.iter_mut() {
        // Calculate depth factor (newer recordings are brighter)
        ghost.depth_factor = (1.0 - (ghost.recording_age as f32 * 0.08)).max(0.15);

        // Health emergency overrides aging
        let health_ratio = character.health as f32 / character.max_health as f32;
        if health_ratio < 0.25 {
            ghost.emergency_override = true;
            ghost.depth_factor = ghost.depth_factor.max(0.8); // Force high visibility
        } else {
            ghost.emergency_override = false;
        }

        // Selection overrides everything
        if selected.0 {
            ghost.depth_factor = 1.0;
        }

        // Apply to the actual light
        if let Ok(mut light) = lights.get_mut(ghost.base_light) {
            // Base intensity adjusted by depth
            let base_intensity = 300.0 * ghost.depth_factor;

            // Add pulsing for emergency health
            if ghost.emergency_override {
                let pulse = (time.elapsed_seconds() * 4.0).sin().abs();
                light.intensity = base_intensity * (0.7 + 0.3 * pulse);
                light.color = Color::srgb(1.0, 0.3, 0.3); // Emergency red tint
            } else {
                light.intensity = base_intensity;
                // Older ghosts get cooler, more desaturated colors
                let warmth = ghost.depth_factor;
                light.color = Color::srgb(
                    0.8 + 0.2 * warmth,     // Red component
                    0.8 + 0.1 * warmth,     // Green component  
                    0.9,                    // Blue stays high (cooler for older)
                );
            }
        }
    }
}

Putting It All Together: Your Complete Lighting Plugin

The Main Plugin Structure

pub struct ArenicLightingPlugin;

impl Plugin for ArenicLightingPlugin {
    fn build(&self, app: &mut App) {
        app
            // Initialize core resources
            .init_resource::<LightingManager>()
            .init_resource::<PerformanceMonitor>()

            // Core lighting systems that run every frame
            .add_systems(
                Update,
                (
                    // Performance monitoring (highest priority)
                    monitor_lighting_performance,

                    // Arena management
                    setup_arena_lighting,
                    cull_distant_arena_lighting,

                    // Character lighting
                    add_selection_highlighting,
                    update_character_selection_lighting,
                    update_ghost_lighting_depth,

                    // Boss telegraphs
                    animate_boss_telegraphs,

                    // Emergency systems
                    apply_emergency_lighting,
                ).chain() // Run in order to avoid frame lag
            );
    }
}

Integration with Your Game

Add this to your main game setup, following the existing project structure:

// In your main.rs, following the existing pattern
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: GAME_NAME.to_string(),
                resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(GameStatePlugin)
        .add_plugins(CameraPlugin)
        .add_plugins(RecordingPlugin)
        .add_plugins(UiPlugin)
        .add_plugins(ArenicLightingPlugin) // Add the lighting system
        .run();
}

Critical Bevy 0.16 Implementation Notes

The current project uses Bevy 0.16.1, which has several important implications for lighting implementation:

1. Required Components Pattern

Rust

// Bevy 0.16 supports Required Components - use this pattern for clean entity composition
#[derive(Component)]
#[require(Transform, Visibility)] // Automatically adds Transform and Visibility
pub struct LightingEntity {
    pub intensity_multiplier: f32,
}

2. Modern Camera System

The project uses the new Camera2d component instead of Camera2dBundle:

Rust

// Current project pattern (correct for Bevy 0.16)
commands.spawn((
    Camera2d,
    Transform::from_xyz(camera_x, camera_y, 0.0),
    Projection::Orthographic(OrthographicProjection { /* ... */ }),
));

3. Sprite Rendering Changes

Sprites now use Sprite component directly instead of SpriteBundle:

Rust

// Bevy 0.16 pattern used in this project
commands.spawn((
    Sprite {
        image: asset_server.load("texture.png"),
        custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
        ..default()
    },
    Transform::from_xyz(x, y, z),
));

4. Color System Updates

Use Color::srgb() instead of deprecated color constructors:

Rust

// Correct for Bevy 0.16
let color = Color::srgb(1.0, 0.5, 0.2); // Red-orange
let color = Color::srgba(1.0, 0.5, 0.2, 0.8); // With alpha

Project-Specific Performance Constraints

Based on the actual project architecture, here are critical performance considerations:

Grid Scale Reality Check

The project uses a 66ร—31 tile grid per arena with 9 total arenas:

  • 18,414 total tiles across all arenas

  • Each tile is 19 pixels (from TILE_SIZE: f32 = 19.0)

  • Arena size: 1,254ร—589 pixels each

  • Total world: 3,762ร—1,767 pixels

This means lighting optimization is absolutely critical - you're not just lighting characters, but potentially thousands of tile-based interactions.

Camera System Integration

The existing camera system supports:

  • Arena-focused navigation (bracket keys for arena switching)

  • Zoom toggle (P key for 3x zoom out to see all arenas)

  • Performance-aware rendering (only active arena gets full attention)

Your lighting system must integrate with this:

Rust

// Hook into the existing CurrentArena system for lighting focus
fn update_lighting_focus(
    arena_query: Query<&CurrentArena, Changed<CurrentArena>>,
    mut lighting_manager: ResMut<LightingManager>,
) {
    if let Ok(current_arena) = arena_query.single() {
        lighting_manager.focused_arena = Some(ArenaIndex(current_arena.0));
    }
}

Recording System Timing Precision

The project has microsecond-level timing requirements:

  • 120-second arena cycles with sub-millisecond precision

  • 60fps (16.67ms) and 240fps (4.17ms) timing tests

  • Deterministic replay requirements

Lighting animations must be time-based, not frame-based:

Rust

// Use game time, not delta time for consistent lighting
let phase_progress = (time.elapsed_seconds() % TELEGRAPH_DURATION) / TELEGRAPH_DURATION;

Character System Architecture Gap

Critical Implementation Note: The current Character struct is minimal:

Rust

pub struct Character {
    name: String,
    health: u32,    // No max_health field!
    level: u32,
}

Before implementing the lighting system, you'll need to either:

  1. Extend Character struct with max_health field

  2. Create companion components like HealthStats, SelectionState

  3. Define health constants based on character level scaling

This affects every lighting calculation that depends on health ratios.


Practical Implementation Roadmap for This Project

Phase 0: Foundation Setup (Required Before Implementation)

Before implementing the lighting system described above, you need to address these architectural gaps:

1. Extend Character System

Rust

// Option A: Extend the existing Character struct
#[derive(Component, Debug)]
pub struct Character {
    name: String,
    health: u32,
    max_health: u32,  // Add this field
    level: u32,
}

// Option B: Create companion components (recommended)
#[derive(Component, Debug)]
pub struct HealthStats {
    pub current: u32,
    pub maximum: u32,
    pub regen_rate: f32,
}

#[derive(Component, Debug)]
pub struct SelectionState {
    pub is_selected: bool,
    pub selection_time: f64, // When was this character last selected
}

2. Create Missing Components

Several components referenced in the lighting system don't exist yet:

Rust

// These need to be implemented:
#[derive(Component)] pub struct Selected(pub bool);
#[derive(Component)] pub struct BossTelegraph { /* ... */ }
#[derive(Component)] pub struct GhostLighting { /* ... */ }

3. Boss System Integration

The current project has boss modules but no telegraph system. You'll need to:

  • Review boss animation patterns in /src/boss/

  • Design attack telegraphs based on each boss's abilities

  • Integrate with the existing BossAnimationConfig system

Phase 1: Start with Arena Lighting Only

Given the project's current state, implement lighting in this order:

Week 1: Static Arena Ambient Lighting

  • Focus only on the ArenaLighting component

  • Use existing ArenaIndex from recording system

  • Create ambient lights for each of the 9 arena types

  • Integrate with existing CurrentArena for camera focus

Week 2: Performance-Aware Culling

  • Implement the LightingManager resource

  • Add distance-based arena light culling

  • Hook into existing camera zoom system (P key toggle)

  • Test with all 9 arenas visible simultaneously

Week 3: Character Health Visualization

  • Extend character system with health components

  • Implement basic selection highlighting

  • Test with placeholder character data

Week 4: Boss Telegraph Foundation

  • Choose 1-2 boss types for initial implementation

  • Create basic telegraph patterns

  • Focus on timing precision integration

Integration Points with Existing Systems

Camera System Hook

Rust

// Add this system to your lighting plugin
fn sync_lighting_with_camera(
    camera_query: Query<&Projection, (With<Camera>, Changed<Projection>)>,
    mut lighting_manager: ResMut<LightingManager>,
) {
    for projection in camera_query.iter() {
        if let Projection::Orthographic(ortho) = projection {
            // Adjust lighting performance based on zoom level
            lighting_manager.performance_level = if ortho.scale >= 3.0 {
                PerformanceLevel::Ultra // Show all arena lighting when zoomed out
            } else {
                PerformanceLevel::High // Focus on current arena when zoomed in
            };
        }
    }
}

Arena System Hook

Rust

// Hook into the existing ArenaTransform trait
impl ArenaTheme {
    fn from_arena_component<T: ArenaTransform>() -> Self {
        match T::INDEX {
            0 => ArenaTheme::GuildHouse,
            1 => ArenaTheme::Pawnshop,
            2 => ArenaTheme::Crucible,
            3 => ArenaTheme::Sanctum,
            4 => ArenaTheme::Bastion,
            5 => ArenaTheme::Labyrinth,
            6 => ArenaTheme::Mountain,
            7 => ArenaTheme::Casino,
            8 => ArenaTheme::Gala,
            _ => ArenaTheme::GuildHouse,
        }
    }
}

Recording System Hook

Rust

// Synchronize lighting with the precise timing system
fn sync_lighting_with_arena_timer(
    arena_timers: Query<(&ArenaTimer, &ArenaIndex)>,
    mut lighting_manager: ResMut<LightingManager>,
) {
    for (timer, index) in arena_timers.iter() {
        if timer.is_running() {
            // Adjust lighting intensity based on cycle progress
            let cycle_progress = timer.progress_normalized();
            // Use this for dynamic lighting effects that sync with 120-second cycles
        }
    }
}

Final Active Recall: Test Your Mastery

Without looking back at the code, answer these integration questions:

Question 1: System Design

You have 8 arenas, 35 characters each, and frame rate just dropped to 45fps. What happens automatically, and in what order?

๐ŸŽฏ Complete System Response

Automatic Response Chain:

  1. PerformanceMonitor detects frame time > 18.5ms (15% over 16.67ms target)

  2. LightingManager drops from current level to next lower level

  3. cull_distant_arena_lighting disables lighting for arenas based on new performance level:

    • If dropped to High: Only arenas within 1000 units + focused arena get lighting

    • If dropped to Medium: Only arenas within 500 units + focused arena get lighting

    • If dropped to Low: Only focused arena gets lighting

  4. Character lighting systems continue running but with reduced intensity multipliers

  5. Telegraph system continues working but with performance scaling applied

  6. System waits 2 seconds before considering another adjustment

Result: Frame rate should recover to 60fps while preserving most critical gameplay information.

Question 2: Emergency Scenarios

A character has 8 health out of 50 maximum, is not selected, and has 12 ghost overlaps. What lighting does this character get, and why?

๐ŸŽฏ Emergency Lighting Logic

Character gets emergency visibility:

  1. Health ratio: 8/50 = 0.16 (below 0.25 threshold)

  2. Ghost lighting: emergency_override = true, depth_factor = max(calculated, 0.8) = 0.8

  3. Selection highlighting: Red pulsing emergency light (not white, since not selected)

  4. Final result: Bright pulsing red light that cuts through all visual noise

Why this works: The emergency system ensures that characters near death are always visible, regardless of ghost overlaps or visual clutter. This prevents players from losing characters due to UI problems.

Question 3: Telegraph Timing

A boss telegraph needs to be perfectly synchronized with a 3-second audio warning. The boss ability has a 1-second buildup, 1.5-second warning, and 0.5-second danger phase. How do you ensure perfect timing?

๐ŸŽฏ Synchronization Solution

Telegraph timing setup:

Rust

BossTelegraph {
    phase_transitions: [1.0, 2.5, 3.0, 3.5], // Buildup, Warning, Danger, Execution end times
    audio_sync_offset: 0.0, // Adjusted based on audio latency measurement
    // ... other fields
}

Synchronization process:

  1. Measure audio latency on game startup

  2. Set audio_sync_offset to compensate for audio system delay

  3. Use deterministic timing based on boss cycle timer, not frame-dependent animations

  4. Validate sync by checking that visual telegraph peaks align with audio cues

Result: Players can learn to respond to either visual or audio cues interchangeably.


Your Next Steps: Implementation Roadmap

Week 1: Foundation (8-12 hours)

Goal: Get basic lighting working with performance monitoring

Milestones:

  • [ ] LightingManager and PerformanceMonitor resources working

  • [ ] Arena ambient lighting for at least 2 arena themes

  • [ ] Basic character selection highlighting

  • [ ] Performance auto-adjustment between Ultra/High/Medium levels

Success criteria: 60fps with up to 3 arenas visible, 20 characters total

Week 2: Character Systems (10-15 hours)

Goal: Solve the ghost overlap visibility problem

Milestones:

  • [ ] Ghost depth-coded lighting working

  • [ ] Emergency health visibility overrides

  • [ ] Selection highlighting works with heavy overlap

  • [ ] Integration with your existing character/recording system

Success criteria: Can identify selected character and low-health characters even with 15+ overlapping ghosts

Week 3: Boss Telegraphs (12-18 hours)

Goal: Clear, dramatic boss ability communication

Milestones:

  • [ ] At least 3 attack types (SingleTarget, AoeCircle, AoeLine) with proper lighting

  • [ ] 4-phase telegraph system with proper timing

  • [ ] Audio synchronization working

  • [ ] Telegraph lights integrate with performance system

Success criteria: Boss attacks are clearly telegraphed and consistently timed across multiple test cycles

Week 4: Performance Optimization (8-12 hours)

Goal: Smooth 60fps with full game load

Milestones:

  • [ ] Emergency mode lighting working

  • [ ] Arena culling based on camera distance

  • [ ] All 5 performance levels functional

  • [ ] Graceful degradation under stress

Success criteria: Maintains 60fps with 8 arenas, 40 characters each, 3 active boss fights

Learning Artifacts for Long-term Retention

Create these reference materials as you implement:

  1. Performance Budget Cheat Sheet:
Ultra: 300 lights max, all arenas lit
High: 200 lights max, 5-6 arenas lit
Medium: 120 lights max, 3-4 arenas lit
Low: 60 lights max, active arena only
Emergency: 20 lights max, critical info only
  1. Telegraph Color Reference:
Physical: Orange-red (1.0, 0.4, 0.2)
Magical: Blue-purple (0.4, 0.2, 1.0)
Fire: Bright red (1.0, 0.2, 0.0)
Ice: Cyan (0.2, 0.8, 1.0)
Poison: Toxic green (0.4, 1.0, 0.2)
Death: Dark magenta (0.8, 0.0, 0.8)
  1. Emergency Visibility Checklist:
  • [ ] Selected characters always visible (white pulsing)

  • [ ] Low health characters always visible (red pulsing)

  • [ ] Boss telegraphs never completely disabled

  • [ ] At least 20 lights available in emergency mode

Troubleshooting Guide

"Frame rate drops when many characters overlap"

  • Check: Are you creating too many lights per character?

  • Solution: Use light pooling, share lights between similar characters

"Can't see selected character in ghost crowd"

  • Check: Is emergency_override working for selection?

  • Solution: Increase selection light intensity, add distinct color

"Boss telegraphs feel laggy or inconsistent"

  • Check: Are you using frame-dependent timing?

  • Solution: Use game time, not delta time for phase calculations

"Lighting looks different on different computers"

  • Check: Are you accounting for different GPU capabilities?

  • Solution: Implement hardware detection, adjust performance budgets accordingly

Generalizing Beyond Arenic

The principles you've learned apply to any game with complex information display:

  • The Emergency Visibility Framework works for any UI-heavy game (strategy, simulation, management)

  • The 3-Layer Architecture applies to any game needing both function and atmosphere

  • The Performance-First Design is essential for any real-time multiplayer game

  • The Telegraph System works for any game with predictable, learnable patterns

Conclusion: From Lighting to Communication

You've learned to think about lighting not as decoration, but as communication architecture. Every light serves a purpose: guiding attention, conveying state, building anticipation, or ensuring critical information is never lost.

This approach - treating game systems as information design problems - will serve you well beyond lighting. UI systems, audio design, animation, and even gameplay mechanics all benefit from this "communication first" mindset.

Remember: Great game systems don't just work correctly - they help players perform at their best. Your lighting system is now a tool that makes players more skillful, more informed, and more confident in their decisions.

That's the difference between good technical implementation and exceptional game design.

0
Subscribe to my newsletter

Read articles from Matthew Harwood directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Matthew Harwood
Matthew Harwood