~3 min read

Editor Scripting — Extending the Unity Editor

Custom Inspectors, EditorWindow, MenuItem, Gizmos, ExecuteAlways — extending the Unity Editor.

The Unity Editor can be extended with C# code just like runtime logic. This lets you:

  • Build custom inspectors for a non-standard field-editing UI.
  • Create editor windows — standalone tool panels inside the editor.
  • Add menu items, gizmos, scene view handles.
  • Run your code in the editor (not only in Play Mode).

Editor scripts — the main rule

Editor scripts live in an Editor/ folder (any nested one), and are compiled into a separate assembly that is not included in the game build.

Assets/
└── Editor/
    └── EnemyEditor.cs           ← Editor script
└── Scripts/
    └── Enemy.cs                  ← Runtime script

Inside Editor scripts you can use using UnityEditor; (this namespace is not available at runtime).

Web

DevTools extensions for Chrome: you write JS that adds a panel to DevTools for debugging your React applications. Only the Unity equivalent goes much deeper — it can change the editor itself.

Unity

An Editor script is a separate assembly that does not exist at runtime. The editor UI is drawn via IMGUI or UI Toolkit. Full access to Selection, AssetDatabase, EditorPrefs.

Custom Inspector — override the UI

The standard Inspector draws fields by default rules. Want to see a “Heal” button or a colored slider instead of a plain float field? You make a CustomEditor:

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(Enemy))]
public class EnemyEditor : Editor
{
    public override void OnInspectorGUI() {
        // First — the standard inspector (you can remove it if you want full control)
        DrawDefaultInspector();

        var enemy = (Enemy)target;

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Actions", EditorStyles.boldLabel);

        if (GUILayout.Button("Reset Health")) {
            Undo.RecordObject(enemy, "Reset Health");
            enemy.CurrentHp = enemy.MaxHp;
            EditorUtility.SetDirty(enemy);
        }

        if (GUILayout.Button("Take 10 Damage")) {
            Undo.RecordObject(enemy, "Take Damage");
            enemy.TakeDamage(10);
            EditorUtility.SetDirty(enemy);
        }

        EditorGUILayout.HelpBox(
            $"HP: {enemy.CurrentHp} / {enemy.MaxHp}",
            MessageType.Info);
    }
}

Now the Enemy component will have two buttons + a colored hint in the Inspector. Especially useful for testing right during Play Mode.

Undo.RecordObject — mandatory before a change

Without Undo.RecordObject, your edits won’t be captured by Ctrl+Z. That’s bad UX — the user clicked “Reset Health”, then changed their mind, and Ctrl+Z does nothing. Recording Undo is a standard of good practice.

SerializedProperty — the right way

For serialized fields, use SerializedObject / SerializedProperty — this is cleaner than reading target directly:

[CustomEditor(typeof(Enemy))]
public class EnemyEditor : Editor
{
    SerializedProperty maxHpProp;
    SerializedProperty currentHpProp;

    void OnEnable() {
        maxHpProp = serializedObject.FindProperty("maxHp");
        currentHpProp = serializedObject.FindProperty("currentHp");
    }

    public override void OnInspectorGUI() {
        serializedObject.Update();

        EditorGUILayout.PropertyField(maxHpProp);
        EditorGUILayout.Slider(currentHpProp, 0, maxHpProp.intValue, "Current HP");

        serializedObject.ApplyModifiedProperties();
    }
}

PropertyField handles multi-object editing by itself (select 5 enemies → the change applies to all of them) and Undo.

EditorWindow — your own panel

If the functionality is not about a specific component but about a “tool” (a level generator, an asset inspector):

using UnityEditor;
using UnityEngine;

public class LevelGeneratorWindow : EditorWindow
{
    private int width = 10;
    private int height = 10;
    private GameObject tilePrefab;

    [MenuItem("Tools/Level Generator")]
    public static void ShowWindow() {
        GetWindow<LevelGeneratorWindow>("Level Generator");
    }

    private void OnGUI() {
        GUILayout.Label("Level Settings", EditorStyles.boldLabel);
        width = EditorGUILayout.IntField("Width", width);
        height = EditorGUILayout.IntField("Height", height);
        tilePrefab = (GameObject)EditorGUILayout.ObjectField(
            "Tile Prefab", tilePrefab, typeof(GameObject), false);

        if (GUILayout.Button("Generate")) {
            Generate();
        }
    }

