Back to Gaming

Unity Game Engine Series Part 13: Tools & Editor Scripting

March 31, 2026 Wasil Zafar 40 min read

Transform your Unity workflow with custom editor tools. Learn to build custom inspectors, editor windows, scene handles, Gizmos, automated build pipelines, and CI/CD integrations that separate hobbyist projects from professional studios.

Table of Contents

  1. Custom Editors & Inspectors
  2. Editor Tools & Windows
  3. Menu Items & Shortcuts
  4. Gizmos & Debug Visualization
  5. Automation & CI/CD
  6. Case Studies
  7. Exercises & Self-Assessment
  8. Editor Tool Spec Generator
  9. Conclusion & Next Steps

Introduction: The Power Behind the Editor

Series Overview: This is Part 13 of our 16-part Unity Game Engine Series. Having mastered multiplayer networking in Part 12, we now turn inward to the editor itself. Custom tools are what separate a 10-person studio shipping monthly from a 10-person studio that ships once a year. The fastest teams build tools that make themselves faster.

Every professional Unity studio has a secret weapon: custom editor tools. While the default Unity editor is powerful, it is a general-purpose tool designed to serve millions of developers building millions of different projects. Your project, however, has specific needs. A level designer placing 500 enemies doesn't want to drag-and-drop each one manually. A technical artist adjusting shader parameters wants instant visual feedback in the Scene View. A build engineer needs one-click deployments to five platforms.

Editor scripting is the discipline of extending the Unity editor to serve your project's unique workflow. Everything in the Unity editor is scriptable: inspectors, windows, menus, the Scene View, the build pipeline, and even the import process for assets. The result is a customized IDE that makes your team faster, reduces errors, and catches bugs before they reach production.

Key Insight: All editor scripts must live inside a folder named Editor (e.g., Assets/Editor/ or Assets/Scripts/Editor/). Code in Editor folders is stripped from builds and only runs inside the Unity editor. This is critical: using UnityEditor namespace code outside an Editor folder will cause build failures.

In this part, we will cover the full spectrum of editor scripting: from simple custom inspectors that improve a single component's workflow, to complex editor windows that serve as full-featured tools, to automated build pipelines that integrate with CI/CD systems like GitHub Actions. By the end, you will have the knowledge to build the same caliber of tools used by studios like Ludeon Studios (RimWorld) and Unknown Worlds (Subnautica).

1. Custom Editors & Inspectors

The Inspector is where your designers spend most of their time. A default inspector shows every serialized field as a flat list of text boxes, sliders, and checkboxes. A custom editor transforms that experience into something purpose-built: grouped sections, conditional visibility, buttons that execute logic, live previews, and validation warnings.

1.1 CustomEditor Attribute & OnInspectorGUI

The [CustomEditor] attribute tells Unity to use your custom class instead of the default inspector for a given component type. Your custom editor class inherits from Editor and overrides OnInspectorGUI() to define the layout.

// Runtime script (NOT in Editor folder)
// EnemyWaveConfig.cs
using UnityEngine;

[System.Serializable]
public class WaveEntry
{
    public GameObject enemyPrefab;
    public int count = 5;
    public float spawnDelay = 0.5f;
    public float healthMultiplier = 1.0f;
}

public class EnemyWaveConfig : MonoBehaviour
{
    [Header("Wave Settings")]
    public string waveName = "Wave 1";
    public float timeBetweenWaves = 10f;
    public bool isBossWave = false;

    [Header("Spawn Configuration")]
    public Transform[] spawnPoints;
    public WaveEntry[] enemies;

    [Header("Difficulty Scaling")]
    [Range(0.5f, 3.0f)]
    public float difficultyMultiplier = 1.0f;
    public AnimationCurve difficultyCurve = AnimationCurve.Linear(0, 1, 1, 2);

    [HideInInspector]
    public int totalEnemyCount;  // Computed by editor

    public void RecalculateTotals()
    {
        totalEnemyCount = 0;
        if (enemies != null)
        {
            foreach (var entry in enemies)
                totalEnemyCount += entry.count;
        }
    }
}
// Editor script (MUST be in an Editor folder)
// EnemyWaveConfigEditor.cs
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(EnemyWaveConfig))]
public class EnemyWaveConfigEditor : Editor
{
    // SerializedProperties for proper Undo/Redo and prefab support
    SerializedProperty waveName;
    SerializedProperty timeBetweenWaves;
    SerializedProperty isBossWave;
    SerializedProperty spawnPoints;
    SerializedProperty enemies;
    SerializedProperty difficultyMultiplier;
    SerializedProperty difficultyCurve;

    private bool showSpawnSettings = true;
    private bool showDifficultySettings = true;

    private void OnEnable()
    {
        // Cache SerializedProperties for performance
        waveName = serializedObject.FindProperty("waveName");
        timeBetweenWaves = serializedObject.FindProperty("timeBetweenWaves");
        isBossWave = serializedObject.FindProperty("isBossWave");
        spawnPoints = serializedObject.FindProperty("spawnPoints");
        enemies = serializedObject.FindProperty("enemies");
        difficultyMultiplier = serializedObject.FindProperty("difficultyMultiplier");
        difficultyCurve = serializedObject.FindProperty("difficultyCurve");
    }

    public override void OnInspectorGUI()
    {
        // Always start with this — syncs the serialized object
        serializedObject.Update();

        // ---- Header Section ----
        EditorGUILayout.Space(5);
        EditorGUILayout.LabelField("Enemy Wave Configuration",
            EditorStyles.boldLabel);
        EditorGUILayout.Space(3);

        // Wave identity
        EditorGUILayout.PropertyField(waveName);
        EditorGUILayout.PropertyField(timeBetweenWaves);
        EditorGUILayout.PropertyField(isBossWave);

        // Conditional styling for boss waves
        if (isBossWave.boolValue)
        {
            EditorGUILayout.HelpBox(
                "BOSS WAVE: This wave will trigger boss music " +
                "and disable normal spawning.", MessageType.Warning);
        }

        EditorGUILayout.Space(10);

        // ---- Spawn Settings (Foldout) ----
        showSpawnSettings = EditorGUILayout.Foldout(
            showSpawnSettings, "Spawn Configuration", true);

        if (showSpawnSettings)
        {
            EditorGUI.indentLevel++;
            EditorGUILayout.PropertyField(spawnPoints, true);
            EditorGUILayout.PropertyField(enemies, true);

            // Compute and display total enemy count
            EnemyWaveConfig config = (EnemyWaveConfig)target;
            config.RecalculateTotals();

            EditorGUILayout.Space(5);
            EditorGUILayout.HelpBox(
                $"Total enemies in this wave: {config.totalEnemyCount}",
                MessageType.Info);
            EditorGUI.indentLevel--;
        }

        EditorGUILayout.Space(10);

        // ---- Difficulty Settings (Foldout) ----
        showDifficultySettings = EditorGUILayout.Foldout(
            showDifficultySettings, "Difficulty Scaling", true);

        if (showDifficultySettings)
        {
            EditorGUI.indentLevel++;
            EditorGUILayout.PropertyField(difficultyMultiplier);
            EditorGUILayout.PropertyField(difficultyCurve,
                GUILayout.Height(50));
            EditorGUI.indentLevel--;
        }

        EditorGUILayout.Space(10);

        // ---- Action Buttons ----
        EditorGUILayout.BeginHorizontal();

        if (GUILayout.Button("Randomize Spawn Points",
            GUILayout.Height(30)))
        {
            Undo.RecordObject(target, "Randomize Spawn Points");
            Debug.Log("Spawn points randomized!");
        }

        if (GUILayout.Button("Test Wave In Editor",
            GUILayout.Height(30)))
        {
            Debug.Log($"Testing wave: {waveName.stringValue} " +
                $"with {((EnemyWaveConfig)target).totalEnemyCount} enemies");
        }

        EditorGUILayout.EndHorizontal();

        // Always end with this — applies changes with Undo support
        serializedObject.ApplyModifiedProperties();
    }
}
Why SerializedProperty? You might wonder why we use SerializedProperty instead of directly accessing the component's fields. Three reasons: (1) Undo/Redo works automatically, (2) prefab overrides are tracked correctly (bold labels for changed values), and (3) multi-object editing works out of the box. Skipping SerializedProperty means losing all three of these critical features.

