Skip to main content

๐Ÿ—๏ธ Lesson 7.4: Mini-Project โ€” A Level-Layout Tool with Addressable Loading

Time to put the whole module together. You'll build a real pipeline tool: an EditorWindow (Lesson 7.2) that lists your Addressable prefabs (Lesson 7.3), lets you stamp placement markers into the scene, and saves the result as a small ScriptableObject layout asset. Then you'll write a runtime LevelLoader that reads that layout and InstantiateAsynces each marker's Addressable โ€” releasing every instance cleanly on unload. Author-time tooling and runtime content management, working as one system.

๐ŸŽฏ What You'll Build

  • A LevelLayout ScriptableObject that stores a list of (Addressable reference, position, rotation) markers
  • A LevelLayoutWindow EditorWindow (UI Toolkit) that lists Addressable prefabs, stamps markers into the active layout, and saves it
  • A runtime LevelLoader MonoBehaviour that loads the layout, spawns every marker asynchronously, and releases all instances on unload

Estimated Time: 90 minutes  ยท  Prerequisites: Lessons 7.1โ€“7.3 (custom Editors, EditorWindows/UI Toolkit, Addressables)

In This Lesson

The Plan & Architecture

The tool has three pieces that pass data down a chain. The Editor window writes into a layout asset; the runtime loader reads that asset and drives Addressables. The layout asset is the hinge โ€” it's authored in the Editor but consumed at runtime, so it must store only serializable, runtime-safe data (an AssetReference, a position, a rotation), never Editor types.

Architecture of the level-layout tool and its runtime loader A left-to-right flow in two lanes. Top lane, "Edit time": the LevelLayoutWindow EditorWindow shows a ListView of Addressable prefabs; a "Stamp Marker" action writes an entry into the LevelLayout ScriptableObject, which stores a list of markers, each an AssetReference plus position and rotation. Bottom lane, "Runtime": the LevelLoader MonoBehaviour reads the LevelLayout, and for each marker calls InstantiateAsync on its AssetReference, producing spawned GameObjects in the scene. An arrow from the spawned objects back to LevelLoader is labelled "ReleaseInstance on unload". โœŽ EDIT TIME (Editor assembly) โ–ถ RUNTIME (ships in build) LevelLayoutWindow EditorWindow ยท UI Toolkit ListView โ€” Addressable prefabs ๐Ÿงฉ Enemies/Goblin ๐Ÿงฉ Props/Barrel ๐Ÿงฉ Levels/Forest Stamp LevelLayout ScriptableObject asset (.asset) List<Marker> markers AssetReference ยท pos ยท rot AssetReference ยท pos ยท rot serializable runtime data only read at runtime LevelLoader MonoBehaviour foreach marker: InstantiateAsync(marker.prefab) tracks every handle it spawns spawn Scene instantiated markers ReleaseInstance on unload โ€” every handle released
Figure 1: The tool's architecture. The LevelLayoutWindow (Editor-only) stamps markers into a LevelLayout ScriptableObject; the runtime LevelLoader reads that asset and InstantiateAsynces each marker's Addressable, tracking every handle so it can ReleaseInstance them all on unload.

โš ๏ธ Mind the assembly boundary (Lessons 1.1 & 7.1)

The LevelLayout ScriptableObject and the LevelLoader are runtime code โ€” they ship in the build, so they go in a normal assembly. The LevelLayoutWindow uses UnityEditor/EditorWindow, so it is Editor-only and must live in an Editor folder or Editor asmdef. Same line we've drawn all module โ€” here it decides which of the three files ships.

Step 1 โ€” The Layout ScriptableObject

Start with the data. A LevelLayout holds a list of markers, and each marker is an AssetReference (which Addressable to spawn) plus a position and rotation. Marking the marker [System.Serializable] lets Unity save it inside the asset; [CreateAssetMenu] lets you make one from the Project window's Create menu.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;   // AssetReferenceGameObject

