By the end of this tutorial, you’ll build a procedurally generated game world with layered terrain, water bodies, and props.

Player Movement Demo

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 chapter 1, 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?

  1. Tileset.
  2. Sockets for tiles because only compatible tiles should fit.
  3. Compatibility rules.
  4. 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.

Comic Panel

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.
D2 Diagram

For our tile-based world: Imagine each grid cell as a Sudoku cell, but instead of numbers, we’re placing tiles. Each tile has sockets, and we define constraint rules about which socket types can connect to each other.

Let’s see this in action using the following water tiles. We’ll learn how constraints propagate to form a coherent environment:

Water center Water top Water bottom Water left Water right
Water corner in TL Water corner in TR Water corner in BL Water corner in BR Water corner out TL
Water corner out TR Water corner out BL Water corner out BR

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

? ? ? ?
? Water center ? ?

The algorithm starts by placing the initial water center tile (almost). This placement immediately constrains the neighboring cells - they now know they need to connect to water on at least one side.

Step 3 - Propagate Constraints

? Water edge Water edge ?
? Water center Water center ?

Constraint propagation kicks in! The algorithm expands the water area by placing more center tiles, and the edge tiles are constrained to have water-facing sides where they connect to the water body.

Step 4 - Final Result

Water Water center Water center Water
Water center Water center Water center Water center

The algorithm completes by filling the edges with appropriate boundary tiles. Notice how each tile connects perfectly - center tiles have water on all sides, edge tiles have water facing inward and grass edges facing outward, creating a coherent geography.

This demonstrates the core Wave Function Collapse algorithm in action:

  1. Find the most constrained cell - the one with the fewest valid tiles that could fit
  2. Place a tile whose sockets are compatible with its neighbors
  3. Propagate constraints - this placement immediately reduces the valid options for surrounding cells
  4. 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.

Setting Up Our Toolkit

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.

[package]
name = "bevy_game"
version = "0.1.0"
edition = "2024" 

[dependencies]
bevy = "0.17.2" // Line update alert 
bevy_procedural_tilemaps = "0.1.3" // Line update alert

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
D2 Diagram

How these systems work together

The library pipeline works in stages: first it processes our rules and builds a generator, then the constraint solver figures out valid tile placements, and finally the entity spawner creates the actual game objects in the Bevy world.

  1. We Define: Create tile definitions, compatibility rules, and generation patterns
  2. Library Processes: Runs the constraint-solving algorithm to find valid tile placements
  3. Library Spawns: Creates Bevy entities with the correct sprites, positions, and components
  4. Result: A coherent, rule-based world appears in our game

The beauty of this system is that we focus on what we want (environment design), while the library handles how to achieve it (complex algorithms). This separation of concerns makes procedural generation accessible to game developers without requiring deep knowledge of constraint-solving algorithms.

What’s a generator?

A generator is the core engine that runs the procedural generation algorithm. It’s a puzzle solver that takes our rules (which tiles can go where) and our grid (the empty world), then systematically figures out how to fill every position with valid tiles. It uses constraint-solving algorithms to ensure that every tile placement follows our compatibility rules, creating a coherent world that makes sense according to our game’s logic.

D2 Diagram

Now that we understand how the procedural generation system works, let’s build our map module.

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 is complex and requires multiple specialized components working together. World generation involves:

  • Asset management - Loading and organizing hundreds of tile images.
  • Rule definitions - Complex 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.

src/
├── main.rs
├── player.rs
└── map/
    ├── mod.rs       
    ├── assets.rs       

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.

// src/map/mod.rs
pub mod assets;   // Exposes assets.rs as a module

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:

  1. Which sprite to use from our tilemap?
  2. Where exactly to position it?
  3. 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.

// src/map/assets.rs

use bevy::{prelude::*, sprite::Anchor};
use bevy_procedural_tilemaps::prelude::*;

#[derive(Clone)]
pub struct SpawnableAsset {
    /// Name of the sprite inside our tilemap atlas
    sprite_name: &'static str,
    /// Offset in grid coordinates (for multi-tile objects)
    grid_offset: GridDelta,
    /// Offset in world coordinates (fine positioning)
    offset: Vec3,
    /// Function to add custom components (like collision, physics, etc.)
    components_spawner: fn(&mut EntityCommands),
}

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.

Tree top-left Tree top-right
Tree bottom-left Tree bottom-right

Grid Offset

Tree Part Grid Offset Description
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:

Rock 1 Rock 2 Rock 3

Offset

Rock Offset Description
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.

Comic Panel

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
Comic Panel

Here’s why this matters for game development:

The Problem Other Languages Have

// Psuedo code warning, don't use
// This would crash in C++ or cause undefined behavior
let sprite_name = {
    let temp = "grass";
    &temp  // temp gets deleted here!
}; 
println!("{}", sprite_name); // CRASH! Using deleted data

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.

// src/map/assets.rs
impl SpawnableAsset {
    pub fn new(sprite_name: &'static str) -> Self {
        Self {
            sprite_name,
            grid_offset: GridDelta::new(0, 0, 0),
            offset: Vec3::ZERO,
            components_spawner: |_| {}, // Default: no extra components
        }
    }

    pub fn with_grid_offset(mut self, offset: GridDelta) -> Self {
        self.grid_offset = offset;
        self
    }
}

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 can’t call it directly like my_function(). Instead, you define it inline where you need it.

Comic Panel

Variable capture example:

let health = 100;
let damage = 25;

// This closure captures 'health' and 'damage' from the surrounding scope
let attack = |_| {
    health - damage  // Uses captured variables
};

What this means:

  • The closure attack “remembers” the values of health and damage from when it was created
  • Even if health and damage change later, the closure still has the original values
  • The closure can use these captured variables when it’s called later.

Why use closures here?
Closures are perfect because they can capture game state (like player health, enemy types, or configuration settings) and use that information when spawning sprites. This allows each sprite to be customized based on the current game context.

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.

Tilemap sprite atlas
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.

// src/map/tilemap.rs
use bevy::math::{URect, UVec2};

pub struct TilemapSprite {
    pub name: &'static str,
    pub pixel_x: u32,
    pub pixel_y: u32,
}

pub struct TilemapDefinition {
    pub tile_width: u32,
    pub tile_height: u32,
    pub atlas_width: u32,
    pub atlas_height: u32,
    pub sprites: &'static [TilemapSprite],
}

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.

  1. tile_width and tile_height - How big each individual sprite is (in our case, 32×32 pixels)
  2. atlas_width and atlas_height - The total size of your entire sprite atlas image (the big image containing all sprites)
  3. 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.

// src/map/tilemap.rs

impl TilemapDefinition {
    pub const fn tile_size(&self) -> UVec2 {
        UVec2::new(self.tile_width, self.tile_height)
    }

    pub const fn atlas_size(&self) -> UVec2 {
        UVec2::new(self.atlas_width, self.atlas_height)
    }

    pub fn sprite_index(&self, name: &str) -> Option<usize> {
        self.sprites.iter().position(|sprite| sprite.name == name)
    }

    pub fn sprite_rect(&self, index: usize) -> URect {
        let sprite = &self.sprites[index];
        let min = UVec2::new(sprite.pixel_x, sprite.pixel_y);
        URect::from_corners(min, min + self.tile_size())
    }
}

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