1.2 SerializedProperty & EditorGUILayout

The EditorGUILayout class provides a rich set of controls for building inspector UIs. Here is a reference table of the most commonly used controls:

Control Method Use Case
Text Field EditorGUILayout.TextField() Names, descriptions, string inputs
Int/Float EditorGUILayout.IntField() / FloatField() Numeric values without range limits
Slider EditorGUILayout.Slider() Bounded numeric values (health, speed)
Toggle EditorGUILayout.Toggle() Boolean on/off switches
Enum Popup EditorGUILayout.EnumPopup() Dropdown for enum selections
Color Field EditorGUILayout.ColorField() Color pickers for materials, effects
Object Field EditorGUILayout.ObjectField() Drag-and-drop asset/component references
Foldout EditorGUILayout.Foldout() Collapsible sections to reduce clutter
Help Box EditorGUILayout.HelpBox() Info, warning, or error messages
Curve Field EditorGUILayout.CurveField() AnimationCurve editors for easing, falloff

1.3 PropertyDrawer & DecoratorDrawer

While CustomEditor overrides the entire inspector for a component, PropertyDrawer customizes how a single field type or attribute renders. This is reusable across every component that uses that type or attribute.

// Custom attribute for clamped range with color feedback
// RangeWithColorAttribute.cs (NOT in Editor folder)
using UnityEngine;

public class RangeWithColorAttribute : PropertyAttribute
{
    public float min;
    public float max;
    public float warningThreshold;
    public float dangerThreshold;

    public RangeWithColorAttribute(float min, float max,
        float warningThreshold, float dangerThreshold)
    {
        this.min = min;
        this.max = max;
        this.warningThreshold = warningThreshold;
        this.dangerThreshold = dangerThreshold;
    }
}

// Usage in any script:
// [RangeWithColor(0, 100, 30, 10)]
// public float health = 100;
// Editor/RangeWithColorDrawer.cs (MUST be in Editor folder)
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(RangeWithColorAttribute))]
public class RangeWithColorDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property,
        GUIContent label)
    {
        RangeWithColorAttribute range =
            (RangeWithColorAttribute)attribute;

        // Determine color based on value thresholds
        float value = property.floatValue;
        Color originalColor = GUI.backgroundColor;

        if (value <= range.dangerThreshold)
            GUI.backgroundColor = new Color(1f, 0.3f, 0.3f); // Red
        else if (value <= range.warningThreshold)
            GUI.backgroundColor = new Color(1f, 0.8f, 0.2f); // Yellow
        else
            GUI.backgroundColor = new Color(0.3f, 1f, 0.4f); // Green

        // Draw the slider
        EditorGUI.Slider(position, property, range.min, range.max, label);

        // Restore original color
        GUI.backgroundColor = originalColor;
    }
}
PropertyDrawer vs CustomEditor: Use PropertyDrawer when you want a reusable field renderer (e.g., a "health bar" slider for any float with a specific attribute). Use CustomEditor when you need to control the entire layout of a specific component's inspector. They complement each other beautifully.

2. Editor Tools & Windows

Beyond inspectors, Unity allows you to create fully custom editor windows and Scene View tools. These are the workhorses of professional studios: level editors, batch asset processors, debug dashboards, and data visualization panels.

2.1 EditorWindow Custom Panels

An EditorWindow is a dockable panel that can display anything you want. Unlike inspectors (which are tied to a selected object), editor windows persist independently and can show project-wide information.

// Editor/LevelDesignToolWindow.cs
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;

public class LevelDesignToolWindow : EditorWindow
{
    // Window state
    private Vector2 scrollPosition;
    private int selectedTab = 0;
    private string[] tabs = { "Object Placer", "Batch Operations", "Statistics" };

    // Object Placer state
    private GameObject prefabToPlace;
    private float placementSpacing = 2f;
    private int gridWidth = 5;
    private int gridHeight = 5;
    private bool randomizeRotation = false;
    private float randomScaleMin = 0.8f;
    private float randomScaleMax = 1.2f;

    // Batch Operations state
    private string searchTag = "Untagged";
    private LayerMask targetLayer;
    private bool includeInactive = false;

    [MenuItem("Tools/Level Design Tool %#l")]  // Ctrl+Shift+L
    public static void ShowWindow()
    {
        var window = GetWindow<LevelDesignToolWindow>(
            "Level Design Tool");
        window.minSize = new Vector2(400, 500);
    }

    private void OnGUI()
    {
        // Title bar
        EditorGUILayout.Space(5);
        EditorGUILayout.LabelField("Level Design Tool",
            EditorStyles.largeLabel);
        EditorGUILayout.LabelField("Streamline your level creation workflow",
            EditorStyles.miniLabel);
        EditorGUILayout.Space(5);

        // Tab bar
        selectedTab = GUILayout.Toolbar(selectedTab, tabs);
        EditorGUILayout.Space(10);

        scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);

        switch (selectedTab)
        {
            case 0: DrawObjectPlacerTab(); break;
            case 1: DrawBatchOperationsTab(); break;
            case 2: DrawStatisticsTab(); break;
        }

