16 KiB
Blueprint vs Code: A Practical Comparison
Back to Index | Code-First Tutorial
The Problem: Editor-Heavy Blueprints Are Tedious
When implementing the bullet-hell game using Blueprints, you face these specific pain points:
Pain Point 1: Defining Multiple Variables
Blueprint Approach (Part 2: Create Player):
For each of the 12 player variables, you must:
- Click "+" button in Variables panel
- Type variable name manually
- Click in Details panel
- Scroll to find "Variable Type" dropdown
- Select type from long list (Float, Integer, Vector 2D, etc.)
- Click "Compile" button
- Wait for compile
- Click on variable again
- Find "Default Value" in Details panel
- Type the value manually
Result: ~60 seconds per variable × 12 variables = ~12 minutes just for player variables
If you make a typo, you have to:
- Delete the variable
- Start over from step 1
Code Approach:
// Copy-paste this entire block (10 seconds):
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float MoveSpeed = 750.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
FVector2D BoundsMin = FVector2D(-850.0f, -450.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
FVector2D BoundsMax = FVector2D(850.0f, 450.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float FireInterval = 0.08f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float BulletSpeed = 2200.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 VolleySize = 3;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float VolleySpread = 12.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxLives = 3;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stats")
int32 CurrentLives = 3;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stats")
int32 Score = 0;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stats")
bool bSpecialUsed = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float FireRate = 0.08f;
Result: 10 seconds to copy-paste entire block
Time saved: ~11 minutes per class × 4 classes (Player, Bullet, Enemy, GameMode) = ~44 minutes saved
Pain Point 2: Copying Parameters Between Classes
Scenario: You want Enemy bullets to have similar but slightly different parameters than Player bullets.
Blueprint Approach:
- Open BP_Bullet
- For each variable you want to copy:
- Memorize or write down: name, type, default value
- Open BP_Enemy
- Click "+" to add variable
- Type name manually (risk of typo)
- Select type from dropdown
- Compile
- Set default value
- Repeat 5-10 times
Result: ~5 minutes to copy 5-10 related variables, error-prone
Code Approach:
// In STGProjectile.h - player bullets
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gameplay")
float Speed = 2200.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gameplay")
float Lifetime = 4.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gameplay")
FLinearColor BulletColor = FLinearColor::Green;
// In STGEnemy.h - copy the block, change the values
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float EnemyBulletSpeed = 1000.0f; // Changed value
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float EnemyBulletLifetime = 6.0f; // Changed value
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
FLinearColor EnemyBulletColor = FLinearColor::Red; // Changed value
Result: 30 seconds to copy-paste and modify
Benefit:
- ✅ No typos in variable names (copy-paste exact)
- ✅ No wrong type selection (copy-paste exact)
- ✅ Easy to see differences (use IDE diff/compare)
- ✅ Can use find/replace to batch rename
Pain Point 3: Blueprint Node Logic vs Code Logic
Example: Enemy firing a radial burst of bullets
Blueprint Approach:
You need to create this node network:
- Event Tick node
- Branch node (check if should fire)
- Get FireTimer variable node
- Float - Float (subtract) node
- Get World Delta Seconds node
- Set FireTimer node
- Float <= Float (comparison) node
- Constant 0.0 node
- Branch node
- For Loop node with Break node
- Get BulletsPerBurst variable node
- Float / Float (divide) node for angle calculation
- Get BurstSpread variable node
- Float * Float (multiply) node
- Integer - Integer (subtract) for centering
- Spawn Actor from Class node
- Make Rotator node
- Get Actor Location node
- Get BulletClass variable node
- Cast to BP_Bullet node
- Set bIsPlayerBullet node
- Set BulletColor node
- ... (30+ nodes total)
Issues:
- Hard to see the big picture (nodes scattered across screen)
- Easy to forget connections (white execution wires vs blue data wires)
- Difficult to debug (print each node's output)
- Can't easily comment blocks of logic
- No IDE autocomplete or type checking
Code Approach:
void ASTGEnemy::Fire()
{
// Fire radial burst of bullets
for (int32 i = 0; i < BulletsPerBurst; i++)
{
// Calculate angle for this bullet in the burst
float AngleDeg = (BurstSpread / BulletsPerBurst) * i;
float AngleRad = FMath::DegreesToRadians(AngleDeg);
// Calculate direction vector
FVector Direction = FVector(
FMath::Cos(AngleRad),
FMath::Sin(AngleRad),
0.0f
);
// Spawn location slightly in front of enemy
FVector SpawnLocation = GetActorLocation() + FVector(0.f, 0.f, -30.f);
// Spawn the bullet
ASTGProjectile* Bullet = GetWorld()->SpawnActor<ASTGProjectile>(
ASTGProjectile::StaticClass(),
SpawnLocation,
Direction.Rotation()
);
if (Bullet)
{
Bullet->bIsPlayerBullet = false;
Bullet->SetSpeed(EnemyBulletSpeed);
Bullet->SetBulletColor(FLinearColor::Red);
}
}
}
Benefits:
- ✅ All logic in one place, easy to read top-to-bottom
- ✅ IDE autocomplete helps prevent typos
- ✅ Inline comments explain the logic
- ✅ Easy to debug (set breakpoints, inspect variables)
- ✅ Can copy-paste entire function to reuse pattern
- ✅ Compiler catches type errors immediately
Pain Point 4: Making Changes to Multiple Parameters
Scenario: After playtesting, you decide all enemy health values should be doubled.
Blueprint Approach:
- Open BP_Enemy
- Find "MaxHealth" variable in My Blueprint panel
- Click on it
- Find Default Value in Details panel
- Change 12 → 24
- Click Compile
- Click Save
- Repeat for any enemy variants (BP_EnemyA, BP_EnemyB, etc.)
If you have 4 enemy types with different health values:
- Open each Blueprint
- Find the variable
- Change the value
- Compile and save
- Result: ~2 minutes
Code Approach:
// Before (in STGEnemy.h):
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxHealth = 12;
// After - just change one line:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxHealth = 24;
// Compile once, all enemies updated
Or use find/replace to change multiple values at once:
- Find:
MaxHealth = 12; - Replace:
MaxHealth = 24; - Done in 5 seconds
Benefit: 24x faster (2 minutes vs 5 seconds)
Pain Point 5: Version Control and Collaboration
Blueprint Approach:
Blueprint files are binary, which means:
❌ Cannot see changes in Git diff
diff --git a/Content/Blueprints/BP_Player.uasset b/Content/Blueprints/BP_Player.uasset
index a1b2c3d..e4f5g6h 100644
Binary files a/Content/Blueprints/BP_Player.uasset and b/Content/Blueprints/BP_Player.uasset differ
You cannot tell:
- What changed?
- Who changed it?
- Why was it changed?
- Is it safe to merge?
❌ Merge conflicts are nightmares
- Two people modify same Blueprint
- Git shows "Binary conflict"
- You must manually recreate changes by:
- Opening both versions in Unreal
- Comparing node-by-node
- Manually rebuilding the merged version
Code Approach:
C++ files are plain text, which means:
✅ Readable Git diffs
diff --git a/Source/BulletHellGame/STGPawn.h b/Source/BulletHellGame/STGPawn.h
index a1b2c3d..e4f5g6h 100644
--- a/Source/BulletHellGame/STGPawn.h
+++ b/Source/BulletHellGame/STGPawn.h
@@ -15,7 +15,7 @@
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
- float MoveSpeed = 750.0f;
+ float MoveSpeed = 850.0f; // Increased for better feel
You can see:
- ✅ Exactly what changed (MoveSpeed increased)
- ✅ Who changed it (Git blame)
- ✅ Why (commit message + comment)
- ✅ Easy to review (GitHub PR with inline comments)
✅ Merge conflicts are manageable
- Git shows exactly which lines conflict
- Use standard merge tools (GitKraken, VSCode, etc.)
- Resolve conflicts like any code file
Real-World Example: Adding a New Enemy Type
Blueprint Approach
Steps:
- Duplicate BP_Enemy → BP_EnemyVariant
- Open BP_EnemyVariant
- Find MaxHealth variable → change default value
- Find ScoreValue variable → change default value
- Find VerticalSpeed variable → change default value
- Find HorizontalAmplitude variable → change default value
- Find FireInterval variable → change default value
- Find BulletsPerBurst variable → change default value
- Compile
- Find MeshComp in Components panel
- Assign different mesh in Details panel
- Find material slot → assign different material
- Save
Time: ~8-10 minutes per variant
If you need 4 enemy types: ~35 minutes
Code Approach
Steps:
- Create child Blueprint from STGEnemy: BP_EnemyVariant
- Open BP_EnemyVariant
- In Class Defaults panel, change visible C++ properties:
- MaxHealth: 24 (was 12)
- ScoreValue: 100 (was 50)
- VerticalSpeed: 300 (was 220)
- FireInterval: 0.2 (was 0.35)
- Assign different mesh
- Assign different material
- Done
Time: ~2 minutes per variant
If you need 4 enemy types: ~8 minutes
OR define variants in C++ enum and configure in constructor:
// In STGEnemy.h:
UENUM(BlueprintType)
enum class EEnemyType : uint8
{
Basic,
Fast,
Tank,
Boss
};
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy")
EEnemyType Type = EEnemyType::Basic;
// In STGEnemy.cpp constructor:
void ASTGEnemy::ConfigureStats()
{
switch (Type)
{
case EEnemyType::Basic:
MaxHealth = 12;
ScoreValue = 50;
VerticalSpeed = 220.0f;
FireInterval = 0.35f;
break;
case EEnemyType::Fast:
MaxHealth = 6;
ScoreValue = 75;
VerticalSpeed = 400.0f;
FireInterval = 0.2f;
break;
case EEnemyType::Tank:
MaxHealth = 30;
ScoreValue = 150;
VerticalSpeed = 120.0f;
FireInterval = 0.5f;
break;
case EEnemyType::Boss:
MaxHealth = 100;
ScoreValue = 500;
VerticalSpeed = 80.0f;
FireInterval = 0.15f;
break;
}
}
Now you can:
- Create ONE Blueprint that inherits from STGEnemy
- Change the "Type" dropdown in editor
- All stats automatically configured!
Time: ~30 seconds to change dropdown per variant
Summary: Time Investment
Full Bullet Hell Game Implementation
| Task | Blueprint | Code-First | Time Saved |
|---|---|---|---|
| Player (12 variables) | 15 min | 2 min | 13 min |
| Player logic (movement, fire) | 60 min | 15 min | 45 min |
| Bullet (5 variables) | 8 min | 1 min | 7 min |
| Bullet logic | 30 min | 10 min | 20 min |
| Enemy (15 variables) | 20 min | 2 min | 18 min |
| Enemy logic | 90 min | 25 min | 65 min |
| Enemy variants (×4) | 35 min | 8 min | 27 min |
| Game Mode (8 variables) | 12 min | 2 min | 10 min |
| Game Mode logic | 45 min | 15 min | 30 min |
| Spawning system | 40 min | 12 min | 28 min |
| TOTAL | ~6-8 hours | ~2-3 hours | ~4-5 hours saved! |
One-Time Learning Investment
- Blueprint tutorial: ~2 hours to learn the editor UI
- C++ tutorial: ~4 hours to learn C++ basics
BUT: After the initial learning, C++ is 3x faster for every project going forward!
Recommended Workflow
🎯 Best Practice: Hybrid Approach
-
Define all logic and variables in C++
- All game mechanics
- All stats, speeds, health values
- All algorithms (movement, firing, spawning)
-
Use Blueprints only for visual assets
- Assign meshes/sprites
- Set material colors
- Configure particle effects
- Place sound effects
Example:
// STGEnemy.h - all logic in C++
class ASTGEnemy : public AActor
{
UPROPERTY(EditAnywhere, Category = "Stats")
int32 MaxHealth = 12;
UPROPERTY(EditAnywhere, Category = "Stats")
float VerticalSpeed = 220.0f;
void Fire(); // Implemented in C++
void UpdateMovement(float DeltaTime); // Implemented in C++
};
Then in Blueprint BP_Enemy (child of ASTGEnemy):
- Inherits all C++ variables and logic
- Only sets: Mesh, Material, Color
- Can still override C++ functions in Blueprint if needed
Best of both worlds:
- ✅ Fast development (C++ for logic)
- ✅ Easy asset management (Blueprint for visuals)
- ✅ Version control friendly (most changes are in C++)
- ✅ Designer-friendly (non-programmers can tweak visuals)
Migration Path
If You've Already Started with Blueprints
Don't worry! You can migrate gradually:
Option 1: Reparent Existing Blueprints
- Create C++ class (e.g.,
STGPawn) - Compile
- Open existing Blueprint (e.g.,
BP_Player) - File → Reparent Blueprint
- Select your C++ class
- Blueprint now inherits from C++!
- Delete duplicate variables (they're now in C++)
- Keep only visual asset assignments
Option 2: Hybrid Migration
- Keep Blueprints for now
- For new features, use C++
- Gradually move logic to C++ over time
- Eventually Blueprints become thin wrappers
Option 3: Fresh Start with Code-First
- Follow Code-First Tutorial
- Reference your existing Blueprint logic
- Reimplement in C++ (faster than original creation!)
- Create new minimal Blueprints that inherit from C++
Conclusion
When Blueprint-Heavy Makes Sense
- ✅ Learning Unreal for the first time (visual learning)
- ✅ Solo project, no version control needed
- ✅ Rapid prototyping (test idea in 30 minutes)
- ✅ Heavy use of animation state machines
- ✅ Level design and visual scripting
When Code-First Makes Sense
- ✅ Multiple variables to configure (the pain you described!)
- ✅ Team project (version control is critical)
- ✅ Medium to large game (6+ hours of development)
- ✅ Complex game logic (algorithms, math, loops)
- ✅ Want to reuse code across projects
- ✅ Want IDE tools (refactoring, autocomplete, debugging)
For This Bullet Hell Game Specifically
Code-First is strongly recommended because:
- ✅ Tons of variables (50+ across all classes)
- ✅ Mathematical logic (radial bursts, sine waves, interpolation)
- ✅ Repeated patterns (firing, movement, spawning)
- ✅ Multiple similar classes (enemy variants)
- ✅ Likely to iterate (balancing, tweaking values)
Get Started
Ready to try the code-first approach?
→ Start the Code-First C++ Tutorial
Or continue with the Blueprint tutorial: