20 KiB
Unity Bullet Hell — Complete Code Documentation
Project: magisterka_2/ (Unity 2D, C#)
Namespace: Magisterka.BulletHell
Purpose: Performance benchmark bullet-hell shooter for comparing Unity vs Unreal Engine.
Duration: 90-second survival round with escalating difficulty.
Architecture Overview
The project follows a scene-free bootstrap pattern: the game builds itself entirely at runtime from C# code, with no dependency on a pre-configured Unity scene. This is done via:
GameBootstrap— Static class with[RuntimeInitializeOnLoadMethod]that ensuresGameInitializerexists.GameInitializer— MonoBehaviour that constructs the entire game hierarchy: camera, player, pools, HUD, spawner, director.
This architecture means the game can run from a completely empty scene — ideal for reproducible benchmarking across machines.
Dependency Graph
GameBootstrap → GameInitializer
├── Camera (orthographic, 2D)
├── BackgroundScroller (parallax starfield)
├── EffectManager (VFX pooling)
├── ScoreManager (HUD + score)
├── BulletPool × 2 (player & enemy)
├── PlayerController
├── EnemySpawner
└── GameDirector (timer, victory, game-over)
Key Design Patterns
| Pattern | Where Used | Why |
|---|---|---|
| Singleton | GameDirector, EnemySpawner, ScoreManager, EffectManager, PlayerController |
Provides global access to managers via .Instance |
| Object Pooling | BulletPool, EffectManager |
Eliminates per-frame allocation; critical for performance at 1000+ active bullets |
| Bootstrap/Factory | GameBootstrap + GameInitializer |
Scene-independent initialization for benchmark reproducibility |
| Component Composition | [RequireComponent(typeof(Health))] on EnemyController and PlayerController |
Ensures Health component always present; decouples damage logic from entity logic |
| Event-driven Communication | Health.Died, Health.Damaged, ScoreManager.ScoreChanged |
Loose coupling between systems via C# events/delegates |
| Blueprint/Data-driven Configuration | EnemyBlueprint class |
Runtime data objects define enemy archetypes without requiring ScriptableObjects |
File-by-File Documentation
1. Faction.cs — Combat Side Identifier
What it does: Defines a simple enum with two values: Player and Enemy.
Why it works this way: Used throughout the collision and damage system to prevent friendly fire. When a bullet hits a Health component, factions are compared — bullets only damage entities of the opposite faction. This is the simplest possible friend-or-foe identification, avoiding complex layer setups.
Key detail: Stored as int (Player=0, Enemy=1), making comparison extremely cheap.
2. GameBootstrap.cs — Runtime Scene Initializer
What it does: A static class with [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] that runs automatically after any scene loads. It checks if a GameInitializer already exists; if not, it creates one and marks it DontDestroyOnLoad.
Why it works this way: This guarantees the game bootstraps correctly regardless of which scene is loaded or how the build is configured. No manual scene setup needed. The AfterSceneLoad timing ensures the scene hierarchy is ready before initialization begins.
Performance relevance: Zero runtime cost — runs exactly once at startup.
3. GameInitializer.cs — Full Scene Builder
What it does: A MonoBehaviour that constructs the entire game programmatically in Awake():
- Creates an orthographic 2D camera (size 6.5, black background, positioned at z=-10)
- Sets
Application.targetFrameRate = 240(uncapped for benchmarking) - Hides cursor
- Creates
BackgroundScroller,EffectManager,ScoreManager - Creates two
BulletPoolinstances (player: 400 warm, cyan; enemy: 900 warm, orange-red) - Creates
PlayerControllerwith calculated movement bounds from camera aspect - Builds the entire HUD (canvas, score label, life icons, timer, game-over text, damage overlay)
- Creates
EnemySpawnerandGameDirector, wiring them together
Why it works this way: Building everything in code means:
- No scene dependency — benchmarks are perfectly reproducible
- Single source of truth — all wiring is visible in one file
- No serialization bugs — no missing references from scene changes
HUD construction detail: The CreateHud() method builds the entire UI canvas from scratch including:
- Score text (upper-left, 24pt)
- Life icons (programmatically generated circular sprites via
BuildLifeSprite()) - Timer text (upper-right, 24pt)
- Game over/victory text (center, 48pt, initially hidden)
- Damage overlay (full-screen red flash, initially transparent)
BuildLifeSprite(): Generates a 32×32 pixel circular texture at runtime using distance-from-center calculation. No external assets needed.
4. GameDirector.cs — Game Flow Controller
What it does: Singleton MonoBehaviour that manages the overall game timer and end conditions:
- Counts down from
totalDuration(90s) to 0 - When timer hits 0: stops the spawner, enters "cleanup phase"
- During cleanup: waits until
_spawner.HasActiveEnemiesis false, then triggers victory - On player death (game over): stops the timer
- Updates the timer UI label every frame in
MM:SSformat
Why it works this way: The two-phase end condition (timer expired → wait for enemies cleared) prevents an abrupt ending while enemies are still on screen. The player must survive AND clear all remaining enemies after time runs out.
State machine:
RUNNING → _timeRemaining <= 0 → EXPIRED (spawning stops)
EXPIRED → no active enemies → VICTORY
RUNNING → player dies → GAME OVER
5. Health.cs — Hit Point System
What it does: Tracks HP for any entity (player or enemy). Exposes:
Configure(faction, maxHP)— sets faction and max healthApplyDamage(amount, hitPoint)— reduces HP, spawns hit effect, firesDamagedevent, triggers death at 0RestoreFull()— resets to max HP (used on player respawn)Kill()— instant death (used by bomb screen-clear)- Events:
Died(Action),Damaged(Action<float, Vector2>)
Why it works this way: Decoupled from entity logic. PlayerController and EnemyController both [RequireComponent(typeof(Health))] and subscribe to events. This means the damage system works identically regardless of who takes damage — the same ApplyDamage path is used for player health, enemy health, and bullet-vs-bullet collision.
Hit effect integration: Every ApplyDamage call automatically triggers EffectManager.SpawnHitEffect(), so visual feedback is always consistent.
6. BulletPool.cs — Object Pool for Bullets
What it does: Manages a pool of Bullet objects using a Queue<Bullet> (for O(1) get/return) and a HashSet<Bullet> for tracking live bullets:
Create(parent, name, color, faction, warmCount)— static factory that creates and pre-warms the poolSpawn(position, direction, speed, damage)— activates a pooled bullet or creates a new one if pool is emptyReturn(bullet)— deactivates and re-queues the bulletClearLiveBullets()— returns all active bullets to pool (used by bomb and screen clear)
Why it works this way: In a bullet-hell game, thousands of bullets are spawned and destroyed per second. Without pooling, Instantiate/Destroy would generate massive GC pressure. The Queue provides FIFO reuse, and the HashSet enables O(1) membership checking for ClearLiveBullets().
Warm capacity: Pre-allocates 400 player bullets and 900 enemy bullets at startup to avoid first-frame allocation spikes. These numbers were tuned empirically to the game's peak bullet counts.
Pool hierarchy: Inactive bullets are parented to a hidden _poolRoot transform. When spawned, bullets are unparented (SetParent(null)) to avoid transform hierarchy overhead.
7. Bullet.cs — Individual Bullet Behavior
What it does: Each bullet moves in a straight line at constant speed, checks bounds, and handles collisions:
- Movement:
transform.position += direction * speed * deltaTime(no physics engine) - Bounds check: despawns if beyond ±14 units from origin
- Collision via
OnTriggerEnter2D:- Bullet vs bullet (opposite faction): both despawn
- Bullet vs Health (opposite faction): applies damage, despawns
- Respects player invulnerability
- Sprite caching:
static Dictionary<Color, Sprite> SpriteCache— generates colored 16×16 pixel textures once and reuses them
Why it works this way: Using transform.position directly instead of Rigidbody2D.velocity gives deterministic control over movement. The Rigidbody2D is set to Kinematic with Interpolation — it exists only so Unity's physics system can generate trigger events, not for actual physics simulation.
Sprite generation: Bullets generate their own 16×16 solid-color sprites via a static cache. This avoids needing any sprite assets and ensures bullets of the same color share the same Sprite object.
8. EnemyBlueprint.cs — Enemy Archetype Configuration
What it does: A sealed immutable data class that defines all parameters for an enemy type:
- Name, color, scale, sprite
- Movement style (
EnemyMovementStyleenum: ZigZag, Straight, Sine, Orbit) - Fire style (
EnemyFireStyleenum: None, Radial, Stream, Burst) - Numerical stats: move speed, horizontal amplitude/frequency, fire interval, bullets per volley, bullet speed/damage, contact damage, health, score value, orbit radius, centering bias
Why it works this way: Using a plain C# class instead of ScriptableObject keeps everything in code (no Unity asset files). The 19-parameter constructor is intentionally explicit — it makes the 4 enemy definitions in EnemySpawner.BuildBlueprints() fully self-documenting.
Four archetypes:
| Name | Movement | Fire | Behavior |
|---|---|---|---|
| Blazer | ZigZag | Radial | Orange, zig-zags down, fires radial bursts (12+ bullets) |
| Striker | Straight | Stream | Purple, drops straight down, fires aimed stream + homing shots |
| Cascade | Sine | Burst | Green, sine-waves, fires tight burst salvos |
| Interceptor | Orbit | None | Yellow, fast orbital movement, no shooting, relies on collision |
9. EnemyController.cs — Enemy AI and Behavior
What it does: Controls individual enemy behavior every frame:
- Movement: Four movement patterns based on
EnemyBlueprint.Movement:ZigZag: Moves down + sinusoidal horizontal offsetStraight: Pure downward movementSine: Downward + absolute sine positioning (position-based, not velocity-based)Orbit: Circular/elliptical path with slower vertical descent
- Firing: Three firing patterns based on
EnemyBlueprint.Fire:Radial: N bullets evenly around 360° with a rotating offsetStream: Downward spread + one homing shot aimed at the playerBurst: Triple salvos of 3 bullets each at increasing spread
- Difficulty scaling: All stats scale with
_difficultyparameter: health increases (+12 per difficulty unit), movement speeds up, fire rate decreases, radial bullet count increases - Death: Awards score, spawns explosion effect, notifies spawner, destroys self
- Contact damage: If the enemy's collider touches the player, deals
ContactDamageand the enemy self-destructs - Despawn: If the enemy falls below the camera view by
despawnPadding, it's cleaned up
Why it works this way: The pattern-based movement system allows each enemy type to feel distinct while sharing the same update loop. Difficulty scaling is multiplicative — the same AdvanceMovement() code handles early weak enemies and late-game tough ones.
Centering bias: Some enemies (Blazer with 1.2 bias) tend to drift toward center X, making them cluster dangerously. This creates organic difficulty without specific AI pathfinding.
10. EnemySpawner.cs — Wave Generation System
What it does: Singleton that continuously spawns enemy waves with escalating difficulty:
- Spawn routine: Coroutine that loops for
totalDuration(90s), spawning waves at decreasing intervals - Spawn delay curve: Linearly interpolates from
spawnDelayStart(1.6s) tospawnDelayEnd(0.45s) based on elapsed time - Introduction phase: First 4 enemies are spawned one at a time (one of each type) so the player sees all archetypes
- Wave composition: After introduction, spawns 2–10 enemies per wave, scaling with elapsed time (
1 + elapsed * 0.05) - Difficulty value: Linearly interpolates from 1.2 to 6.0 based on game progress
- Spatial distribution: Enemies spawn at the top of the camera view, horizontally distributed with slight randomness and centering bias
- Screen clear:
ClearScreen()kills all active enemies and clears all enemy bullets (used by player bomb) - Procedural sprites:
GenerateSprite()creates 64×64 pixel textures for four shapes: Disc, Triangle, Diamond, Arrow - Blueprint definitions:
BuildBlueprints()creates all four enemy archetypes with hardcoded stats
Why it works this way: The coroutine-based spawn loop allows natural timing control without complex state machines. The escalating difficulty curve ensures the game gets progressively harder over 90 seconds — from manageable waves of 2–3 enemies to frantic swarms of 10.
Enemy tracking: A List<EnemyController> _liveEnemies tracks all active enemies. This is critical for:
HasActiveEnemies(checked by GameDirector for victory condition)ClearScreen()(iterating all enemies to kill them)
11. PlayerController.cs — Player Input and Progression
What it does: Comprehensive player logic:
Movement (WASD/arrows):
- Base speed 12 units/s, focus mode (LeftShift) at 0.6× speed
- Clamped to camera bounds with 0.5 unit padding
Shooting (Z key or left mouse):
- Auto-fire at 0.12s intervals (8.3 shots/sec)
- Fires a volley of bullets in a fan pattern, size based on level
- Base volley: 5 bullets with angular spread increasing by level
- Extra directional shots unlock at levels 2, 4, 6, 8 (side, diagonal, back shots)
Bomb (X key or space or right mouse):
- One-use screen clear: destroys all enemies, clears all bullets
- Visual: player flashes white, dashes upward then returns
- Grants temporary invulnerability during animation
Progression system:
- Level calculated from score using exponential thresholds:
baseLevelThreshold(1500), doubling each level - Max level: 12
- Each level-up: visual flash, explosion effect, camera shake
- Stats affected: volley width (more bullets), extra directional shots
Health/Lives:
- Starts with
maxLives(default 1) - On death: explosion VFX, renderer disabled, wait
respawnDelay(0.7s), clear screen, reposition to bottom, flash invulnerability for 2s - If lives < 0: game over sequence
Test modes (command-line or runtime toggles):
--invincible/ I key: permanent invulnerability (yellow color indicator)--stationary/ P key: disables all movement and shooting (for passive performance testing)
Why it works this way: The progression mechanic (score → level → more bullets) creates a natural performance escalation: as the player gets stronger, more player bullets are on screen, increasing GPU/CPU load. This is deliberate for benchmarking — the workload scales organically over the 90-second run.
Test modes rationale: --invincible prevents game-over during unattended benchmark runs. --stationary isolates enemy/spawner performance by removing player bullet generation.
12. ScoreManager.cs — Score and HUD Controller
What it does: Singleton that manages:
- Score accumulation and display (
Score: N) - Life icon visibility (enabling/disabling
Imagecomponents) - Status messages ("GAME OVER" in red, "YOU WIN" in green)
- Damage flash overlay (full-screen red pulse with sine-curve fade)
ScoreChangedevent (consumed byPlayerControllerfor level-up detection)
Why it works this way: The queued initialization pattern (_queuedLives, _queuedStatus) handles race conditions where SetLives() or status messages are called before the HUD is fully constructed. This is essential because PlayerController.Initialize() and ScoreManager.Initialize() may run in unpredictable order.
Damage flash: The DamageFlashRoutine uses a sine curve (sin(normalized * PI)) to create a smooth peak-and-fade effect. Strength parameter controls peak alpha (0.35–0.75).
13. EffectManager.cs — Visual Effects Hub
What it does: Singleton managing all visual feedback using pooled EffectPulse sprites:
SpawnHitEffect(position, faction, intensity)— 1–3 expanding circles (color by faction)SpawnExplosion(position, faction, size)— 4 concentric expanding circlesSpawnScreenClear(position, radius)— 6 large expanding circles + camera shakeShakeCamera(duration, strength)— delegates toCameraShaker- Internal pool of
EffectPulseobjects (warmed to 64, recycled via callbacks)
Why it works this way: Like BulletPool, effect objects are pooled to avoid GC spikes. The multi-pulse explosion technique (multiple expanding circles at different speeds) creates convincing explosion effects using only simple scaling sprites — no particle systems, no shaders.
14. EffectPulse.cs — Animated Sprite Effect
What it does: A single expanding, fading sprite:
Play(position, color, startSize, endSize, duration, callback)— configure and start- Each frame: interpolates scale from start to end, fades alpha from 1 to 0
- On completion: deactivates self, invokes recycle callback
Sprite generation: BuildPulseSprite() creates a 32×32 radial gradient texture (alpha = (1 - distance)²) giving a soft, circular glow effect.
Why it works this way: The radial gradient sprite combined with scale animation creates the appearance of an expanding shockwave or explosion ring. The quadratic alpha falloff (alpha * alpha) ensures edges are smooth.
15. CameraShaker.cs — Screen Shake
What it does: Attached to the main camera, provides juicy screen shake:
Shake(duration, strength)— sets shake parameters (max of current and new)- Each frame during shake: random offset within
_shakeStrengthcircle, strength decaying over time - After shake: smoothly lerps back to original position
Why it works this way: The "max of current and new" approach allows overlapping shakes without resetting, creating more organic screen feedback during intense combat.
16. BackgroundScroller.cs — Parallax Starfield
What it does: Creates two large starfield tiles and scrolls them downward at different speeds:
- Tile A: scrolls at
scrollSpeed(full speed) - Tile B: scrolls at
scrollSpeed * parallaxFactor(0.6× speed, creating depth illusion) - When a tile scrolls off the bottom, it teleports back above the top (infinite loop)
GenerateStarSprite(): Creates a procedural starfield texture — dark background with random white pixels (star density: ~1 per 45 pixels)
Why it works this way: The parallax effect gives depth to a 2D game with zero external assets. Two tiles ensure seamless continuous scrolling. The procedural generation means no texture files are needed in the build.
Performance-Critical Design Decisions
| Decision | Rationale |
|---|---|
| Object pooling for bullets and effects | Avoids GC allocation spikes from Instantiate/Destroy |
transform.position movement (no physics forces) |
Deterministic movement, no physics solver overhead |
| Kinematic Rigidbody2D on bullets | Enables trigger detection without full physics simulation |
| Procedural sprite/texture generation | Zero asset loading, reproducible across machines |
targetFrameRate = 240 |
Uncapped rendering for benchmarking |
| Scene-free bootstrap | Eliminates scene deserialization variability |
| HashSet for live bullet tracking | O(1) membership test during screen clears |
| Static sprite caching (Bullet.SpriteCache) | Single shared texture per color |