// One placed object: which Addressable, and where.
[System.Serializable]
public class LayoutMarker
{
    public AssetReferenceGameObject prefab;   // stored as an Addressables key
    public Vector3 position;
    public Vector3 eulerRotation;
}

// The saved level: just a list of markers. Runtime-safe, serializable data.
[CreateAssetMenu(fileName = "LevelLayout", menuName = "Level Tools/Level Layout")]
public class LevelLayout : ScriptableObject
{
    public List<LayoutMarker> markers = new();
}

That's the entire persistence layer. Because it's a ScriptableObject, it's a real project asset you can select, inspect, version-control, and hand to a designer. Because every field is a plain serializable type, it loads at runtime with no Editor dependency. The AssetReferenceGameObject is doing the heavy lifting from Lesson 7.3 โ€” it remembers which Addressable without hard-loading it.

Step 2 โ€” The EditorWindow Tool

Now the authoring window. It needs a field to point at the active LevelLayout asset, a ListView of the Addressable prefabs you can place, and buttons to stamp and save. We build the shell in CreateGUI exactly as in Lesson 7.2. To discover which prefabs are Addressable, we read the Addressables settings in the Editor:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;
using UnityEditor.AddressableAssets;              // AddressableAssetSettingsDefaultObject
using UnityEditor.AddressableAssets.Settings;     // AddressableAssetEntry

public class LevelLayoutWindow : EditorWindow
{
    LevelLayout activeLayout;                      // the asset we're editing
    readonly List<string> addressableKeys = new(); // addresses we can stamp
    ListView prefabList;
    Label statusLabel;

    [MenuItem("Tools/Level Layout Tool")]
    public static void ShowWindow()
    {
        var w = GetWindow<LevelLayoutWindow>();
        w.titleContent = new GUIContent("Level Layout");
        w.minSize = new Vector2(340, 320);
    }

    public void CreateGUI()
    {
        var root = rootVisualElement;
        root.style.paddingTop = root.style.paddingLeft =
            root.style.paddingRight = root.style.paddingBottom = 8;

        var title = new Label("Level Layout Tool");
        title.style.fontSize = 16;
        title.style.unityFontStyleAndWeight = FontStyle.Bold;
        title.style.marginBottom = 8;
        root.Add(title);

        // Field to assign the LevelLayout asset we're authoring into.
        var layoutField = new ObjectField("Layout Asset")
            { objectType = typeof(LevelLayout), allowSceneObjects = false };
        layoutField.RegisterValueChangedCallback(evt =>
            activeLayout = evt.newValue as LevelLayout);
        root.Add(layoutField);

        RefreshAddressableKeys();

        prefabList = new ListView(addressableKeys, 20,
            makeItem: () => new Label(),
            bindItem: (e, i) => ((Label)e).text = "๐Ÿงฉ " + addressableKeys[i]);
        prefabList.selectionType = SelectionType.Single;
        prefabList.style.flexGrow = 1;
        prefabList.style.marginTop = 6;
        root.Add(prefabList);

        // Button row.
        var row = new VisualElement();
        row.style.flexDirection = FlexDirection.Row;
        row.style.marginTop = 6;

        var stampBtn = new Button(StampSelected) { text = "Stamp Marker at Scene View" };
        stampBtn.style.flexGrow = 1;
        var saveBtn = new Button(SaveLayout) { text = "Save" };
        saveBtn.style.flexGrow = 1;
        row.Add(stampBtn);
        row.Add(saveBtn);
        root.Add(row);

        statusLabel = new Label("Assign a Layout Asset, pick a prefab, then Stamp.");
        statusLabel.style.marginTop = 6;
        statusLabel.style.color = new Color(0.6f, 0.66f, 0.72f);
        root.Add(statusLabel);
    }

    // Read every Addressable entry's address from the project's settings.
    void RefreshAddressableKeys()
    {
        addressableKeys.Clear();
        var settings = AddressableAssetSettingsDefaultObject.Settings;
        if (settings == null) return;
        foreach (var group in settings.groups)
            foreach (AddressableAssetEntry entry in group.entries)
                addressableKeys.Add(entry.address);
    }
}

