By the end of this tutorial, you’ll build a procedurally generated game world with layered terrain, water bodies, and props.
Prerequisites: This is Chapter 2 of our Bevy tutorial series. Join our community for updates on new releases. Before starting, complete Chapter 1: Let There Be a Player, or clone the Chapter 1 code from this repository to follow along.

Before We Begin: I'm constantly working to improve this tutorial and make your learning journey enjoyable. Your feedback matters - share your frustrations, questions, or suggestions on Reddit/Discord/LinkedIn. Loved it? Let me know what worked well for you! Together, we'll make game development with Rust and Bevy more accessible for everyone.
Procedural Generation
I respect artists who hand craft tiles to build game worlds. But I belong to the impatient/lazy species.
I went on an exploration and came across procedural generation.
Little did I know the complexities involved. I was on the verge of giving up, however because of the comments and messages from readers of previous chapter, I kept going. And the enlightenment came three days ago, all the pieces fit together.
Basically it’s about automatically fitting things together like a jigsaw puzzle. To solve this problem, let’s again think in systems.
What do we need to generate the game world procedurally?
- Tileset.
- Sockets for tiles because only compatible tiles should fit.
- Compatibility rules.
- Magic algorithm that uses these components to generate a coherent world.
How does this magic algorithm work?
That “magic algorithm” has a name: Wave Function Collapse (WFC). The easiest way to see it is with a tiny Sudoku. Same idea: pick the cell with the fewest valid options, place a value, update neighbors, and repeat. If a choice leads to a dead end, undo that guess and try the next option.
Small 4×4 Sudoku
Let’s solve this step by step, focusing on the most constrained cells first.
Initial Puzzle: We need to fill in the empty cells (marked with dots) following Sudoku rules.
| ? | . | 2 | . |
| . | 3 | . | . |
| . | . | . | 1 |
| 4 | . | . | . |
Step 1 — Finding the most constrained cell:
Let's analyze the top-left 2×2 box:
- Row 1 already has: 2
- Column 1 already has: 4
- Top-left box already has: 3
- Available numbers: 1, 2, 3, 4
- Eliminating: 2 (in row), 4 (in column), 3 (in box)
- Only 1 remains!
| 1 | . | 2 | . |
| . | 3 | . | . |
| . | . | . | 1 |
| 4 | . | . | . |
Propagation Effect: Now that we placed 1, we can eliminate 1 from:
- Row 1: ✓ (already done)
- Column 1: ✓ (already done)
- Top-left 2×2 box: ✓ (already done)
This makes other cells more constrained!
Step 2 — Next most constrained cell:
Now let's find the next cell with the fewest options.
| 1 | ? | 2 | . |
| . | 3 | . | . |
| . | . | . | 1 |
| 4 | . | . | . |
Analysis for the position:
- Row 1 already has: 1, 2
- Column 2 already has: 3
- Top-left box already has: 1, 3
- Available numbers: 1, 2, 3, 4
- Eliminating: 1 (in row), 2 (in row), 3 (in column and box)
- Only 4 remains!
| 1 | 4 | 2 | . |
| . | 3 | . | . |
| . | . | . | 1 |
| 4 | . | . | . |
Key Insight
This is the essence of constraint propagation! Each placement immediately reduces the options for neighboring cells, making the puzzle progressively easier to solve.
We continue this process: pick the most constrained cell → place the only possible value → propagate constraints → repeat.
If any cell ends up with zero possibilities, we've hit a contradiction—in Sudoku, you backtrack and try a different value.
For our tile-based world: Imagine each grid cell as a Sudoku cell, but instead of numbers, we’re placing tiles.
Then we craft rules for valid connections:
- Rule 1: Water center tiles connect to other water tiles on all sides
- Rule 2: Water edge tiles have two types of sides - water-facing sides connect to other water tiles, land-facing sides connect to the shore
The algorithm uses these rules to ensure tiles fit together properly, creating coherent water bodies with natural-looking shorelines.
Let’s see this in action:
Step 1 - Initial Grid
We start with an empty grid where every cell can potentially hold any tile. The ? symbols represent the “superposition” - each cell contains all possible tiles until we begin constraining them through the algorithm.
Step 2 - First Placement
| ? | ? | ? | ? |
| ? | ![]() |
? | ? |
The algorithm starts by placing the initial water center tile. Following Rule 1, this center tile needs other water tiles on all sides. This immediately constrains the neighboring cells - they must be water tiles that can connect to the center.
Step 3 - Propagate Constraints
Constraint propagation kicks in! The algorithm expands the water area by placing more center tiles. The edge tiles at the top follow Rule 2 - their bottom sides (water-facing) connect to the center tiles, while their top sides (land-facing) will connect to the shore.
Step 4 - Final Result
The algorithm completes by filling the edges with appropriate boundary tiles. Notice how our rules create perfect connections - center tiles (Rule 1) have water on all sides, while edge and corner tiles (Rule 2) have water-facing sides connecting inward and land-facing sides connecting to the shore, creating a coherent geography.
This demonstrates the core Wave Function Collapse algorithm in action:
- Find the most constrained cell - the one with the fewest valid tiles that could fit
- Place a tile whose sockets are compatible with its neighbors
- Propagate constraints - this placement immediately reduces the valid options for surrounding cells
- Repeat until the grid is complete
When we hit a dead end (no valid tiles for a cell), our implementation takes a simpler approach than Sudoku: instead of backtracking through previous choices, we restart with a fresh random seed (up to a retry limit) and run the entire process again until we generate a valid map.
What do you mean by fresh random seed?
A “random seed” is a starting number that controls which “random” sequence the algorithm will follow. Same seed = same tile placement order every time. When we hit a dead end, instead of backtracking, we generate a new random seed and start over—this gives us a completely different sequence of tile choices to try.
Can configuring this randomness help us customize maps?
Yes! The algorithm’s randomness comes from the order in which it picks cells and tiles, and we can control this to influence the final result. By adjusting the random seed or the selection strategy, we can:
- Bias toward certain patterns - Weight certain tiles more heavily to create specific landscape types.
- Control size and complexity - Influence whether we get small ponds or large lakes.
- Create predictable variations - Use the same seed for consistent results, or different seeds for variety.
The same tileset can generate endless variations of coherent landscapes, from simple ponds to complex branching river systems, all by tweaking the randomness probability configuration.
While Wave Function Collapse is powerful, it has its limitations.
- No large-scale structure control - WFC focuses on tile compatibility, so it won't automatically create big patterns like "one large lake" or "mountain ranges".
- Can get stuck - Complex rules might lead to impossible situations where no valid tiles remain, requiring restarts.
- Performance depends on complexity - More tile types and stricter rules increase computation time and failure rates.
- Requires careful rule design - Poorly designed compatibility rules can lead to unrealistic or broken landscapes.
We'll address these limitations in a later chapter. For now, we'll focus on building a functional section of our game world that will become the foundation for building larger game worlds.
From Theory to Implementation
Now that we understand how Wave Function Collapse works—the constraint propagation, socket compatibility, and tile placement logic. It’s time to transform this knowledge into actual running code.
The reality of implementation:
Building a WFC algorithm from scratch is complex. You’d need to implement:
- Constraint propagation across the entire grid
- Backtracking when hitting dead ends
- Efficient data structures for tracking possibilities
- Grid coordinate management
- Random selection with proper probability weights
That’s a lot of algorithmic complexity before we even get to the game-specific parts, like sprites, rules, and world design.
Our approach:
Instead of reinventing the wheel, we’ll use a library that handles the WFC algorithm internals. This lets us focus on what makes our game unique: the tiles, the rules, the world aesthetics. We define what we want; the library figures out how to achieve it.
Let’s add the procedural generation library to our project. We’ll be using the bevy_procedural_tilemaps crate, which I built by forking ghx_proc_gen library. I created this fork primarily to ensure compatibility with Bevy 0.17 and to simplify this tutorial.
If you need advanced features, check out the original ghx_proc_gen crate by Guillaume Henaux, which includes 3D capabilities and debugging tools.
Hope you are following the code from first chapter. Here’s the source code.
Update your Cargo.toml with the bevy_procedural_tilemaps crate.
Bevy Procedural Tilemaps
The bevy_procedural_tilemaps library handles the complex logic of generating coherent, rule-based worlds.
What the library handles
The library takes care of the algorithmic complexity of procedural generation:
- Rule Processing: Converts our game rules into the library’s internal format
- Generator Creation: Builds the procedural generation engine with our configuration
- Constraint Solving: Figures out which tiles can go where based on rules
- Grid Management: Handles the 2D grid system and coordinate transformations
- Entity Spawning: Creates Bevy entities and positions them correctly
What we need to provide
We need to give the library the game-specific information it needs:
- Sprite Definitions: What sprites to use for each tile type
- Compatibility Rules: Which tiles can be placed next to each other
- Generation Configuration: The patterns and constraints for our specific game world
- Asset Data: Sprite information, positioning, and custom components
Now that we understand how the procedural generation system works, let’s build our map module.
Important Note
Unlike Chapter 1 where you could see immediate results, procedural generation requires doing some ground work. You'll create asset loaders, tile spawning infrastructure before seeing your generated world.
The payoff comes when you finish the grass layer, at that point, you've learned the complete pattern. Adding water and props becomes straightforward repetition with different sprites and connection rules. Along the way, you'll understand Rust concepts (lifetimes, trait bounds, closures) that apply to any Rust project.
Don't give up if concepts feel unclear on first read. That's normal with procedural generation. Revisit confusing sections, experiment with the code, and understanding will emerge. Mastery comes through tinkering, not perfect comprehension on the first pass.
The Map Module
We’ll create a dedicated map folder inside the src folder to house all our world generation logic.
Why create a separate folder for map generation?
The map system requires multiple components working together. World generation involves:
- Asset management - Loading and organizing hundreds of tile images.
- Rule definitions - Compatibility rules between different terrain types.
- Grid setup - Configuring map dimensions and coordinate systems.
Trying to fit all this logic into a single file would create a large file that can become difficult to navigate.
What’s mod.rs
The mod.rs file is Rust’s way of declaring what modules exist in a folder. It’s like the “table of contents” for our map module. Add the following line to your mod.rs.
Why mod.rs specifically?
It’s Rust convention, when you create a folder, Rust looks for mod.rs to understand the module structure.
Creating SpawnableAsset
Let’s start by creating our assets.rs file inside the map folder. This will be the foundation that defines how we spawn sprites in our world.
The bevy_procedural_tilemaps library needs to know what to actually place at each generated location.
It requires the following details:
- Which sprite to use from our tilemap atlas?
- Where exactly to position it?
- What components to add (collision, physics, etc.)?
The library expects us to provide this information in a very specific format. And doing this for every single tile type in your game - grass, dirt, trees, rocks, water, etc will result in redundant code.
This is where SpawnableAsset comes in. It’s our abstraction layer to help you avoid unnecessary boilerplate.
SpawnableAsset Struct
The SpawnableAsset struct contains all the information needed to spawn a tile in our world. The sprite_name field gives a name to your sprite (like “grass”, “tree”, “rock”).
The grid_offset is used for objects that span multiple tiles - it’s a positioning within the tile grid itself.
For example, the follow tree asset needs four tiles.
Grid Offset
| Bottom-left | (0, 0) | Stays at original position |
| Bottom-right | (1, 0) | Moves one tile right |
| Top-left | (0, 1) | Moves one tile up |
| Top-right | (1, 1) | Moves one tile up and right |
The offset field, on the other hand, is for fine-tuning the position within the tile - like moving a rock slightly to the left or making sure a tree trunk is perfectly centered within its tile space.
Let’s see how offset works with rock positioning:
Offset
| Rock 1 | (0, 0) | Centered in tile |
| Rock 2 | (-8, -6) | Moved slightly left and up |
| Rock 3 | (6, 5) | Moved slightly right and down |
Finally, the components_spawner is a function that adds custom behavior like collision, physics, or other game mechanics.
Why is sprite name defined as &'static str?
Let’s break down &'static str piece by piece to understand why we use it for sprite names.
The & symbol means “reference to” - instead of making a new copy of the text, we just note where the original text is located.
The 'static is a string literal that tells Rust “this text will exist for the entire duration of your game.” When you write "grass" directly in your code, Rust bakes it into your game file when you build it. It’s always there, from game startup to shutdown.
What’s a string literal?
A string literal is text you write directly in quotes in your code: "grass", "dirt", "tree".
What’s a lifetime and what has 'static got to do with it?
A lifetime is Rust’s way of tracking how long data lives in memory. Rust needs to know when it’s safe to use data and when it might be deleted.
Most data has a limited lifetime. For example:
- Local variables live only while a function runs
- Function parameters live only while the function executes
- Data created in a loop might be deleted when the loop ends
But some data lives forever - like string literals embedded in your program. The 'static lifetime means “this data lives for the entire duration of the program” - it never gets deleted.
This is perfect for our sprite names because they’re hardcoded in our source code (like "grass", "tree", "rock") and will never change or be deleted while the program runs. Rust can safely let us use these references anywhere in our code because it knows the data will always be there.
Why does Rust need to know when it’s safe to use data? Other languages don’t seem to care about this.
Most languages (like C, C++, Java, Python) handle memory safety differently:
- C/C++: Don’t track lifetimes at all - you can accidentally use deleted data, leading to crashes or security vulnerabilities
- Java/Python/C#: Use garbage collection - the runtime automatically deletes unused data, but this adds overhead and unpredictable pauses
- Rust: Tracks lifetimes at compile time - prevents crashes without runtime overhead
The Problem Other Languages Have
Rust Prevents This
Rust’s compiler analyzes your code and says “Hey, you’re trying to use data that might be deleted. I won’t let you compile this unsafe code.” This catches bugs before your game even runs.
Does str mean String data type?
Not quite. str represents text data, but you can only use it through a reference like &str (a view of text stored somewhere else). String is text you own and can modify. Our sprite names like “grass” are baked into the program, so &str just points to that text without copying it - much more efficient than using String.
&'static str means “a reference to a string slice that lives for the entire program duration.” This gives us the best of all worlds: memory efficiency (no copying), performance (direct access), and safety (Rust knows the data will always be valid).
What’s GridDelta?
GridDelta is a struct that represents movement in grid coordinates. It specifies “how many tiles to move” in each direction. For example, GridDelta::new(1, 0, 0) means “move one tile to the right”, while GridDelta::new(0, 1, 0) means “move one tile up”. It’s used for positioning multi-tile objects like the tree sprite with multiple tiles we mentioned earlier in grid offset.
Why’s components_spawner defined as fn(&mut EntityCommands)?
This is a function pointer that takes a mutable reference to EntityCommands (Bevy’s way of adding components to entities). Looking at the code in assets.rs, we can see it defaults to an empty function that does nothing.
The function pointer allows us to customize what components get added to each spawned entity. For example, a tree sprite might need collision components for physics, while a decorative flower might only need basic rendering components. Each sprite can have its own custom set of components without affecting others.
Why do we need a mutable reference to EntityCommands?
Yes! In Rust, you need a mutable reference (&mut) when you want to modify something. EntityCommands needs to be mutable because it’s used to add, remove, or modify components on entities.
Now let’s add some helpful methods to our SpawnableAsset struct to make it easier to create and configure sprite assets.
Append the following code to the same assets.rs file.
What’s -> Self?
In Rust, you must specify the return type of functions (unlike some languages that can infer it). The -> Self tells the compiler exactly what type the function returns, which helps catch errors at compile time. Self means “the same type as the struct this method belongs to” - so Self refers to SpawnableAsset here.
What’s |_| {}?
This is a closure (anonymous function) that does nothing. The |_| means “takes one parameter but ignores it” (the underscore means we don’t use the parameter), and {} is an empty function body.
We need this because our SpawnableAsset struct requires a components_spawner field (as we saw in the struct definition), but for basic sprites we don’t want to add any custom components. This empty closure serves as a “do nothing” default. We’ll learn how to use this field to add custom components in later chapters, but for now it’s just a placeholder that satisfies the struct’s requirements.
What’s a closure? What do you mean by anonymous function?
A closure is a function that can “capture” variables from its surrounding environment. An anonymous function means it doesn’t have a name - you define it inline where you need it, rather than declaring it separately like fn my_function().
Example usage
Why use closures here?
In our SpawnableAsset struct closure can be used to allow each sprite to have custom behavior when spawned. For example, a tree might need collision components, while a decorative flower might need different components. The closure can capture game state and configuration to customize spawning behavior for each sprite type.
Why is semicolon missing in the last line of these functions?
In Rust, the last expression in a function is automatically returned without needing a return keyword or semicolon. This makes it easier to specify what value should be returned - you just write the expression you want to return, and Rust handles the rest. This is Rust’s way of making code cleaner and more concise.
Why can’t you manipulate or retrieve grid_offset directly?
The fields are private (no pub keyword), which means they can only be accessed from within the same module. This is called “encapsulation” - it prevents developers from making mistakes by modifying the struct’s data directly, which could break the internal logic. We provide the public method with_grid_offset() to safely modify it while maintaining the struct’s integrity.
Now that we understand how to define our sprites with SpawnableAsset, how do we load and use these sprites in our game?
Loading Sprite Assets
Our game uses a sprite atlas - a single large image containing all our sprites. Bevy needs to know where each sprite is located within this image, and we need to avoid reloading the same image multiple times.
Create a folder tile_layers in your src/assets folder and place tilemap.png inside it, you can get it from this github repo.
The tilemap assets used in this example are based on 16x16 Game Assets by George Bailey, available on OpenGameArt under CC-BY 4.0 license. However, to follow this tutorial, please use tilemap.png provide from the chapter's github repo.
Now inside src/map folder create a file tilemap.rs. When you add a file inside map folder, ensure to register it in mod.rs by adding the line pub mod tilemap.
This is where our tilemap definition comes in - it acts as a “map” that tells Bevy the coordinates of every sprite in our atlas.
The TilemapSprite struct represents a single sprite within our atlas. It stores the sprite’s name (like “dirt” or “green_grass”) and its exact pixel coordinates within the atlas image.
The TilemapDefinition struct serves as the “blueprint” that Bevy uses to understand how to slice up our atlas image into individual sprites.
- tile_width and tile_height - How big each individual sprite is (in our case, 32×32 pixels)
- atlas_width and atlas_height - The total size of your entire sprite atlas image (the big image containing all sprites)
- sprites - The list of all sprites in your atlas, each with its name and location
Though our tilemap stores sprite names and pixel coordinates, Bevy’s texture atlas system requires numeric indices and rectangular regions. These methods perform the necessary conversions.
Append the following code to your tilemap.rs.
The tile_size() method converts our tile dimensions into a UVec2 (unsigned 2D vector), which Bevy uses for size calculations. Similarly, atlas_size() provides the total atlas dimensions as a UVec2, which Bevy uses to create the texture atlas layout.
The sprite_index() method helps in finding sprites by name. When we want to render a “dirt” tile, this method searches through our sprite array and returns the index position of that sprite.
Finally, sprite_rect() takes a sprite index and calculates the exact rectangular region within our atlas that contains that sprite. It uses URect (unsigned rectangle) to define the boundaries, which Bevy’s texture atlas system requires to know which part of the large image to display.
Now let’s put our tilemap definition to use by adding our first sprite - the dirt tile.
Adding the Dirt Tile
Let’s start with a simple dirt tile to test our tilemap system. The dirt tile sits at pixel coordinates (128, 0) in our 256x320 atlas image. We’ll add more sprites later as we build out our game world.
Append this code to tilemap.rs
Notice how we’re using a const definition - this means all this sprite metadata is determined at compile time.
Connecting the Tilemap to Asset Loading
Now that we’ve defined our tilemap and sprites in tilemap.rs, we need to connect this to our asset loading system in assets.rs.
Let’s update the imports in assets.rs to bring in our TILEMAP definition:
With the import in place, we can now build the three key functions that helps our procedural rendering system:
- TilemapHandles - Container that holds our loaded atlas and layout data
- prepare_tilemap_handles - Loads the atlas image from disk and creates the texture atlas layout defining each sprite’s rectangular region
- load_assets - Converts sprite names into Sprite data structures ready for rendering
Let’s build these step by step.
Creating the TilemapHandles Struct
First, we need a way to hold references to both the atlas image and its layout. Go ahead and append this code into your assets.rs:
The TilemapHandles struct is a container for two handles: image points to our loaded sprite sheet file, while layout points to the atlas layout that tells Bevy how to slice that image into individual sprites.
The sprite(atlas_index) method is a convenience function that creates a ready-to-render Sprite by combining the image and layout with a specific index. For example, if the dirt tile is at index 0, calling tilemap_handles.sprite(0) gives us a Sprite configured to display just the dirt tile from our atlas.
Loading the Atlas from Disk
Now let’s create the function that actually loads the atlas image file and sets up the layout. We will be using our TILEMAP definition from earlier.
Breaking it down:
- Load the image: asset_server.load() requests the atlas image file from disk
- Create empty layout: TextureAtlasLayout::new_empty(TILEMAP.atlas_size()) creates a layout matching our 256x320 atlas
- Register each sprite: The loop iterates through all sprites in TILEMAP, using TILEMAP.sprite_rect(index) to get each sprite’s coordinates and adding them to the layout
- Store and return: The layout is added to Bevy’s asset system, and we return a TilemapHandles containing both handles
This is where TILEMAP.atlas_size() and TILEMAP.sprite_rect() from our tilemap definition come into play - they tell Bevy exactly how to slice up our atlas image!
This function loads the atlas into memory and sets up the layout structure, but it doesn't actually generate the game world yet. We're just preparing the tools that the procedural generator will use later to create the map.
Converting Sprite Names to Renderable Sprites
Finally, we need a way to convert sprite names (like “dirt”) into Sprite objects that can be rendered.
Why the two loops?
Some tiles are simple and need just one sprite (like dirt). Others are complex and need multiple sprites (like a tree that needs 4 parts).
The outer loop says “for each type of tile,” and the inner loop says “for each sprite that tile needs.”
Let’s walk through what happens when we load a dirt tile:
- We have: SpawnableAsset { sprite_name: "dirt", ... }
- The function asks TILEMAP: “Where is ‘dirt’?” → TILEMAP replies: “Index 0”
- It then asks TilemapHandles: “Give me a Sprite for index 0” → Gets back a Sprite object
- Finally, it packages everything together with the positioning info and stores it
What does the final data look like?
After load_assets completes, we have a collection of ModelAsset objects in memory. Here’s what the data structure looks like for a few tiles:
| Dirt | assets_bundle | Sprite(atlas_index: 0) | Points to dirt sprite in atlas |
| grid_offset | (0, 0, 0) | No grid offset needed | |
| world_offset | (0, 0, 0) | No world offset needed | |
| Tree (bottom) | assets_bundle | Sprite(atlas_index: 31) | Points to tree bottom sprite |
| grid_offset | (0, 0, 0) | Placed at base position | |
| world_offset | (0, 0, 0) | Centered | |
| Tree (top) | assets_bundle | Sprite(atlas_index: 30) | Points to tree top sprite |
| grid_offset | (0, 1, 0) | One tile up from bottom | |
| world_offset | (0, 0, 0) | Centered |
Important: These are just data structures in memory - nothing is drawn on screen yet! The actual rendering happens later when the procedural generator uses these prepared ModelAsset objects to spawn entities.
Great Progress! You've made it through the foundation layer - sprites, tilemaps, and asset loading. Now we have the visual pieces (assets), but how does the generator know which tiles can be placed next to each other? That's where models and sockets come in!
From Tiles to Models
You already understand tiles - the individual visual pieces like grass, dirt, and water. Now we need to build models by adding sockets to these tiles and define connection rules so the generator can figure out valid placements.
How Models Expose Sockets
Models expose sockets - labeled connection points on each edge. Let’s look at a green grass model and see how it exposes sockets in different directions.
Horizontal Plane (x and y directions)
↑
up (y_pos)
grass.material
↓
←
left (x_neg)
grass.material
→
GREEN
GRASS
←
right (x_pos)
grass.material
→
↑
down (y_neg)
grass.material
↓
Vertical Axis (z direction)
↑
top (z_pos)
grass.layer_up
↓
GREEN
GRASS
↑
bottom (z_neg)
grass.layer_down
↓
How does z-axis make sense in a 2D game?
Even though we’re building a 2D game, the z-axis represents layering - Imagine stacking transparent sheets on top of each other. Here’s how it works with our yellow grass example:
The Layering System:
- Dirt tiles form the base layer (ground level)
- Green grass tiles can sit on top of dirt (one layer up)
- Yellow grass tiles can sit on top of green grass (another layer up)
Building Models
Now that we understand how models expose sockets in all six directions, we need a way to create these models and link them to their visual sprites.
We’ll use a helper called TerrainModelBuilder that keeps models and sprites paired correctly as we build our world.
The TerrainModelBuilder
Create a new file models.rs inside the map folder, and don’t forget to add pub mod models; to your mod.rs.
The TerrainModelBuilder holds:
- models: What the WFC algorithm uses
- assets: The sprites for the respective model
Now let’s add these methods to the builder.
The new() method creates an empty builder to start with.
The create_model() method both a socket definition and the corresponding sprites, then adds them to their respective collections at the same index.
Finally, into_parts() splits the builder back into separate collections when you’re done building, so the assets can go to the renderer and the models can go to the WFC generator.
What’s <T> doing in pub fn create_model<T>?
The <T> is Rust’s generic type parameter - it’s a placeholder that gets filled in with the actual type when you call the function. In our case, we might pass in different types of socket definitions (simple single-socket tiles or complex multi-socket tiles), but we want to perform the same operation on all of them.
Generics let us write one function that works with multiple types, as long as they can all be converted into a ModelTemplate. This is powerful because it means we can add new socket definition types in the future without changing our TerrainModelBuilder code.
What’s this where T: Into<ModelTemplate<Cartesian3D>>?
This is a trait bound that tells Rust what capabilities the generic type T must have. The where clause says “T must be able to convert itself into a ModelTemplate<Cartesian3D> (a 3D model template).”
Into is Rust’s way of saying “this type knows how to transform itself into that type” - like how a string can be converted into a number, or how our socket definitions can be converted into model templates. This means we can pass in any type that knows how to become a ModelTemplate - whether it’s simple single-socket tiles, complex multi-socket tiles, or even a custom socket type you create later.
This gives us flexibility while ensuring type safety. The compiler will catch any attempts to pass in a type that can’t be converted, preventing runtime errors!
Building the Foundation
Now that we understand how to keep models and assets synchronized, let’s start building our procedural world from the ground up. The dirt layer forms the foundation that everything else sits on.
Layers Make WFC Simpler
Without layers, we’d need to cram all our rules into a single layer: “water connects to water and grass”, “grass connects to grass and dirt”, “trees connect to grass”, “dirt connects to dirt” - plus all the edge cases and special connections.
This creates a massive web of interdependencies that makes the WFC algorithm struggle to find valid solutions.
By using layers, we break this complexity into manageable pieces. Each layer only needs to worry about its own connections, making the WFC algorithm much more likely to find valid solutions quickly.
Let’s create our dirt layer, make a new file sockets.rs inside the map folder, and don’t forget to add pub mod sockets; to your mod.rs.
The dirt layer needs three types of sockets.
-
layer_up - This socket handles what can be placed in the layer above dirt. Remember layers are to separate rule cram concerns (water can be above grass without touching it).
-
layer_down - It handles what layer the dirt itself can be placed on. For the base layer, this will connect to void (empty space).
-
material - This takes care of horizontal connections between dirt tiles, ensuring they connect properly to form continuous ground.
Initializing the Sockets
Now we need to actually create these socket instances. Append this function to sockets.rs:
The create_sockets function takes a SocketCollection and creates all our socket instances. The new_socket closure is a helper that calls socket_collection.create() to generate unique socket IDs. Each socket gets a unique identifier that the WFC algorithm uses to track compatibility rules.
Building the Dirt Layer
Now that we have our socket system defined and initialized, we need to create the rules that tell the WFC algorithm how to use these sockets. This is where we define models and how they connect to each other.
Create a new file rules.rs inside the map folder, and don’t forget to add pub mod rules; to your mod.rs.
Understanding the Dirt Layer Rules:
- Creates a dirt model - Defines a tile that exposes sockets on all six sides
- Exposes socket types - Horizontal sides expose dirt.material, vertical sides expose layer sockets
- Assigns a sprite - SpawnableAsset::new("dirt") tells the renderer which sprite to use
- Sets the weight - .with_weight(20.) makes dirt tiles 20 times more likely to be placed
- Defines connection rules - add_connections tells WFC that dirt.material can connect to other dirt.material
This creates a simple but effective foundation layer that can form continuous ground while supporting other layers on top!
Now let’s append the build_world function that the generator will call to get all our dirt layer rules and models:
What This Function Does:
- Creates the socket collection - This is where all our socket connection rules are stored
- Gets our socket definitions - Calls create_sockets() to get all the socket types we defined
- Creates the model builder - This keeps our models and assets synchronized
- Builds the dirt layer - Calls our build_dirt_layer function to create all the dirt models and rules
- Returns the three collections - Assets for rendering, models for WFC rules, and socket collection for connections
This function is what the generator calls to get all the rules and models needed to create our procedural world!
Generating Dirt
Now that we have all our components - assets, models, sockets, and rules - we need to configure the procedural generation engine.
Create a new file generate.rs inside the map folder, and don’t forget to add pub mod generate; to your mod.rs.
Understanding the Configuration Constants:
Let’s break down what each of these constants controls:
-
GRID_X and GRID_Y - These define the size of our generated world in tiles. A 25×18 grid means 450 total tiles (25 × 18 = 450). You can adjust these to create larger or smaller worlds, though larger grids may cause the WFC algorithm to struggle - we’ll address scaling issues in a later chapter.
-
TILE_SIZE - This is the size of each tile in world units. Since we’re using 32×32 pixel sprites, each tile takes up 32 world units. This affects how big your world appears on screen.
-
NODE_SIZE - This tells the generator how much space each grid cell occupies in the 3D world. Equal values = perfect tile fit, smaller NODE_SIZE = overlapping sprites, larger NODE_SIZE = gaps between tiles.
-
GRID_Z - This defines how many layers our world has. We’re currently using 1 layer for dirt, but we’ll add more layers later to stack different terrain types on top of each other (dirt, grass, yellow grass, water, props).
-
ASSETS_SCALE - This controls the size multiplier for sprites. Vec3::ONE means sprites render at their original size.
Now let’s append the setup_generator function that sets up our procedural generation engine:
Rules Initialization
This creates the constraint solver that the WFC algorithm uses. It takes our tile definitions and connection rules and converts them into a format the algorithm can understand.
Why Direction::ZForward?
Since we’re building a 2D game, we need to tell the system which axis to use for rotations. Direction::ZForward means tiles rotate around the Z-axis (pointing toward/away from the screen), which makes sense for a 2D top-down view.
Grid
This creates our world space where tiles will be placed. The three boolean parameters control wrapping behavior:
- (false, false, false) - Most games like Minecraft, Terraria (hard boundaries)
- (true, true, false) - Classic Asteroids or Pac-Man (wraps left-right and up-down)
- (true, true, true) - Advanced simulations with infinite-feeling 3D worlds
Configuring the Algorithm
This is where we configure how the WFC algorithm behaves:
- RngMode::RandomSeed - Uses random seeds (same seed = same world every time)
- NodeSelectionHeuristic::MinimumRemainingValue - Always picks the most constrained cell (fewest valid tiles)
- ModelSelectionHeuristic::WeightedProbability - Picks tiles based on their weights (higher weight = more likely)
Loading Assets and Spawning the Generator
prepare_tilemap_handles() loads our sprite atlas from disk, while load_assets() converts our sprite definitions into renderable assets.
The commands.spawn() creates the generator entity with a Transform that centers the world on screen and a NodesSpawner that handles the actual tile creation.
The with_z_offset_from_y(true) setting uses Y coordinates for Z-layer positioning - tiles higher up on screen render in front, creating natural depth ordering (e.g., tree at Y=10 appears in front of rock at Y=5).
Final Module Structure
Throughout this chapter, we’ve been building our procedural generation system across multiple files. Before we integrate everything into your main game, let’s make sure your src/map/mod.rs file includes all the modules we’ve created:
Make sure your mod.rs file matches this structure before proceeding to the integration step.
Integrating the Generator into Your Game
Now that we’ve built our procedural generation system, we need to integrate it into our main game. We’ll update the main.rs file to include the procedural generation plugin and set up the window size to match our generated world.
Updating main.rs
We need to add the procedural generation plugin and configure the window size to match our generated world. Update your main.rs:
What’s New:
- Map module import - mod map; brings in our procedural generation code
- Window sizing - map_pixel_dimensions() calculates the window size based on our grid dimensions
- Procedural generation plugin - ProcGenSimplePlugin handles the WFC algorithm execution
- Generator setup - setup_generator runs at startup to create our world
- Image filtering - ImagePlugin::default_nearest() keeps pixel art crisp
Running Your Procedural World
Now run your game:
You should see a procedurally generated world with dirt tiles following the rules we defined! The world will be centered on screen, and the window size will match your grid dimensions (25×18 tiles = 800×576 pixels).

Where’s the player?
The player is actually there, but it’s rendering behind the dirt tiles.
We need to make the player render on top of other layers we have.
Add a Z position constant
Update the spawn function to use this Z value and scale the player slightly down (for better visual proportion with our generated world).
Run your again:
Your player renders in front of all tiles and looks proportional to the 32×32 tile world!

Adding the Grass Layer
Now that we have a working dirt foundation, let’s add grass on top. The grass layer will create patches of green grass that sit on the dirt, with proper edge tiles for smooth transitions.
Step 1: Adding Grass Sprites to the Tilemap
First, we need to add all the grass sprite coordinates to our tilemap. Append these sprites to the sprites array in tilemap.rs:
These sprites include the main grass tile, inner corners, outer corners, and side edges for smooth transitions between grass and dirt.
Step 2: Adding Grass Sockets
Now we need to define the sockets for the grass layer. Update your sockets.rs:
Then update the TerrainSockets struct to include grass:
Finally, update the create_sockets function to initialize the grass sockets:
Why does grass need more sockets than dirt?
Dirt is simple - it fills the entire base layer, so every dirt tile connects to another dirt tile. Grass is different - it creates patches on top of dirt, which means grass tiles need to handle edges where grass meets empty space.
Here’s what each socket handles:
- material - Connects grass to grass (like dirt’s material socket)
- layer_up and layer_down - Vertical connections (like dirt)
- void_and_grass - Transitions from empty space (left) to grass (right)
- grass_and_void - Transitions from grass (left) to empty space (right)
- grass_fill_up - Allows layers above to fill down into grass areas
These transition sockets (void_and_grass and grass_and_void) are what create smooth edges. Without them, grass patches would have hard, blocky borders instead of the curved corners and sides we want.
Step 3: Building the Grass Layer Rules
Now let’s create the function that builds the grass layer. Append this function to rules.rs:
Understanding the Grass Layer Function
This function does several things - let’s break it down step by step.
1. The Void Model - Empty Space
This creates an “invisible” tile - a spot where no grass grows. Notice Vec::new() means no sprite is rendered. The WFC algorithm needs this to create patches of grass rather than covering everything.
2. The Main Grass Tile
This is the center grass tile. All four horizontal sides use grass.material, meaning they connect to other grass tiles. The z_pos has two options - allowing either another layer above OR the special grass_fill_up socket for yellow grass later.
A template is a reusable socket pattern. Instead of writing the same socket configuration four times (once for each rotation), we create it once and rotate it. The .to_template() converts it into a format that can be rotated.
4. Understanding Rotation - What’s Actually Happening?
We’re rotating the socket pattern.
Each tile has a sprite (the visual) and sockets (the connection rules). When we rotate a template, the sockets shift to different edges.
How Socket Rotation Works
|
Original Template (0°)
Sockets: |
After 90° Rotation
Sockets: |
Notice: the sprites are different (top-left vs bottom-left corner), but the socket pattern shifted clockwise by 90°. The void_and_grass socket moved from the right edge to the forward edge.
This is powerful because we define one socket pattern and pair it with different sprites at different rotations. The result: four unique corner models from one template definition.
We do the same for corner inside and side edges of grass tiles as well.
5. How Simple Rules Create Coherent Shapes
Here’s where the magic happens. We define only three connection rules, yet they create complex, natural-looking grass patches. Let’s see how.
The Three Rules:
- void connects to void - Empty space stays empty
- grass.material connects to grass.material - Grass centers connect to each other
- void_and_grass connects to grass_and_void - Transition sockets create smooth edges
That’s it! But how do these simple rules create coherent grass patches? Let’s visualize a 3×3 grass patch forming:
Why Every Edge Matches Perfectly:
Let’s trace through the top row to understand how sockets work. Remember: each tile has sockets on its edges that define what can connect to it.
Look at the grass tile on the top left - green_grass_corner_out_tl tile (second row, second column in the grid above). This tile has a socket called void_and_grass on its right edge
Now look at the green_grass_side_t tile immediately to its right. This tile has a socket called grass_and_void on its left edge. When these two tiles sit next to each other, their edges touch. The void_and_grass socket (from the corner) connects to the grass_and_void socket (from the side) because of Rule 3
The same pattern repeats across the entire grid. green_grass_side_t has grass_and_void on its right edge. green_grass_corner_out_tr has void_and_grass on its left edge. Where they touch, these sockets match perfectly!
The green_grass center tile has material sockets on all edges, so it connects to any adjacent grass tile that also has material on the touching edge.
The WFC algorithm uses these three simple rules to check every tile placement. Before placing a tile, it verifies that all its sockets match the sockets of neighboring tiles. The result: organic-looking grass patches with smooth, curved edges!
6. Layer Connections
This tells the WFC algorithm that grass can sit on top of dirt. The add_rotated_connection means this rule applies regardless of how the tiles are rotated - grass can always sit on dirt.
Step 4: Calling the Grass Layer Function
Now update the build_world function to call build_grass_layer:
Step 5: Updating Grid Layers
Finally, update generate.rs to use 2 layers instead of 1:
Now run your game:
You should see patches of green grass growing on top of the dirt layer, with smooth edges and corners transitioning between grass and dirt!