        EditorGUILayout.EndScrollView();
    }

    private void DrawObjectPlacerTab()
    {
        EditorGUILayout.LabelField("Grid Object Placement",
            EditorStyles.boldLabel);

        prefabToPlace = (GameObject)EditorGUILayout.ObjectField(
            "Prefab", prefabToPlace, typeof(GameObject), false);

        placementSpacing = EditorGUILayout.FloatField(
            "Spacing", placementSpacing);
        gridWidth = EditorGUILayout.IntSlider(
            "Grid Width", gridWidth, 1, 20);
        gridHeight = EditorGUILayout.IntSlider(
            "Grid Height", gridHeight, 1, 20);

        EditorGUILayout.Space(5);
        randomizeRotation = EditorGUILayout.Toggle(
            "Randomize Rotation", randomizeRotation);

        EditorGUILayout.LabelField("Random Scale Range");
        EditorGUILayout.BeginHorizontal();
        randomScaleMin = EditorGUILayout.FloatField(
            "Min", randomScaleMin);
        randomScaleMax = EditorGUILayout.FloatField(
            "Max", randomScaleMax);
        EditorGUILayout.EndHorizontal();

        EditorGUILayout.Space(10);

        // Preview info
        int totalObjects = gridWidth * gridHeight;
        EditorGUILayout.HelpBox(
            $"Will place {totalObjects} objects in a " +
            $"{gridWidth}x{gridHeight} grid.\n" +
            $"Total area: {(gridWidth - 1) * placementSpacing:F1} x " +
            $"{(gridHeight - 1) * placementSpacing:F1} units",
            MessageType.Info);

        EditorGUILayout.Space(5);

        GUI.enabled = prefabToPlace != null;
        if (GUILayout.Button("Place Grid", GUILayout.Height(35)))
        {
            PlaceObjectGrid();
        }
        GUI.enabled = true;

        if (prefabToPlace == null)
        {
            EditorGUILayout.HelpBox(
                "Assign a prefab to enable placement.",
                MessageType.Warning);
        }
    }

    private void PlaceObjectGrid()
    {
        Undo.SetCurrentGroupName("Place Object Grid");
        int group = Undo.GetCurrentGroup();

        GameObject parent = new GameObject(
            $"Grid_{prefabToPlace.name}_{gridWidth}x{gridHeight}");
        Undo.RegisterCreatedObjectUndo(parent, "Create Grid Parent");

        Vector3 startPos = SceneView.lastActiveSceneView != null
            ? SceneView.lastActiveSceneView.pivot
            : Vector3.zero;

        // Center the grid on the start position
        Vector3 offset = new Vector3(
            (gridWidth - 1) * placementSpacing * 0.5f, 0,
            (gridHeight - 1) * placementSpacing * 0.5f);

        for (int x = 0; x < gridWidth; x++)
        {
            for (int z = 0; z < gridHeight; z++)
            {
                Vector3 pos = startPos - offset +
                    new Vector3(x * placementSpacing, 0,
                        z * placementSpacing);

                Quaternion rot = randomizeRotation
                    ? Quaternion.Euler(0, Random.Range(0f, 360f), 0)
                    : Quaternion.identity;

                GameObject obj = (GameObject)PrefabUtility
                    .InstantiatePrefab(prefabToPlace);
                obj.transform.position = pos;
                obj.transform.rotation = rot;

                if (randomScaleMin != 1f || randomScaleMax != 1f)
                {
                    float scale = Random.Range(
                        randomScaleMin, randomScaleMax);
                    obj.transform.localScale = Vector3.one * scale;
                }

                obj.transform.SetParent(parent.transform);
                Undo.RegisterCreatedObjectUndo(obj, "Place Object");
            }
        }

        Undo.CollapseUndoOperations(group);
        Debug.Log($"Placed {gridWidth * gridHeight} objects in grid.");
    }

    private void DrawBatchOperationsTab()
    {
        EditorGUILayout.LabelField("Batch Object Operations",
            EditorStyles.boldLabel);

        searchTag = EditorGUILayout.TagField("Search Tag", searchTag);
        includeInactive = EditorGUILayout.Toggle(
            "Include Inactive", includeInactive);

        EditorGUILayout.Space(10);

        // Batch rename
        EditorGUILayout.LabelField("Batch Rename Selected",
            EditorStyles.boldLabel);
        if (GUILayout.Button("Rename Selected Objects Sequentially"))
        {
            var selected = Selection.gameObjects;
            if (selected.Length > 0)
            {
                Undo.SetCurrentGroupName("Batch Rename");
                int group = Undo.GetCurrentGroup();
                string baseName = selected[0].name.Split('_')[0];
                for (int i = 0; i < selected.Length; i++)
                {
                    Undo.RecordObject(selected[i], "Rename");
                    selected[i].name = $"{baseName}_{i:D3}";
                }
                Undo.CollapseUndoOperations(group);
            }
        }

        EditorGUILayout.Space(5);

        // Align objects
        EditorGUILayout.LabelField("Alignment Tools",
            EditorStyles.boldLabel);

        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("Align X"))
            AlignSelection(Vector3.right);
        if (GUILayout.Button("Align Y"))
            AlignSelection(Vector3.up);
        if (GUILayout.Button("Align Z"))
            AlignSelection(Vector3.forward);
        EditorGUILayout.EndHorizontal();

        EditorGUILayout.Space(5);

        // Ground objects (raycast down)
        if (GUILayout.Button("Ground Selected Objects (Raycast Down)",
            GUILayout.Height(30)))
        {
            GroundSelectedObjects();
        }
    }

    private void AlignSelection(Vector3 axis)
    {
        var selected = Selection.gameObjects;
        if (selected.Length < 2) return;

        Undo.SetCurrentGroupName("Align Objects");
        int group = Undo.GetCurrentGroup();

        // Align all objects to the first selected object's position
        Vector3 target = selected[0].transform.position;
        foreach (var obj in selected)
        {
            Undo.RecordObject(obj.transform, "Align");
            Vector3 pos = obj.transform.position;
            if (axis == Vector3.right) pos.x = target.x;
            else if (axis == Vector3.up) pos.y = target.y;
            else if (axis == Vector3.forward) pos.z = target.z;
            obj.transform.position = pos;
        }
        Undo.CollapseUndoOperations(group);
    }

    private void GroundSelectedObjects()
    {
        Undo.SetCurrentGroupName("Ground Objects");
        int group = Undo.GetCurrentGroup();

        foreach (var obj in Selection.gameObjects)
        {
            Undo.RecordObject(obj.transform, "Ground Object");
            if (Physics.Raycast(obj.transform.position + Vector3.up * 100f,
                Vector3.down, out RaycastHit hit, 200f))
            {
                obj.transform.position = hit.point;
            }
        }
        Undo.CollapseUndoOperations(group);
    }

    private void DrawStatisticsTab()
    {
        EditorGUILayout.LabelField("Scene Statistics",
            EditorStyles.boldLabel);
        EditorGUILayout.Space(5);

        var allObjects = FindObjectsByType<GameObject>(
            FindObjectsSortMode.None);
        var meshRenderers = FindObjectsByType<MeshRenderer>(
            FindObjectsSortMode.None);
        var lights = FindObjectsByType<Light>(
            FindObjectsSortMode.None);
        var cameras = FindObjectsByType<Camera>(
            FindObjectsSortMode.None);

        EditorGUILayout.LabelField(
            $"Total GameObjects: {allObjects.Length}");
        EditorGUILayout.LabelField(
            $"Mesh Renderers: {meshRenderers.Length}");
        EditorGUILayout.LabelField(
            $"Lights: {lights.Length}");
        EditorGUILayout.LabelField(
            $"Cameras: {cameras.Length}");

        int totalTriangles = 0;
        foreach (var mr in meshRenderers)
        {
            var mf = mr.GetComponent<MeshFilter>();
            if (mf != null && mf.sharedMesh != null)
                totalTriangles += mf.sharedMesh.triangles.Length / 3;
        }

        EditorGUILayout.Space(5);
        EditorGUILayout.LabelField(
            $"Total Triangles: {totalTriangles:N0}",
            EditorStyles.boldLabel);

        if (totalTriangles > 500000)
        {
            EditorGUILayout.HelpBox(
                "High triangle count detected! Consider LODs, " +
                "occlusion culling, or mesh simplification.",
                MessageType.Warning);
        }
    }
}

2.2 SceneView Handles & Overlays

The Handles API lets you draw interactive 3D controls directly in the Scene View. These are the same types of controls Unity uses internally for its Move, Rotate, and Scale tools. You can create custom handles for positioning waypoints, defining volumes, editing splines, and more.

