Pattern Fill: How Adungeon Lays Down "Stone" Shapes Automatically
If you have ever tried to hand-place a bunch of stone outlines in a pixel dungeon, you know what happens. You either get weird gaps, shapes that bump into each other, or a pattern that looks too perfect and grid-like.
Pattern Fill in adungeon.com is meant to skip that whole mess. You click a region, and it fills it with rounded rectangle outlines that look like organic stones or bricks. The goal is straightforward: pack in as many shapes as possible, keep spacing consistent, and never let shapes overlap or nest inside each other.
Below is how it works, with the ASCII diagrams and code-style bits intact.
Region Detection (what am I filling?)
When you click with the Pattern Fill tool, the first job is figuring out which pixels count as the region. This is a classic flood fill using 4-direction connectivity (up, down, left, right). Anything connected to your click point that matches the target color becomes part of the fill region.
Initial State (click at X):
+------------------+
| |
| ............ |
| . . |
| . X . |
| . . |
| ............ |
| |
+------------------+
After Flood Fill:
+------------------+
| |
| FFFFFFFFFFFF |
| F F |
| F X F |
| F F |
| FFFFFFFFFFFF |
| |
+------------------+
F = pixels in fill region
From this point on, every placement rule is constrained to that set of pixels. If an outline pixel falls outside the region, the shape is rejected.
Rounded Rectangle Generation (what a “stone” is)
Each stone is a rounded rectangle defined by its position (top-left corner), width, and height. The corner radius is calculated as 40% of the smaller dimension, with a minimum of 2 pixels. That keeps corners chunky enough to read as rounded in pixel art.
Rounded Rectangle Structure:
Corner radius = min(width, height) * 0.4
r = corner radius
|---|
+---+=========+---+ ---
/ \ |
| | | r
+ + ---
| |
| |
| |
+ + ---
| | | r
\ / |
+---+=========+---+ ---
The outline is generated in two parts: straight edges plus corner arcs.
Straight edges are the four sides excluding the corner regions. Corner arcs are quarter circles drawn at each corner using parametric circle equations with enough steps to make curves look smooth:
for each step i from 0 to num_steps:
angle = start_angle + (PI/2) * (i / num_steps)
x = center_x + radius * cos(angle)
y = center_y + radius * sin(angle)
Each corner uses a different starting angle. Top-left starts at 180 degrees (PI), top-right starts at 270 degrees (1.5 PI), bottom-right starts at 0 degrees, and bottom-left starts at 90 degrees (0.5 PI).
The Buffer System (spacing rules that keep it clean)
Spacing is a big part of why the output looks intentional instead of chaotic. Pattern Fill uses two buffers: one for shape-to-shape spacing, and one for keeping away from the canvas edge.
Shape Buffer
The shape buffer defines the minimum distance between any two shape outlines. When checking if a new shape can be placed, each outline pixel is tested against all previously placed outline pixels with a square neighborhood search.
Buffer = 1 (default):
Shape A outline: Buffer zone around A: Shape B cannot overlap:
**** ...... ......
* * ........ ........
* * ..****.. ..****..
* * ..* *.. ..* *.. B's outline
* * ..* *.. ..* *.. must stay
**** ..****.. ..****.. outside dots
........ ........
...... ......
The check iterates through a square region around each point, including diagonals:
for dr from -buffer to +buffer:
for dc from -buffer to +buffer:
if occupied[row + dr][col + dc]:
return "too close"
Edge Buffer
The edge buffer prevents shapes from being placed too close to the canvas boundary. Positions within the edge buffer distance from any edge are rejected early.
Edge Buffer = 1:
+------------------------+
|xxxxxxxxxxxxxxxxxxxxxxxx| x = forbidden zone
|x x|
|x (placeable area) x|
|x x|
|xxxxxxxxxxxxxxxxxxxxxxxx|
+------------------------+
Phase 1: Initial Placement (get the big pieces down)
Phase 1 is where most of the coverage happens. It keeps attempting to place rounded rectangles until it runs out of valid spots.
Sizes are chosen randomly in configured bounds. Width is random between MIN_SIZE and MAX_SIZE, and height is random between MIN_SIZE and MAX_SIZE. That randomness is doing a lot of the visual work because it prevents the whole fill from turning into a repeating pattern.
Placement positions start out spread around the region, then shift into a more clustered style after a configured threshold. Early on, random positions are sampled from the fill region, which helps distribute the first shapes across the whole area.
Cluster threshold = 4
Placement 1-4: Random positions
+------------------+
| [1] |
| [3] |
| [2] |
| [4] |
+------------------+
After the threshold is reached, new shapes prefer to appear near existing shapes. The algorithm maintains a set of “adjacent positions” based on a zone around each placed shape.
After placement, adjacent zone extends MIN_SIZE + 3 pixels:
Existing shape
|
+-------v-------+
| adjacent |
| +-----+ |
| |shape| |
| +-----+ |
| zone |
+---------------+
New shapes prefer positions within these zones.
Before a shape is accepted, it has to pass the same core checks every time: it must respect the edge buffer, every outline pixel must be inside the flood-filled region, outline pixels must respect the shape buffer, nesting must be prevented in both directions, and it must keep distance from any pre-existing non-target pixels.
Iterative Compaction (scoot shapes to make room)
Once Phase 1 placement stalls, Pattern Fill compacts the shapes and then tries placing again. Compaction moves each shape up and left as far as it can without breaking any placement constraints. This can open up usable gaps that were blocked earlier.
Before Compaction: After Compaction:
+------------------+ +------------------+
| | | +--+ +--+ |
| +--+ | | | | | | |
| | | +--+ | -> | +--+ +--+ |
| +--+ | | | | +--+ |
| +--+ | | | | |
| | | +--+ |
+------------------+ +------------------+
The movement order is randomized per shape, which helps avoid the exact same “everything slides the same way” look.
for each shape:
remove shape from tracking
if random() < 0.5:
// Move up first, then left
while can_move_up:
move shape up by 1
while can_move_left:
move shape left by 1
else:
// Move left first, then up
while can_move_left:
move shape left by 1
while can_move_up:
move shape up by 1
place shape at new position
Placement and compaction repeat until compaction no longer helps.
Phase 1.5: Expansion (make placed shapes a bit bigger)
After Phase 1 placement and compaction settle, the algorithm tries to expand existing shapes. Each shape is tested to see if it can grow by 1 pixel in width or height while still obeying all constraints.
Original Shape: After Width Expansion: After Height Expansion:
+----+ +--------+ +----+
| | | | | |
| | -> | | -> | |
| | | | | |
+----+ +--------+ | |
| |
+----+
It randomizes whether to try width first or height first so aspect ratios stay varied:
for each shape:
remove shape from tracking
if random() < 0.5:
// Expand width first, then height
try increasing width by 1:
if valid placement:
keep new width
try increasing height by 1:
if valid placement:
keep new height
else:
// Expand height first, then width
try increasing height by 1:
if valid placement:
keep new height
try increasing width by 1:
if valid placement:
keep new width
place shape with final dimensions
Phase 2: Gap Filling (deal with the awkward leftovers)
Phase 2 is the cleanup pass for small gaps. It uses a smaller minimum size (configurable, default 5) and targets pixels that are still free inside the region while respecting buffer distance.
First, it identifies candidate pixels that are not part of any existing shape outline or interior.
After Phase 1: Phase 2 Candidates (dots):
+------------------+ +------------------+
| +--+ +--+ | | +--+ +--+ ..... |
| | | | | | | | | | | ..... |
| +--+ +--+ | -> | +--+ +--+ ..... |
| +--+ | | +--+ ..... |
| | | | | | | ..... |
| +--+ | | +--+ ..... |
+------------------+ +------------------+
Then, for each candidate, Phase 2 starts with a minimum-sized rounded rectangle and grows it outward while it remains valid. It usually tries width growth, then height growth, and places the result if it still meets minimum size requirements.
Phase 2 shapes also go through compaction and expansion using the same logic as Phase 1. The idea is consistent: get something down, nudge it to pack tighter, then see if it can grow.
Interior Fill Color (optional, but fun)
Pattern Fill supports an optional interior fill color separate from the outline color. If you use it, the pixels inside each rounded rectangle are filled (excluding the outline), which makes shapes read as solid blocks.
Outline only: With interior fill:
+----+ +----+
/ \ /######\
| | |########|
| | -> |########|
| | |########|
\ / \######/
+----+ +----+
# = interior fill color
+ = outline color
Interior detection uses scan-line logic. For each row, it finds the leftmost and rightmost outline pixels and fills the pixels between them that are not part of the outline.
Config Parameters (what you tweak to change the vibe)
Min Size and Max Size (Phase 1) decide the general “stone” scale. If Min Size is higher, you get fewer chunkier shapes. If it is lower, you get more shapes and a busier texture.
Phase 2 Min Size sets how small gap-filling shapes are allowed to be. Smaller fills more gaps, but it can get noisy if you go too low.
Cluster After controls how quickly the algorithm shifts from scattered placement to clustered placement. Lower values produce tighter groupings sooner.
Buffer controls how much “mortar” space you get between outlines. Edge Buffer controls the empty border near the canvas edge.
##
Tools (same rules, one big shape)
Three additional tools use the same region detection and buffer constraints, but place a single best-fit shape: Fill Largest Circle, Fill Largest Square, and Fill Largest Rounded Rectangle. They all play nicely with Pattern Fill because the spacing rules are consistent across the set.
Implementation Notes (where it lives)
The implementation uses string-based coordinate keys (like "row,col") stored in JavaScript Set objects for fast lookup of occupied positions, interior positions, and region membership. That keeps constraint checking efficient even for large grids.
The algorithm is implemented in resources/js/components/dungeon/TileCanvas.tsx in the patternFill function, with UI controls in resources/js/pages/admin/tiles/edit.tsx. All configuration values are persisted to localStorage so your settings stick between editing sessions.