Adding the Yellow Grass Layer
Now that we have green grass, let’s add yellow grass patches that can grow on top of it! Yellow grass creates visual variety and demonstrates how layers can stack.
Step 1: Add Yellow Grass Sprites to Tilemap
First, let’s add the yellow grass sprites to our tilemap definition. Open src/map/tilemap.rs and add these entries to the sprites array:
Step 2: Define Yellow Grass Sockets
Yellow grass has a special behavior - it sits on top of green grass, not on dirt. This means it needs different socket connections.
Add the socket structure to src/map/sockets.rs:
Then update TerrainSockets to include yellow grass:
Finally, initialize the yellow grass sockets in create_sockets:
Why does yellow grass only need 3 sockets?
Unlike green grass, yellow grass doesn’t need void_and_grass transition sockets. Why? Because yellow grass reuses the green grass edges. When yellow grass meets empty space, the green grass layer below provides the edge tiles. Yellow grass only appears where green grass already exists, so it uses green grass’s material socket for horizontal connections.
The yellow_grass_fill_down socket is special - it connects to green grass’s grass_fill_up socket, allowing yellow grass to “fill down” into the green grass layer below.
Step 3: Building the Yellow Grass Layer Rules
Now let’s create the function that builds the yellow grass layer. Add this function to rules.rs:
Notice how the yellow grass models reuse green grass’s transition sockets (void_and_grass and grass_and_void) for horizontal connections. This is the clever part - yellow grass doesn’t define its own edges, it borrows them from green grass!
The connection rules establish two important relationships:
- grass.layer_up connects to yellow_grass.layer_down - Yellow grass sits on top of green grass
- yellow_grass_fill_down connects to grass_fill_up - This allows yellow grass to appear where green grass has the special “fill up” socket
Step 4: Calling the Yellow Grass Layer Function
Update build_world in rules.rs to call build_yellow_grass_layer:
Step 5: Updating Grid Layers
We need one more layer for yellow grass. Update the constant in generate.rs:
Now run your game:
You should see yellow grass patches appearing on top of green grass, creating a beautiful layered terrain!

