Skip to main content

๐Ÿงฑ Lesson 1.1: Assembly Definitions & Structuring a Large Project

By default, every C# script in your project lands in one giant assembly โ€” Assembly-CSharp.dll. Change one line and Unity recompiles everything. On a small game that's fine; on a real project it means ten-second waits after every keystroke and a codebase where anything can reference anything. This lesson introduces the single most important structural tool Unity gives you: the assembly definition. You'll learn what an .asmdef is, why splitting your code into assemblies makes compiles faster and dependencies enforceable, and how to lay out a large project so it stays sane as it grows.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an .asmdef file is and how it maps a folder of scripts to a compiled assembly
  • Justify assemblies in terms of faster incremental compiles and enforced dependency boundaries
  • Create an assembly definition, wire up assembly references, and diagnose a cyclic-reference error
  • Split editor-only code from runtime code, and use an .asmref to extend an assembly across folders
  • Constrain an assembly by platform and by scripting define
  • Lay out a large project as Core / Gameplay / UI / Editor / Tests with a clean dependency direction

Estimated Time: 55 minutes  ยท  Prerequisite: Confident C# and comfort with the Unity Editor's Project window (Unity Intermediate)

In This Lesson

The One-Assembly Problem

When you write a script and don't do anything special, Unity compiles it into a predefined assembly called Assembly-CSharp.dll. Every other loose script goes there too. This has two consequences that get more painful the bigger your project gets.

First: compile times. C# compiles a whole assembly at a time. If all 2,000 of your scripts live in one assembly, touching a single one forces the compiler to rebuild all 2,000 โ€” and Unity to reload the whole domain โ€” before you can press Play. Split those scripts into ten assemblies and editing a UI script only recompiles the UI assembly. Incremental compilation is the headline reason studios adopt assemblies early.

Second: no boundaries. When everything is in one assembly, every class can using and call every other class. Your enemy AI can reach straight into your save system, your UI can poke gameplay internals, and nothing stops it. Architecture becomes a suggestion. Assemblies turn "please don't depend on that" into a compile error โ€” the only kind of rule a codebase actually keeps.

๐Ÿ“– Definition

An assembly is a single compiled unit โ€” a .dll. C# resolves references and enforces access at the assembly level: an assembly can only see the assemblies it explicitly references, and internal members are visible only within their own assembly. Assembly definitions let you decide where those boundaries fall in your project instead of dumping everything into one.

What an .asmdef Actually Is

An assembly definition file (.asmdef) is a small JSON asset you drop into a folder. It tells Unity: "compile every script in this folder and its subfolders into one assembly, named this, referencing these other assemblies." The folder becomes the assembly's boundary; the JSON becomes its rulebook.

Here's what one looks like on disk. This is the raw JSON Unity's inspector edits for you โ€” worth reading once so the checkboxes in the UI make sense:

{
    "name": "Studio.Gameplay",
    "rootNamespace": "Studio.Gameplay",
    "references": [
        "Studio.Core",
        "Unity.InputSystem"
    ],
    "includePlatforms": [],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": false,
    "precompiledReferences": [],
    "autoReferenced": true,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}

The fields you'll touch most:

  • name โ€” the compiled assembly's name (and the name other .asmdefs use to reference it). Convention: Company.Feature, e.g. Studio.Gameplay.
  • references โ€” the other assemblies this one is allowed to use. If it's not listed here, you can't using it. This is the boundary.
  • includePlatforms / excludePlatforms โ€” restrict which build targets this assembly compiles for (see the constraints section).
  • autoReferenced โ€” when true, the default Assembly-CSharp can see this assembly automatically. Turn it false for editor/tools assemblies you don't want leaking into game code.
  • defineConstraints โ€” only compile this assembly when a scripting define symbol is present.
๐Ÿ’ก One rule to internalize. The folder an .asmdef lives in defines the assembly's contents; the references list defines what it may depend on. Move a script into that folder tree and it joins the assembly automatically โ€” no manual file lists.