The ObjectField restricts its picker to LevelLayout assets, so you can only aim the tool at the right kind of asset. RefreshAddressableKeys walks the Addressables settings โ€” every group, every entry โ€” and collects each entry's address into the list the ListView displays. Selecting a row selects the prefab you're about to stamp.

Step 3 โ€” Stamping & Saving Markers

Stamping adds a marker for the selected prefab at the Scene view camera's pivot, so you place things where you're looking. Because the layout is a serialized asset, we edit it through the Undo system and mark it dirty so the change persists โ€” the same discipline as Lesson 7.1's Inspector edits. Add these methods to the window:

    void StampSelected()
    {
        if (activeLayout == null)
        {
            statusLabel.text = "โš  Assign a Layout Asset first.";
            return;
        }
        int i = prefabList.selectedIndex;
        if (i < 0)
        {
            statusLabel.text = "โš  Select a prefab in the list first.";
            return;
        }

        string address = addressableKeys[i];

        // Place at the Scene view's pivot (where the camera is looking).
        Vector3 pos = SceneView.lastActiveSceneView != null
            ? SceneView.lastActiveSceneView.pivot
            : Vector3.zero;

        // Record Undo on the asset BEFORE mutating it.
        Undo.RecordObject(activeLayout, "Stamp Marker");

        activeLayout.markers.Add(new LayoutMarker
        {
            // Build an AssetReference from the Addressable's GUID.
            prefab = new AssetReferenceGameObject(AddressToGuid(address)),
            position = pos,
            eulerRotation = Vector3.zero
        });

        EditorUtility.SetDirty(activeLayout);   // mark for save
        statusLabel.text = $"Stamped '{address}' โ€” {activeLayout.markers.Count} marker(s).";
    }

    void SaveLayout()
    {
        if (activeLayout == null) return;
        AssetDatabase.SaveAssets();             // write the .asset to disk
        statusLabel.text = $"Saved {activeLayout.markers.Count} marker(s).";
    }

    // Look up an Addressable entry's asset GUID from its address.
    static string AddressToGuid(string address)
    {
        var settings = AddressableAssetSettingsDefaultObject.Settings;
        foreach (var group in settings.groups)
            foreach (var entry in group.entries)
                if (entry.address == address)
                    return entry.guid;
        return null;
    }

โœ… Why go through Undo and SetDirty

An EditorWindow editing an asset faces the same trap as a custom Inspector: change the object directly and Unity may never save it, and the edit won't be undoable. Undo.RecordObject before the change and EditorUtility.SetDirty after are the window-side equivalents of Lesson 7.1's serializedObject brackets โ€” they earn Undo and guarantee the layout persists.

That completes the Editor half: point the tool at a LevelLayout, select an Addressable prefab, frame the Scene view where you want it, and Stamp. Each stamp appends a fully runtime-safe marker to the asset. Now for the half that ships.

Step 4 โ€” The Runtime LevelLoader

The loader is a plain MonoBehaviour โ€” no Editor code โ€” that takes a LevelLayout and brings it to life. For each marker it calls InstantiateAsync, awaits the handle, and keeps the handle so it can release the instance later. This is the "clear owner for every handle" pattern from Lesson 7.3, made concrete: the loader owns every instance it spawns and releases them all on unload.