// src/map/tilemap.rs
pub const TILEMAP: TilemapDefinition = TilemapDefinition {
    tile_width: 32,
    tile_height: 32,
    atlas_width: 256,
    atlas_height: 320,
    sprites: &[
          TilemapSprite {
            name: "dirt",
            pixel_x: 128,
            pixel_y: 0,
        },
    ]
};

Notice how we’re using a const definition - this means all this sprite metadata is determined at compile time, making it very efficient.

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:

// src/map/assets.rs
use bevy::prelude::*; 
use bevy_procedural_tilemaps::prelude::*;
use crate::map::tilemap::TILEMAP; // <--- line update alert

With the import in place, we can now build the three key functions that helps our procedural rendering system:

  1. TilemapHandles - Container that holds our loaded atlas and layout data
  2. prepare_tilemap_handles - Loads the atlas image from disk and builds the layout structure
  3. 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:

// src/map/assets.rs
#[derive(Clone)]
pub struct TilemapHandles {
    pub image: Handle<Image>,
    pub layout: Handle<TextureAtlasLayout>,
}

impl TilemapHandles {
    pub fn sprite(&self, atlas_index: usize) -> Sprite {
        Sprite::from_atlas_image(
            self.image.clone(),
            TextureAtlas::from(self.layout.clone()).with_index(atlas_index),
        )
    }
}

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.

// src/map/assets.rs
pub fn prepare_tilemap_handles(
    asset_server: &Res<AssetServer>,
    atlas_layouts: &mut ResMut<Assets<TextureAtlasLayout>>,
    assets_directory: &str,
    tilemap_file: &str,
) -> TilemapHandles {
    let image = asset_server.load::<Image>(format!("{assets_directory}/{tilemap_file}"));
    let mut layout = TextureAtlasLayout::new_empty(TILEMAP.atlas_size());
    for index in 0..TILEMAP.sprites.len() {
        layout.add_texture(TILEMAP.sprite_rect(index));
    }
    let layout = atlas_layouts.add(layout);

    TilemapHandles { image, layout }
}

Breaking it down:

  1. Load the image: asset_server.load() requests the atlas image file from disk
  2. Create empty layout: TextureAtlasLayout::new_empty(TILEMAP.atlas_size()) creates a layout matching our 256x320 atlas
  3. 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
  4. 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.

// src/map/assets.rs
pub fn load_assets(
    tilemap_handles: &TilemapHandles,
    assets_definitions: Vec<Vec<SpawnableAsset>>,
) -> ModelsAssets<Sprite> {
    let mut models_assets = ModelsAssets::<Sprite>::new();
    for (model_index, assets) in assets_definitions.into_iter().enumerate() {
        for asset_def in assets {
            let SpawnableAsset {
                sprite_name,
                grid_offset,
                offset,
                components_spawner,
            } = asset_def;

            let Some(atlas_index) = TILEMAP.sprite_index(sprite_name) else {
                panic!("Unknown atlas sprite '{}'", sprite_name);
            };

            models_assets.add(
                model_index,
                ModelAsset {
                    assets_bundle: tilemap_handles.sprite(atlas_index),
                    grid_offset,
                    world_offset: offset,
                    spawn_commands: components_spawner,
                },
            )
        }
    }
    models_assets
}

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:

  1. We have: SpawnableAsset { sprite_name: "dirt", ... }
  2. The function asks TILEMAP: “Where is ‘dirt’?” → TILEMAP replies: “Index 0”
  3. It then asks TilemapHandles: “Give me a Sprite for index 0” → Gets back a Sprite object
  4. 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:

Model Field Value What It Means
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.

// src/map/models.rs
use bevy_procedural_tilemaps::prelude::*;
use crate::map::assets::SpawnableAsset;

/// Utility wrapper that ensures model declarations and their asset bindings stay aligned.
pub struct TerrainModelBuilder {
    pub models: ModelCollection<Cartesian3D>,
    pub assets: Vec<Vec<SpawnableAsset>>,
}

The TerrainModelBuilder holds:

  1. models: What the WFC algorithm uses
  2. assets: The sprites for the respective model

Now let’s add these methods to the builder.

// src/map/models.rs
impl TerrainModelBuilder {
    pub fn new() -> Self {
        Self {
            models: ModelCollection::new(),
            assets: Vec::new(),
        }
    }

    pub fn create_model<T>(
        &mut self,
        template: T,
        assets: Vec<SpawnableAsset>,
    ) -> &mut Model<Cartesian3D>
    where
        T: Into<ModelTemplate<Cartesian3D>>,
    {
        let model_ref = self.models.create(template);
        self.assets.push(assets);
        model_ref
    }

    pub fn into_parts(self) -> (Vec<Vec<SpawnableAsset>>, ModelCollection<Cartesian3D>) {
        (self.assets, self.models)
    }
}

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 - literally! 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.

// src/map/sockets.rs
use bevy_procedural_tilemaps::prelude::*;

pub struct TerrainSockets {
    pub dirt: DirtLayerSockets,
}

pub struct DirtLayerSockets {
    pub layer_up: Socket,      // What can sit on top of dirt
    pub layer_down: Socket,     // What dirt can sit on
    pub material: Socket,       // What dirt connects to horizontally
}

The dirt layer needs three types of sockets.

  1. 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).

  2. layer_down - It handles what layer the dirt itself can be placed on. For the base layer, this will connect to void (empty space).

  3. 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:

// src/map/sockets.rs
pub fn create_sockets(socket_collection: &mut SocketCollection) -> TerrainSockets {
    let mut new_socket = || -> Socket { socket_collection.create() };
    
    let sockets = TerrainSockets {
        dirt: DirtLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
        },
    };
    sockets
}

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.

// src/map/rules.rs
use crate::map::assets::SpawnableAsset;
use crate::map::models::TerrainModelBuilder;
use crate::map::sockets::*;
use bevy_procedural_tilemaps::prelude::*;

fn build_dirt_layer(
    terrain_model_builder: &mut TerrainModelBuilder,
    terrain_sockets: &TerrainSockets,
    socket_collection: &mut SocketCollection,
) {
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.dirt.material, // right
                x_neg: terrain_sockets.dirt.material, // left
                z_pos: terrain_sockets.dirt.layer_up, // top 
                z_neg: terrain_sockets.dirt.layer_down, // bottom
                y_pos: terrain_sockets.dirt.material, // up
                y_neg: terrain_sockets.dirt.material, // down
            },
            vec![SpawnableAsset::new("dirt")],
        )
        .with_weight(20.);

    socket_collection.add_connections(vec![(
        terrain_sockets.dirt.material,
        vec![terrain_sockets.dirt.material],
    )]);
}

Understanding the Dirt Layer Rules:

  1. Creates a dirt model - Defines a tile that exposes sockets on all six sides
  2. Exposes socket types - Horizontal sides expose dirt.material, vertical sides expose layer sockets
  3. Assigns a sprite - SpawnableAsset::new("dirt") tells the renderer which sprite to use
  4. Sets the weight - .with_weight(20.) makes dirt tiles 20 times more likely to be placed
  5. 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:

// src/map/rules.rs
pub fn build_world() -> (
    Vec<Vec<SpawnableAsset>>,
    ModelCollection<Cartesian3D>,
    SocketCollection,
) {
    let mut socket_collection = SocketCollection::new();
    let terrain_sockets = create_sockets(&mut socket_collection);

    let mut terrain_model_builder = TerrainModelBuilder::new();

    // Build dirt layer
    build_dirt_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    let (assets, models) = terrain_model_builder.into_parts();

    (assets, models, socket_collection)
}

What This Function Does:

  1. Creates the socket collection - This is where all our socket connection rules are stored
  2. Gets our socket definitions - Calls create_sockets() to get all the socket types we defined
  3. Creates the model builder - This keeps our models and assets synchronized
  4. Builds the dirt layer - Calls our build_dirt_layer function to create all the dirt models and rules
  5. 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.

// src/map/generate.rs
use bevy_procedural_tilemaps::prelude::*;
use bevy::prelude::*;

use crate::map::{
    assets::{load_assets, prepare_tilemap_handles},
    rules::build_world,
};

// -----------------  Configurable values ---------------------------
/// Modify these values to control the map size.
pub const GRID_X: u32 = 25;
pub const GRID_Y: u32 = 18;

// ------------------------------------------------------------------

const ASSETS_PATH: &str = "tile_layers";
const TILEMAP_FILE: &str = "tilemap.png";
/// Size of a block in world units (in Bevy 2d, 1 pixel is 1 world unit)
pub const TILE_SIZE: f32 = 32.;
/// Size of a grid node in world units
const NODE_SIZE: Vec3 = Vec3::new(TILE_SIZE, TILE_SIZE, 1.);

const ASSETS_SCALE: Vec3 = Vec3::ONE;
/// Number of z layers in the map, derived from the default terrain layers.
const GRID_Z: u32 = 1;

pub fn map_pixel_dimensions() -> Vec2 {
    Vec2::new(TILE_SIZE * GRID_X as f32, TILE_SIZE * GRID_Y as f32)
}

Understanding the Configuration Constants:

Let’s break down what each of these constants controls:

  1. 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.

  2. 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.

  3. 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.

  4. 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).

  5. 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:

// src/map/generate.rs
pub fn setup_generator(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
    // 1. Rules Initialization - Get tile definitions and connection rules
    let (assets_definitions, models, socket_collection) = build_world();

    let rules = RulesBuilder::new_cartesian_3d(models, socket_collection)
        // Use ZForward as the up axis (rotation axis for models) since we are using Bevy in 2D
        .with_rotation_axis(Direction::ZForward)
        .build()
        .unwrap();

    // 2. Grid - Create 3D world space with wrapping behavior (false, false, false)
    let grid = CartesianGrid::new_cartesian_3d(GRID_X, GRID_Y, GRID_Z, false, false, false);

    // 3. Configuring the Algorithm - Set up WFC behavior
    let gen_builder = GeneratorBuilder::new()
        .with_rules(rules)
        .with_grid(grid.clone())
        .with_rng(RngMode::RandomSeed)
        .with_node_heuristic(NodeSelectionHeuristic::MinimumRemainingValue)
        .with_model_heuristic(ModelSelectionHeuristic::WeightedProbability);
    
    let generator = gen_builder.build().unwrap();

    // 4. Loading Assets - Load sprite atlas and convert to renderable assets
    let tilemap_handles =
        prepare_tilemap_handles(&asset_server, &mut atlas_layouts, ASSETS_PATH, TILEMAP_FILE);
    let models_assets = load_assets(&tilemap_handles, assets_definitions);

    // 5. Spawning the Generator - Create entity with Transform and NodesSpawner
    commands.spawn((
        Transform::from_translation(Vec3 {
            x: -TILE_SIZE * grid.size_x() as f32 / 2.,
            y: -TILE_SIZE * grid.size_y() as f32 / 2.,
            z: 0.,
        }),
        grid,
        generator,
        NodesSpawner::new(models_assets, NODE_SIZE, ASSETS_SCALE).with_z_offset_from_y(true),
    ));
}

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:

  1. (false, false, false) - Most games like Minecraft, Terraria (hard boundaries)
  2. (true, true, false) - Classic Asteroids or Pac-Man (wraps left-right and up-down)
  3. (true, true, true) - Advanced simulations with infinite-feeling 3D worlds

Configuring the Algorithm

This is where we configure how the WFC algorithm behaves:

  1. RngMode::RandomSeed - Uses random seeds (same seed = same world every time)
  2. NodeSelectionHeuristic::MinimumRemainingValue - Always picks the most constrained cell (fewest valid tiles)
  3. 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:

// src/map/mod.rs
pub mod generate;
pub mod assets;
pub mod models;
pub mod rules;
pub mod sockets;
pub mod tilemap;

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:

// src/main.rs
mod map;
mod player;

use bevy::{
    prelude::*,
    window::{Window, WindowPlugin, WindowResolution},
};

use bevy_procedural_tilemaps::prelude::*;

use crate::map::generate::{map_pixel_dimensions, setup_generator};
use crate::player::PlayerPlugin;

fn main() {
    let map_size = map_pixel_dimensions();

    App::new()
        .insert_resource(ClearColor(Color::WHITE))
        .add_plugins(
            DefaultPlugins
                .set(AssetPlugin {
                    file_path: "src/assets".into(),
                    ..default()
                })
                .set(WindowPlugin {
                    primary_window: Some(Window {
                        resolution: WindowResolution::new(map_size.x as u32, map_size.y as u32),
                        resizable: false,
                        ..default()
                    }),
                    ..default()
                })
                .set(ImagePlugin::default_nearest()),
        )
        .add_plugins(ProcGenSimplePlugin::<Cartesian3D, Sprite>::default())
        .add_systems(Startup, (setup_camera, setup_generator))
        .add_plugins(PlayerPlugin)
        .run();
}

fn setup_camera(mut commands: Commands) {
    commands.spawn(Camera2d);
}

What’s New:

  1. Map module import - mod map; brings in our procedural generation code
  2. Window sizing - map_pixel_dimensions() calculates the window size based on our grid dimensions
  3. Procedural generation plugin - ProcGenSimplePlugin handles the WFC algorithm execution
  4. Generator setup - setup_generator runs at startup to create our world
  5. Image filtering - ImagePlugin::default_nearest() keeps pixel art crisp

Running Your Procedural World

Now run your game:

cargo run

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).

Dirt Layer

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

// src/player.rs, please it below ANIM_DT const
const PLAYER_Z: f32 = 20.0; 

Update the spawn function to use this Z value and scale the player slightly down (for better visual proportion with our generated world).

// src/player.rs - Update the Transform line in spawn_player
Transform::from_translation(Vec3::new(0., 0., PLAYER_Z)).with_scale(Vec3::splat(0.8)),

Run your again:

cargo run

Your player renders in front of all tiles and looks proportional to the 32×32 tile world!

Dirt Layer

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:

// src/map/tilemap.rs - Add these to the sprites array after the dirt sprite inside TilemapDefinition struct
TilemapSprite {
    name: "green_grass",
    pixel_x: 160,
    pixel_y: 0,
},
TilemapSprite {
    name: "green_grass_corner_in_tl",
    pixel_x: 192,
    pixel_y: 0,
},
TilemapSprite {
    name: "green_grass_corner_in_tr",
    pixel_x: 224,
    pixel_y: 0,
},
TilemapSprite {
    name: "green_grass_corner_in_bl",
    pixel_x: 192,
    pixel_y: 32,
},
TilemapSprite {
    name: "green_grass_corner_in_br",
    pixel_x: 224,
    pixel_y: 32,
},
TilemapSprite {
    name: "green_grass_corner_out_tl",
    pixel_x: 0,
    pixel_y: 64,
},
TilemapSprite {
    name: "green_grass_corner_out_tr",
    pixel_x: 32,
    pixel_y: 64,
},
TilemapSprite {
    name: "green_grass_corner_out_bl",
    pixel_x: 0,
    pixel_y: 96,
},
TilemapSprite {
    name: "green_grass_corner_out_br",
    pixel_x: 32,
    pixel_y: 96,
},
TilemapSprite {
    name: "green_grass_side_t",
    pixel_x: 64,
    pixel_y: 64,
},
TilemapSprite {
    name: "green_grass_side_r",
    pixel_x: 96,
    pixel_y: 64,
},
TilemapSprite {
    name: "green_grass_side_l",
    pixel_x: 64,
    pixel_y: 96,
},
TilemapSprite {
    name: "green_grass_side_b",
    pixel_x: 96,
    pixel_y: 96,
},

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:

// src/map/sockets.rs - Add this struct after DirtLayerSockets
pub struct GrassLayerSockets {
    pub layer_up: Socket,
    pub layer_down: Socket,
    pub material: Socket,
    pub void_and_grass: Socket,
    pub grass_and_void: Socket,
    pub grass_fill_up: Socket,
}

Then update the TerrainSockets struct to include grass:

// src/map/sockets.rs - Update TerrainSockets
pub struct TerrainSockets {
    pub dirt: DirtLayerSockets,
    pub void: Socket, // line update alert
    pub grass: GrassLayerSockets, // line update alert
}

Finally, update the create_sockets function to initialize the grass sockets:

// src/map/sockets.rs - Update create_sockets function
pub fn create_sockets(socket_collection: &mut SocketCollection) -> TerrainSockets {
    let mut new_socket = || -> Socket { socket_collection.create() };
    
    let sockets = TerrainSockets {
        dirt: DirtLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
        },
         // line update alert
        void: new_socket(), 
         // lines update alert
        grass: GrassLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
            void_and_grass: new_socket(),
            grass_and_void: new_socket(),
            grass_fill_up: new_socket(),
        },
    };
    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:

// src/map/rules.rs - Add this function after build_dirt_layer
fn build_grass_layer(
    terrain_model_builder: &mut TerrainModelBuilder,
    terrain_sockets: &TerrainSockets,
    socket_collection: &mut SocketCollection,
) {
    // Void model - empty space above dirt where no grass exists
    terrain_model_builder.create_model(
        SocketsCartesian3D::Simple {
            x_pos: terrain_sockets.void,
            x_neg: terrain_sockets.void,
            z_pos: terrain_sockets.grass.layer_up,
            z_neg: terrain_sockets.grass.layer_down,
            y_pos: terrain_sockets.void,
            y_neg: terrain_sockets.void,
        },
        Vec::new(),
    );

    // Main grass tile
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Multiple {
                x_pos: vec![terrain_sockets.grass.material],
                x_neg: vec![terrain_sockets.grass.material],
                z_pos: vec![
                    terrain_sockets.grass.layer_up,
                    terrain_sockets.grass.grass_fill_up,
                ],
                z_neg: vec![terrain_sockets.grass.layer_down],
                y_pos: vec![terrain_sockets.grass.material],
                y_neg: vec![terrain_sockets.grass.material],
            },
            vec![SpawnableAsset::new("green_grass")],
        )
        .with_weight(5.);

    // Outer corner template
    let green_grass_corner_out = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.void_and_grass,
        x_neg: terrain_sockets.void,
        z_pos: terrain_sockets.grass.layer_up,
        z_neg: terrain_sockets.grass.layer_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.grass.grass_and_void,
    }
    .to_template();

    // Inner corner template
    let green_grass_corner_in = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.grass_and_void,
        x_neg: terrain_sockets.grass.material,
        z_pos: terrain_sockets.grass.layer_up,
        z_neg: terrain_sockets.grass.layer_down,
        y_pos: terrain_sockets.grass.material,
        y_neg: terrain_sockets.grass.void_and_grass,
    }
    .to_template();

    // Side edge template
    let green_grass_side = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.void_and_grass,
        x_neg: terrain_sockets.grass.grass_and_void,
        z_pos: terrain_sockets.grass.layer_up,
        z_neg: terrain_sockets.grass.layer_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.grass.material,
    }
    .to_template();

    // Create rotated versions of outer corners
    terrain_model_builder.create_model(
        green_grass_corner_out.clone(),
        vec![SpawnableAsset::new("green_grass_corner_out_tl")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_out.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_out_bl")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_out.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_out_br")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_out.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_out_tr")],
    );

    // Create rotated versions of inner corners
    terrain_model_builder.create_model(
        green_grass_corner_in.clone(),
        vec![SpawnableAsset::new("green_grass_corner_in_tl")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_in.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_in_bl")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_in.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_in_br")],
    );
    terrain_model_builder.create_model(
        green_grass_corner_in.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_corner_in_tr")],
    );

    // Create rotated versions of side edges
    terrain_model_builder.create_model(
        green_grass_side.clone(),
        vec![SpawnableAsset::new("green_grass_side_t")],
    );
    terrain_model_builder.create_model(
        green_grass_side.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_side_l")],
    );
    terrain_model_builder.create_model(
        green_grass_side.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_side_b")],
    );
    terrain_model_builder.create_model(
        green_grass_side.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("green_grass_side_r")],
    );

    // Add connection rules
    socket_collection.add_rotated_connection(
        terrain_sockets.dirt.layer_up,
        vec![terrain_sockets.grass.layer_down],
    );
    socket_collection.add_connections(vec![
        (terrain_sockets.void, vec![terrain_sockets.void]),
        (
            terrain_sockets.grass.material,
            vec![terrain_sockets.grass.material],
        ),
        (
            terrain_sockets.grass.void_and_grass,
            vec![terrain_sockets.grass.grass_and_void],
        ),
    ]);
}

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:
Left: void
Right: void_and_grass
Forward: void
Backward: grass_and_void
After 90° Rotation



Sockets:
Left: void
Right: grass_and_void
Forward: void_and_grass
Backward: void

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:

  1. void connects to void - Empty space stays empty
  2. grass.material connects to grass.material - Grass centers connect to each other
  3. 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:

How a Grass Patch Forms

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:

// src/map/rules.rs - Update build_world function
pub fn build_world() -> (
    Vec<Vec<SpawnableAsset>>,
    ModelCollection<Cartesian3D>,
    SocketCollection,
) {
    let mut socket_collection = SocketCollection::new();
    let terrain_sockets = create_sockets(&mut socket_collection);

    let mut terrain_model_builder = TerrainModelBuilder::new();

    // Build dirt layer
    build_dirt_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Line update alert
    // Build grass layer
    build_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    let (assets, models) = terrain_model_builder.into_parts();

    (assets, models, socket_collection)
}

Step 5: Updating Grid Layers

Finally, update generate.rs to use 2 layers instead of 1:

// src/map/generate.rs - Update GRID_Z constant
const GRID_Z: u32 = 2;

Now run your game:

cargo run

You should see patches of green grass growing on top of the dirt layer, with smooth edges and corners transitioning between grass and dirt!

Grass Layer

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:

// src/map/tilemap.rs - Add these after the water sprites
TilemapSprite {
    name: "yellow_grass",
    pixel_x: 0,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_corner_in_tl",
    pixel_x: 32,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_corner_in_tr",
    pixel_x: 64,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_corner_in_bl",
    pixel_x: 32,
    pixel_y: 288,
},
TilemapSprite {
    name: "yellow_grass_corner_in_br",
    pixel_x: 64,
    pixel_y: 288,
},
TilemapSprite {
    name: "yellow_grass_corner_out_tl",
    pixel_x: 96,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_corner_out_tr",
    pixel_x: 128,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_corner_out_bl",
    pixel_x: 96,
    pixel_y: 288,
},
TilemapSprite {
    name: "yellow_grass_corner_out_br",
    pixel_x: 128,
    pixel_y: 288,
},
TilemapSprite {
    name: "yellow_grass_side_t",
    pixel_x: 160,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_side_r",
    pixel_x: 192,
    pixel_y: 256,
},
TilemapSprite {
    name: "yellow_grass_side_l",
    pixel_x: 160,
    pixel_y: 288,
},
TilemapSprite {
    name: "yellow_grass_side_b",
    pixel_x: 192,
    pixel_y: 288,
},

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:

// src/map/sockets.rs - Add after GrassLayerSockets
pub struct YellowGrassLayerSockets {
    pub layer_up: Socket,
    pub layer_down: Socket,
    pub yellow_grass_fill_down: Socket,
}

Then update TerrainSockets to include yellow grass:

// src/map/sockets.rs - Update TerrainSockets
pub struct TerrainSockets {
    pub dirt: DirtLayerSockets,
    pub void: Socket,
    pub grass: GrassLayerSockets,
    pub yellow_grass: YellowGrassLayerSockets, // Add this line
}

Finally, initialize the yellow grass sockets in create_sockets:

// src/map/sockets.rs - Update create_sockets function
pub fn create_sockets(socket_collection: &mut SocketCollection) -> TerrainSockets {
    let mut new_socket = || -> Socket { socket_collection.create() };
    
    let sockets = TerrainSockets {
        dirt: DirtLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
        },
        void: new_socket(),
        grass: GrassLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
            void_and_grass: new_socket(),
            grass_and_void: new_socket(),
            grass_fill_up: new_socket(),
        },
        yellow_grass: YellowGrassLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            yellow_grass_fill_down: new_socket(),
        },
    };
    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:

// src/map/rules.rs - Add this function after build_grass_layer
fn build_yellow_grass_layer(
    terrain_model_builder: &mut TerrainModelBuilder,
    terrain_sockets: &TerrainSockets,
    socket_collection: &mut SocketCollection,
) {
    // Void model - empty space where no yellow grass exists
    terrain_model_builder.create_model(
        SocketsCartesian3D::Simple {
            x_pos: terrain_sockets.void,
            x_neg: terrain_sockets.void,
            z_pos: terrain_sockets.yellow_grass.layer_up,
            z_neg: terrain_sockets.yellow_grass.layer_down,
            y_pos: terrain_sockets.void,
            y_neg: terrain_sockets.void,
        },
        Vec::new(),
    );

    // Main yellow grass tile
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.grass.material,
                x_neg: terrain_sockets.grass.material,
                z_pos: terrain_sockets.yellow_grass.layer_up,
                z_neg: terrain_sockets.yellow_grass.yellow_grass_fill_down,
                y_pos: terrain_sockets.grass.material,
                y_neg: terrain_sockets.grass.material,
            },
            vec![SpawnableAsset::new("yellow_grass")],
        )
        .with_weight(5.);

    // Outer corner template
    let yellow_grass_corner_out = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.void_and_grass,
        x_neg: terrain_sockets.void,
        z_pos: terrain_sockets.yellow_grass.layer_up,
        z_neg: terrain_sockets.yellow_grass.yellow_grass_fill_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.grass.grass_and_void,
    }
    .to_template();

    // Inner corner template
    let yellow_grass_corner_in = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.grass_and_void,
        x_neg: terrain_sockets.grass.material,
        z_pos: terrain_sockets.yellow_grass.layer_up,
        z_neg: terrain_sockets.yellow_grass.yellow_grass_fill_down,
        y_pos: terrain_sockets.grass.material,
        y_neg: terrain_sockets.grass.void_and_grass,
    }
    .to_template();

    // Side edge template
    let yellow_grass_side = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.grass.void_and_grass,
        x_neg: terrain_sockets.grass.grass_and_void,
        z_pos: terrain_sockets.yellow_grass.layer_up,
        z_neg: terrain_sockets.yellow_grass.yellow_grass_fill_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.grass.material,
    }
    .to_template();

    // Create rotated versions of outer corners
    terrain_model_builder.create_model(
        yellow_grass_corner_out.clone(),
        vec![SpawnableAsset::new("yellow_grass_corner_out_tl")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_out.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_out_bl")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_out.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_out_br")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_out.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_out_tr")],
    );

    // Create rotated versions of inner corners
    terrain_model_builder.create_model(
        yellow_grass_corner_in.clone(),
        vec![SpawnableAsset::new("yellow_grass_corner_in_tl")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_in.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_in_bl")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_in.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_in_br")],
    );
    terrain_model_builder.create_model(
        yellow_grass_corner_in.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_corner_in_tr")],
    );

    // Create rotated versions of side edges
    terrain_model_builder.create_model(
        yellow_grass_side.clone(),
        vec![SpawnableAsset::new("yellow_grass_side_t")],
    );
    terrain_model_builder.create_model(
        yellow_grass_side.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_side_l")],
    );
    terrain_model_builder.create_model(
        yellow_grass_side.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_side_b")],
    );
    terrain_model_builder.create_model(
        yellow_grass_side.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("yellow_grass_side_r")],
    );

    // Add connection rules
    socket_collection
        .add_rotated_connection(
            terrain_sockets.grass.layer_up,
            vec![terrain_sockets.yellow_grass.layer_down],
        )
        .add_rotated_connection(
            terrain_sockets.yellow_grass.yellow_grass_fill_down,
            vec![terrain_sockets.grass.grass_fill_up],
        );
}

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:

  1. grass.layer_up connects to yellow_grass.layer_down - Yellow grass sits on top of green grass
  2. 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:

// src/map/rules.rs - Update build_world function
pub fn build_world() -> (
    Vec<Vec<SpawnableAsset>>,
    ModelCollection<Cartesian3D>,
    SocketCollection,
) {
    let mut socket_collection = SocketCollection::new();
    let terrain_sockets = create_sockets(&mut socket_collection);

    let mut terrain_model_builder = TerrainModelBuilder::new();

    // Build dirt layer
    build_dirt_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build grass layer
    build_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Line update alert
    // Build yellow grass layer
    build_yellow_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    let (assets, models) = terrain_model_builder.into_parts();

    (assets, models, socket_collection)
}

Step 5: Updating Grid Layers

We need one more layer for yellow grass. Update the constant in generate.rs:

// src/map/generate.rs - Update GRID_Z
const GRID_Z: u32 = 3; // Changed from 2 to 3

Now run your game:

cargo run

You should see yellow grass patches appearing on top of green grass, creating a beautiful layered terrain!

Yellow Grass Layer

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:

// src/map/tilemap.rs - Add these after the tree stump sprites
TilemapSprite {
    name: "water",
    pixel_x: 32,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_corner_in_tl",
    pixel_x: 64,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_corner_in_tr",
    pixel_x: 96,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_corner_in_bl",
    pixel_x: 64,
    pixel_y: 224,
},
TilemapSprite {
    name: "water_corner_in_br",
    pixel_x: 96,
    pixel_y: 224,
},
TilemapSprite {
    name: "water_corner_out_tl",
    pixel_x: 128,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_corner_out_tr",
    pixel_x: 160,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_corner_out_bl",
    pixel_x: 128,
    pixel_y: 224,
},
TilemapSprite {
    name: "water_corner_out_br",
    pixel_x: 160,
    pixel_y: 224,
},
TilemapSprite {
    name: "water_side_t",
    pixel_x: 192,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_side_r",
    pixel_x: 224,
    pixel_y: 192,
},
TilemapSprite {
    name: "water_side_l",
    pixel_x: 192,
    pixel_y: 224,
},
TilemapSprite {
    name: "water_side_b",
    pixel_x: 224,
    pixel_y: 224,
},

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:

// src/map/sockets.rs - Add after YellowGrassLayerSockets
pub struct WaterLayerSockets {
    pub layer_up: Socket,
    pub layer_down: Socket,
    pub material: Socket,
    pub void_and_water: Socket,
    pub water_and_void: Socket,
    pub ground_up: Socket,
}

Then update TerrainSockets to include water:

// src/map/sockets.rs - Update TerrainSockets
pub struct TerrainSockets {
    pub dirt: DirtLayerSockets,
    pub void: Socket,
    pub grass: GrassLayerSockets,
    pub yellow_grass: YellowGrassLayerSockets,
    pub water: WaterLayerSockets, // Add this line
}

Finally, initialize the water sockets in create_sockets:

// src/map/sockets.rs - Update create_sockets function
pub fn create_sockets(socket_collection: &mut SocketCollection) -> TerrainSockets {
    let mut new_socket = || -> Socket { socket_collection.create() };
    
    let sockets = TerrainSockets {
        dirt: DirtLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
        },
        void: new_socket(),
        grass: GrassLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
            void_and_grass: new_socket(),
            grass_and_void: new_socket(),
            grass_fill_up: new_socket(),
        },
        yellow_grass: YellowGrassLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            yellow_grass_fill_down: new_socket(),
        },
        water: WaterLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            material: new_socket(),
            void_and_water: new_socket(),
            water_and_void: new_socket(),
            ground_up: new_socket(),
        },
    };
    sockets
}

Water has 6 sockets because it behaves like green grass - it creates patches with transitions:

  1. material - Connects water to water (like grass’s material socket)
  2. layer_up and layer_down - Vertical connections
  3. void_and_water and water_and_void - Transition sockets for smooth edges
  4. 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:

// src/map/rules.rs - Add this function after build_yellow_grass_layer
pub fn build_water_layer(
    terrain_model_builder: &mut TerrainModelBuilder,
    terrain_sockets: &TerrainSockets,
    socket_collection: &mut SocketCollection,
) {
    // Void model - represents land areas where no water exists
    terrain_model_builder.create_model(
        SocketsCartesian3D::Multiple {
            x_pos: vec![terrain_sockets.void],
            x_neg: vec![terrain_sockets.void],
            z_pos: vec![
                terrain_sockets.water.layer_up,
                terrain_sockets.water.ground_up,
            ],
            z_neg: vec![terrain_sockets.water.layer_down],
            y_pos: vec![terrain_sockets.void],
            y_neg: vec![terrain_sockets.void],
        },
        Vec::new(),
    );

    // Main water tile
    const WATER_WEIGHT: f32 = 0.02;
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.water.material,
                x_neg: terrain_sockets.water.material,
                z_pos: terrain_sockets.water.layer_up,
                z_neg: terrain_sockets.water.layer_down,
                y_pos: terrain_sockets.water.material,
                y_neg: terrain_sockets.water.material,
            },
            vec![SpawnableAsset::new("water")],
        )
        .with_weight(10. * WATER_WEIGHT);

    // Outer corner template
    let water_corner_out = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.water.void_and_water,
        x_neg: terrain_sockets.void,
        z_pos: terrain_sockets.water.layer_up,
        z_neg: terrain_sockets.water.layer_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.water.water_and_void,
    }
    .to_template()
    .with_weight(WATER_WEIGHT);

    // Inner corner template
    let water_corner_in = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.water.water_and_void,
        x_neg: terrain_sockets.water.material,
        z_pos: terrain_sockets.water.layer_up,
        z_neg: terrain_sockets.water.layer_down,
        y_pos: terrain_sockets.water.material,
        y_neg: terrain_sockets.water.void_and_water,
    }
    .to_template()
    .with_weight(WATER_WEIGHT);

    // Side edge template
    let water_side = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.water.void_and_water,
        x_neg: terrain_sockets.water.water_and_void,
        z_pos: terrain_sockets.water.layer_up,
        z_neg: terrain_sockets.water.layer_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.water.material,
    }
    .to_template()
    .with_weight(WATER_WEIGHT);

    // Create rotated versions of outer corners
    terrain_model_builder.create_model(
        water_corner_out.clone(),
        vec![SpawnableAsset::new("water_corner_out_tl")],
    );
    terrain_model_builder.create_model(
        water_corner_out.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_out_bl")],
    );
    terrain_model_builder.create_model(
        water_corner_out.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_out_br")],
    );
    terrain_model_builder.create_model(
        water_corner_out.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_out_tr")],
    );

    // Create rotated versions of inner corners
    terrain_model_builder.create_model(
        water_corner_in.clone(),
        vec![SpawnableAsset::new("water_corner_in_tl")],
    );
    terrain_model_builder.create_model(
        water_corner_in.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_in_bl")],
    );
    terrain_model_builder.create_model(
        water_corner_in.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_in_br")],
    );
    terrain_model_builder.create_model(
        water_corner_in.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("water_corner_in_tr")],
    );

    // Create rotated versions of side edges
    terrain_model_builder.create_model(
        water_side.clone(),
        vec![SpawnableAsset::new("water_side_t")],
    );
    terrain_model_builder.create_model(
        water_side.rotated(ModelRotation::Rot90, Direction::ZForward),
        vec![SpawnableAsset::new("water_side_l")],
    );
    terrain_model_builder.create_model(
        water_side.rotated(ModelRotation::Rot180, Direction::ZForward),
        vec![SpawnableAsset::new("water_side_b")],
    );
    terrain_model_builder.create_model(
        water_side.rotated(ModelRotation::Rot270, Direction::ZForward),
        vec![SpawnableAsset::new("water_side_r")],
    );

    // Add connection rules
    socket_collection.add_connections(vec![
        (
            terrain_sockets.water.material,
            vec![terrain_sockets.water.material],
        ),
        (
            terrain_sockets.water.water_and_void,
            vec![terrain_sockets.water.void_and_water],
        ),
    ]);

    // Connect water layer to yellow grass layer
    socket_collection.add_rotated_connection(
        terrain_sockets.yellow_grass.layer_up,
        vec![terrain_sockets.water.layer_down],
    );
}