    private void Generate() {
        if (tilePrefab == null) return;
        var parent = new GameObject("GeneratedLevel").transform;
        for (int x = 0; x < width; x++) {
            for (int z = 0; z < height; z++) {
                var tile = PrefabUtility.InstantiatePrefab(tilePrefab) as GameObject;
                tile.transform.position = new Vector3(x, 0, z);
                tile.transform.SetParent(parent);
            }
        }
        Undo.RegisterCreatedObjectUndo(parent.gameObject, "Generate Level");
    }
}

After saving, “Level Generator” will appear in the Tools menu. It opens as a separate window with configurable fields.

public class CustomMenu
{
    [MenuItem("Tools/Cleanup/Remove All Empty GameObjects")]
    static void RemoveEmpty() {
        int removed = 0;
        foreach (var go in Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None)) {
            if (go.transform.childCount == 0 &&
                go.GetComponents<Component>().Length == 1) { // only Transform
                Object.DestroyImmediate(go);
                removed++;
            }
        }
        Debug.Log($"Removed {removed} empty GameObjects");
    }

    [MenuItem("CONTEXT/Light/Reset Intensity")]
    static void ResetLightIntensity(MenuCommand command) {
        var light = (Light)command.context;
        Undo.RecordObject(light, "Reset Intensity");
        light.intensity = 1f;
    }
}

The first is an item in the main menu. The second is an item in the context menu of the Light component (right-click on Light in the Inspector).

OnDrawGizmos — visualization in the Scene View

The most common Editor scripting use case is drawing a debug shape right in the 3D viewport:

public class Patrol : MonoBehaviour
{
    public Vector3[] points;

    private void OnDrawGizmos() {
        if (points == null || points.Length == 0) return;

        Gizmos.color = Color.green;
        for (int i = 0; i < points.Length; i++) {
            Vector3 p = transform.TransformPoint(points[i]);
            Gizmos.DrawWireSphere(p, 0.3f);
            if (i > 0) {
                Vector3 prev = transform.TransformPoint(points[i - 1]);
                Gizmos.DrawLine(prev, p);
            }
        }
    }

    private void OnDrawGizmosSelected() {
        // Drawn only when the object is selected
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, 2f); // visibility range
    }
}

OnDrawGizmos is a standard Unity function and does not require an Editor folder. It works right in a runtime script.

ExecuteAlways — runtime logic in the editor

Want your script to update in Edit Mode (without Play)? The [ExecuteAlways] attribute:

[ExecuteAlways]
public class Compass : MonoBehaviour
{
    public Transform target;

    private void Update() {
        if (target == null) return;
        Vector3 dir = (target.position - transform.position).normalized;
        transform.rotation = Quaternion.LookRotation(dir);
    }
}

Now the “compass needle” turns toward the target right in the Scene View, without Play Mode. The analog of Godot’s @tool.

ExecuteAlways — be careful

A script in the editor calls Update every frame. Heavy code slows down the editor. An infinite loop will freeze Unity. Before using ExecuteAlways, weigh whether you really need it.

Asset Postprocessor

Want to automatically set the Compression Mode for all imported PNGs? AssetPostprocessor:

public class TexturePostprocessor : AssetPostprocessor
{
    void OnPreprocessTexture() {
        var importer = (TextureImporter)assetImporter;
        if (assetPath.Contains("/UI/")) {
            importer.textureCompression = TextureImporterCompression.Uncompressed;
            importer.spritePixelsPerUnit = 100;
        }
    }
}

This code fires on the first import of each PNG. Very handy for projects with rules like “UI textures must be uncompressed, otherwise they get blurry”.

  • UI Toolkit for Editor (UIElements) — the modern way to build complex editor UI, replacing IMGUI. Supports UXML+USS just like runtime UI Toolkit.
  • CustomPropertyDrawer — for a single property type, so that Unity draws it the same way in all inspectors.
  • EditorPrefs — storing per-machine settings for your tool.

Comparison with Godot

The concepts map 1:1:

  • [CustomEditor] ↔ Godot EditorInspectorPlugin._parse_property
  • EditorWindow ↔ Godot EditorPlugin.add_control_to_dock
  • [MenuItem] ↔ Godot EditorPlugin.add_tool_menu_item
  • OnDrawGizmos ↔ Godot EditorNode3DGizmoPlugin._redraw
  • [ExecuteAlways] ↔ Godot @tool

In Godot, all of this is written in GDScript. In Unity — in C# in a separate assembly.