using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class LevelLoader : MonoBehaviour
{
    public LevelLayout layout;

    // We keep a handle per spawned instance so we can release every one.
    readonly List<AsyncOperationHandle<GameObject>> spawned = new();

    async void Start()
    {
        if (layout != null)
            await LoadAsync();
    }

    public async Task LoadAsync()
    {
        foreach (LayoutMarker marker in layout.markers)
        {
            if (marker.prefab == null || !marker.prefab.RuntimeKeyIsValid())
                continue;

            // Load-and-spawn this marker's Addressable at its saved transform.
            AsyncOperationHandle<GameObject> handle = marker.prefab.InstantiateAsync(
                marker.position, Quaternion.Euler(marker.eulerRotation));

            await handle.Task;   // wait for this one before the next (or fire them all)

            if (handle.Status == AsyncOperationStatus.Succeeded)
                spawned.Add(handle);          // track it for release
            else
                Debug.LogError($"Failed to spawn a marker in {layout.name}");
        }
        Debug.Log($"Level loaded: {spawned.Count} objects.");
    }

    public void Unload()
    {
        // THE GOLDEN RULE: release everything we instantiated.
        foreach (var handle in spawned)
            if (handle.IsValid())
                Addressables.ReleaseInstance(handle);   // destroys instance + drops ref count
        spawned.Clear();
    }

    void OnDestroy() => Unload();   // safety net โ€” never leak on scene teardown
}

Two design choices are worth calling out. First, the loop awaits each spawn in turn for clarity; in production you'd often start all the loads and await them together (Task.WhenAll) so they overlap. Second, Unload and the OnDestroy safety net guarantee that no matter how the level ends โ€” manual unload, scene change, object destruction โ€” every InstantiateAsync gets its matching ReleaseInstance. That symmetry is the entire memory-safety story of the tool.

โš ๏ธ RuntimeKeyIsValid() guards against empty markers

An AssetReference that was never assigned (or whose asset was deleted) has an invalid runtime key; instantiating it throws. Checking marker.prefab.RuntimeKeyIsValid() before loading skips those gracefully โ€” cheap insurance for a designer-authored asset that might have holes.

Step 5 โ€” Run It

Wire the whole thing together with these numbered steps:

  1. Make prefabs Addressable. Select a few prefabs, tick Addressable in their Inspector, and give them readable addresses (e.g. Props/Barrel) in the Window โ–ธ Asset Management โ–ธ Addressables โ–ธ Groups window.
  2. Create a layout asset. In the Project window, Create โ–ธ Level Tools โ–ธ Level Layout. This is the LevelLayout ScriptableObject you'll author into.
  3. Open the tool. Tools โ–ธ Level Layout Tool. Drag your new layout asset into the Layout Asset field.
  4. Stamp markers. Select a prefab in the list, move the Scene view so its pivot is where you want the object, and click Stamp Marker at Scene View. Repeat for several prefabs and positions, then click Save.
  5. Add the loader. Create an empty GameObject in a scene, add the LevelLoader component, and assign your layout asset to its Layout field.
  6. Play. On enter Play mode, LevelLoader.Start runs LoadAsync, and each marker's Addressable instantiates at its saved transform. On exit Play mode, OnDestroy releases every instance.
๐Ÿ’ก Verify the release. Open the Addressables Event Viewer (Window โ–ธ Asset Management โ–ธ Addressables โ–ธ Event Viewer) while you enter and exit Play mode. You'll watch the reference counts rise as markers spawn and fall back to zero on unload โ€” visible proof the golden rule from Lesson 7.3 is holding.

What You Built & Extend It

You've shipped a complete little content pipeline. An Editor tool that reads your Addressables catalog and lets a designer compose a level as data; a serializable asset that carries that composition from edit time to runtime with no Editor dependency; and a runtime loader that streams the content in asynchronously and โ€” critically โ€” releases all of it cleanly. Every idea from Module 7 shows up: the assembly boundary, the serialization/Undo discipline, UI Toolkit windows, Addressable loading, and the release rule.

๐Ÿš€ Extend it

  • Visual gizmos. Add an editor script that draws a wireframe gizmo at each marker's position so the layout is visible in the Scene view before you press Play.
  • Drag to place. Replace "stamp at pivot" with a Scene-view raycast so you click directly on the ground to place a marker.
  • Parallel loading. Swap the sequential await loop for Task.WhenAll so all markers load concurrently, with a progress bar driven by each handle's PercentComplete.
  • Remote levels. Move the prefabs' group to remote and call DownloadDependenciesAsync before LoadAsync to preload the whole level behind a loading screen.
  • Edit & remove. Add a second ListView of the current layout's markers with a "Delete" button, so authoring is round-trip, not append-only.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: A "Clear Markers" button with Undo

