DEV Community

Game Dev Notes (Korea)
Game Dev Notes (Korea)

Posted on

Unity vs Unreal: 5 Things I Had to Relearn the Hard Way

The first time I opened Unreal after years of living in Unity, I sat there for a full ten minutes trying to figure out where the play button was. Not because I couldn't see it. Because I didn't trust that pressing it would do what I expected. Every muscle memory I'd built up — drag a script onto a GameObject, hit play, see the thing wiggle — was suddenly worthless. The viewport looked similar enough to lull me into a false sense of familiarity, and then five seconds later I was fighting the editor over what "save" even means.

Going the other direction is just as bad. Unreal devs who jump into Unity often spend their first week asking why the engine keeps eating their references on hot reload, or why the Inspector won't show their perfectly reasonable struct.

I've shipped work in both engines at this point, and the honest truth is that the syntax barrier (C# vs C++) is the easy part. What actually slows you down is the dozens of small mental model mismatches that nobody warns you about until you've already lost a weekend to them. The engines look like they solve the same problems, so you assume the shape of the solution is the same. It isn't.

Here are five things our studio had to genuinely rewire our brains around. Not engine trivia — the stuff that quietly costs you days if you don't catch it early.

1. Garbage Collection Lives in Different Universes

Unity gives you C# and the .NET GC. Unreal gives you C++ and a custom mark-and-sweep GC that only knows about objects you've explicitly told it about. These sound similar. They are not.

In Unity, you write a class, you new it up, and the GC takes care of it whenever it feels like. The classic gotcha is performance (allocations in Update = GC spikes), but the correctness model is straightforward. Everything's an object, everything's tracked.

In Unreal, the GC only tracks UObject subclasses, and only through references it can see — meaning references marked with UPROPERTY(). If you store a UObject* as a raw pointer without that macro, the GC has no idea you care about it, and your pointer becomes a dangling reference the next sweep. The object literally gets collected out from under you.

UCLASS()
class AEnemy : public AActor {
    GENERATED_BODY()

    // Safe: GC knows you reference this
    UPROPERTY()
    UWeapon* EquippedWeapon;

    // Time bomb: GC will happily delete this
    UWeapon* SecretWeapon;
};
Enter fullscreen mode Exit fullscreen mode

The mental rewire: in Unity, the GC is a performance concern. In Unreal, the GC is a correctness concern. You don't fight it for frame time, you fight it for object lifetime. Once that clicked, half of my "why is my pointer null" bugs disappeared.

The flip side: Unity devs jumping to Unreal often reach for TWeakObjectPtr reflexively because they're scared of the GC. Most of the time you just want a UPROPERTY()-marked pointer — it keeps the object alive and tracked. Save TWeakObjectPtr for the case it's actually designed for: you want to reference something without preventing its destruction (a UI panel watching a target actor that might die first).

2. Tick Is Not Update (And Lifecycle Is Not Awake)

On paper, MonoBehaviour.Update() and AActor::Tick() look like the same idea. Per-frame callback, runs every frame, do your thing. In practice the lifecycle and scope rules around them are different enough that you can't just port habits across.

Unity's MonoBehaviour has a fairly chatty lifecycle (Awake, OnEnable, Start, Update, LateUpdate, OnDisable, OnDestroy) and most of those fire reliably in the editor too. You can lean on Start for cheap initialization and forget about it.

Unreal splits actor lifetime across PostInitializeComponents, BeginPlay, Tick, EndPlay, with components having their own OnRegister / InitializeComponent flow. BeginPlay only fires when the game actually starts, which catches a lot of Unity converts off guard the first time they try to do initialization in the constructor and wonder why nothing's wired up yet.

AEnemy::AEnemy() {
    // Constructor runs for the CDO at engine init, and again for every instance.
    // In both cases the world isn't valid — no gameplay state, no other actors.
    // Defaults only; gameplay setup goes in BeginPlay.
    PrimaryActorTick.bCanEverTick = true;
}

void AEnemy::BeginPlay() {
    Super::BeginPlay();
    // Now the world exists. Do gameplay setup here.
}
Enter fullscreen mode Exit fullscreen mode

And then there's the other direction, which bites just as hard. Unreal devs coming into Unity expect a staged lifecycle and instead get a flat one with a bunch of quietly-different rules. Awake runs early, sure, but it runs before the scene's other objects are guaranteed to be wired up — so anything that reaches across to a sibling MonoBehaviour belongs in Start, not Awake. Start itself doesn't fire when you Instantiate the object; it waits for the first frame the object is active. OnEnable and OnDisable fire every time you toggle the GameObject, not just at creation — which is great for pooling and a trap if you treated them as one-shot setup hooks. And the C++ instinct to "just put real init in the constructor" has to be unlearned outright: Unity reserves the right to construct MonoBehaviours for serialization purposes when no scene exists, so gameplay code in the constructor is at best ignored, at worst crashes the editor on domain reload. On top of all that, Update runs at a variable rate; physics-coupled logic belongs in FixedUpdate, and that distinction is one of those things you only learn after your character starts vibrating at high frame rates.

The rewire: in Unity, lifecycle is flat and most things fire on a single timeline. In Unreal, lifecycle is staged: constructor for defaults, BeginPlay for runtime, and Tick is opt-in (you have to enable it explicitly, which is honestly a feature, not a bug). Treat them as different shaped tools.

3. Reflection Is the Whole Engine

This is the one I underestimated the longest.

Unity uses [SerializeField], [Serializable], and [SerializeReference] to drive its inspector and YAML serializer. It feels lightweight — public fields serialize by default, private ones need a single attribute — but the rules quietly bite: polymorphic references need [SerializeReference], custom classes need [Serializable] to appear in the inspector at all, and IL2CPP will happily strip any type only reached via reflection unless you preserve it with a link.xml or [Preserve]. It's lower-ceremony than Unreal, but it's not free.