Creating One & Wiring References

Creating an assembly definition is a right-click away. The steps:

  1. Make a folder for the module, e.g. Assets/Scripts/Gameplay.
  2. Right-click it โ†’ Create โ–ธ Scripting โ–ธ Assembly Definition. Name it after the assembly: Studio.Gameplay.
  3. Select the new .asmdef. In the Inspector, under Assembly Definition References, click + and add the assemblies this module needs โ€” say Studio.Core and Unity.InputSystem.
  4. Click Apply. Unity recompiles, and now Studio.Gameplay is its own .dll.

The moment you add that first .asmdef, the rules change: scripts in that folder can only reach engine assemblies plus what you listed. Reference a class you haven't wired up and you get a hard The type or namespace name could not be found โ€” the boundary talking.

โš ๏ธ Referencing packages: name vs GUID

You can reference assemblies by name (like Unity.InputSystem) or by GUID. Name references read clearly in the JSON and survive across projects; GUID references survive an assembly rename. Unity's default is GUID with the name shown in the UI. Either works โ€” just be consistent, and prefer names when you hand-edit JSON so a teammate can read it.

A second lever hides in the same inspector: Use GUIDs, No Engine References (for pure logic assemblies that shouldn't touch UnityEngine at all โ€” handy for testable, portable code), and Auto Referenced. For a normal gameplay module leave these at defaults; you'll reach for them deliberately as your architecture matures.

Cyclic References & Dependency Direction

Assemblies form a directed acyclic graph โ€” dependencies must flow one way, and you may never form a loop. If Studio.Core references Studio.Gameplay and Studio.Gameplay references Studio.Core, Unity refuses to compile either and reports a cyclic assembly reference. There's no override; the loop must be broken.

This constraint is a feature. It forces you to answer "who depends on whom?" up front. The healthy answer is a layered one: low-level, stable code at the bottom that knows nothing about the layers above it, and feature code on top that depends downward. Below is the shape you're aiming for.

Allowed layered assembly dependencies versus a forbidden cycle On the left, a legal dependency graph: a Core assembly at the bottom is referenced by a Gameplay assembly above it, which is referenced by a UI assembly at the top; separate Editor and Tests assemblies reference downward into Gameplay and Core. All arrows point downward toward Core. On the right, a forbidden configuration shows Core and Gameplay pointing at each other, forming a cycle marked with a red cross as illegal. โœ” Allowed โ€” dependencies flow one way UI Gameplay Core Editor Tests every arrow points down toward Core โ€” no loops โœ— Forbidden โ€” a cycle Gameplay Core cyclic assembly reference โ†’ won't compile Core must not know about Gameplay
Figure 1: Legal layered dependencies (left) all point downward toward a stable Core. A cycle (right) โ€” where Core also depends back on Gameplay โ€” is rejected by the compiler. When two assemblies want to reference each other, the fix is to extract the shared piece into a lower assembly they both point down to.

โœ… How to break a cycle

When A and B both want each other, extract the thing they share โ€” usually an interface or a data type โ€” into a lower assembly (say Core) that both reference. Now A and B both point down to Core and never at each other. This is the same seam-through-interfaces idea you'll formalize in Lesson 1.2: Dependency Injection & Inversion of Control.

Editor vs Runtime, and .asmref

Editor code โ€” custom inspectors, EditorWindows, build scripts โ€” uses the UnityEditor namespace, which does not exist in a build. If editor code ends up in a runtime assembly, your player build fails to compile. Assemblies are how you keep the two apart cleanly.

The convention: put editor code in an Editor subfolder with its own .asmdef whose Include Platforms is set to Editor only. That assembly references its runtime sibling (to extend it) plus the engine's editor assemblies, and โ€” because it's editor-only โ€” it simply isn't part of any build.

{
    "name": "Studio.Gameplay.Editor",
    "references": [ "Studio.Gameplay", "Studio.Core" ],
    "includePlatforms": [ "Editor" ],
    "autoReferenced": false
}

Sometimes you want a folder's scripts to join an existing assembly rather than form a new one โ€” for instance, a feature split across two locations. That's what an assembly definition reference (.asmref) is for. Create one via Create โ–ธ Scripting โ–ธ Assembly Definition Reference, point it at a target .asmdef, and every script in that folder compiles into the target assembly instead of the default one. An .asmref adds folders to an assembly; an .asmdef creates one.

๐Ÿ’ก The classic use. A big feature lives under Assets/Features/Inventory, but its editor tools naturally sit under Assets/Tools/InventoryEditor. Drop an .asmref in the tools folder targeting Studio.Inventory.Editor and both locations compile into one editor assembly โ€” no code moves.

Platform & Define Constraints

Two more knobs let an assembly compile only under certain conditions.

Platform constraints (includePlatforms / excludePlatforms) restrict which build targets an assembly is part of. An editor-only assembly uses includePlatforms: ["Editor"]. A console-specific input layer might exclude WebGL. If neither list is set, the assembly builds for every platform โ€” the usual case for gameplay code.

Define constraints (defineConstraints) compile the assembly only when a given scripting define symbol is present. This is how you gate optional integrations: an analytics adapter that only exists when ENABLE_ANALYTICS is defined, or code that depends on a package that may not be installed.

{
    "name": "Studio.Analytics.Adapter",
    "references": [ "Studio.Core" ],
    "defineConstraints": [ "ENABLE_ANALYTICS" ]
}

Closely related, version defines auto-create a define symbol when a specific package version is present โ€” the mechanism behind code that says "use the fast path only if the Collections package is 2.1 or newer." You set the package name and version range in the inspector, and Unity defines the symbol for you. You'll see this pattern when we integrate optional packages later in the course.

โš ๏ธ Constraints hide code silently

An assembly gated behind a missing define simply doesn't compile โ€” its types vanish, and any code referencing them errors as if they never existed. When a class mysteriously "isn't found," check whether its assembly has a define or platform constraint that isn't satisfied in your current setup. It's a common half-hour-lost bug.

A Sane Large-Project Layout

Put it together and a professional project's scripts organize into a handful of assemblies with a clear dependency direction. A solid starting skeleton:

Assets/
  Scripts/
    Core/            Studio.Core            (no game deps; utils, interfaces, data types)
      Studio.Core.asmdef
    Gameplay/        Studio.Gameplay        โ†’ references Core
      Studio.Gameplay.asmdef
    UI/              Studio.UI              โ†’ references Gameplay, Core
      Studio.UI.asmdef
    Editor/          Studio.Editor          โ†’ references Gameplay, Core   (Editor platform only)
      Studio.Editor.asmdef
  Tests/
    EditMode/        Studio.Tests.EditMode  โ†’ references Core, Gameplay   (Editor only)
      Studio.Tests.EditMode.asmdef
    PlayMode/        Studio.Tests.PlayMode  โ†’ references Gameplay, UI
      Studio.Tests.PlayMode.asmdef

The guiding principles:

  • Core is the foundation. It holds interfaces, shared data types, and pure utilities, and it references nothing of yours. Everything depends down toward it; it depends on nobody. Consider ticking No Engine References for the parts that are pure logic so they stay unit-testable and portable.
  • Features depend downward, never sideways in a loop. UI knows about Gameplay; Gameplay does not know about UI. If UI needs to signal Gameplay, it does so through an interface or event defined in Core โ€” not a back-reference.
  • Editor and Tests are leaves. They reference the runtime assemblies but nothing references them, and both are editor-platform-only so they never enter a build. Test assemblies also reference UnityEngine.TestRunner (Lesson 8.1).
  • Split further as it grows. A big Gameplay assembly can become Studio.Combat, Studio.Inventory, Studio.Economy โ€” each its own compile unit โ€” the day compile times or coupling start to hurt.

You don't need this on day one of a game jam. But the moment a project is going to live for months and be touched by more than one person, assemblies are the difference between a codebase that stays workable and one that congeals. They are the structural bedrock every other technique in this module โ€” DI, patterns, the bootstrapper โ€” sits on top of.

โœ… Rule of thumb

Draw your dependency arrows before you draw your folders. If every arrow points toward a small, stable Core and none form a loop, your assembly layout is right โ€” the folders just follow.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Split a project into assemblies

Objective: Turn a one-assembly project into a layered one and feel the boundary bite.

  1. In any project, create folders Scripts/Core, Scripts/Gameplay, and Scripts/UI.
  2. Add an .asmdef to each: Studio.Core, Studio.Gameplay, Studio.UI.
  3. Wire references so Gameplay โ†’ Core, and UI โ†’ Gameplay + Core. Leave Core referencing nothing.
  4. Put a simple class in Core (say public static class MathUtil), use it from Gameplay โ€” works. Now try to use a Gameplay class from Core.
  5. Observe the compile error. Then try making Core reference Gameplay to "fix" it and read the cyclic-reference error.
โœ… What you should see

Using Core from Gameplay compiles fine (the arrow points the right way). Using Gameplay from Core fails with a "type or namespace not found" error, because Core doesn't reference Gameplay. Adding that reference to Core then triggers a cyclic assembly reference error affecting both assemblies. The lesson: shared code belongs down in Core, referenced by both โ€” not a back-edge.

๐Ÿ‹๏ธ Exercise 2: Isolate editor code

Add an Editor subfolder under Gameplay with its own .asmdef set to Editor-only platform, referencing Studio.Gameplay. Put a trivial [CustomEditor] or a script that using UnityEditor; inside it. Then confirm a player build compiles. As a contrast, temporarily move that editor script into the runtime Gameplay folder and note the build break.

โœ… Expected result

With the editor script in the Editor-only assembly, the build succeeds โ€” that assembly is excluded from non-editor targets. Moved into the runtime assembly, using UnityEditor; fails to compile for the player build because UnityEditor doesn't exist there. This is exactly why editor code needs its own editor-platform assembly (or an Editor folder).

๐ŸŽฏ Quick Quiz

Question 1: What is the primary compile-time benefit of splitting scripts into multiple assemblies?

Question 2: Studio.Core references Studio.Gameplay, and Studio.Gameplay references Studio.Core. What happens?

Question 3: You want a folder of scripts to compile into an existing assembly rather than create a new one. Which asset do you use?

Question 4: How do you keep using UnityEditor; code from breaking your player build?

Summary

๐ŸŽ‰ Key Takeaways

  • Without .asmdefs, every script compiles into one Assembly-CSharp.dll โ€” slow incremental compiles and zero dependency boundaries.
  • An assembly definition makes a folder into its own .dll; its references list defines exactly what it may depend on.
  • Assembly dependencies must form a directed acyclic graph โ€” cycles are a hard compile error, fixed by extracting shared code into a lower assembly.
  • Editor code belongs in an Editor-platform-only assembly so UnityEditor never leaks into a build; an .asmref lets extra folders join an existing assembly.
  • Platform and define constraints compile an assembly only for certain targets or when a scripting define is present.
  • A sane layout is layered โ€” Core โ† Gameplay โ† UI, with Editor and Tests as leaves โ€” every arrow pointing down toward a small, stable Core.

๐Ÿš€ What's Next?

Assemblies enforce where dependencies may point; the next lesson tackles how objects get the things they depend on without hard-wiring themselves together. In Lesson 1.2: Dependency Injection & Inversion of Control, you'll replace FindObjectOfType and singletons with interfaces and injection โ€” the seams that let a UI assembly talk to gameplay without either reaching into the other.

๐Ÿงฑ You have a structure

Layered assemblies with one-way dependencies are the skeleton of every professional Unity project. Everything else in this module hangs off that skeleton.