// Runtime: PatrolPath.cs
using UnityEngine;
using System.Collections.Generic;

public class PatrolPath : MonoBehaviour
{
    public List<Vector3> waypoints = new List<Vector3>()
    {
        Vector3.zero,
        new Vector3(5, 0, 0),
        new Vector3(5, 0, 5),
        new Vector3(0, 0, 5)
    };

    public bool isLooping = true;
    public float waypointRadius = 0.5f;
    public Color pathColor = Color.cyan;
}
// Editor/PatrolPathEditor.cs
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(PatrolPath))]
public class PatrolPathEditor : Editor
{
    private PatrolPath path;
    private int selectedWaypointIndex = -1;

    private void OnEnable()
    {
        path = (PatrolPath)target;
    }

    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();

        EditorGUILayout.Space(10);
        EditorGUILayout.LabelField("Waypoint Controls",
            EditorStyles.boldLabel);

        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("Add Waypoint"))
        {
            Undo.RecordObject(path, "Add Waypoint");
            Vector3 lastPoint = path.waypoints.Count > 0
                ? path.waypoints[path.waypoints.Count - 1]
                : path.transform.position;
            path.waypoints.Add(lastPoint + Vector3.forward * 3f);
            EditorUtility.SetDirty(path);
        }

        GUI.enabled = selectedWaypointIndex >= 0 &&
            selectedWaypointIndex < path.waypoints.Count;
        if (GUILayout.Button("Remove Selected"))
        {
            Undo.RecordObject(path, "Remove Waypoint");
            path.waypoints.RemoveAt(selectedWaypointIndex);
            selectedWaypointIndex = -1;
            EditorUtility.SetDirty(path);
        }
        GUI.enabled = true;
        EditorGUILayout.EndHorizontal();

        if (selectedWaypointIndex >= 0)
        {
            EditorGUILayout.HelpBox(
                $"Selected waypoint: {selectedWaypointIndex}",
                MessageType.Info);
        }

        // Calculate and display total path length
        float totalLength = 0;
        for (int i = 0; i < path.waypoints.Count - 1; i++)
        {
            totalLength += Vector3.Distance(
                path.waypoints[i], path.waypoints[i + 1]);
        }
        if (path.isLooping && path.waypoints.Count > 1)
        {
            totalLength += Vector3.Distance(
                path.waypoints[path.waypoints.Count - 1],
                path.waypoints[0]);
        }
        EditorGUILayout.LabelField(
            $"Total Path Length: {totalLength:F2} units");
    }

    private void OnSceneGUI()
    {
        if (path.waypoints == null || path.waypoints.Count == 0)
            return;

        Transform transform = path.transform;

        // Draw path lines
        Handles.color = path.pathColor;
        for (int i = 0; i < path.waypoints.Count; i++)
        {
            Vector3 worldPos = transform.TransformPoint(
                path.waypoints[i]);

            // Draw line to next waypoint
            if (i < path.waypoints.Count - 1)
            {
                Vector3 nextPos = transform.TransformPoint(
                    path.waypoints[i + 1]);
                Handles.DrawLine(worldPos, nextPos, 2f);
            }
            else if (path.isLooping && path.waypoints.Count > 1)
            {
                Vector3 firstPos = transform.TransformPoint(
                    path.waypoints[0]);
                Handles.DrawDottedLine(worldPos, firstPos, 4f);
            }

            // Draw interactive handle at each waypoint
            float handleSize = HandleUtility.GetHandleSize(worldPos)
                * 0.15f;

            // Highlight selected waypoint
            if (i == selectedWaypointIndex)
                Handles.color = Color.yellow;
            else
                Handles.color = path.pathColor;

            // FreeMoveHandle for drag-and-drop positioning
            EditorGUI.BeginChangeCheck();
            Vector3 newWorldPos = Handles.FreeMoveHandle(
                worldPos, handleSize,
                Vector3.one * 0.5f,  // Snap increment
                Handles.SphereHandleCap);

            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(path, "Move Waypoint");
                path.waypoints[i] = transform.InverseTransformPoint(
                    newWorldPos);
                selectedWaypointIndex = i;
                EditorUtility.SetDirty(path);
            }

            // Draw waypoint label
            Handles.Label(worldPos + Vector3.up * 1.5f,
                $"WP {i}",
                new GUIStyle(EditorStyles.boldLabel)
                {
                    normal = { textColor = Color.white }
                });

            // Draw waypoint radius disc
            Handles.color = new Color(path.pathColor.r,
                path.pathColor.g, path.pathColor.b, 0.15f);
            Handles.DrawSolidDisc(worldPos, Vector3.up,
                path.waypointRadius);
        }
    }
}

2.3 Level Design Tools

Level design tools combine editor windows, handles, and custom inspectors to create a cohesive workflow. Here is a practical example of a snapping placement tool that lets designers click to place objects on terrain with automatic alignment to the surface normal:

// Editor/SurfacePlacementTool.cs
using UnityEngine;
using UnityEditor;

public class SurfacePlacementTool : EditorWindow
{
    private GameObject prefabToPlace;
    private bool alignToNormal = true;
    private float randomRotationRange = 0f;
    private Vector2 randomScaleRange = new Vector2(0.8f, 1.2f);
    private LayerMask placementMask = ~0;
    private bool isPlacing = false;

    [MenuItem("Tools/Surface Placement Tool")]
    public static void ShowWindow()
    {
        GetWindow<SurfacePlacementTool>("Surface Placer");
    }

    private void OnGUI()
    {
        EditorGUILayout.LabelField("Surface Placement Tool",
            EditorStyles.boldLabel);
        EditorGUILayout.Space(5);

        prefabToPlace = (GameObject)EditorGUILayout.ObjectField(
            "Prefab", prefabToPlace, typeof(GameObject), false);

        alignToNormal = EditorGUILayout.Toggle(
            "Align to Surface Normal", alignToNormal);
        randomRotationRange = EditorGUILayout.Slider(
            "Random Y Rotation", randomRotationRange, 0f, 360f);
        randomScaleRange = EditorGUILayout.Vector2Field(
            "Scale Range (Min/Max)", randomScaleRange);

        EditorGUILayout.Space(10);

        // Toggle placement mode
        GUI.enabled = prefabToPlace != null;
        string btnText = isPlacing
            ? "Stop Placing (Active)"
            : "Start Placing";
        Color originalColor = GUI.backgroundColor;
        GUI.backgroundColor = isPlacing
            ? new Color(1f, 0.4f, 0.4f) : originalColor;

        if (GUILayout.Button(btnText, GUILayout.Height(35)))
        {
            isPlacing = !isPlacing;
            if (isPlacing)
                SceneView.duringSceneGui += OnSceneGUI;
            else
                SceneView.duringSceneGui -= OnSceneGUI;

            SceneView.RepaintAll();
        }

        GUI.backgroundColor = originalColor;
        GUI.enabled = true;

        if (isPlacing)
        {
            EditorGUILayout.HelpBox(
                "Click in Scene View to place objects.\n" +
                "Hold Shift+Click for rapid placement.\n" +
                "Press Escape or click the button to stop.",
                MessageType.Info);
        }
    }

