How Do You Create Lighting That Actually Helps Players Win?

Table of contents
- Your Learning Journey Map
- The Mental Model: Lighting as a Communication System
- The Arenic Challenge: Why Normal Lighting Fails
- Building Schema: The Emergency Visibility Framework
- Implementation Phase 1: Building the Foundation
- Character Lighting: The Heart of Player Communication
- Boss Telegraph System: Dramatic Danger Communication
- Performance Optimization: The Graceful Degradation System
- Advanced Feature: Ghost Overlap Handling
- Putting It All Together: Your Complete Lighting Plugin
- Practical Implementation Roadmap for This Project
- Final Active Recall: Test Your Mastery
- Your Next Steps: Implementation Roadmap
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):
Immediate Death Threats (Boss telegraphs) - Always maximum visibility
Character Health Emergencies - Cuts through all visual noise
Player Selection State - Must remain visible for control clarity
Strategic Information - Visible when screen isn't chaotic
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:
How many lights will be created?
What color will the ambient light be?
Where will the lights be positioned in 3D space?
๐ฏ Verify Your Prediction
Results for Bastion Arena:
1 light total - Bastion uses default case, so only ambient light created
Cool blue color -
Color::srgb(0.4, 0.6, 0.8)
from the ambient_color() methodPosition: Ambient light at
(0, 0, 5)
- centered on arena, elevated above characters
If it were Casino Arena:
7 lights total - 1 ambient + 6 glittering accent lights
Gold base color with warm gold accents
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):
What's the health ratio?
What's the emergency boost value?
What's the pulse factor?
What's the final light intensity?
What color will the light be?
๐งฎ Work Through the Math
Step-by-step calculation:
Health ratio: 15/60 = 0.25 (exactly at the emergency threshold)
Emergency boost: Since health_ratio == 0.25, boost = 0.0 (no emergency boost)
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
Final intensity: 400.0 (1.0 + 0.0) 0.999 โ 399.6
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:
What's the intensity_base?
What's the pulse_frequency?
What's the performance_scale?
What's the pulse_factor? (assume sin(31.5) โ -0.5)
What's the final light intensity?
๐ฏ Work Through the Telegraph Math
Step-by-step:
intensity_base: 600.0 + (400.0 * 0.6) = 600.0 + 240.0 = 840.0
pulse_frequency: 3.0 (1.0 + 0.6 2.0) = 3.0 * 2.2 = 6.6
performance_scale: High level = 0.8
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
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:
Extend Character struct with
max_health
fieldCreate companion components like
HealthStats
,SelectionState
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
componentUse existing
ArenaIndex
from recording systemCreate ambient lights for each of the 9 arena types
Integrate with existing
CurrentArena
for camera focus
Week 2: Performance-Aware Culling
Implement the
LightingManager
resourceAdd 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:
PerformanceMonitor detects frame time > 18.5ms (15% over 16.67ms target)
LightingManager drops from current level to next lower level
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
Character lighting systems continue running but with reduced intensity multipliers
Telegraph system continues working but with performance scaling applied
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:
Health ratio: 8/50 = 0.16 (below 0.25 threshold)
Ghost lighting:
emergency_override = true
,depth_factor = max(calculated, 0.8) = 0.8
Selection highlighting: Red pulsing emergency light (not white, since not selected)
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:
Measure audio latency on game startup
Set audio_sync_offset to compensate for audio system delay
Use deterministic timing based on boss cycle timer, not frame-dependent animations
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
andPerformanceMonitor
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:
- 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
- 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)
- 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.
Subscribe to my newsletter
Read articles from Matthew Harwood directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