Key Points About Water:

  1. 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.

  2. 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.

  3. 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:

// src/map/rules.rs - Update build_world function
pub fn build_world() -> (
    Vec<Vec<SpawnableAsset>>,
    ModelCollection<Cartesian3D>,
    SocketCollection,
) {
    let mut socket_collection = SocketCollection::new();
    let terrain_sockets = create_sockets(&mut socket_collection);

    let mut terrain_model_builder = TerrainModelBuilder::new();

    // Build dirt layer
    build_dirt_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build grass layer
    build_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build yellow grass layer
    build_yellow_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Line update alert
    // Build water layer
    build_water_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    let (assets, models) = terrain_model_builder.into_parts();

    (assets, models, socket_collection)
}

Step 5: Updating Grid Layers

We need one more layer for water. Update the constant in generate.rs:

// src/map/generate.rs - Update GRID_Z
const GRID_Z: u32 = 4; // Changed from 3 to 4

Now run your game:

cargo run

You should see water bodies forming on your terrain, creating lakes and ponds alongside the grass patches!

Water Layer

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:

// src/map/tilemap.rs - Add these after the water sprites
TilemapSprite {
    name: "big_tree_1_tl",
    pixel_x: 0,
    pixel_y: 0,
},
TilemapSprite {
    name: "big_tree_1_tr",
    pixel_x: 32,
    pixel_y: 0,
},
TilemapSprite {
    name: "big_tree_1_bl",
    pixel_x: 0,
    pixel_y: 32,
},
TilemapSprite {
    name: "big_tree_1_br",
    pixel_x: 32,
    pixel_y: 32,
},
TilemapSprite {
    name: "big_tree_2_tl",
    pixel_x: 64,
    pixel_y: 0,
},
TilemapSprite {
    name: "big_tree_2_tr",
    pixel_x: 96,
    pixel_y: 0,
},
TilemapSprite {
    name: "big_tree_2_bl",
    pixel_x: 64,
    pixel_y: 32,
},
TilemapSprite {
    name: "big_tree_2_br",
    pixel_x: 96,
    pixel_y: 32,
},
TilemapSprite {
    name: "plant_1",
    pixel_x: 128,
    pixel_y: 64,
},
TilemapSprite {
    name: "plant_2",
    pixel_x: 160,
    pixel_y: 64,
},
TilemapSprite {
    name: "plant_3",
    pixel_x: 192,
    pixel_y: 64,
},
TilemapSprite {
    name: "plant_4",
    pixel_x: 224,
    pixel_y: 64,
},
TilemapSprite {
    name: "rock_1",
    pixel_x: 0,
    pixel_y: 128,
},
TilemapSprite {
    name: "rock_2",
    pixel_x: 32,
    pixel_y: 128,
},
TilemapSprite {
    name: "rock_3",
    pixel_x: 64,
    pixel_y: 128,
},
TilemapSprite {
    name: "rock_4",
    pixel_x: 96,
    pixel_y: 128,
},
TilemapSprite {
    name: "small_tree_top",
    pixel_x: 128,
    pixel_y: 128,
},
TilemapSprite {
    name: "small_tree_bottom",
    pixel_x: 128,
    pixel_y: 160,
},
TilemapSprite {
    name: "tree_stump_1",
    pixel_x: 192,
    pixel_y: 128,
},
TilemapSprite {
    name: "tree_stump_2",
    pixel_x: 224,
    pixel_y: 128,
},
TilemapSprite {
    name: "tree_stump_3",
    pixel_x: 0,
    pixel_y: 192,
},

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:

// src/map/sockets.rs - Add after WaterLayerSockets
pub struct PropsLayerSockets {
    pub layer_up: Socket,
    pub layer_down: Socket,
    pub props_down: Socket,
    pub big_tree_1_base: Socket,
    pub big_tree_2_base: Socket,
}

Then update TerrainSockets to include props:

// src/map/sockets.rs - Update TerrainSockets
pub struct TerrainSockets {
    pub dirt: DirtLayerSockets,
    pub void: Socket,
    pub grass: GrassLayerSockets,
    pub yellow_grass: YellowGrassLayerSockets,
    pub water: WaterLayerSockets,
    pub props: PropsLayerSockets, // Add this line
}

Finally, initialize the props sockets in create_sockets:

// src/map/sockets.rs - Update create_sockets function
pub fn create_sockets(socket_collection: &mut SocketCollection) -> TerrainSockets {
    let mut new_socket = || -> Socket { socket_collection.create() };
    
    let sockets = TerrainSockets {
        dirt: DirtLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
        },
        void: new_socket(),
        grass: GrassLayerSockets {
            layer_up: new_socket(),
            material: new_socket(),
            layer_down: new_socket(),
            void_and_grass: new_socket(),
            grass_and_void: new_socket(),
            grass_fill_up: new_socket(),
        },
        yellow_grass: YellowGrassLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            yellow_grass_fill_down: new_socket(),
        },
        water: WaterLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            material: new_socket(),
            void_and_water: new_socket(),
            water_and_void: new_socket(),
            ground_up: new_socket(),
        },
        props: PropsLayerSockets {
            layer_up: new_socket(),
            layer_down: new_socket(),
            props_down: new_socket(),
            big_tree_1_base: new_socket(),
            big_tree_2_base: new_socket(),
        },
    };
    sockets
}

Understanding Props Sockets:

Props have 5 sockets with special purposes:

  1. layer_up and layer_down - Standard vertical connections
  2. props_down - Connects to water’s ground_up socket (ensures props only on land)
  3. 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:

// src/map/rules.rs - Add this function after build_water_layer
pub fn build_props_layer(
    terrain_model_builder: &mut TerrainModelBuilder,
    terrain_sockets: &TerrainSockets,
    socket_collection: &mut SocketCollection,
) {
    // Void model - represents areas where no props exist
    terrain_model_builder.create_model(
        SocketsCartesian3D::Multiple {
            x_pos: vec![terrain_sockets.void],
            x_neg: vec![terrain_sockets.void],
            z_pos: vec![terrain_sockets.props.layer_up],
            z_neg: vec![terrain_sockets.props.layer_down],
            y_pos: vec![terrain_sockets.void],
            y_neg: vec![terrain_sockets.void],
        },
        Vec::new(),
    );

    // Weight constants for different prop types
    const PROPS_WEIGHT: f32 = 0.025;
    const ROCKS_WEIGHT: f32 = 0.008;
    const PLANTS_WEIGHT: f32 = 0.025;
    const STUMPS_WEIGHT: f32 = 0.012;

    // Base prop template - single tile props
    let prop = SocketsCartesian3D::Simple {
        x_pos: terrain_sockets.void,
        x_neg: terrain_sockets.void,
        z_pos: terrain_sockets.props.layer_up,
        z_neg: terrain_sockets.props.props_down,
        y_pos: terrain_sockets.void,
        y_neg: terrain_sockets.void,
    }
    .to_template()
    .with_weight(PROPS_WEIGHT);

    // Create different prop types with different weights
    let plant_prop = prop.clone().with_weight(PLANTS_WEIGHT);
    let stump_prop = prop.clone().with_weight(STUMPS_WEIGHT);
    let rock_prop = prop.clone().with_weight(ROCKS_WEIGHT);

    // Small tree (2 tiles high)
    terrain_model_builder.create_model(
        plant_prop.clone(),
        vec![
            SpawnableAsset::new("small_tree_bottom"),
            SpawnableAsset::new("small_tree_top").with_grid_offset(GridDelta::new(0, 1, 0)),
        ],
    );

    // Big tree 1 (2x2 tiles)
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.props.big_tree_1_base,
                x_neg: terrain_sockets.void,
                z_pos: terrain_sockets.props.layer_up,
                z_neg: terrain_sockets.props.props_down,
                y_pos: terrain_sockets.void,
                y_neg: terrain_sockets.void,
            },
            vec![
                SpawnableAsset::new("big_tree_1_bl"),
                SpawnableAsset::new("big_tree_1_tl").with_grid_offset(GridDelta::new(0, 1, 0)),
            ],
        )
        .with_weight(PROPS_WEIGHT);

    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.void,
                x_neg: terrain_sockets.props.big_tree_1_base,
                z_pos: terrain_sockets.props.layer_up,
                z_neg: terrain_sockets.props.props_down,
                y_pos: terrain_sockets.void,
                y_neg: terrain_sockets.void,
            },
            vec![
                SpawnableAsset::new("big_tree_1_br"),
                SpawnableAsset::new("big_tree_1_tr").with_grid_offset(GridDelta::new(0, 1, 0)),
            ],
        )
        .with_weight(PROPS_WEIGHT);

    // Big tree 2 (2x2 tiles)
    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.props.big_tree_2_base,
                x_neg: terrain_sockets.void,
                z_pos: terrain_sockets.props.layer_up,
                z_neg: terrain_sockets.props.props_down,
                y_pos: terrain_sockets.void,
                y_neg: terrain_sockets.void,
            },
            vec![
                SpawnableAsset::new("big_tree_2_bl"),
                SpawnableAsset::new("big_tree_2_tl").with_grid_offset(GridDelta::new(0, 1, 0)),
            ],
        )
        .with_weight(PROPS_WEIGHT);

    terrain_model_builder
        .create_model(
            SocketsCartesian3D::Simple {
                x_pos: terrain_sockets.void,
                x_neg: terrain_sockets.props.big_tree_2_base,
                z_pos: terrain_sockets.props.layer_up,
                z_neg: terrain_sockets.props.props_down,
                y_pos: terrain_sockets.void,
                y_neg: terrain_sockets.void,
            },
            vec![
                SpawnableAsset::new("big_tree_2_br"),
                SpawnableAsset::new("big_tree_2_tr").with_grid_offset(GridDelta::new(0, 1, 0)),
            ],
        )
        .with_weight(PROPS_WEIGHT);

    // Tree stumps
    terrain_model_builder.create_model(
        stump_prop.clone(),
        vec![SpawnableAsset::new("tree_stump_1")],
    );
    terrain_model_builder.create_model(
        stump_prop.clone(),
        vec![SpawnableAsset::new("tree_stump_2")],
    );
    terrain_model_builder.create_model(
        stump_prop.clone(),
        vec![SpawnableAsset::new("tree_stump_3")],
    );

    // Rocks
    terrain_model_builder.create_model(rock_prop.clone(), vec![SpawnableAsset::new("rock_1")]);
    terrain_model_builder.create_model(rock_prop.clone(), vec![SpawnableAsset::new("rock_2")]);
    terrain_model_builder.create_model(rock_prop.clone(), vec![SpawnableAsset::new("rock_3")]);
    terrain_model_builder.create_model(rock_prop.clone(), vec![SpawnableAsset::new("rock_4")]);

    // Plants
    terrain_model_builder.create_model(plant_prop.clone(), vec![SpawnableAsset::new("plant_1")]);
    terrain_model_builder.create_model(plant_prop.clone(), vec![SpawnableAsset::new("plant_2")]);
    terrain_model_builder.create_model(plant_prop.clone(), vec![SpawnableAsset::new("plant_3")]);
    terrain_model_builder.create_model(plant_prop.clone(), vec![SpawnableAsset::new("plant_4")]);

    // Add connection rules
    socket_collection.add_connections(vec![
        (
            terrain_sockets.props.big_tree_1_base,
            vec![terrain_sockets.props.big_tree_1_base],
        ),
        (
            terrain_sockets.props.big_tree_2_base,
            vec![terrain_sockets.props.big_tree_2_base],
        ),
    ]);

    // Connect props to water layer
    socket_collection
        .add_rotated_connection(
            terrain_sockets.water.layer_up,
            vec![terrain_sockets.props.layer_down],
        )
        .add_rotated_connection(
            terrain_sockets.props.props_down,
            vec![terrain_sockets.water.ground_up],
        );
}

Key Points About Props:

  1. Multi-tile Objects - Big trees use GridDelta::new(0, 1, 0) to place the top half one tile up
  2. Weight System - Different prop types have different spawn probabilities (rocks are rarer than plants)
  3. Land-only Rule - props_down connects to water.ground_up, ensuring props never spawn in water
  4. 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:

// src/map/rules.rs - Update build_world function
pub fn build_world() -> (
    Vec<Vec<SpawnableAsset>>,
    ModelCollection<Cartesian3D>,
    SocketCollection,
) {
    let mut socket_collection = SocketCollection::new();
    let terrain_sockets = create_sockets(&mut socket_collection);

    let mut terrain_model_builder = TerrainModelBuilder::new();

    // Build dirt layer
    build_dirt_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build grass layer
    build_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build yellow grass layer
    build_yellow_grass_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Build water layer
    build_water_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    // Line update alert
    // Build props layer
    build_props_layer(
        &mut terrain_model_builder,
        &terrain_sockets,
        &mut socket_collection,
    );

    let (assets, models) = terrain_model_builder.into_parts();

    (assets, models, socket_collection)
}

Step 5: Updating Grid Layers

We need one final layer for props. Update the constant in generate.rs:

// src/map/generate.rs - Update GRID_Z
const GRID_Z: u32 = 5; // Changed from 4 to 5

Now run your game:

cargo run

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.

Props Layer

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.

const WATER_WEIGHT: f32 = 0.07;

Now run your game:

cargo run

Props Layer

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.