Posts tagged with "Mobile"
Posts tagged with: Mobile
  • MarbleMaze: Deep Dive — Procedural Maze Generation with Kruskal's Algorithm

    This is a deep dive into the procedural level generator behind MarbleMaze: Galactic Stars. Every level the player encounters is generated at runtime — no hand-crafted layouts, no level files. Each one is unique, yet fully reproducible from a single integer seed.

    The four steps established for this generation are all described in this post.
    Read the full process below…

    The Goal

    The generator needs to produce a perfect maze — a grid of cells connected by passages where every cell is reachable and there are no loops. On top of that, it needs to:

    • Place hazard tiles (ice, spikes, doors, moving platforms) in a way that feels hand-crafted
    • Guarantee start and end positions
    • Scatter collectible stars at meaningful distances from each other
    • Do all of this deterministically from a single seed (the level index), so the same level always looks the same

    The whole pipeline runs in four sequential steps, orchestrated by a single static entry point:

    // Generator.cs
    public static Grid GenerateMaze(int levelIndex, GeneratorParameters_SO p, out int usedSeed)
    {
        usedSeed = levelIndex == -1
            ? (p.inputSeed == -1 ? Random.Range(int.MinValue, int.MaxValue) : p.inputSeed)
            : levelIndex;
    
        var rng = new System.Random(usedSeed);
    
        Grid grid = GridFactory.CreateWallGrid(p.gridWidth, p.gridHeight);
    
        // 1. Perfect maze
        var carved = MazeGenerator.GenerateKruskalMaze(p.gridWidth, p.gridHeight, rng);
    
        // 2. Start / End
        var start = GridUtils.GetStartPosition(grid, p);
        var end   = GridUtils.ResolveEndPosition(grid, p, rng);
        GridUtils.MarkStartAndEnd(grid, start, end);
    
        // 3. Tile rules
        MazeGenerator.ApplyMaze(grid, carved, p, rng);
    
        // 4. Stars
        StarPlacer.PlaceStars(grid, p, carved, start, end, rng);
    
        return grid;
    }

    Passing levelIndex directly as the seed is the simplest possible reproducibility guarantee — regenerating level 42 will always produce the same maze.


    Step 0 — The Grid & CellData

    Before anything is generated, GridFactory.CreateWallGrid initialises the grid with every cell marked as empty (a wall). Generation then works by carving passages through it.

    // GridFactory.cs
    public static Grid CreateWallGrid(int width, int height)
    {
        Grid grid = new Grid(width, height);
        for (int x = 0; x < width; x++)
            for (int y = 0; y < height; y++)
                grid.SetCell(x, y, new CellData { isEmpty = true, ground = GroundType.Floor });
        return grid;
    }

    Each cell in the grid is a CellData struct — a value type stored directly in a 2D array. No heap allocations during traversal, no GC pressure on mobile:

    // CoreData.cs
    [System.Serializable]
    public struct CellData
    {
        public bool isEmpty;                   // true = wall, false = walkable
        public GroundType ground;              // Floor, Ice, Piques, MovingPlatform …
        public OverlayType overlay;            // None, Start, End, Star
        public bool isEnd;
        public bool isHorizontal;             // orientation flag for line hazards
        public bool requiresTwoSolidNeighbours;
    }

    The Grid class wraps CellData[,] and exposes two accessor styles. GetCell returns a copy (safe for reading), while GetCellRef returns a ref to the struct for in-place mutation without boxing or a round-trip copy:

    public ref CellData GetCellRef(int x, int y) => ref cells[x, y];

    This pattern appears everywhere in the generator — every tile rule modifies cells through ref accessors to keep the pipeline allocation-free.


    Step 1 — Kruskal’s Algorithm for Perfect Mazes

    Kruskal’s algorithm builds a minimum spanning tree of a graph. Applied to a grid, it builds a spanning tree of all cells — which is exactly what a perfect maze is: every cell connected, no loops, exactly one path between any two points.

    The Even-Cell Trick

    A key implementation detail: cells only live at even coordinates. A 10×10 grid has cells at (0,0), (0,2), (2,0), (2,2) … and so on. The odd coordinates are the walls between cells.

    When two cells are connected by removing the wall between them, three positions become walkable:

    • cell A (even, even)
    • cell B (even, even)
    • the wall between them — the midpoint (A + B) / 2

    This is the standard “cell + passage” encoding for grid mazes.

    // MazeGenerator.cs
    public static HashSet<Vector2Int> GenerateKruskalMaze(int width, int height, System.Random rng)
    {
        // 1. Collect cells at even coordinates only
        var cells = new List<Vector2Int>();
        for (int x = 0; x < width; x += 2)
            for (int y = 0; y < height; y += 2)
                cells.Add(new Vector2Int(x, y));
    
        // 2. Build the edge list (connections between adjacent cells, 2 apart)
        var edges = new List<Edge>();
        foreach (var c in cells)
        {
            TryAddEdge(c, Vector2Int.right * 2);
            TryAddEdge(c, Vector2Int.up * 2);
        }
    
        // 3. Shuffle edges for randomness
        GridUtils.Shuffle(edges, rng);
    
        // 4. Kruskal: union cells, carve passages for accepted edges
        var uf = new UnionFind<Vector2Int>(cells);
        var carved = new HashSet<Vector2Int>();
    
        foreach (var e in edges)
        {
            if (!uf.Union(e.a, e.b)) continue; // already connected — skip
    
            carved.Add(e.a);                    // cell A
            carved.Add(e.b);                    // cell B
            carved.Add((e.a + e.b) / 2);       // wall between them
        }
    
        return carved;
    }

    Union returns false when the two cells already share a root — meaning connecting them would create a loop. By skipping those edges, Kruskal’s guarantees the result is loop-free.

    The even-cell maze grid and Kruskal's carving

    Grid & Kruskal’s — The even-cell encoding is shown as a 7×7 coordinate grid. Cell nodes sit at even coordinates (marked with dots), walls at odd ones. The teal path traces a sample spanning tree carved by the algorithm — notice how every connected passage lights up three tiles: cell A, cell B, and the midpoint wall between them.


    Step 2 — Union-Find Data Structure

    The correctness of Kruskal’s depends entirely on efficiently answering one question: “Are these two cells already connected?”

    This is exactly the Union-Find (or Disjoint Set Union) data structure. I implemented it as a generic class so it can work with any comparable type — Vector2Int in this case:

    // UnionFind.cs
    public class UnionFind<T>
    {
        private readonly Dictionary<T, T> parent;
        private readonly Dictionary<T, int> rank;
    
        public UnionFind(IEnumerable<T> nodes)
        {
            parent = new Dictionary<T, T>();
            rank   = new Dictionary<T, int>();
    
            foreach (var n in nodes)
            {
                parent[n] = n; // each node is its own root initially
                rank[n]   = 0;
            }
        }
    
        public T Find(T x)
        {
            if (!parent[x].Equals(x))
                parent[x] = Find(parent[x]); // path compression
            return parent[x];
        }
    
        public bool Union(T a, T b)
        {
            T rootA = Find(a);
            T rootB = Find(b);
    
            if (rootA.Equals(rootB)) return false; // same tree — would create a cycle
    
            // Union by rank: attach the shorter tree under the taller one
            if      (rank[rootA] < rank[rootB]) parent[rootA] = rootB;
            else if (rank[rootA] > rank[rootB]) parent[rootB] = rootA;
            else { parent[rootB] = rootA; rank[rootA]++; }
    
            return true;
        }
    }

    Two classical optimisations are in play:

    Path compression (in Find): after traversing up to the root, every node along the path is re-pointed directly at the root. Future lookups on the same node take O(1).

    Union by rank (in Union): the tree with the smaller depth is attached under the taller one, keeping trees balanced and preventing worst-case O(n) chains.

    Together these give O(α(n)) amortised per operation, where α is the inverse Ackermann function — effectively constant for any input size you’d encounter in practice.

    Union-Find: path compression and union by rank

    Union-Find — The left side shows path compression in action: a 4-deep chain (A→B→C→D) collapses to a flat star shape after a single Find(E), so every subsequent lookup is O(1). The right side shows union by rank: Tree B (rank 1) is attached under Tree A (rank 2) rather than the other way around, keeping the merged tree balanced.


    Step 3 — Tile Decoration (3-Pass System)

    The carved HashSet<Vector2Int> from Kruskal’s only tells us which cells are walkable — it says nothing about what kind of floor tile each cell should be.

    MazeGenerator.ApplyMaze runs three passes in sequence, each adding a layer of tile variety with progressively stricter context requirements.

    Pass 1 — Base Tiles

    Every carved cell gets a ground type via weighted random selection from the active hazard list. The weights are normalised at runtime so the ratios sum to at most 1.0 — any remainder becomes plain Floor:

    private static void ApplyBaseTiles(Grid grid, HashSet<Vector2Int> carved,
        TileDatabase_SO db, System.Random rng)
    {
        var hazards = db.SimpleHazards.ToList();
    
        foreach (var pos in carved)
        {
            ref var cell = ref grid.GetCellRef(pos);
            if (cell.overlay != OverlayType.None) continue; // don't overwrite Start/End
    
            cell.isEmpty = false;
    
            float total   = hazards.Sum(h => h.ratio);
            float scale   = Mathf.Min(1f, total) / total; // normalise to ≤ 1.0
            double roll   = rng.NextDouble();
            float cumulative = 0f;
    
            foreach (var h in hazards)
            {
                cumulative += h.ratio * scale;
                if (roll < cumulative) { cell.ground = h.groundType; break; }
            }
        }
    }

    Pass 2 — Two-Solid-Neighbour Tiles

    Some hazards (e.g. wall spikes) only make sense when the cell has at least two solid (non-empty) neighbours — otherwise they’d appear floating in mid-air. This pass collects all eligible candidates, shuffles them, then fills up to a ratio target:

    private static bool IsValidTwoSolidPlacement(Grid grid, Vector2Int pos)
    {
        int solidNeighbours = 0;
        foreach (var d in Directions4)
        {
            var n = pos + d;
            if (!grid.IsInside(n)) continue;
            var neighbor = grid.GetCell(n.x, n.y);
    
            // Reject if a neighbour is already a two-solid tile — prevents clustering
            if (neighbor.requiresTwoSolidNeighbours) return false;
    
            if (!neighbor.isEmpty) solidNeighbours++;
        }
        return solidNeighbours >= 2;
    }

    The anti-clustering check (requiresTwoSolidNeighbours) prevents two of these tiles from sitting next to each other, which would look and play poorly.

    Pass 3 — Line Tiles (3-cell Hazards)

    The last pass places 3-cell hazards (e.g. a moving platform with two edge pieces). These require three consecutive walkable cells in a row or column:

    // Collect valid center positions for horizontal and vertical lines
    if (x-1 >= 0 && x+1 < grid.Width &&
        IsWalkable(grid, x-1, y) && IsWalkable(grid, x, y) && IsWalkable(grid, x+1, y))
        candidates.Add((new Vector2Int(x, y), horizontal: true));

    After placement, all three cells are recorded in a bool[,] reserved array to prevent overlapping lines from the same or subsequent passes:

    private static void ApplyLine(Grid grid, bool[,] reserved,
        (Vector2Int center, bool horizontal) c, HazardTileDefinition_SO hazard)
    {
        // Center cell gets the primary ground type
        ref var centerCell = ref grid.GetCellRef(c.center);
        centerCell.ground = hazard.groundType;
        centerCell.isHorizontal = c.horizontal;
    
        // Side cells get a different "edge" type (e.g. platform endcap)
        if (c.horizontal)
        {
            SetSide(grid, c.center.x - 1, c.center.y, hazard);
            SetSide(grid, c.center.x + 1, c.center.y, hazard);
            Reserve(reserved, c.center.x - 1, c.center.y, c.center.x, c.center.y, c.center.x + 1, c.center.y);
        }
        // ... vertical case mirrors this
    }

    This three-pass design — base → context-aware → structural — mirrors how a level designer would layer detail: first paint the floor, then add wall-hugging elements, then place the large set-pieces.

    The three-pass tile decoration system

    3-pass decoration — Each panel shows the same maze at a different stage. Pass 1 lays down random floor types. Pass 2 uses the neighbour context (the red arrows) to place a spike tile that has ≥2 solid neighbours. Pass 3 places a 3-cell moving platform across three consecutive walkable cells, with a reserved array preventing overlaps.


    Step 4 — Star Placement

    Stars use a two-pass placement strategy to balance the ideal spacing constraint against the reality that small or heavily decorated mazes might not have enough spread-out candidates.

    public static void PlaceStars(Grid grid, GeneratorParameters_SO p,
        HashSet<Vector2Int> walkable, Vector2Int start, Vector2Int end, System.Random rng)
    {
        var placed     = new List<Vector2Int>();
        var candidates = walkable.Where(c => c != start && c != end).ToList();
        GridUtils.Shuffle(candidates, rng);
    
        // Pass 1: strict — enforce minStarDistance between all placed stars
        foreach (var pos in candidates)
        {
            if (placed.Count >= p.starCount) break;
            if (grid.GetCell(pos.x, pos.y).overlay != OverlayType.None) continue;
            if (grid.GetCell(pos.x, pos.y).isEmpty) continue;
            if (grid.GetCell(pos.x, pos.y).requiresTwoSolidNeighbours) continue;
            if (placed.Any(s => Vector2Int.Distance(s, pos) < p.minStarDistance)) continue;
    
            grid.GetCellRef(pos.x, pos.y).overlay = OverlayType.Star;
            placed.Add(pos);
        }
    
        // Pass 2: relaxed — drop the distance constraint to fill the quota
        if (placed.Count < p.starCount)
        {
            foreach (var pos in candidates)
            {
                if (placed.Count >= p.starCount) break;
                if (grid.GetCellRef(pos.x, pos.y).overlay != OverlayType.None) continue;
                if (grid.GetCell(pos.x, pos.y).isEmpty) continue;
                if (grid.GetCell(pos.x, pos.y).requiresTwoSolidNeighbours) continue;
                if (placed.Contains(pos)) continue;
    
                grid.GetCellRef(pos.x, pos.y).overlay = OverlayType.Star;
                placed.Add(pos);
            }
        }
    }

    Stars also refuse to land on two-solid-neighbour tiles — those are already decorated with context-dependent hazards and would be confusing to navigate.

    The two-pass star placement strategy

    Star placement — Pass 1 shows the minimum-distance exclusion circles around each placed star, with a rejected candidate (red ✕) that falls inside one. Pass 2 shows a small maze where no spread-out candidates remain, so the quota is filled anyway by placing stars close together — guaranteeing the level never breaks.


    Configuration: The GeneratorParameters_SO

    All generation parameters live in a ScriptableObject, making them adjustable in the Unity inspector without touching code:

    public class GeneratorParameters_SO : ScriptableObject
    {
        public int gridWidth  = 10;
        public int gridHeight = 10;
        public bool randomEnd = true;
        public int  endMaxHeightPercent = 20; // end stays in the bottom 20% of the grid
        public int  inputSeed = -1;           // -1 = random; set for fixed test levels
        public TileDatabase_SO tileDatabase_SO;
        [Range(0, 20)] public int starCount = 3;
        [Range(1, 10)] public int minStarDistance = 2;
    }

    The RuntimeLevelProgression system modifies these parameters before each level — growing the grid size, adjusting hazard ratios, and changing star count as the player advances. That system is covered in its own deep-dive.


    Summary

    The summary of the system is the following:

    ComponentRole
    GridFactoryInitialises an all-wall grid
    UnionFind<T>Cycle detection for Kruskal’s; path compression + union by rank
    MazeGenerator.GenerateKruskalMazeBuilds the carved passage set
    MazeGenerator.ApplyMaze3-pass tile decoration
    GridUtilsStart/end placement, shuffle, safety queries
    StarPlacer2-pass star placement with distance fallback
    GeneratorPipeline entry point, seed management
    GeneratorParameters_SODesigner-configurable generation settings

    The key design choices that hold everything together:

    • Seeded System.Random rather than UnityEngine.Random — deterministic, no global state
    • Struct-based CellData in a 2D array — allocation-free pipeline, no GC spikes on mobile
    • ref accessors — in-place mutation without boxing
    • 3-pass decoration — each pass has access to the output of the previous, enabling context-aware rules
    • 2-pass star placement — strict constraint with a guaranteed fallback prevents levels from breaking on small grids
    ← Back to Project Overview Next: Adaptive Difficulty Engine →

    Created on February 2026
© 2026 Samuel Styles