Adding the Water Layer
Water adds life to our procedural world! Unlike grass layers that stack on top of each other, water appears alongside yellow grass at the same layer level. This creates interesting terrain where water bodies can form near grassy areas.
Step 1: Add Water Sprites to Tilemap
First, let’s add the water sprites to our tilemap definition. Open src/map/tilemap.rs and add these entries to the sprites array:
Step 2: Define Water Sockets
Water goes on the next Z-layer above yellow grass. “Layer” here refers to the Z-coordinate in our 3D grid, not geological layers.
We’ve been stacking these: dirt at Z=0, green grass at Z=1, yellow grass at Z=2, and now water at Z=3.
At any grid position, you can have dirt at the bottom Z-level and water at a higher Z-level—they occupy the same X,Y position but different Z heights.
Add the socket structure to src/map/sockets.rs:
Then update TerrainSockets to include water:
Finally, initialize the water sockets in create_sockets:
Water has 6 sockets because it behaves like green grass - it creates patches with transitions:
- material - Connects water to water (like grass’s material socket)
- layer_up and layer_down - Vertical connections
- void_and_water and water_and_void - Transition sockets for smooth edges
- ground_up - Special socket that allows props above to know they’re not on water
Step 3: Building the Water Layer Rules
Now let’s create the function that builds the water layer. Add this function to rules.rs:
Key Points About Water:
-
Low Weight Values - Notice WATER_WEIGHT: f32 = 0.02. This makes water appear less frequently than grass, creating occasional water bodies instead of covering everything.
-
Multiple z_pos Options - The void model has two options for z_pos: water.layer_up (another water layer could go here) and water.ground_up (props can sit here). This prepares us for the props layer we’ll add next.
-
Same Pattern as Grass - Water uses the same template and rotation approach as grass, demonstrating how the WFC pattern scales to different terrain types.
Step 4: Calling the Water Layer Function
Update build_world in rules.rs to call build_water_layer:
Step 5: Updating Grid Layers
We need one more layer for water. Update the constant in generate.rs:
Now run your game:
You should see water bodies forming on your terrain, creating lakes and ponds alongside the grass patches!