Objective: Extend the window with a destructive action that stays undoable.

Add a Clear All Markers button to LevelLayoutWindow that empties activeLayout.markers. It must record Undo (so Ctrl+Z restores them) and mark the asset dirty so the cleared state saves.

๐Ÿ’ก Hint

Mirror StampSelected: guard for a null layout, call Undo.RecordObject(activeLayout, "Clear Markers") before activeLayout.markers.Clear(), then EditorUtility.SetDirty(activeLayout). The Undo record must come before the mutation or there's nothing to restore.

โœ… Solution
var clearBtn = new Button(() =>
{
    if (activeLayout == null) return;
    Undo.RecordObject(activeLayout, "Clear Markers");  // record BEFORE mutating
    activeLayout.markers.Clear();
    EditorUtility.SetDirty(activeLayout);
    statusLabel.text = "Cleared all markers.";
}) { text = "Clear All Markers" };
root.Add(clearBtn);

Because you recorded the object before clearing, Ctrl+Z restores the full marker list; because you set it dirty, the cleared state survives a save.

๐Ÿ‹๏ธ Exercise 2: Guarantee no leaks under a stress test

Objective: Prove the loader releases everything.

Add a public Reload() method to LevelLoader that calls Unload() then await LoadAsync(). Then, from a test button, call Reload() 50 times in a loop. Using the Addressables Event Viewer (or Memory Profiler from Module 2), confirm the reference counts return to their baseline after the loop rather than climbing. Why does Unload before each reload matter?

โœ… Answer

Without the Unload() at the top of Reload(), each reload would InstantiateAsync a fresh set of instances while the previous set's handles stay in spawned โ€” and, worse, get overwritten so they can never be released. Reference counts would climb 50ร— and the assets would pin memory for the session. With Unload() first, every prior InstantiateAsync gets its matching ReleaseInstance before the next batch spawns, so counts oscillate around a stable baseline. It's the load/release symmetry from Lesson 7.3, verified under stress.

๐ŸŽฏ Quick Quiz

Question 1: Why is the LevelLayout a ScriptableObject storing an AssetReference rather than a direct prefab reference?

Question 2: Which file must live in an Editor folder / Editor asmdef?

Question 3: How does LevelLoader avoid leaking the objects it spawns?

Question 4: Inside StampSelected, why call Undo.RecordObject and EditorUtility.SetDirty around the marker addition?

Summary

๐ŸŽ‰ Key Takeaways

  • You built a three-part tool: a ScriptableObject layout asset, an EditorWindow authoring tool, and a runtime LevelLoader โ€” spanning both halves of Module 7.
  • The layout asset stores only serializable, runtime-safe data (AssetReference + position + rotation), so it crosses the edit-time/runtime boundary cleanly.
  • The window reads the Addressables settings to list placeable prefabs, and edits the asset through Undo.RecordObject + EditorUtility.SetDirty so changes are undoable and saved.
  • The loader InstantiateAsynces each marker and tracks every handle, releasing them all with ReleaseInstance in Unload/OnDestroy โ€” one release per load, no leaks.
  • The assembly boundary decides what ships: the loader and layout are runtime; the window is Editor-only.

๐Ÿš€ What's Next?

Module 7 gave you the tools of a professional Unity engineer โ€” a customizable Editor and disciplined content management. The final module makes sure what you build actually works and ships. In Lesson 8.1: The Unity Test Framework โ€” Edit & Play Mode Tests, you'll write automated tests for your game logic, the first step toward continuous integration and a confident release.

๐Ÿ—๏ธ You built a real pipeline tool

Authoring UI, a data asset, and an async runtime loader that never leaks โ€” this is exactly the kind of internal tool studios live on. Module 7, complete.