    private void OnSceneGUI(SceneView sceneView)
    {
        if (!isPlacing || prefabToPlace == null) return;

        Event e = Event.current;
        Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);

        if (Physics.Raycast(ray, out RaycastHit hit, 1000f,
            placementMask))
        {
            // Draw preview handle
            Handles.color = new Color(0, 1, 0, 0.5f);
            Handles.DrawWireCube(hit.point,
                Vector3.one * 0.5f);
            if (alignToNormal)
            {
                Handles.DrawLine(hit.point,
                    hit.point + hit.normal * 2f);
            }

            // Place object on click
            if (e.type == EventType.MouseDown &&
                e.button == 0 && !e.alt)
            {
                PlaceObjectAtPoint(hit);
                e.Use();
            }
        }

        // Exit on Escape
        if (e.type == EventType.KeyDown &&
            e.keyCode == KeyCode.Escape)
        {
            isPlacing = false;
            SceneView.duringSceneGui -= OnSceneGUI;
            Repaint();
            e.Use();
        }

        sceneView.Repaint();
    }

    private void PlaceObjectAtPoint(RaycastHit hit)
    {
        GameObject obj = (GameObject)PrefabUtility
            .InstantiatePrefab(prefabToPlace);

        Undo.RegisterCreatedObjectUndo(obj, "Surface Place");

        obj.transform.position = hit.point;

        if (alignToNormal)
        {
            obj.transform.rotation =
                Quaternion.FromToRotation(Vector3.up, hit.normal);
        }

        if (randomRotationRange > 0)
        {
            obj.transform.Rotate(Vector3.up,
                Random.Range(-randomRotationRange,
                    randomRotationRange), Space.Self);
        }

        float scale = Random.Range(
            randomScaleRange.x, randomScaleRange.y);
        obj.transform.localScale = Vector3.one * scale;
    }

    private void OnDisable()
    {
        if (isPlacing)
        {
            SceneView.duringSceneGui -= OnSceneGUI;
            isPlacing = false;
        }
    }
}
Real-World Application

How Studios Use Level Design Tools

Professional studios rarely place objects one at a time. Instead, they build brush-based systems that paint terrain details (grass, rocks, trees), spline tools for roads and rivers, room template systems for procedural dungeons, and validation tools that check for floating objects, missing colliders, and performance budgets. These tools often represent months of development but save years of manual labor over a project's lifetime.

Productivity Level Design Tool-Driven Workflow

4. Gizmos & Debug Visualization

Gizmos are visual debug helpers drawn in the Scene View (and optionally the Game View). They are essential for visualizing invisible game systems: AI patrol ranges, trigger volumes, audio falloff distances, spawn zones, and line-of-sight cones. Without Gizmos, debugging spatial logic becomes a frustrating guessing game.

4.1 OnDrawGizmos & OnDrawGizmosSelected

// Runtime: EnemyAI.cs (Gizmo methods work in runtime scripts)
using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    [Header("Detection Settings")]
    public float detectionRange = 10f;
    public float attackRange = 2f;
    public float fieldOfViewAngle = 120f;

    [Header("Patrol Settings")]
    public Transform[] patrolPoints;
    public float waypointReachDistance = 0.5f;

    [Header("Debug Visualization")]
    public bool showGizmosAlways = false;
    public Color detectionColor = new Color(1f, 1f, 0f, 0.15f);
    public Color attackColor = new Color(1f, 0f, 0f, 0.25f);
    public Color fovColor = new Color(0f, 1f, 0f, 0.1f);

    private Transform currentTarget;

    // Always drawn (every frame, every selected state)
    private void OnDrawGizmos()
    {
        if (!showGizmosAlways) return;
        DrawAllGizmos();
    }

    // Only drawn when this object is selected
    private void OnDrawGizmosSelected()
    {
        DrawAllGizmos();
    }

    private void DrawAllGizmos()
    {
        Vector3 pos = transform.position;

        // Detection range sphere (wireframe)
        Gizmos.color = detectionColor;
        Gizmos.DrawWireSphere(pos, detectionRange);

        // Attack range sphere (solid, translucent)
        Gizmos.color = attackColor;
        Gizmos.DrawSphere(pos, attackRange);

        // Field of view cone
        DrawFOVGizmo(pos);

        // Patrol path
        if (patrolPoints != null && patrolPoints.Length > 1)
        {
            Gizmos.color = Color.cyan;
            for (int i = 0; i < patrolPoints.Length; i++)
            {
                if (patrolPoints[i] == null) continue;

                // Draw waypoint sphere
                Gizmos.DrawSphere(patrolPoints[i].position, 0.3f);

                // Draw line to next waypoint
                int next = (i + 1) % patrolPoints.Length;
                if (patrolPoints[next] != null)
                {
                    Gizmos.DrawLine(patrolPoints[i].position,
                        patrolPoints[next].position);
                }
            }
        }

        // Draw line to current target
        if (currentTarget != null)
        {
            Gizmos.color = Color.red;
            Gizmos.DrawLine(pos, currentTarget.position);
            Gizmos.DrawSphere(currentTarget.position, 0.2f);
        }
    }

    private void DrawFOVGizmo(Vector3 pos)
    {
        Gizmos.color = fovColor;

        float halfFOV = fieldOfViewAngle * 0.5f;
        Vector3 forward = transform.forward * detectionRange;

        // Left boundary of FOV
        Quaternion leftRot = Quaternion.AngleAxis(-halfFOV,
            Vector3.up);
        Vector3 leftDir = leftRot * forward;

        // Right boundary of FOV
        Quaternion rightRot = Quaternion.AngleAxis(halfFOV,
            Vector3.up);
        Vector3 rightDir = rightRot * forward;

        Gizmos.DrawLine(pos, pos + leftDir);
        Gizmos.DrawLine(pos, pos + rightDir);

        // Draw arc approximation
        int segments = 20;
        Vector3 prevPoint = pos + leftDir;
        for (int i = 1; i <= segments; i++)
        {
            float angle = -halfFOV +
                (fieldOfViewAngle * i / segments);
            Quaternion rot = Quaternion.AngleAxis(angle,
                Vector3.up);
            Vector3 point = pos + rot * forward;
            Gizmos.DrawLine(prevPoint, point);
            prevPoint = point;
        }
    }
}

4.2 Handles API for Scene Editing

While Gizmos are read-only visualizations, Handles are interactive. The key difference: Gizmos are drawn in OnDrawGizmos (MonoBehaviour), while Handles are drawn in OnSceneGUI (Editor scripts). Handles support clicking, dragging, and editing values directly in the Scene View.

