Tools & Tech Art / Dungeon Generator
End-to-End Graph-Based Dungeon Generator
A Unity editor tool that lets designers hand-author level flow without giving up procedural generation.
Built on Unity's GraphView API, this generator lets designers draw the shape of a run as a graph — then fills it procedurally. Paired with a custom tilemap serialization tool so a room can be drawn and saved in one click.
Engineering highlights
Hover to preview · click to pin
DungeonGraphView.cs
public DungeonGraphView(SerializedObject serializedObject, DungeonGraphEditorWindow window)
{
m_serializedObject = serializedObject;
m_dungeonGraph = (DungeonGraphAsset)serializedObject.targetObject;
m_window = window;
m_graphNodes = new List<DungeonGraphEditorNode>();
m_nodeDictionary = new Dictionary<string, DungeonGraphEditorNode>();
m_connectionDictionary = new Dictionary<Edge, DungeonGraphConnection>();
// Load persisted parameters
LoadPreferences();
m_searchProvider = ScriptableObject.CreateInstance<DungeonGraphWindowSearchProvider>();
m_searchProvider.graph = this;
this.nodeCreationRequest = ShowSearchWindow;
StyleSheet style = DungeonGraphPaths.LoadEditorStyleSheet();
if (style != null)
styleSheets.Add(style);
GridBackground background = new GridBackground();
background.name = "Grid";
Add(background);
//background.SendToBack();
// Ensure the grid fills the view and cannot eat mouse events
background.StretchToParentSize();
background.pickingMode = PickingMode.Ignore;
// Make sure it really sits behind everything else
background.SendToBack();
//background.style.zIndex = 0;
// Keep the graph content above the grid (and pickable)
this.style.flexGrow = 1;
//contentViewContainer.style.zIndex = 1;
contentViewContainer.pickingMode = PickingMode.Position;
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new RectangleSelector());
this.AddManipulator(new ClickSelector());
this.SetupZoom(0.5f / 3f, 10f);
RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
// Enable copy/paste
serializeGraphElements = SerializeGraphElementsCallback;
unserializeAndPaste = UnserializeAndPasteCallback;
canPasteSerializedData = CanPasteSerializedDataCallback;
DrawNodes();
DrawConnections();
RegisterCallback<MouseDownEvent>(OnGlobalMouseDown, TrickleDown.TrickleDown);
RegisterCallback<MouseUpEvent>(OnGlobalMouseUp, TrickleDown.TrickleDown);
// Set the default pickability once after building the view
SetPickabilityDuringDrag(false, null, Direction.Output);
// var sheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/DungeonGraph/Scripts/Editor/DungeonGraphEditor.uss");
// if (sheet != null) styleSheets.Add(sheet);
graphViewChanged += OnGraphViewChangedEvent;
// Track node selection changes
RegisterCallback<PointerDownEvent>(OnPointerDown, TrickleDown.TrickleDown);
BuildToolsPanel();
BuildNodeSettingsPanel();
// Sync dropdown when the user creates/deletes/renames floor folders in the Project window
DungeonFloorsWatcher.OnFloorsChanged += OnExternalFloorsChanged;
RegisterCallback<DetachFromPanelEvent>(_ => DungeonFloorsWatcher.OnFloorsChanged -= OnExternalFloorsChanged);
}
private void BuildToolsPanel()
{
// Callers that rebuild the panel (floor list refresh, style change) would otherwise
// leave the previous, now-empty Blackboard parented to the view forever.
if (m_toolsBoard != null && m_toolsBoard.parent != null)
m_toolsBoard.RemoveFromHierarchy();
// A movable ShaderGraph-style panel
m_toolsBoard = new Blackboard(this)
{
title = "Dungeon Tools",
subTitle = m_dungeonGraph != null ? m_dungeonGraph.name : string.Empty
};
// Make it draggable | collapsible | resizable
m_toolsBoard.capabilities |= Capabilities.Movable | Capabilities.Collapsible | Capabilities.Resizable;
// Position at top-right corner with reasonable default size
// Will be repositioned on geometry change to stick to top-right
float defaultWidth = 320;
float defaultHeight = 600;
// Use a safe default position if layout width is not yet initialized
float xPos = layout.width > defaultWidth ? layout.width - defaultWidth - 16 : 16;
m_toolsBoard.SetPosition(new Rect(xPos, 16, defaultWidth, defaultHeight));
m_toolsBoard.style.minWidth = 300;
m_toolsBoard.style.minHeight = 100;
var addButton = m_toolsBoard.Q<Button>("addButton");
if (addButton != null)
addButton.style.display = DisplayStyle.None;
ApplyStaticHeaderStyles(m_toolsBoard);
// All content lives inside this scroll view so the panel can be freely shrunk
var mainScrollView = new ScrollView(ScrollViewMode.Vertical);
mainScrollView.style.flexGrow = 1;
mainScrollView.style.flexShrink = 1;
mainScrollView.style.minHeight = 0;
mainScrollView.verticalScrollerVisibility = ScrollerVisibility.Auto;
mainScrollView.horizontalScrollerVisibility = ScrollerVisibility.Hidden;
// ===== ACTIONS =====
var actionsHeader = new Label("Actions");
actionsHeader.AddToClassList("section-header");
mainScrollView.Add(actionsHeader);
var actions = new VisualElement();
actions.style.marginBottom = 10;
m_generateDungeonBtn = new Button(GenerateDungeon)
{
text = "Generate Dungeon",
tooltip = "Generate complete dungeon with rooms and corridors"
};
m_generateDungeonBtn.style.height = 35;
m_generateDungeonBtn.style.marginBottom = 6;
actions.Add(m_generateDungeonBtn);
var buttonRow = new VisualElement();
buttonRow.style.flexDirection = FlexDirection.Row;
buttonRow.style.justifyContent = Justify.SpaceBetween;
m_generateRoomsBtn = new Button(() => GenerateRooms())
{
text = "Generate Rooms",
tooltip = "Generate only rooms using physics simulation"
};
m_generateRoomsBtn.style.height = 28;
m_generateRoomsBtn.style.flexGrow = 1;
m_generateRoomsBtn.style.marginRight = 4;
buttonRow.Add(m_generateRoomsBtn);
m_generateCorridorsBtn = new Button(GenerateCorridors)
{
text = "Generate Corridors",
tooltip = "Generate corridors connecting existing rooms"
};
m_generateCorridorsBtn.style.height = 28;
m_generateCorridorsBtn.style.flexGrow = 1;
buttonRow.Add(m_generateCorridorsBtn);
actions.Add(buttonRow);
var clearBtn = new Button(ClearDungeon)
{
text = "Clear Dungeon",
tooltip = "Remove all generated rooms and corridors"
};
clearBtn.style.height = 28;
clearBtn.style.marginTop = 6;
actions.Add(clearBtn);
mainScrollView.Add(actions);
// ===== FLOOR SELECTION =====
var floorHeader = new Label("Dungeon Floor");
floorHeader.AddToClassList("section-header");
floorHeader.style.marginTop = 10;
mainScrollView.Add(floorHeader);
var floorContainer = new VisualElement();
floorContainer.style.marginBottom = 10;
var floorDropdown = new UnityEngine.UIElements.PopupField<DungeonFloorConfig>(
"Selected Floor",
m_availableFloors,
m_selectedFloorIndex >= 0 && m_selectedFloorIndex < m_availableFloors.Count ? m_availableFloors[m_selectedFloorIndex] : null
);
floorDropdown.tooltip = "Select which dungeon floor to use for room generation";
floorDropdown.RegisterValueChangedCallback(evt =>
{
m_selectedFloorIndex = m_availableFloors.IndexOf(evt.newValue);
UpdateCurrentFloorPath();
SavePreferences();
});
floorContainer.Add(floorDropdown);
// --- Create buttons row ---
var floorCreateRow = new VisualElement();
floorCreateRow.style.flexDirection = FlexDirection.Row;
floorCreateRow.style.marginTop = 4;
var createFloorBtn = new Button(() =>
{
CreateFloorWindow.ShowWindow((floorName) =>
{
if (!DungeonFloorManager.CreateNewFloor(floorName))
return false; // DungeonFloorManager already showed the specific error dialog
RefreshFloorList(floorName);
EditorUtility.DisplayDialog("Success",
$"Floor '{floorName}' created successfully with all standard and custom node folders!", "OK");
return true;
});
})
{
text = "Create New Floor",
tooltip = "Create a floor with all standard node type subfolders and blank room prefabs"
};
createFloorBtn.style.height = 24;
createFloorBtn.style.flexGrow = 1;
createFloorBtn.style.marginRight = 3;
floorCreateRow.Add(createFloorBtn);
var createEmptyBtn = new Button(() =>
{
CreateFloorWindow.ShowWindow(
(folderName) =>
{
if (!DungeonFloorManager.CreateEmptyFloor(folderName))
{
EditorUtility.DisplayDialog("Error",
$"Failed to create folder '{folderName}'. It may already exist.", "OK");
}
else
{
RefreshFloorList(folderName);
}
return true; // always close — empty folders don't need Addressable group validation
},
windowTitle: "Create Empty Folder",
helpText: "Creates a bare folder in the floors directory with no subfolders or room prefabs."
);
})
{
text = "Create Empty Folder",
tooltip = "Create a bare folder in the floors directory — no subfolders or prefabs generated"
};
createEmptyBtn.style.height = 24;
createEmptyBtn.style.flexGrow = 1;
floorCreateRow.Add(createEmptyBtn);
floorContainer.Add(floorCreateRow);
mainScrollView.Add(floorContainer);
// ===== GENERATION STYLE SETTINGS =====
var generationStyleHeader = new Label("Generation Style Settings");
generationStyleHeader.AddToClassList("section-header");
generationStyleHeader.style.marginTop = 10;
mainScrollView.Add(generationStyleHeader);
m_generationStyleField = new EnumField("Generation Style", m_generationStyle);
m_generationStyleField.tooltip = "Algorithm used to lay out rooms. Each style has its own " +
"parameters, shown below; Basic Settings applies to all styles.";
m_generationStyleField.style.marginBottom = 4;
m_generationStyleField.RegisterValueChangedCallback(evt =>
{
m_generationStyle = (GenerationStyle)evt.newValue;
SavePreferences();
RebuildStyleParameters();
});
mainScrollView.Add(m_generationStyleField);
// Swapped out wholesale whenever the style changes
m_styleParametersContainer = new VisualElement();
m_styleParametersContainer.name = "style-parameters";
mainScrollView.Add(m_styleParametersContainer);
RebuildStyleParameters();
// ===== BASIC SETTINGS =====
var basicSettingsHeader = new Label("Basic Settings");
basicSettingsHeader.AddToClassList("section-header");
basicSettingsHeader.style.marginTop = 10;
mainScrollView.Add(basicSettingsHeader);
// ===== ROOM SETTINGS =====
var roomSettings = new BlackboardSection { title = "Room Settings" };
roomSettings.name = "room-settings";
var idealDistanceSlider = new Slider("Ideal Distance", 1f, 50f) { value = m_idealDistance };
idealDistanceSlider.showInputField = true;
idealDistanceSlider.tooltip = "Target distance between connected rooms";
idealDistanceSlider.RegisterValueChangedCallback(evt =>
{
m_idealDistance = evt.newValue;
SavePreferences();
});
roomSettings.Add(idealDistanceSlider);
var speedSlider = new Slider("Simulation Speed", 1f, 1000f) { value = m_simulationSpeed };
speedSlider.showInputField = true;
speedSlider.tooltip = "Steps per second while real-time generation is enabled";
speedSlider.SetEnabled(m_realTimeSimulation);
speedSlider.RegisterValueChangedCallback(evt =>
{
m_simulationSpeed = evt.newValue;
SavePreferences();
});
var realTimeToggle = new Toggle("Real-Time Simulation") { value = m_realTimeSimulation };
realTimeToggle.tooltip = "Animate generation in real-time instead of resolving the whole layout at once";
realTimeToggle.RegisterValueChangedCallback(evt =>
{
m_realTimeSimulation = evt.newValue;
SavePreferences();
speedSlider.SetEnabled(evt.newValue);
});
roomSettings.Add(realTimeToggle);
roomSettings.Add(speedSlider);
mainScrollView.Add(roomSettings);
// ===== CORRIDOR SETTINGS =====
var corridorSettings = new BlackboardSection { title = "Corridor Settings" };
corridorSettings.name = "corridor-settings";
var corridorTileField = new ObjectField("Corridor Tile")
{
objectType = typeof(UnityEngine.Tilemaps.TileBase),
value = m_corridorTile,
tooltip = "Tile used to paint corridors connecting rooms"
};
corridorTileField.RegisterValueChangedCallback(evt =>
{
m_corridorTile = evt.newValue as UnityEngine.Tilemaps.TileBase;
SavePreferences();
});
corridorSettings.Add(corridorTileField);
var corridorWidthField = new IntegerField("Corridor Width") { value = m_corridorWidth };
corridorWidthField.tooltip = "Width of corridors in tiles";
corridorWidthField.RegisterValueChangedCallback(evt =>
{
m_corridorWidth = evt.newValue;
SavePreferences();
});
corridorSettings.Add(corridorWidthField);
var corridorTypeField = new EnumField("Corridor Type", m_corridorType);
corridorTypeField.tooltip = "Pathfinding algorithm for corridor generation";
corridorTypeField.RegisterValueChangedCallback(evt =>
{
m_corridorType = (CorridorType)evt.newValue;
SavePreferences();
});
corridorSettings.Add(corridorTypeField);
mainScrollView.Add(corridorSettings);
// ===== ADVANCED SETTINGS (shared by all generation styles) =====
var advancedFoldout = new Foldout { text = "Advanced Settings", value = m_showAdvanced };
advancedFoldout.tooltip = "Advanced parameters for fine-tuning dungeon generation";
var advancedContent = new VisualElement();
var forceModeToggle = new Toggle("Force Mode") { value = m_forceMode };
forceModeToggle.tooltip = "Keep going until the layout resolves instead of stopping at the " +
"configured attempt limits (Organic caps at 2096 iterations)";
forceModeToggle.RegisterValueChangedCallback(evt =>
{
m_forceMode = evt.newValue;
SavePreferences();
// Only present while the Organic style is selected
m_simulationIterationsField?.SetEnabled(!evt.newValue);
});
advancedContent.Add(forceModeToggle);
var chaosSlider = new Slider("Chaos Factor", 0.0f, 1.0f) { value = m_chaosFactor };
chaosSlider.showInputField = true;
chaosSlider.tooltip = "Randomness introduced during generation — room velocities under Organic, " +
"exit-pair choice under the Doorway style (0 = ordered, 1 = chaotic)";
chaosSlider.RegisterValueChangedCallback(evt =>
{
m_chaosFactor = evt.newValue;
SavePreferences();
});
advancedContent.Add(chaosSlider);
var maxRoomRegenerationsField = new IntegerField("Max Room Regenerations") { value = m_maxRoomRegenerations };
maxRoomRegenerationsField.tooltip = "Maximum attempts to regenerate room layout if overlaps are detected";
maxRoomRegenerationsField.SetEnabled(!m_allowRoomOverlap);
maxRoomRegenerationsField.RegisterValueChangedCallback(evt =>
{
m_maxRoomRegenerations = evt.newValue;
SavePreferences();
});
var allowOverlapToggle = new Toggle("Allow Room Overlap") { value = m_allowRoomOverlap };
allowOverlapToggle.tooltip = "Allow rooms to overlap during generation";
allowOverlapToggle.RegisterValueChangedCallback(evt =>
{
m_allowRoomOverlap = evt.newValue;
SavePreferences();
maxRoomRegenerationsField.SetEnabled(!evt.newValue);
});
advancedContent.Add(allowOverlapToggle);
advancedContent.Add(maxRoomRegenerationsField);
var maxCorridorRegenerationsField = new IntegerField("Max Corridor Regenerations") { value = m_maxCorridorRegenerations };
maxCorridorRegenerationsField.tooltip = "Maximum attempts to regenerate corridors if overlaps are detected";
maxCorridorRegenerationsField.RegisterValueChangedCallback(evt =>
{
m_maxCorridorRegenerations = evt.newValue;
SavePreferences();
});
advancedContent.Add(maxCorridorRegenerationsField);
advancedFoldout.Add(advancedContent);
advancedFoldout.RegisterValueChangedCallback(evt =>
{
m_showAdvanced = evt.newValue;
SavePreferences();
});
mainScrollView.Add(advancedFoldout);
m_toolsBoard.Add(mainScrollView);
Add(m_toolsBoard);
}Demo
RoomAuthoringTool.cs
public static class RoomAuthoringTools
{
// ---------- PUBLIC MENUS ----------
/// <summary>
/// Quick-save: re-bakes the room and overwrites the existing prefab asset.
/// If the room is not yet saved, opens Save As... instead.
/// </summary>
[MenuItem("Tools/Dungeon Graph/Rooms/Save")]
public static void BakeSaveQuick()
{
var go = GetSelectedRoomRootOrWarn();
if (!go) return;
BakeRoom(go);
if (!PrefabUtility.IsPartOfPrefabInstance(go))
{
// Not yet a prefab — open the full Save As dialog
SaveRoomAsWindow.Show(go);
return;
}
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(go);
string assetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(prefabRoot);
if (string.IsNullOrEmpty(assetPath))
{
SaveRoomAsWindow.Show(go);
return;
}
SavePrefab(go, assetPath);
// Re-register labels in case the prefab moved or labels were removed
AddressableRoomRegistrar.TryRegisterFromPath(assetPath);
}
/// <summary>
/// Save As...: bakes the room, then opens the floor/type picker dialog.
/// </summary>
[MenuItem("Tools/Dungeon Graph/Rooms/Save As...")]
public static void BakeSaveAs()
{
var go = GetSelectedRoomRootOrWarn();
if (!go) return;
BakeRoom(go);
SaveRoomAsWindow.Show(go);
}
// ---------- CORE HELPERS ----------
private static GameObject GetSelectedRoomRootOrWarn()
{
var go = Selection.activeGameObject;
if (!go)
{
Debug.LogWarning("Select your Room root GameObject in the Hierarchy.");
return null;
}
if (go.GetComponentInChildren<Grid>() == null ||
go.GetComponentsInChildren<Tilemap>(true).Length == 0)
{
Debug.LogError("Selected object doesn't look like a room (need a Grid and at least one Tilemap).");
return null;
}
return go;
}
private static void BakeRoom(GameObject go)
{
var tms = go.GetComponentsInChildren<Tilemap>(true);
foreach (var tm in tms)
{
tm.CompressBounds();
var cb = tm.cellBounds;
if (cb.size.x == 0 || cb.size.y == 0)
{
Object.DestroyImmediate(tm.gameObject);
continue;
}
for (int x = cb.xMin; x < cb.xMax; x++)
for (int y = cb.yMin; y < cb.yMax; y++)
{
var p = new Vector3Int(x, y, 0);
if (tm.GetTile(p) == null) continue;
tm.SetTileFlags(p, TileFlags.LockColor | TileFlags.LockTransform);
}
}
var rt = go.GetComponent<DungeonGraph.RoomTemplate>() ?? go.AddComponent<DungeonGraph.RoomTemplate>();
rt.Recompute();
PopulateExits(go, rt);
}
private static void PopulateExits(GameObject go, DungeonGraph.RoomTemplate rt)
{
Transform exitsContainer = go.transform.Find("Exits");
if (exitsContainer == null) return;
int childCount = exitsContainer.childCount;
if (childCount == 0)
{
Debug.LogWarning("[RoomAuthoringTool] Found 'Exits' container but it has no children.");
rt.exits = new Transform[0];
return;
}
rt.exits = new Transform[childCount];
for (int i = 0; i < childCount; i++)
rt.exits[i] = exitsContainer.GetChild(i);
}
internal static void SavePrefab(GameObject go, string file)
{
if (PrefabUtility.IsPartOfAnyPrefab(go))
PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.UserAction);
var prefab = PrefabUtility.SaveAsPrefabAsset(go, file);
if (prefab != null)
{
EditorUtility.SetDirty(prefab);
AssetDatabase.SaveAssets();
}
else
{
Debug.LogError($"[RoomAuthoringTool] Failed to save prefab at: {file}");
}
}
}Demo
Code
Demo
The tension this resolves
Pure procedural generation gives you infinite levels that all feel the same. Pure hand-authoring gives you great levels and no scale. Most dungeon generators pick a side.
This one splits the difference by moving the authoring up a level of abstraction. The designer doesn’t draw the dungeon, they draw the graph of the dungeon: which rooms connect to which, where the branches are, what has to be visited before the boss. The generator then fills that structure with procedurally selected and arranged rooms, so pacing is authored while content stays varied.
Removing the hand-off
The serialization tool exists because of a workflow problem rather than a technical one. Previously, getting a designer’s room into the generator meant someone translating it into data by hand. Now the designer draws the room in the tilemap, presses one button, and it becomes a node the generator can use immediately.
That shortened the iteration loop more than any generation improvement did.