Unreal's UPROPERTY(), UFUNCTION(), and UCLASS() macros look like the same idea but they're load-bearing structural pillars. They feed the reflection system that powers serialization, the editor, Blueprint exposure, network replication, GC tracking (see #1), and the property system. A field without UPROPERTY is invisible to all of it. A function without UFUNCTION can't be called from Blueprint or RPCs. A class without UCLASS doesn't exist to the engine at all.

UCLASS(Blueprintable)
class AEnemy : public AActor {
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
    float Health = 100.f;

    UFUNCTION(BlueprintCallable, Category="Combat")
    void TakeHit(float Damage);
};
Enter fullscreen mode Exit fullscreen mode

Each meta specifier (EditAnywhere, BlueprintReadWrite, Category, Replicated, etc.) is a load-bearing instruction to a different subsystem. You'll find yourself reading the Unreal docs on property specifiers more than any other page.

The rewire: in Unity, attributes are sugar. In Unreal, macros are the API. When something doesn't show up in the editor, doesn't replicate, doesn't appear in Blueprint, or gets GC'd unexpectedly, 90% of the time it's because you forgot a specifier. Build the habit of writing the macro first, the field second.

4. Editor Extension Is a Different Sport

Both engines let you extend the editor. Both will let you build custom inspectors, tool windows, asset processors. But the shape of how you do it diverges so hard that your tooling knowledge basically doesn't carry over.

Unity editor scripting is mostly C# in an Editor/ folder, using UnityEditor APIs, EditorWindow, CustomEditor, and either IMGUI for quick stuff or UI Toolkit (UXML/USS) for anything you'd actually want to maintain. It's the same language as your gameplay code, which makes it cheap to start writing. Half our internal tools at the studio started as a 50-line EditorWindow someone wrote during lunch.

Unreal splits editor code into separate modules (you'll be editing .Build.cs files), and the UI is Slate, a declarative C++ DSL that looks like nothing else you've ever written. It's powerful, but the learning curve is real.

// Slate looks like this. Yes, really.
SNew(SVerticalBox)
+ SVerticalBox::Slot()
  .AutoHeight()
  [
      SNew(STextBlock)
      .Text(FText::FromString("Hello, tools"))
  ];
Enter fullscreen mode Exit fullscreen mode

You can avoid Slate for a while using Editor Utility Widgets (UMG in the editor) or Editor Utility Blueprints, which is honestly where I tell anyone starting out to live. Pure Slate is for when you need something that genuinely belongs in the editor chrome.

The rewire: in Unity, "I'll write a quick editor tool" is a fifteen-minute commitment. In Unreal, it's an afternoon if you go native, or fifteen minutes if you accept that Editor Utility Widgets are good enough for 80% of cases. Pick the right tier for the job. Don't try to write Slate for a button that just renames some assets.

5. The Asset Pipeline Has Opinions

The thing nobody tells you: your project structure isn't just organization. It's a contract with the build system.

Unity treats anything in Assets/ as fair game. You move things around freely, the .meta files keep references intact, AssetBundles are an opt-in deployment thing you set up when you need streaming or DLC. The pipeline is forgiving. Maybe too forgiving. Most Unity projects I've inherited had Assets/ folders that looked like a teenager's desktop.

Unreal's Content/ directory is a more structured place. Every asset is a .uasset with cooked variants, references are tracked by path, and moving things around outside the editor will absolutely break your references in ways that aren't always obvious until you try to package. The cook step at build time is a whole second pipeline you have to think about: what's referenced, what gets included, what gets stripped.

// Unity                  // Unreal
Assets/                   Content/
  Scripts/                  Blueprints/
  Prefabs/                  Maps/
  Scenes/                   Materials/
  Materials/                Characters/
  Resources/                ...
                          (Cooked output → Saved/Cooked/<Platform>/)
Enter fullscreen mode Exit fullscreen mode

Two specific traps worth flagging early:

  • Unity's Resources/ folder is convenient and almost always the wrong answer at scale. Anything in there ships in your build whether you reference it or not. For anything beyond a prototype, move to Addressables (or whichever streaming asset system your Unity version blesses — the recommendation has shifted more than once).
  • Unreal's hard-vs-soft references (UPROPERTY() of UObject* vs TSoftObjectPtr<>) determine what gets pulled into memory when. Get this wrong on a mid-size project and your initial map load time will quietly balloon.

The rewire: Unity's pipeline lets you defer thinking about deployment until late. Unreal's pipeline forces you to think about it early. Neither approach is wrong, but pretending you're in the other engine's model is how you ship a 4 GB build that should've been 800 MB.

Closing Thoughts

The pattern across all five of these: the engines look like they solve the same problems with slightly different syntax, and that's the trap. They actually have different philosophies about who owns what — the GC, the editor, the build, the lifecycle, the reflection system. Once you stop trying to translate Unity habits into Unreal (or vice versa) and start learning each engine's actual mental model, everything gets faster.

The other thing this taught me: every time I assumed two systems were "basically the same," I was about to lose a day. Now when I jump into something new, I make myself find the one thing that's structurally different from what I'm used to, before I touch any code. Saves a lot of grief.

If you've made the jump in either direction, I'd genuinely love to hear what tripped you up the worst. The five above are the ones that hit our studio hardest, but I'm sure there's a sixth waiting for the next person who switches engines. Probably something about input systems, knowing my luck.

This blog's where we plan to write down the small, unglamorous stuff we keep learning the hard way. No newsletter pitch — just more of these when we hit the next one.

Top comments (0)