// Editor/SpawnZoneEditor.cs
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(SpawnZone))]
public class SpawnZoneEditor : Editor
{
    private void OnSceneGUI()
    {
        SpawnZone zone = (SpawnZone)target;
        Transform t = zone.transform;

        // Draw and edit the zone size with interactive handles
        EditorGUI.BeginChangeCheck();

        // Position handle
        Vector3 newPos = Handles.PositionHandle(
            t.position, t.rotation);

        // Size handles (like a box collider editor)
        Handles.color = new Color(0, 1, 0, 0.3f);
        Vector3 size = zone.zoneSize;

        // Draw the wire cube
        Handles.DrawWireCube(t.position, size);

        // Draw filled translucent cube
        Handles.color = new Color(0, 1, 0, 0.05f);
        Handles.DrawSolidRectangleWithOutline(
            new Vector3[]
            {
                t.position + new Vector3(-size.x/2, 0, -size.z/2),
                t.position + new Vector3( size.x/2, 0, -size.z/2),
                t.position + new Vector3( size.x/2, 0,  size.z/2),
                t.position + new Vector3(-size.x/2, 0,  size.z/2)
            },
            new Color(0, 1, 0, 0.1f),
            new Color(0, 1, 0, 0.5f));

        // Radius handle for the zone size
        Handles.color = Color.green;
        float newRadius = Handles.RadiusHandle(
            t.rotation, t.position, zone.spawnRadius);

        if (EditorGUI.EndChangeCheck())
        {
            Undo.RecordObject(zone, "Modify Spawn Zone");
            Undo.RecordObject(t, "Move Spawn Zone");
            t.position = newPos;
            zone.spawnRadius = newRadius;
            EditorUtility.SetDirty(zone);
        }

        // Label with spawn info
        Handles.Label(t.position + Vector3.up * (size.y + 1f),
            $"Spawn Zone: {zone.zoneName}\n" +
            $"Max Enemies: {zone.maxEnemies}\n" +
            $"Radius: {zone.spawnRadius:F1}m",
            new GUIStyle(EditorStyles.helpBox)
            {
                alignment = TextAnchor.MiddleCenter,
                fontSize = 11,
                fontStyle = FontStyle.Bold
            });
    }
}
Gizmos vs Handles: Use Gizmos (in MonoBehaviour) for passive visualization that designers can toggle on/off from the Gizmos dropdown. Use Handles (in Editor scripts) for interactive editing tools that respond to mouse input. Both are essential: Gizmos show the state of your game, Handles let you change it.

5. Automation & CI/CD

Automation is where editor scripting delivers the biggest ROI. A manual build that takes 45 minutes of an engineer's attention can be reduced to a single command (or no command at all, with CI/CD). Asset import rules that are enforced by documentation become enforced by code that cannot be bypassed.

5.1 BuildPipeline & AssetPostprocessor

// Editor/AutomatedBuildPipeline.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Build.Reporting;
using System;
using System.IO;

public static class AutomatedBuildPipeline
{
    private static readonly string BuildRoot = "Builds";

    [MenuItem("Build/Build All Platforms")]
    public static void BuildAll()
    {
        BuildWindows();
        BuildLinux();
        BuildWebGL();
        Debug.Log("All platform builds complete!");
    }

    [MenuItem("Build/Windows (64-bit)")]
    public static void BuildWindows()
    {
        BuildTarget target = BuildTarget.StandaloneWindows64;
        string path = GetBuildPath(target, "Windows",
            $"{PlayerSettings.productName}.exe");
        PerformBuild(target, path);
    }

    [MenuItem("Build/Linux")]
    public static void BuildLinux()
    {
        BuildTarget target = BuildTarget.StandaloneLinux64;
        string path = GetBuildPath(target, "Linux",
            PlayerSettings.productName);
        PerformBuild(target, path);
    }

    [MenuItem("Build/WebGL")]
    public static void BuildWebGL()
    {
        BuildTarget target = BuildTarget.WebGL;
        string path = GetBuildPath(target, "WebGL", "");
        PerformBuild(target, path);
    }

    private static string GetBuildPath(BuildTarget target,
        string platformName, string fileName)
    {
        string version = PlayerSettings.bundleVersion;
        string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmm");
        string dir = Path.Combine(BuildRoot, platformName,
            $"v{version}_{timestamp}");
        Directory.CreateDirectory(dir);
        return Path.Combine(dir, fileName);
    }

    private static void PerformBuild(BuildTarget target,
        string locationPathName)
    {
        // Get all scenes from Build Settings
        string[] scenes = new string[
            EditorBuildSettings.scenes.Length];
        for (int i = 0; i < scenes.Length; i++)
        {
            scenes[i] = EditorBuildSettings.scenes[i].path;
        }

        BuildPlayerOptions options = new BuildPlayerOptions
        {
            scenes = scenes,
            locationPathName = locationPathName,
            target = target,
            options = BuildOptions.None
        };

        Debug.Log($"Starting build for {target}...");
        BuildReport report = BuildPipeline.BuildPlayer(options);
        BuildSummary summary = report.summary;

        if (summary.result == BuildResult.Succeeded)
        {
            Debug.Log($"Build succeeded: {summary.totalSize} bytes" +
                $" in {summary.totalTime.TotalSeconds:F1}s");
            Debug.Log($"Output: {locationPathName}");
        }
        else
        {
            Debug.LogError($"Build FAILED for {target}: " +
                $"{summary.totalErrors} error(s)");
        }
    }

    // Static method called by CI/CD (command-line builds)
    public static void BuildForCI()
    {
        string targetArg = GetCommandLineArg("-buildTarget");
        string outputArg = GetCommandLineArg("-buildOutput");

        BuildTarget target = targetArg switch
        {
            "Windows" => BuildTarget.StandaloneWindows64,
            "Linux" => BuildTarget.StandaloneLinux64,
            "WebGL" => BuildTarget.WebGL,
            "Android" => BuildTarget.Android,
            "iOS" => BuildTarget.iOS,
            _ => BuildTarget.StandaloneWindows64
        };

        string output = string.IsNullOrEmpty(outputArg)
            ? $"Builds/CI/{targetArg}" : outputArg;

        PerformBuild(target, output);
    }

    private static string GetCommandLineArg(string name)
    {
        string[] args = Environment.GetCommandLineArgs();
        for (int i = 0; i < args.Length - 1; i++)
        {
            if (args[i] == name)
                return args[i + 1];
        }
        return null;
    }
}
// Editor/TextureAssetPostprocessor.cs
using UnityEngine;
using UnityEditor;

public class TextureAssetPostprocessor : AssetPostprocessor
{
    // Called before a texture is imported
    void OnPreprocessTexture()
    {
        TextureImporter importer = (TextureImporter)assetImporter;

        // Auto-configure textures based on folder location
        if (assetPath.Contains("/UI/"))
        {
            // UI textures: no compression, full quality
            importer.textureType = TextureImporterType.Sprite;
            importer.textureCompression =
                TextureImporterCompression.Uncompressed;
            importer.filterMode = FilterMode.Point;
            importer.spritePixelsPerUnit = 100;
            Debug.Log($"[AssetPostprocessor] UI texture " +
                $"configured: {assetPath}");
        }
        else if (assetPath.Contains("/NormalMaps/"))
        {
            // Normal maps: specific settings
            importer.textureType = TextureImporterType.NormalMap;
            importer.textureCompression =
                TextureImporterCompression.CompressedHQ;
            Debug.Log($"[AssetPostprocessor] Normal map " +
                $"configured: {assetPath}");
        }
        else if (assetPath.Contains("/Environment/"))
        {
            // Environment textures: compressed, power-of-two
            importer.textureCompression =
                TextureImporterCompression.Compressed;
            importer.maxTextureSize = 2048;
            importer.mipmapEnabled = true;
            Debug.Log($"[AssetPostprocessor] Environment " +
                $"texture configured: {assetPath}");
        }

        // Enforce maximum texture sizes
        if (importer.maxTextureSize > 4096)
        {
            importer.maxTextureSize = 4096;
            Debug.LogWarning($"[AssetPostprocessor] Texture " +
                $"size clamped to 4096: {assetPath}");
        }
    }