Adding the Props Layer
Props are the final layer that brings our world to life! Trees, rocks, plants, and stumps should appear on land but not in water.
This layer sits at the top of our Z-stack and uses special connection rules to ensure props only spawn on solid ground.
Step 1: Add Props Sprites to Tilemap
First, let’s add all the props sprites to our tilemap definition. Open src/map/tilemap.rs and add these entries to the sprites array:
Step 2: Define Props Sockets
Props need special socket handling because they must only appear on land, never in water. They also include multi-tile objects like big trees that span multiple grid positions.
Add the socket structure to src/map/sockets.rs:
Then update TerrainSockets to include props:
Finally, initialize the props sockets in create_sockets:
Understanding Props Sockets:
Props have 5 sockets with special purposes:
- layer_up and layer_down - Standard vertical connections
- props_down - Connects to water’s ground_up socket (ensures props only on land)
- big_tree_1_base and big_tree_2_base - Special sockets for multi-tile trees that need to connect their base parts
Step 3: Building the Props Layer Rules
Now let’s create the function that builds the props layer. Add this function to rules.rs:
Key Points About Props:
- Multi-tile Objects - Big trees use GridDelta::new(0, 1, 0) to place the top half one tile up
- Weight System - Different prop types have different spawn probabilities (rocks are rarer than plants)
- Land-only Rule - props_down connects to water.ground_up, ensuring props never spawn in water
- Base Sockets - Big trees use special base sockets to connect their left and right halves
Step 4: Calling the Props Layer Function
Update build_world in rules.rs to call build_props_layer:
Step 5: Updating Grid Layers
We need one final layer for props. Update the constant in generate.rs:
Now run your game:
You should see a complete procedural world with dirt, grass, water, and props! Trees and rocks will only appear on land, never in water, creating a realistic and varied landscape.

Congratulations!
You’ve successfully built a complete procedural terrain generation system using Wave Function Collapse! Your world now has:
- Dirt base layer - The foundation
- Green grass patches - With smooth edges and corners
- Yellow grass variety - Stacking on green grass
- Water bodies - Creating lakes and ponds
- Props - Trees, rocks, and plants that only appear on land
This demonstrates the power of WFC for creating coherent, natural-looking game worlds with just a few simple rules!
Wait, Just one more thing!
Go to rules.rs and change the water weight.
Now run your game:

Woah, with a simple modification you are able to change the world to have more water! This demonstrates the power of procedural generation—by tweaking just a few numbers, you can create completely different landscapes.
Try experimenting with the weight values for different layers to see how dramatically you can transform your world.
Also notice our player can walk on water. And that too without any cheat code? We will work on collision detection and also on approaches to build larger maps in our upcoming chapters.
Don't miss Chapter 3!
Join our community to get notified when the next chapter drops and share your amazing procedural world creations with fellow developers.
Let's stay connected! Here are some ways
- Follow the project on GitHub
- Join the discussion and share your progress on Reddit
- Comment on my LinkedIn post and make my network jealous
- Engage with me on X/Twitter
.png)