    // Called after all assets in a batch are imported
    static void OnPostprocessAllAssets(
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromAssetPaths)
    {
        foreach (string path in importedAssets)
        {
            Debug.Log($"[Import] {path}");
        }

        foreach (string path in deletedAssets)
        {
            Debug.LogWarning($"[Deleted] {path}");
        }
    }
}

5.2 CI/CD with GameCI & GitHub Actions

GameCI is an open-source project that provides Docker images and GitHub Actions for building, testing, and deploying Unity projects automatically. It eliminates the "works on my machine" problem and ensures every commit is validated.

# .github/workflows/unity-build.yml
name: Unity Build & Test Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}

jobs:
  # Step 1: Run all tests
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    strategy:
      matrix:
        testMode:
          - playmode
          - editmode
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - uses: actions/cache@v3
        with:
          path: Library
          key: Library-test-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
          restore-keys: |
            Library-test-

      # GameCI Test Runner
      - uses: game-ci/unity-test-runner@v4
        id: tests
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
        with:
          testMode: ${{ matrix.testMode }}
          artifactsPath: test-results
          checkName: ${{ matrix.testMode }} Test Results

      # Upload test results
      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: Test-Results-${{ matrix.testMode }}
          path: test-results

  # Step 2: Build for all platforms (after tests pass)
  build:
    name: Build for ${{ matrix.targetPlatform }}
    runs-on: ubuntu-latest
    needs: test
    strategy:
      fail-fast: false
      matrix:
        targetPlatform:
          - StandaloneWindows64
          - StandaloneLinux64
          - WebGL
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - uses: actions/cache@v3
        with:
          path: Library
          key: Library-${{ matrix.targetPlatform }}-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
          restore-keys: |
            Library-${{ matrix.targetPlatform }}-

      # GameCI Builder
      - uses: game-ci/unity-builder@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
        with:
          targetPlatform: ${{ matrix.targetPlatform }}
          buildName: MyGame
          versioning: Semantic

      # Upload build artifacts
      - uses: actions/upload-artifact@v3
        with:
          name: Build-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}
Security Note: Never commit your Unity license file or credentials to version control. Store them as GitHub Secrets (UNITY_LICENSE, UNITY_EMAIL, UNITY_PASSWORD). GameCI provides a helper action (game-ci/unity-activate) to generate the license file from your credentials in CI.

5.3 Unity Test Framework

The Unity Test Framework (based on NUnit) provides both Edit Mode tests (run without entering Play Mode, for pure logic testing) and Play Mode tests (run inside Play Mode, for integration testing with MonoBehaviours, physics, and coroutines). Automated tests are a prerequisite for reliable CI/CD.

// Tests/EditMode/HealthComponentTests.cs
using NUnit.Framework;
using UnityEngine;

[TestFixture]
public class HealthComponentTests
{
    private GameObject testObject;
    private HealthComponent health;

    [SetUp]
    public void Setup()
    {
        testObject = new GameObject("TestEnemy");
        health = testObject.AddComponent<HealthComponent>();
    }

    [TearDown]
    public void Teardown()
    {
        Object.DestroyImmediate(testObject);
    }

    [Test]
    public void Health_StartsAtMaxValue()
    {
        // Health initializes in Awake, but in Edit Mode tests
        // we can call it manually or test the default state
        Assert.That(health, Is.Not.Null);
    }

    [Test]
    public void TakeDamage_ReducesHealth()
    {
        health.SetMaxHealth(100);
        health.TakeDamage(30);
        Assert.AreEqual(70, health.CurrentHealth);
    }

    [Test]
    public void TakeDamage_ClampsAtZero()
    {
        health.SetMaxHealth(100);
        health.TakeDamage(150);
        Assert.AreEqual(0, health.CurrentHealth);
        Assert.IsTrue(health.IsDead);
    }

    [Test]
    public void Heal_DoesNotExceedMaxHealth()
    {
        health.SetMaxHealth(100);
        health.TakeDamage(50);
        health.Heal(80);
        Assert.AreEqual(100, health.CurrentHealth);
    }

    [TestCase(10, 90)]
    [TestCase(50, 50)]
    [TestCase(100, 0)]
    [TestCase(200, 0)]
    public void TakeDamage_ParameterizedTests(
        int damage, int expectedHealth)
    {
        health.SetMaxHealth(100);
        health.TakeDamage(damage);
        Assert.AreEqual(expectedHealth, health.CurrentHealth);
    }
}
// Tests/PlayMode/PlayerMovementTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class PlayerMovementTests
{
    private GameObject player;
    private PlayerMovement movement;

    [UnitySetUp]
    public IEnumerator Setup()
    {
        player = new GameObject("TestPlayer");
        player.AddComponent<Rigidbody>();
        movement = player.AddComponent<PlayerMovement>();

        // Wait one frame for Start() to execute
        yield return null;
    }

    [UnityTearDown]
    public IEnumerator Teardown()
    {
        Object.Destroy(player);
        yield return null;
    }

    [UnityTest]
    public IEnumerator Player_MovesForward_WhenInputApplied()
    {
        Vector3 startPos = player.transform.position;

        // Simulate forward movement for 1 second
        movement.SimulateInput(Vector3.forward);
        yield return new WaitForSeconds(1f);

        // Player should have moved forward (positive Z)
        Assert.Greater(player.transform.position.z, startPos.z,
            "Player should move forward on Z axis");
    }

    [UnityTest]
    public IEnumerator Player_StopsMoving_WhenNoInput()
    {
        movement.SimulateInput(Vector3.forward);
        yield return new WaitForSeconds(0.5f);

        movement.SimulateInput(Vector3.zero);
        yield return new WaitForSeconds(0.5f);

        Vector3 posAfterStop = player.transform.position;
        yield return new WaitForSeconds(0.5f);

        // Position should not change significantly
        float drift = Vector3.Distance(posAfterStop,
            player.transform.position);
        Assert.Less(drift, 0.1f,
            "Player should stop when input is zero");
    }
}
Aspect Edit Mode Tests Play Mode Tests
Speed Very fast (no scene loading) Slower (requires Play Mode startup)
Can test Pure C# logic, ScriptableObjects, serialization MonoBehaviour lifecycle, physics, coroutines, UI
Attributes [Test], [TestCase] [UnityTest] (returns IEnumerator)
Use for Unit tests, data validation, math utilities Integration tests, gameplay verification
CI/CD support Excellent (fast, reliable) Good (needs GPU in some cases)

6. Case Studies

6.1 RimWorld Modding Tools

Case Study

RimWorld: Editor Tools That Enabled a Modding Empire

RimWorld by Ludeon Studios is one of the most heavily modded games on Steam, with over 15,000 mods on the Workshop. This modding ecosystem was not accidental: it was architected through editor tooling.

Ludeon Studios built their game using a data-driven architecture where nearly every game element (colonist traits, items, buildings, research, events) is defined in XML "Def" files rather than hardcoded in C#. Their custom editor tools include:

  • Def Inspector Windows: Custom EditorWindows for browsing, editing, and validating thousands of XML definitions. Each Def type (ThingDef, RecipeDef, TraitDef) has its own specialized editor with autocomplete for cross-references, inline previews, and error checking for circular dependencies.
  • Map Visualization Tools: Custom Scene View overlays that visualize temperature gradients, beauty scores, roof coverage, room detection boundaries, and pathfinding costs. Designers can see the "invisible systems" that govern colonist behavior directly in the editor.
  • Balance Tuning Dashboards: EditorWindows that simulate combat encounters, resource chains, and colony progression without entering Play Mode. Designers adjust DPS values and immediately see how a weapon compares to every other weapon in the game via auto-generated charts.
  • Mod Compatibility Validator: Automated tools that detect when a mod overrides a Def that another mod also modifies, surfacing potential conflicts before players encounter them.

The lesson: RimWorld's modding community thrived because the same tools the developers used were accessible to modders. When your internal tools are clean and well-structured, exposing them to your community becomes a natural extension rather than a rewrite.

Data-Driven Design Modding Support XML Def System 15,000+ Mods

6.2 Subnautica Level Tools

Case Study

Subnautica: Custom Tools for a 5km Underwater World

Subnautica by Unknown Worlds Entertainment features a hand-crafted underwater world spanning approximately 5 kilometers with over a dozen distinct biomes, each with unique flora, fauna, terrain, and lighting. Building this world required custom editor tools that went far beyond Unity's defaults.

Key tools developed by the Unknown Worlds team:

  • Biome Painting System: A custom brush tool that paints biome identifiers onto terrain. Each biome ID triggers different procedural spawning rules, ambient audio, water coloring, creature behavior, and lighting. Designers paint broad strokes; the engine fills in the details.
  • Entity Scatter Tool: Instead of placing each coral, rock, and kelp individually (there are hundreds of thousands), designers defined scatter rules: "In the Kelp Forest biome, spawn CreepVine between 5m and 200m depth with density 0.3 per square meter, avoiding slopes greater than 45 degrees." A custom EditorWindow previewed the scatter results in real-time and exported placement data.
  • Depth-Based Systems Editor: Custom handles and overlays that visualized depth layers directly in the Scene View. Color-coded horizontal planes showed where different creature types spawn, where the Cyclops submarine can navigate, and where oxygen runs out. Designers could literally see the game's pressure mechanics.
  • Performance Budget Tool: A Scene View overlay that color-coded terrain chunks by their rendering cost (triangle count, overdraw, dynamic lights). Red chunks needed optimization; green chunks had budget to spare. This prevented the team from discovering performance problems only at build time.

The key insight: Subnautica's tools were designed around the specific challenges of underwater open-world design. Generic tools would have required the level designers to mentally translate between "flat editor view" and "3D underwater space with depth-based mechanics." Purpose-built tools eliminated that translation entirely.

Open World Procedural Scatter Biome Painting Performance Budgeting
Common Pattern: Both RimWorld and Subnautica demonstrate the same principle: invest in tools that match your game's unique challenges. RimWorld's challenge was data volume (thousands of interlinked definitions). Subnautica's challenge was spatial complexity (a 3D underwater world with depth-dependent mechanics). Their tools were tailored to these specific problems, not generic "level editor" solutions.

Exercises & Self-Assessment

Exercise 1

Custom Inspector Challenge

Create a TreasureChest component with these fields: chestName (string), lootTable (list of items), isLocked (bool), lockDifficulty (int, only visible when isLocked is true), trapType (enum: None, Poison, Explosion, Teleport). Build a custom editor that:

  1. Shows conditional fields (lockDifficulty hidden when unlocked)
  2. Displays a warning HelpBox when trapType is not None
  3. Has a "Randomize Loot" button that populates the loot table
  4. Shows the total gold value of all items in the loot table
  5. Uses foldouts for "Lock Settings" and "Trap Settings" sections
Exercise 2

EditorWindow: Asset Audit Tool

Build a custom EditorWindow that scans your project and reports:

  1. All textures larger than 2048x2048
  2. All meshes with more than 10,000 triangles
  3. Materials using the Standard shader (suggest upgrading to URP/HDRP Lit)
  4. Scripts with no namespace declaration
  5. Display results in a scrollable list with "Select" buttons that ping the asset in the Project window
  6. Add an "Export Report" button that saves results to a CSV file
Exercise 3

Scene View Gizmo System

Create a "combat arena" system with these Gizmo visualizations:

  1. A CombatArena component that draws its boundary as a circle in the Scene View
  2. An EnemySpawner with OnDrawGizmosSelected that shows spawn radius, max range, and directional facing cone
  3. A CoverPoint that draws a shield icon and a line showing which direction it provides cover from
  4. Use different colors for friendly (green), enemy (red), and neutral (yellow) areas
  5. Add a custom editor with Handles.FreeMoveHandle to let designers drag spawn points in the Scene View
Exercise 4

CI/CD Pipeline Setup

  1. Create a GitHub repository with a basic Unity project
  2. Write 5 Edit Mode tests for a utility class (e.g., damage calculation, inventory management)
  3. Write 2 Play Mode tests for a MonoBehaviour (e.g., player health, enemy spawning)
  4. Set up a GameCI GitHub Actions workflow that runs tests on every push
  5. Add a build step that produces a WebGL build artifact on successful test completion
  6. Configure branch protection rules requiring CI to pass before merging to main
Exercise 5

Reflective Questions

  1. Why must editor scripts be placed in a folder named "Editor"? What happens if you use UnityEditor namespace code in a runtime script?
  2. Explain the difference between CustomEditor and PropertyDrawer. When would you choose one over the other?
  3. A designer reports that Undo does not work on your custom editor tool. What is the most likely cause and how do you fix it?
  4. Your CI build takes 45 minutes. What strategies would you use to reduce this? Consider caching, incremental builds, and parallelization.
  5. Design an editor tool for a hypothetical city-building game. What custom windows, inspectors, and Scene View tools would you build? What would you automate?

Editor Tool Spec Document Generator

Generate a professional specification document for your custom Unity editor tool. Download as Word, Excel, PDF, or PowerPoint.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

Editor scripting is the multiplier that turns a productive developer into a productive team. The tools you build today will pay dividends for the entire lifetime of your project. Here are the key takeaways from Part 13:

  • Custom Inspectors ([CustomEditor]) transform flat field lists into purpose-built interfaces with foldouts, conditional visibility, validation, and action buttons. Always use SerializedProperty for Undo/Redo and prefab support.
  • PropertyDrawers create reusable field renderers that work across any component using a specific type or attribute, making them ideal for project-wide conventions.
  • EditorWindows provide persistent, dockable panels for project-wide tools: level design utilities, batch operations, asset audits, and statistics dashboards.
  • Handles & Gizmos bring your invisible game systems to life in the Scene View. Gizmos are for passive visualization; Handles are for interactive editing.
  • MenuItems with keyboard shortcuts provide instant access to frequently used operations. Combined with validation methods, they stay disabled when they would not apply.
  • BuildPipeline & AssetPostprocessor automate builds and enforce asset import rules, eliminating human error from repetitive processes.
  • CI/CD with GameCI ensures every commit is tested and buildable. Combined with the Unity Test Framework, it catches regressions before they reach players.

Next in the Series

In Part 14: Architecture & Clean Code, we'll explore how to structure your Unity projects for long-term maintainability. Topics include service locators, dependency injection, ScriptableObject-based architecture, event-driven systems, and design patterns that keep codebases clean as they scale from prototype to production.

Gaming