Introduction


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Every year, the fine fellows over at r/roguelikedev run a Tutorial Tuesday series - encouraging new programmers to join the ranks of roguelike developers. Most languages end up being represented, and this year (2019) I decided that I'd use it as an excuse to learn Rust. I didn't really want to use libtcod, the default engine - so I created my own, RLTK. My initial entry into the series isn't very good, but I learned a lot from it - you can find it here, if you are curious.

The series always points people towards an excellent series of tutorials, using Python and libtcod. You can find it here. Section 1 of this tutorial mirrors the structure of this tutorial - and tries to take you from zero (how do I open a console to say Hello Rust) to hero (equipping items to fight foes in a multi-level dungeon). I'm hoping to continue to extend the series.

I also really wanted to use an Entity Component System. Rust has an excellent one called Specs, so I went with it. I've used ECS-based setups in previous games, so it felt natural to me to use it. It's also a cause of continual confusion on the subreddit, so hopefully this tutorial can shine some light on its benefits and why you might want to use one.

I've had a blast writing this - and hope to continue writing. Please feel free to contact me (I'm @herberticus on Twitter) if you have any questions, ideas for improvements, or things you'd like me to add. Also, sorry about all the Patreon spam - hopefully someone will find this sufficiently useful to feel like throwing a coffee or two my way. :-)


Copyright (C) 2019, Herbert Wolverson.


Chapter 1 : Hello Rust


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


This tutorial is primarily about learning to make roguelikes (and by extension other games), but it should also help you get used to Rust and RLTK - The Roguelike Tool Kit we'll be using to provide input/output. Even if you don't want to use Rust, my hope is that you can benefit from the structure, ideas and general game development advice.

Why Rust?

Rust first appeared in 2010, but has only relatively recently hit "stable" status - that is, code you write is pretty unlikely to stop working when the language changes now. Development is very much ongoing, with whole new sections of the language (such as the asynchronous system) still appearing/stabilizing. This tutorial will stay away from the bleeding edge of development - it should be stable.

Rust was designed to be a "better systems language" - that is, low-level like C++, but with far fewer opportunities to shoot yourself in the foot, a focus on avoiding the many "gotchas" that make C++ development difficult, and a massive focus on memory and thread safety: it's designed to be really difficult to write a program that corrupts its memory, or suffers from race conditions (it's not impossible, but you have to try!). It is rapidly gaining traction, with everyone from Mozilla to Microsoft showing interest - and an ever expanding number of tools being written in it.

Rust is also designed to have a better ecosystem than C++. Cargo provides a complete package manager (so do vcpkg, conan, etc. in C++ land, but cargo is well-integrated), a complete build system (similar to cmake, make, meson, etc. - but standardized). It doesn't run on as many platforms as C or C++, but the list is ever-growing.

I tried Rust (after urging from friends), and found that while it doesn't replace C++ in my daily toolbox - there are times that it really helped get a project out of the door. It's syntax takes a bit of getting used to, but it really does drop in nicely to existing infrastructure.

Learning Rust

If you've used other programming languages, then there's a lot of help available!

If you find that you need something that isn't in there, it's quite likely that someone has written a crate ("package" in every other language, but cargo deals with crates...) to help. Once you have a working environment, you can type cargo search <my term> to look for crates that help. You can also head to crates.io to see a full list of crates that are on offer in Cargo - complete with documentation and examples.

If you are completely new to programming, then a piece of bad news: Rust is a relatively young language, so there isn't a lot of "learn programming from scratch with Rust" material out there - yet. You may find it easier to start with a higher-level language, and then move "down" (closer to the metal, as it were) to Rust. The tutorials/guides linked above should get you started if you decide to take the plunge, however.

Getting Rust

On most platforms, rustup is enough to get you a working Rust toolchain. On Windows, it's an easy download - and you get a working Rust environment when it is done. On Unix-derived systems (such as Linux, and OS X) it provides some command-line instructions to install the environment.

Once it is installed, verify that it is working by typing cargo --version on your command line. You should see something like cargo 1.36.0 (c4fcfb725 2019-05-15) (the version will change over time).

Getting comfortable with a development environment

You want to make a directory/folder for your development work (I personally use users/herbert/dev/rust - but that's a personal choice. It really can be anywhere you like!). You'll also want a text editor. I'm a fan of Visual Studio Code, but you can use whatever you are comfortable with. If you do use Visual Studio Code, I recommend the following extensions:

  • Better TOML : makes reading toml files nice; Rust uses them a lot
  • C/C++ : uses the C++ debugger system to debug Rust code
  • Rust (rls) : not the fastest, but thorough syntax highlighting and error checking as you go.

Once you've picked your environment, open up an editor and navigate to your new folder (in VS Code, File -> Open Folder and choose the folder).

Creating a project

Now that you are in your chosen folder, you want to open a terminal/console window there. In VS Code, this is Terminal -> New Terminal. Otherwise, open a command line as normal and cd to your folder.

Rust has a built-in package manager called cargo. Cargo can make project templates for you! So to create your new project, type cargo init hellorust. After a moment, a new folder has appeared in your project - titled hellorust. It will contain the following files and directories:

src\main.rs
Cargo.toml
.gitignore

These are:

  • The .gitignore is handy if you are using git - it stops you from accidentally putting files into the git repository that don't need to be there. If you aren't using git, you can ignore it.
  • src\main.rs is a simple Rust "hello world" program source.
  • Cargo.toml defines your project, and how it should be built.

Quick Rust Introduction - The Anatomy of Hello World

The auto-generated main.rs file looks like this:

fn main() {
    println!("Hello, world!");
}

If you've used other programming languages, this should look somewhat familiar - but the syntax/keywords are probably different. Rust started out as a mashup between ML and C, with the intent to create a flexible "systems" language (meaning: you can write bare-metal code for your CPU without needing a virtual machine like Java or C# do). Along the way, it inherited a lot of syntax from the two languages. I found the syntax looked awful for the first week of using it, and came quite naturally after that. Just like a human language, it takes a while for your brain to key into the syntax and layout.

So what does this all mean?

  1. fn is Rust's keyword for function. In JavaScript or Java, this would read function main(). In C, it would read void main() (even though main is meant to return an int in C). In C#, it would be static void Main(...).
  2. main is the name of the function. In this case, the name is a special case: the operating system needs to know what to run first when it loads a program into memory - and Rust will do the extra work to mark main as the first function. You generally need a main function if you want your program to do anything, unless you are making a library (a collection of functions for other programs to use).
  3. The () is the function arguments or parameters. In this case, there aren't any - so we just use empty opening and closing parentheses.
  4. The { indicates the start of a block. In this case, the block is the body of the function. Everything within the { and } is the content of the function: instructions for it to run, in turn. Blocks also denote scope - so anything you declare inside the function has its access limited to that function. In other words, if you make a variable inside a function called cheese - it won't be visible from inside a function called mouse (and vice versa). There are ways around this, and we'll cover them as we build our game.
  5. println! is a macro. You can tell Rust macros because they have an ! after their name. You can learn all about macros here; for now, you just need to know that they are special functions that are parsed into other code during compilation. Printing to the screen can be quite complicated - you might want to say more than "hello world" - and the println! macro covers a lot of formatting cases. (If you are familiar with C++, it's equivalent to std::fmt. Most languages have their own string formatting system, since programmers tend to have to output a lot of text!)
  6. The final } closes the block started in 4.

Go ahead and type cargo run. After some compilation, if everything is working you will be greeted with "Hello World" on your terminal.

Useful cargo commands

Cargo is quite the tool! You can learn a bit about it from the Learn Rust book, and everything about it from The Cargo Book if you are interested.

You'll be interacting with cargo a lot while you work in Rust. If you initialize your program with cargo init, your program is a cargo crate. Compilation, testing, running, updating - Cargo can help you with all of it. It even sets up git for you by default.

You may find the following cargo features handy:

  • cargo init creates a new project. That's what you used to make the hello world program. If you really don't want to be using git, you can type cargo init --vcs none (projectname).
  • cargo build downloads all dependencies for a project and compiles them, and then compiles your program. It doesn't actually run your program - but this is a good way to quickly find compiler errors.
  • cargo update will fetch new versions of the crates you listed in your cargo.toml file (see below).
  • cargo clean can be used to delete all of the intermediate work files for your project, freeing up a bunch of disk space. They will automatically download and recompile the next time you run/build your project. Occasionally, a cargo clean can help when things aren't working properly - particularly IDE integration.
  • cargo verify-project will tell you if your Cargo settings are correct.
  • cargo install can be used to install programs via Cargo. This is helpful for installing tools that you need.

Cargo also supports extensions - that is, plugins that make it do even more. There are some that you may find particularly useful:

  • Cargo can reformat all your source code to look like standard Rust from the Rust manuals. You need to type rustup component add rustfmt once to install the tool. After that's done, you can type cargo fmt to format your code at any time.
  • If you'd like to work with the mdbook format - used for this book! - cargo can help with that, too. Just once, you need to run cargo install mdbook to add the tools to your system. After that, mdbook build will build a book project, mdbook init will make a new one, and mdbook serve will give you a local webserver to view your work! You can learn all about mdbook on their documentation page.
  • Cargo can also integrate with a "linter" - called Clippy. Clippy is a little pedantic (just like his Microsoft Office namesake!). Just the once, run rustup component add clippy. You can now type cargo clippy at any time to see suggestions for what may be wrong with your code!

Making a new project

Lets modify the newly created "hello world" project to make use of RLTK - the Roguelike Toolkit.

Setup Cargo.toml

The auto-generated Cargo file will look like this:

[package]
name = "helloworld"
version = "0.1.0"
authors = ["Your name if it knows it"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Go ahead and make sure that your name is correct! Next, we're going to ask Cargo to use RLTK - the Roguelike toolkit library. Rust makes this very easy. Adjust the dependencies section to look like this:

[dependencies]
rltk = { version = "0.7.0" }

We're telling it that the package is named rltk, and is available in Cargo - so we just have to give it a version. You can do cargo search rltk to see the latest version at any time, or go to the crate webpage.

It's a good idea to occasionally run cargo update - this will update the libraries used by your program.

Hello Rust - RLTK Style!

Go ahead and replace the contents of src\main.rs with:

use rltk::{Rltk, GameState, Console};

struct State {}
impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();
        ctx.print(1, 1, "Hello Rust World");
    }
}

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let gs = State{ };
    rltk::main_loop(context, gs);
}

Now create a new folder called resources. RLTK needs a few files to run, and this is where we put them. Download resources.zip, and unzip it into this folder. Be careful to have resources/backing.fs (etc.) and not resources/resources/backing.fs.

Save, and go back to the terminal. Type cargo run, and you will be greeted with a console window showing Hello Rust.

Screenshot

If you're new to Rust, you are probably wondering what exactly the Hello Rust code does, and why it is there - so we'll take a moment to go through it.

  1. The first line is equivalent to C++'s #include or C#'s using. It simply tells the compiler that we are going to require Rltk, GameState, and Console types from the namespace rltk. You used to need an additional extern crate line here, but the most recent version of Rust can now figure it out for you.
  2. With struct State{}, we are creating a new structure. Structures are like Records in Pascal, or Classes in many other languages: you can store a bunch of data inside them, and you can also attach "methods" (functions) to them. In this case, we don't actually need any data - we just need a place to attach code. If you'd like to learn more about Structs, this is the Rust Book chapter on the topic
  3. impl GameState for State is quite a mouthful! We're telling Rust that our State structure implements the trait GameState. Traits are like interfaces or base classes in other languages: they setup a structure for you to implement in your own code, which can then interact with the library that provides them - without that library having to know anything else about your code. In this case, GameState is a trait provided by RLTK. RLTK requires that you have one - it uses it to call into your program on each frame. You can learn about traits in this chapter of the Rust book.
  4. fn tick(&mut self, ctx : &mut Rltk) is a function definition. We're inside the trait implementation scope, so we are implementing the function for the trait - so it has to match the type required by the trait. Functions are a basic building block of Rust, I recommend the Rust book chapter on the topic.
    1. In this case, fn tick means "make a function, called tick" (it's called "tick" because it "ticks" with each frame that is rendered; it's common in game programming to refer to each iteration as a tick).
    2. It doesn't end with an -> type, so it is equivalent to a void function in C - it doesn't return any data once called. The parameters can also benefit from a little explanation.
    3. &mut self means "this function requires access to the parent structure, and may change it" (the mut is short for "mutable" - meaning it can change variables inside the structure - "state"). You can also have functions in a structure that just have &self - meaning, we can see the content of the structure, but can't change it. If you omit the &self altogether, the function can't see the structure at all - but can be called as if the structure was a namespace (you see this a lot with functions called new - they make a new copy of the structure for you).
    4. ctx: &mut Rltk means "pass in a variable called ctx" (ctx is an abbreviation for "context"). The colon indicates that we're specifying what type of variable it must be.
    5. & means "pass a reference" - which is a pointer to an existing copy of the variable. The variable isn't copied, you are working on the version that was passed in; if you make a change, you are changing the original. The Rust Book explains this better than I can.
    6. mut once again indicates that this is a "mutable" reference: you are allowed to make changes to the context.
    7. Finally Rltk is the type of the variable you are receiving. In this case, it's a struct defined inside the RLTK library that provides various things you can do to the screen.
  5. ctx.cls(); says "call the cls function provided by the variable ctx. cls is a common abbreviation for "clear the screen" - we're telling our context that it should clear the virtual terminal. It's a good idea to do this at the beginning of a frame, unless you specifically don't want to.
  6. ctx.print(1, 1, "Hello Rust World"); is asking the context to print "Hello Rust World" at the location (1,1).
  7. Now we get to fn main(). Every program has a main function: it tells the operating system where to start the program.
  8.  use rltk::RltkBuilder;
     let context = RltkBuilder::simple80x50()
         .with_title("Roguelike Tutorial")
         .build();
    
    is an example of calling a function from inside a struct - where that struct doesn't take a "self" function. In other languages, this would be called a constructor. We're calling the function simple80x50 (which is a builder provided by RLTK to make a terminal 80 characters wide by 50 characters high. The window title is "Roguelike Tutorial".
  9. let gs = State{ }; is an example of a variable assignment (see The Rust Book). We're making a new variable called gs (short for "game state"), and setting it to be a copy of the State struct we defined above.
  10. rltk::main_loop(context, gs); calls into the rltk namespace, activating a function called main_loop. It needs both the context and the GameState we made earlier - so we pass those along. RLTK tries to take some of the complexity of running a GUI/game application away, and provides this wrapper. The function now takes over control of the program, and will call your tick function (see above) every time the program "ticks" - that is, finishes one cycle and moves to the next. This can happen 60 or more times per second!

Hopefully that made some sense!

Playing with the tutorials

You'd probably like to play with the tutorial code without having to type it all in! The good news is that it is up on GitHub for your perusal. You need to have git installed (RustUp should have helped you with that). Choose where you would like to have the tutorials, and open a terminal:

cd <path to tutorials>
git clone https://github.com/thebracket/rustrogueliketutorial .

After a while, this will download the complete tutorial (including the source code for this book!). It is laid out as follows (this isn't complete!):

───book
├───chapter-01-hellorust
├───chapter-02-helloecs
├───chapter-03-walkmap
├───chapter-04-newmap
├───chapter-05-fov
├───resources
├───src

What's here?

  • The book folder contains the source code for this book. You can ignore it, unless you feel like correcting my spelling!
  • Each chapter's example code is contained in chapter-xy-name folders; for example, chapter-01-hellorust.
  • The src folder contains a simple script to remind you to change to a chapter folder before running anything.
  • resources has the contents of the ZIP file you downloaded for this example. All the chapter folders are preconfigured to use this.
  • Cargo.toml is setup to include all of the tutorials as "workspace entries" - they share dependencies, so it won't eat your whole drive re-downloading everything each time you use it.

To run an example, open your terminal and:

cd <where you put the tutorials>
cd chapter-01-hellorust
cargo run

If you are using Visual Studio Code, you can instead use File -> Open Folder to open the whole directory that you checked out. Using the inbuilt terminal, you can simply cd to each example and cargo run it.

Accessing Tutorial Source Code

You can get to the source code for all of the tutorials at https://github.com/thebracket/rustrogueliketutorial.

Updating the Tutorial

I update this tutorial a lot - adding chapters, fixing issues, etc. You will periodically want to open the tutorial directory, and type git pull. This tells git (the source control manager) to go to the Github repository and look for what's new. It will then download everything that has changed, and you once again have up-to-date tutorials.

Updating Your Project

You may find that rltk_rs or another package has updated, and you would like the latest version. From your project's folder, you can type cargo update to update everything. You can type cargo update --dryrun to see what it would like to update, and not change anything (people update their crates a lot - so this can be a big list!).

Updating Rust Itself

I don't recommend running this from inside Visual Studio Code or another IDE, but if you'd like to ensure that you have the most recent release of Rust (and associated tools), you can type rustup self update. This updates the Rust update tools (I know that sounds rather recursive). You can then type rustup update and install the latest versions of all of the tools.

Getting Help

There's a number of ways to get help:

  • Feel free to contact me (I'm @herberticus on Twitter) if you have any questions, ideas for improvements, or things you'd like me to add.
  • The fine people on /r/rust are VERY helpful with Rust language issues.
  • The awesome people of /r/roguelikedev are VERY helpful when it comes to Roguelike issues. Their Discord is pretty active, too.

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Chapter 2 - Entities and Components


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


This chapter will introduce the entire of an Entity Component System (ECS), which will form the backbone of the rest of this tutorial. Rust has a very good ECS, called Specs - and this tutorial will show you how to use it, and try to demonstrate some of the early benefits of using it.

About Entities and Components

If you've worked on games before, you may well be used to an object oriented design (this is very common, even in the original Python libtcod tutorial that inspired this one). There's nothing really wrong with an object-oriented (OOP) design - but game developers have moved away from it, mostly because it can become quite confusing when you start to expand your game beyond your original design ideas.

You've probably seen a "class hierarchy" such as this simplified one:

BaseEntity
    Monster
        MeleeMob
            OrcWarrior
        ArcherMob
            OrcArcher

You'd probably have something more complicated than that, but it works as an illustration. BaseEntity would contain code/data required to appear on the map as an entity, Monster indicates that it's a bad guy, MeleeMob would hold the logic for finding melee targets, closing in, and killing them. Likewise, ArcherMob would try to maintain the optimal range and use their ranged weapon to fire from a safe distance. The problem with a taxonomy like this is that it can be restrictive, and before you know it - you are starting to write separate classes for more complicated combinations. For example, what if we come up with an orc that can do both melee and archery - and may become friendly if you've completed the Friends With The Greenskins quest? You might well end up combining logic from all of them into one special case class. It works - and plenty of games have published doing just that - but what if there were an easier way?

Entity Component based design tries to eliminate the hierarchy, and instead implement a set of "components" that describe what you want. An "entity" is a thing - anything, really. An orc, a wolf, a potion, an Ethereal hard-drive formatting ghost - whatever you want. It's also really simple: little more than an identification number. The magic comes from entities being able to have as many components as you want to add. Components are just data, grouped by whatever properties you want to give an entity.

For example, you could build the same set of mobs with components for: Position, Renderable, Hostile, MeleeAI, RangedAI, and some sort of CombatStats component (to tell you about their weaponry, hit points, etc.). An Orc Warrior would need a position so you know where they are, a renderable so you know how to draw them. It's Hostile, so you mark it as such. Give it a MeleeAI and a set of game stats, and you have everything you need to make it approach the player and try to hit them. An Archer might be the same thing, but replacing MeleeAI with RangedAI. A hybrid could keep all the components, but either have both AIs or an additional one if you want custom behavior. If your orc becomes friendly, you could remove the Hostile component - and add a Friendly one.

In other words: components are just like your inheritance tree, but instead of inheriting traits you compose them by adding components until it does what you want. This is often called "composition".

The "S" in ECS stands for "Systems". A System is a piece of code that gathers data from the entity/components list and does something with it. It's actually quite similar to an inheritance model, but in some ways it's "backwards". For example, drawing in an OOP system is often: For each BaseEntity, call that entity's Draw command. In an ECS system, it would be Get all entities with a position and a renderable component, and use that data to draw them.

For small games, an ECS often feels like it's adding a bit of extra typing to your code. It is. You take the additional work up front, to make life easier later.

That's a lot to digest, so we'll look at a simple example of how an ECS can make your life a bit easier.

It's important to know that ECS is just one way of handling composition. There are many others, and there really is no right answer. With a bit of searching, you can find a bunch of different ways to approach ECS. There's plenty of object-oriented approaches. There are plenty of "free function" approaches. They all have merit, and can work for you. I've gone with the Entity-Component approach in this book, but there are many other ways to skin the cat. As you gain experience, you'll find one that's comfortable for you! My advice: if anyone tells you that a particular method is the "right" one, ignore them - programming is the art of making something that works, rather than a quest for purity!

Including Specs in the project

To start, we want to tell Cargo that we're going to use Specs. Open your Cargo.toml file, and change the dependencies section to look like this:

[dependencies]
rltk = { version = "0.7.0" }
specs = "0.16.1"
specs-derive = "0.4.0"

This is pretty straightforward: we're telling Rust that we still want to use RLTK, and we're also asking for specs (the version number is current at the time of writing; you can check for new ones by typing cargo search specs). We're also adding specs-derive - which provides some helper code to reduce the amount of boilerplate typing you have to do.

At the top of main.rs we add a few lines of code:


# #![allow(unused_variables)]
#fn main() {
use rltk::{Console, GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::Component;
#}

use rltk:: is shorthand; you can type rltk::Console every time you want a console; this tells Rust that we'd like to just type Console instead. Likewise the use specs::prelude::* line is there so we aren't continually typing specs::prelude::World when we just want World.

Old Rust required a scary looking macro_use call. You don't need that anymore: you can just directly use the macro.

We need the derivations from Specs' derive component: so we add use specs_derive::Component;.

Defining a position component

We're going to build a little demo that uses an ECS to put characters on the screen and move them around. A basic part of this is to define a position - so that entities know where they are. We'll keep it simple: positions are just an X and Y coordinate on the screen.

So, we define a struct (these are like structs in C, records in Pascal, etc. - a group of data stored together. See the Rust Book chapter on Structures):


# #![allow(unused_variables)]
#fn main() {
struct Position {
    x: i32,
    y: i32,
}
#}

Very simple! A Position component has an x and y coordinate, as 32-bit integers. Our Position structure is what is known as a POD - short for "plain old data". That is, it is just data, and doesn't have any logic of its own. This is a common theme with "pure" ECS (Entity Component System) components: they are just data, with no associated logic. The logic will be implemented elsewhere. There are two reasons to use this model: it keeps all of your code that does something in "systems" (that is, code that runs across components and entities), and performance - it's very fast to keep all of the positions next to each other in memory with no redirects.

At this point, you could use Positions, but there's very little to help you store them or assign them to anyone - so we need to tell Specs that this is a component. Specs provides a lot of options for this, but we want to keep it simple. The long-form (no specs-derive help) would look like this:


# #![allow(unused_variables)]
#fn main() {
struct Position {
    x: i32,
    y: i32,
}

impl Component for Position {
    type Storage = VecStorage<Self>;
}
#}

You will probably have a lot of components by the time your game is done - so that's a lot of typing. Not only that, but it's lots of typing the same thing over and over - with the potential to get confusing. Fortunately, specs-derive provides an easier way. You can replace the previous code with:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}
#}

What does this do? #[derive(x)] is a macro that says "from my basic data, please derive the boilerplate needed for x"; in this case, the x is a Component. The macro generates the additional code for you, so you don't have to type it in for every component. It makes it nice and easy to use components! The #[macro_use] use specs_derive::Component; from earlier is making use of this; derive macros are a special type of macro that implements additional functionality for a structure on your behalf - saving lots of typing.

Defining a renderable component

A second part of putting a character on the screen is what character should we draw, and in what color? To handle this, we'll create a second component - Renderable. It will contain a foreground, background, and glyph (such as @) to render. So we'll create a second component structure:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
struct Renderable {
    glyph: u8,
    fg: RGB,
    bg: RGB,
}
#}

RGB comes from RLTK, and represents a color. That's why we have the use rltk::{... RGB} statement - otherwise, we'd be typing rltk::RGB every time there - saving keystrokes. Once again, this is a plain old data structure, and we are using the derive macro to add the component storage information without having to type it all out.

Worlds and Registration

So now we have two component types, but that's not very useful without somewhere to put them! Specs requires that you register your components at start-up. What do you register it with? A World!

A World is an ECS, provided by the Rust crate Specs. You can have more than one if you want, but we won't go there yet. We'll extend our State structure to have a place to store the world:


# #![allow(unused_variables)]
#fn main() {
struct State {
    ecs: World
}
#}

And now in main, when we create the world - we'll put an ECS into it:


# #![allow(unused_variables)]
#fn main() {
let mut gs = State {
    ecs: World::new()
};
#}

Notice that World::new() is another constructor - it's a method inside the World type, but without a reference to self. So it doesn't work on existing World objects - it can only make new ones. This is a pattern used everywhere in Rust, so it's a good idea to be familiar with it. The Rust Book has a section on the topic.

The next thing to do is to tell the ECS about the components we have created. We do this right after we create the world:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
#}

What this does is it tells our World to take a look at the types we are giving it, and do some internal magic to create storage systems for each of them. Specs has made this easy; so long as it implements Component, you can put anything you like in as a component!

Creating entities

Now we've got a World that knows how to store Position and Renderable components. Having these components simply exist doesn't help us, beyond providing an indication of structure. In order to use them, they need to be attached to something in the game. In the ECS world, that something is called an entity. Entities are quite simple; they are little more than an identification number, telling the ECS that an entity exists. They can have any combination of components attached to them. In this case, we're going to make an entity that knows where it is on the screen, and knows how it should be represented on the screen.

We can create an entity with both a Renderable and a Position component like this:


# #![allow(unused_variables)]
#fn main() {
gs.ecs
    .create_entity()
    .with(Position { x: 40, y: 25 })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .build();
#}

What this does, is it tells our World (ecs in gs - our game state) that we'd like a new entity. That entity should have a position (we've picked the middle of the console), and we'd like it to be renderable with an @ symbol in yellow on black. That's very simple; we aren't even storing the entity (we could if we wanted to) - we're just telling the world that it's there!

Notice that we are using an interesting layout: lots of functions that don't end in an ; to separate out the end of the statement, but instead lots of . calls to another function. This is called the builder pattern, and is very common in Rust. Combining functions in this fashion is called method chaining (a method is a function inside a structure). It works because each function returns a copy of itself - so each function runs in turn, passing itself as the holder for the next method in the chain. So in this example, we start with a create_entity call - which returns a new, empty, entity. On that entity, we call with - which attaches a component to it. That in turn returns the partially built entity - so we can call with again to add the Renderable component. Finally, .build() takes the assembled entity and does the hard part - actually putting together all of the disparate parts into the right parts of the ECS for you.

You could easily add a bunch more entities, if you want. Lets do just that:


# #![allow(unused_variables)]
#fn main() {
for i in 0..10 {
    gs.ecs
    .create_entity()
    .with(Position { x: i * 7, y: 20 })
    .with(Renderable {
        glyph: rltk::to_cp437('☺'),
        fg: RGB::named(rltk::RED),
        bg: RGB::named(rltk::BLACK),
    })
    .build();
}
#}

This is the first time we've called a for loop in the tutorial! If you've used other programming languages, the concept will be familiar: run the loop with i set to every value from 0 to 9. Wait - 9, you say? Rust ranges are exclusive - they don't include the very last number in the range! This is for familiarity with languages like C which normally write for (i=0; i<10; ++i). If you actually want to go all the way to the end of the range (so 0 to 10), you would write the rather cryptic for i in 0..=10. The Rust Book provides a great primer for understanding control flow in Rust.

You'll notice that we're putting them at different positions (every 7 characters, 10 times), and we've changed the @ to an - a smiley face (to_cp437 is a helper RLTK provides to let you type/paste Unicode and get the equivalent member of the old DOS/CP437 character set. You could replace the to_cp437('☺') with a 1 for the same thing). You can find the glyphs available here.

Iterating entities - a generic render system

So we now have 11 entities, with differing render characteristics and positions. It would be a great idea to do something with that data! In our tick function, we replace the call to draw "Hello Rust" with the following:


# #![allow(unused_variables)]
#fn main() {
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();

for (pos, render) in (&positions, &renderables).join() {
    ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
}
#}

What does this do? let positions = self.ecs.read_storage::<Position>(); asks the ECS for read access to the container it is using to store Position components. Likewise, we ask for read access to the Renderable storage. It only makes sense to draw a character if it has both of these - you need a Position to know where to draw, and Renderable to know what to draw! You can learn more about these stores in The Specs Book. The important part is read_storage - we're asking for read-only access to the structure used to store components of each type.

Fortunately, Specs has our back:


# #![allow(unused_variables)]
#fn main() {
for (pos, render) in (&positions, &renderables).join() {
#}

This line says join positions and renderables; like a database join, it only returns entities that have both. It then uses Rust's "destructuring" to place each result (one result per entity that has both components). So for each iteration of the for loop - you get both components belonging to the same entity. That's enough to draw it!

The join function returns an iterator. The Rust Book has a great section on iterators. In C++, iterators provide a begin, next and end function - and you can move between elements in collections with them. Rust extends the same concept, only on steroids: just about anything can be made into an iterator if you put your mind to it. Iterators work very well with for loops - you can provide any iterator as the target in for x in iterator loops. The 0..10 we discussed earlier really is a range - and offers an iterator for Rust to navigate.

The other interesting thing here are the parentheses. In Rust, when you wrap variables in brackets you are making a tuple. These are just a collection of variables, grouped together - but without needing to go and make a structure just for this case. You can access them individually via numeric access (mytuple.0, mytuple.1, etc.) to get to each field, or you can destructure them. (one, two) = (1, 2) sets the variable one to 1, and the variable two to 2. That's what we're doing here: the join iterator is returning tuples containing a Position and a Renderable component as .0 and .1. Since typing that is ugly and unclear, we destructure them into the named variables pos and render. This can be confusing at first, so if you are struggling I recommend Rust By Example's section on Tuples.


# #![allow(unused_variables)]
#fn main() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
#}

We're running this for every entity that has both a Position and a Renderable component. The join method is passing us both, guaranteed to belong to the same enitity. Any entities that have one or the other - but not both - simply won't be included in the data returned to us.

ctx is the instance of RLTK passed to us when tick runs. It offers a function called set, that sets a single terminal character to the glyph/colors of your choice. So we pass it the data from pos (the Position component for that entity), and the colors/glyph from render (the Renderable component for that entity).

With that in place, any entity that has both a Position and a Renderable will be rendered to the screen! You could add as many as you like, and they will render. Remove one component or the other, and they won't be rendered (for example, if an item is picked up you might remove its Position component - and add another indicating that it's in your backpack; more on that in later tutorials)

Rendering - complete code

If you've typed all of that in correctly, your main.rs now looks like this:

use rltk::{Console, GameState, Rltk, RGB};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::Component;

#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Renderable {
    glyph: u8,
    fg: RGB,
    bg: RGB,
}

struct State {
    ecs: World
}

impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();
        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();

        for (pos, render) in (&positions, &renderables).join() {
            ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
        }
    }
}

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();

    gs.ecs
        .create_entity()
        .with(Position { x: 40, y: 25 })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .build();

    for i in 0..10 {
        gs.ecs
        .create_entity()
        .with(Position { x: i * 7, y: 20 })
        .with(Renderable {
            glyph: rltk::to_cp437('☺'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .build();
    }

    rltk::main_loop(context, gs);
}

Running it (with cargo run) will give you the following:

Screenshot

An example system - random movement

This example showed you how an ECS can get a disparate bag of entities to render. Go ahead and play around with the entity creation - you can do a lot with this! Unfortunately, it's pretty boring - nothing is moving! Lets rectify that a bit, and make a shooting gallery type look.

First, we'll create a new component called LeftMover. Entities that have this component are indicating that they really like going to the left. The component definition is very simple; a component with no data like this is called a "tag component". We'll put it up with our other component definitions:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
struct LeftMover {}
#}

Now we have to tell the ECS to use the type. With our other register calls, we add:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<LeftMover>();
#}

Now, lets only make the red smiley faces left movers. So their definition grows to:


# #![allow(unused_variables)]
#fn main() {
for i in 0..10 {
    gs.ecs
    .create_entity()
    .with(Position { x: i * 7, y: 20 })
    .with(Renderable {
        glyph: rltk::to_cp437('☺'),
        fg: RGB::named(rltk::RED),
        bg: RGB::named(rltk::BLACK),
    })
    .with(LeftMover{})
    .build();
}
#}

Notice how we've added one line: .with(LeftMover{}) - that's all it takes to add one more component to these entities (and not the yellow @).

Now to actually make them move. We're going to define our first system. Systems are a way to contain entity/component logic together, and have them run independently. There's lots of complex flexibility available, but we're going to keep it simple. Here's everything required for our LeftWalker system:


# #![allow(unused_variables)]
#fn main() {
struct LeftWalker {}

impl<'a> System<'a> for LeftWalker {
    type SystemData = (ReadStorage<'a, LeftMover>, 
                        WriteStorage<'a, Position>);

    fn run(&mut self, (lefty, mut pos) : Self::SystemData) {
        for (_lefty,pos) in (&lefty, &mut pos).join() {
            pos.x -= 1;
            if pos.x < 0 { pos.x = 79; }
        }
    }
}
#}

This isn't as nice/simple as I'd like, but it does make sense when you understand it. Lets go through it a piece at a time:

  • struct LeftWalker {} just defines an empty structure - somewhere to attach the logic.
  • impl<'a> System<'a> for LeftWalker means we are implementing Specs' System trait for our LeftWalker structure. The 'a are lifetime specifiers: the system is saying that the components it uses must exist long enough for the system to run. For now, it's not worth worrying too much about it. If you are interested, the Rust Book can clarify a bit.
  • type SystemData is defining a type to tell Specs what the system requires. In this case, read access to LeftMover components, and write access (since it updates them) to Position components. You can mix and match whatever you need in here, as we'll see in later chapters.
  • fn run is the actual trait implementation, required by the impl System. It takes itself, and the SystemData we defined.
  • The for loop is system shorthand for the same iteration we did in the rendering system: it will run once for each entity that has both a LeftMover and a Position. Note that we're putting an underscore before the LeftMover variable name: we never actually use it, we just require that the entity has one. The underscore tells Rust "we know we aren't using it, this isn't a bug!" and stops it from warning us every time we compile.
  • The meat of the loop is very simple: we subtract one from the position component, and if it is less than zero we scoot back to the right of the screen.

Notice that this is very similar to how we wrote the rendering code - but instead of calling in to the ECS, the ECS system is calling into our function/system. It can be a tough judgment call on which to use. If your system just needs data from the ECS, then a system is the right place to put it. If it also needs access to other parts of your program, it is probably better implemented on the outside - calling in.

Now that we've written our system, we need to be able to use it. We'll add a run_systems function to our State:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut lw = LeftWalker{};
        lw.run_now(&self.ecs);
        self.ecs.maintain();
    }
}
#}

This is relatively straightforward:

  1. impl State means we would like to implement functionality for State.
  2. fn run_systems(&mut self) means we are defining a function, and it needs mutable (i.e. it is allowed to change things) access to self; this means it can access the data in its instance of State with the self. keyword.
  3. let mut lw = LeftWalker{} makes a new (changeable) instance of the LeftWalker system.
  4. lw.run_now(&self.ecs) tells the system to run, and tells it how to find the ECS.
  5. self.ecs.maintain() tells Specs that if any changes were queued up by the systems, they should apply to the world now.

Finally, we actually want to run our systems. In the tick function, we add:


# #![allow(unused_variables)]
#fn main() {
self.run_systems();
#}

The nice thing is that this will run all systems we register into our dispatcher; so as we add more, we don't have to worry about calling them (or even calling them in the right order). You still sometimes need more access than the dispatcher has; our renderer isn't a system because it needs the Context from RLTK (we'll improve that in a future chapter).

So your code now looks like this:

use rltk::{Console, GameState, Rltk, RGB};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::Component;

#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Renderable {
    glyph: u8,
    fg: RGB,
    bg: RGB,
}

#[derive(Component)]
struct LeftMover {}
 
struct State {
    ecs: World,
}

impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();

        self.run_systems();

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();

        for (pos, render) in (&positions, &renderables).join() {
            ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
        }
    }
}

struct LeftWalker {}

impl<'a> System<'a> for LeftWalker {
    type SystemData = (ReadStorage<'a, LeftMover>, 
                        WriteStorage<'a, Position>);

    fn run(&mut self, (lefty, mut pos) : Self::SystemData) {
        for (_lefty,pos) in (&lefty, &mut pos).join() {
            pos.x -= 1;
            if pos.x < 0 { pos.x = 79; }
        }
    }
}

impl State {
    fn run_systems(&mut self) {
        let mut lw = LeftWalker{};
        lw.run_now(&self.ecs);
        self.ecs.maintain();
    }
}

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();
    gs.ecs.register::<LeftMover>();

    gs.ecs
        .create_entity()
        .with(Position { x: 40, y: 25 })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .build();

    for i in 0..10 {
        gs.ecs
        .create_entity()
        .with(Position { x: i * 7, y: 20 })
        .with(Renderable {
            glyph: rltk::to_cp437('☺'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(LeftMover{})
        .build();
    }

    rltk::main_loop(context, gs);
}

If you run it (with cargo run), the red smiley faces zoom to the left, while the @ watches.

Screenshot

Moving the player

Finally, lets make the @ move with keyboard controls. So we know which entity is the player, we'll make a new tag component:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
struct Player {}
#}

We'll add it to registration:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<Player>();
#}

And we'll add it to the player's entity:


# #![allow(unused_variables)]
#fn main() {
gs.ecs
    .create_entity()
    .with(Position { x: 40, y: 25 })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Player{})
    .build();
#}

Now we implement a new function, try_move_player:


# #![allow(unused_variables)]
#fn main() {
fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        pos.x = min(79 , max(0, pos.x + delta_x));
        pos.y = min(49, max(0, pos.y + delta_y));
    }
}
#}

Drawing on our previous experience, we can see that this gains write access to Player and Position. It then joins the two, ensuring that it will only work on entities that have both component types - in this case, just the player. It then adds delta_x to x and delta_y to y - and does some checks to make sure that you haven't tried to leave the screen.

We'll add a second function to read the keyboard information provided by RLTK:


# #![allow(unused_variables)]
#fn main() {
fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    match ctx.key {
        None => {} // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            _ => {}
        },
    }
}
#}

There's quite a bit of functionality here that we haven't seen before! The context is providing information about a key - but the user may or may not be pressing one! Rust provides a feature for this, called Option types. Option types have two possible value: None (no data), or Some(x) - indicating that there is data here, held inside.

The context provides a key variable. It is an enumeration - that is, a variable that can hold a value from a set of pre-defined values (in this case, keys on the keyboard). Rust enumerations are really powerful, and can actually hold values as well - but we won't use that yet.

So to get the data out of an Option, we need to unwrap it. There's a function called unwrap - but if you call it when there isn't any data, your program will crash! So we'll use Rust's match command to peek inside. Matching is one of Rust's strongest benefits, and I highly recommend the Rust book chapter on it, or the Rust by Example section if you prefer learning by examples.

So we call match ctx.key - and Rust expects us to provide a list of possibles matches. In the case of ctx.key, there are only two possible values: Some or None. The None => {} line says "match the case in which ctx.key has no data" - and runs an empty block. Some(key) is the other option; there is some data - and we'll ask Rust to give it to us as a variable named key (you can name it whatever you like).

We then match again, this time on the key. We have a line for each eventuality we want to handle: VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs) says that if key equals VirtualKeyCode::Left (VirtualKeyCode is the name of the enumeration type), we should call our try_move_player function with (-1, 0). We repeat that for all four directions. The _ => {} is rather odd looking; _ means anything else. So we're telling Rust that any other key code can be ignored here. Rust is rather pedantic: if you don't specify every possible enumeration, it will give a compiler error! By including the default, we don't have to type every possible keystroke.

This function takes the current game state and context, looks at the key variable in the context, and calls the appropriate move command if the relevant movement key is pressed. Lastly, we add it into tick:


# #![allow(unused_variables)]
#fn main() {
player_input(self, ctx);
#}

If you run your program (with cargo run), you now have a keyboard controlled @ symbol, while the smiley faces zoom to the left!

Screenshot

The final code for chapter 2

The source code for this completed example may be found ready-to-run in chapter-02-helloecs. It looks like this:

use rltk::{Console, GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::Component;



#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Renderable {
    glyph: u8,
    fg: RGB,
    bg: RGB,
}

#[derive(Component)]
struct LeftMover {}
 
#[derive(Component, Debug)]
struct Player {}

struct State {
    ecs: World
}

fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        pos.x = min(79 , max(0, pos.x + delta_x));
        pos.y = min(49, max(0, pos.y + delta_y));
    }
}

fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    match ctx.key {
        None => {} // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            _ => {}
        },
    }
}

impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();

        player_input(self, ctx);
        self.run_systems();

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();

        for (pos, render) in (&positions, &renderables).join() {
            ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
        }
    }
}

struct LeftWalker {}

impl<'a> System<'a> for LeftWalker {
    type SystemData = (ReadStorage<'a, LeftMover>, 
                        WriteStorage<'a, Position>);

    fn run(&mut self, (lefty, mut pos) : Self::SystemData) {
        for (_lefty,pos) in (&lefty, &mut pos).join() {
            pos.x -= 1;
            if pos.x < 0 { pos.x = 79; }
        }
    }
}

impl State {
    fn run_systems(&mut self) {
        let mut lw = LeftWalker{};
        lw.run_now(&self.ecs);
        self.ecs.maintain();
    }
}

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();
    gs.ecs.register::<LeftMover>();
    gs.ecs.register::<Player>();

    gs.ecs
        .create_entity()
        .with(Position { x: 40, y: 25 })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Player{})
        .build();

    for i in 0..10 {
        gs.ecs
        .create_entity()
        .with(Position { x: i * 7, y: 20 })
        .with(Renderable {
            glyph: rltk::to_cp437('☺'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(LeftMover{})
        .build();
    }

    rltk::main_loop(context, gs);
}

This chapter was a lot to digest, but provides a really solid base on which to build. The great part is: you've now got further than many aspiring developers! You have entities on the screen, and can move around with the keyboard.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Chapter 3 - Walking a Map


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


The remainder of this tutorial will be dedicated to making a Roguelike. [Rogue](https://en.wikipedia.org/wiki/Rogue_(video_game) appeared in 1980, as a text-mode dungeon exploration game. It has spawned an entire genre of "roguelikes": procedurally generated maps, hunting an objective over multiple levels and "permadeath" (restart when you die). The definition is the source of many online fights; I'd rather avoid that!

A Roguelike without a map to explore is a bit pointless, so in this chapter we'll put together a basic map, draw it, and let your player walk around a bit. We're starting with the code from chapter 2, but with the red smiley faces (and their leftward tendencies) removed.

Defining the map tiles

We'll start by allowing two tile types: walls and floors. We can represent this with an enum (to learn more about enumerations, The Rust Book has a large section on them):


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
enum TileType {
    Wall, Floor
}
#}

Notice that we've included some derived features (more usage of derive macros, this time built into Rust itself): Copy and Clone. Clone adds a .clone() method to the type, allowing a copy to be made programmatically. Copy changes the default from moving the object on assignment to making a copy - so tile1 = tile2 leaves both values valid and not in a "moved from" state.

PartialEq allows us to use == to see if two tile types match. If we didn't derive these features, if tile_type == TileType::Wall would fail to compile!

Building a simple map

Now we'll make a function that returns a vec (vector) of tiles, representing a simple map. We'll use a vector sized to the whole map, which means we need a way to figure out which array index is at a given x/y position. So first, we make a new function xy_idx:


# #![allow(unused_variables)]
#fn main() {
pub fn xy_idx(x: i32, y: i32) -> usize {
    (y as usize * 80) + x as usize
}
#}

This is simple: it multiplies the y position by the map width (80), and adds x. This guarantees one tile per location, and efficiently maps it in memory for left-to-right reading.

We're using a Rust function shorthand here. Notice that the function returns a usize (equivalent to size_t in C/C++ - whatever the basic size type used for a platform is) - and the function body lacks a ; at the end? Any function that ends with a statement that lacks a semicolon treats that line as a return statement. So it's the same as typing return (y as usize * 80) + x as usize. This comes from the Rust author's other favorite language, ML - which uses the same shorthand. It's considered "Rustacean" (canonical Rust; I always picture a Rust Monster with cute little claws and shell) to use this style, so we've adopted it for the tutorial.

Then we write a constructor function to make a map:


# #![allow(unused_variables)]
#fn main() {
fn new_map() -> Vec<TileType> {
    let mut map = vec![TileType::Floor; 80*50];

    // Make the boundaries walls
    for x in 0..80 {
        map[xy_idx(x, 0)] = TileType::Wall;
        map[xy_idx(x, 49)] = TileType::Wall;
    }
    for y in 0..50 {
        map[xy_idx(0, y)] = TileType::Wall;
        map[xy_idx(79, y)] = TileType::Wall;
    }

    // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration.
    // First, obtain the thread-local RNG:
    let mut rng = rltk::RandomNumberGenerator::new();

    for _i in 0..400 {
        let x = rng.roll_dice(1, 79);
        let y = rng.roll_dice(1, 49);
        let idx = xy_idx(x, y);
        if idx != xy_idx(40, 25) {
            map[idx] = TileType::Wall;
        }
    }

    map
}
#}

There's a fair amount of syntax that we haven't encountered before here, so lets break this down:

  1. fn new_map() -> Vec<TileType> species a function named new_map. It doesn't take any parameters, so it can be called from anywhere.
  2. It returns a Vec. Vec is a Rust Vector (if you're familiar with C++, it's pretty much exactly the same as a C++ std::vector). A vector is like an array (see this Rust by Example chapter), which lets you put a bunch of data into a list and access each element. Unlike an array, a Vec doesn't have a size limit - and the size can change while the program runs. So you can push (add) new items, and remove them as you go. Rust by Example has a great chapter on Vectors; it's a good idea to learn about them - they are used everywhere.
  3. let mut map = vec![TileType::Floor; 80*50]; is a confusing looking statement! Lets break it down:
    1. let mut map is saying "make a new variable" (let), "let me change it" (mut) and call it "map".
    2. vec! is a macro, another one build into the Rust standard library. The exclamation mark is Rust's way of saying "this is a procedural macro" (as opposed to a derive macro, like we've seen before). Procedural macros run like a function - they define a procedure, they just greatly reduce your typing.
    3. The vec! macro takes its parameters in square brackets.
    4. The first parameter is the value for each element of the new vector. In this case, we're setting every entry we create to be a Floor (from the TileType enumeration).
    5. The second parameter is how many tiles we should create. They will all be set to the value we set above. In this case, our map is 80x50 tiles (4,000 tiles - but we'll let the compiler do the math for us!). So we need to make 4,000 tiles.
    6. You could have replaced the vec! call with for _i in 0..4000 { map.push(TileType::Floor); }. In fact, that's pretty much what the macro did for you - but it's definitely less typing to have the macro do it for you!
  4. for x in 0..80 { is a for loop (see here), just like we used in the previous example. In this case, we're iterating x from 0 to 79.
  5. map[xy_idx(x, 0)] = TileType::Wall; first calls the xy_idx function we defined above to get the vector index for x, 0. It then indexes the vector, telling it to set the vector entry at that position to be a wall. We do this again for x,49.
  6. We do the same thing, but looping y from 0..49 - and setting the vertical walls on our map.
  7. let mut rng = rltk::RandomNumberGenerator::new(); calls the RandomNumberGenerator type in RLTK's new function, and assigns it to a variable called rng. We are asking RLTK to give us a new dice roller.
  8. for _i in 0..400 { is the same as other for loops, but notice the _ before i. We aren't actually looking at the value of i - we just want the loop to run 400 times. Rust will give you a warning if you have a variable you don't use; adding the underscore prefix tells Rust that it's ok, we meant to do that.
  9. let x = rng.roll_dice(1, 79); calls the rng we grabbed in 7, and asks it for a random number from 1 to 79. RLTK does not go with an exclusive range, because it is trying to mirror the old D&D convention of dice being 1d20 or similar. In this case, we should be glad that computers don't care about the geometric difficulty of inventing a 79-sided dice! We also obtain a y value between 1 and 49. We've rolled imaginary dice, and found a random location on the map.
  10. We set the variable idx (short for "index") to the vector index (via xy_idx we defined earlier) for the coordinates we rolled.
  11. if idx != xy_idx(40, 25) { checks that idx isn't the exact middle (we'll be starting there, so we don't want to start inside a wall!).
  12. If it isn't the middle, we set the randomly rolled location to be a wall.

It's pretty simple: it places walls around the outer edges of the map, and then adds 400 random walls anywhere that isn't the player's starting point.

Making the map visible to the world

Specs includes a concept of "resources" - shared data the whole ECS can use. So in our main function, we add a randomly generated map to the world:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(new_map());
#}

The map is now available from anywhere the ECS can see! Now inside your code, you can access the map with the rather unwieldy let map = self.ecs.get_mut::<Vec<TileType>>();; it's available to systems in an easier fashion. There's actually several ways to get the value of map, including ecs.get, ecs.fetch. get_mut obtains a "mutable" (you can change it) reference to the map - wrapped in an optional (in case the map isn't there). fetch skips the Option type and gives you a map directly. You can learn more about this in the Specs Book.

Draw the map

Now that we have a map available, we should put it on the screen! The complete code for the new draw_map function looks like this:


# #![allow(unused_variables)]
#fn main() {
fn draw_map(map: &[TileType], ctx : &mut Rltk) {
    let mut y = 0;
    let mut x = 0;
    for tile in map.iter() {
        // Render a tile depending upon the tile type
        match tile {
            TileType::Floor => {
                ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
            }
            TileType::Wall => {
                ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
            }
        }

        // Move the coordinates
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
#}

This is mostly straightforward, and uses concepts we've already visited. In the declaration, we pass the map as &[TileType] rather than &Vec<TileType>; this allows us to pass in "slices" (parts of) a map if we so choose. We won't do that yet, but it may be useful later. It's also considered a more "rustic" (that is: idiomatic Rust) way to do things, and the linter (clippy) warns about it. The Rust Book can teach you about slices, if you are interested.

Otherwise, it takes advantage of the way we are storing our map - rows together, one after the other. So it iterates through the entire map structure, adding 1 to the x position for each tile. If it hits the map width, it zeroes x and adds one to y. This way we aren't repeatedly reading all over the array - which can get slow. The actual rendering is very simple: we match the tile type, and draw either a period or a hash for walls/floors.

We should also call the function! In our tick function, add:


# #![allow(unused_variables)]
#fn main() {
let map = self.ecs.fetch::<Vec<TileType>>();
draw_map(&map, ctx);
#}

The fetch call is new (we mentioned it above). fetch requires that you promise that you know that the resource you are requesting really does exist - and will crash if it doesn't. It doesn't quite return a reference - it's a shred type, which acts like a reference most of the time but occasionally needs a bit of coercing to be one. We'll worry about that bridge when it comes time to cross it, but consider yourself warned!

Making walls solid

So now if you run the program (cargo run), you'll have a green and grey map with a yellow @ who can move around. Unfortunately, you'll quickly notice that the player can walk through walls! Fortunately, that's pretty easy to rectify.

To accomplish this, we modify the try_move_player to read the map and check that the destination is open:


# #![allow(unused_variables)]
#fn main() {
fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Vec<TileType>>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));
        }
    }
}
#}

The new parts are the let map = ... part, which uses fetch just the same way as the main loop (this is the advantage of storing it in the ECS - you can get to it everywhere without trying to coerce Rust into letting you use global variables!). We calculate the cell index of the player's destination with let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); - and if it isn't a wall, we move as normal.

Run the program (cargo run) now, and you have a player in a map - and can move around, properly obstructed by walls.

Screenshot

The full program now looks like this:

use rltk::{Console, GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::*;



#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Renderable {
    glyph: u8,
    fg: RGB,
    bg: RGB,
}
 
#[derive(Component, Debug)]
struct Player {}

#[derive(PartialEq, Copy, Clone)]
enum TileType {
    Wall, Floor
}

struct State {
    ecs: World
}

pub fn xy_idx(x: i32, y: i32) -> usize {
    (y as usize * 80) + x as usize
}

fn new_map() -> Vec<TileType> {
    let mut map = vec![TileType::Floor; 80*50];

    // Make the boundaries walls
    for x in 0..80 {
        map[xy_idx(x, 0)] = TileType::Wall;
        map[xy_idx(x, 49)] = TileType::Wall;
    }
    for y in 0..50 {
        map[xy_idx(0, y)] = TileType::Wall;
        map[xy_idx(79, y)] = TileType::Wall;
    }

    // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration.
    // First, obtain the thread-local RNG:
    let mut rng = rltk::RandomNumberGenerator::new();

    for _i in 0..400 {
        let x = rng.roll_dice(1, 79);
        let y = rng.roll_dice(1, 49);
        let idx = xy_idx(x, y);
        if idx != xy_idx(40, 25) {
            map[idx] = TileType::Wall;
        }
    }

    map
}

fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Vec<TileType>>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));
        }
    }
}

fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    match ctx.key {
        None => {} // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            _ => {}
        },
    }
}

fn draw_map(map: &[TileType], ctx : &mut Rltk) {
    let mut y = 0;
    let mut x = 0;
    for tile in map.iter() {
        // Render a tile depending upon the tile type
        match tile {
            TileType::Floor => {
                ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
            }
            TileType::Wall => {
                ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
            }
        }

        // Move the coordinates
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}

impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();

        player_input(self, ctx);
        self.run_systems();

        let map = self.ecs.fetch::<Vec<TileType>>();
        draw_map(&map, ctx);

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();

        for (pos, render) in (&positions, &renderables).join() {
            ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
        }
    }
}

impl State {
    fn run_systems(&mut self) {
        self.ecs.maintain();
    }
}

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();
    gs.ecs.register::<Player>();

    gs.ecs.insert(new_map());

    gs.ecs
        .create_entity()
        .with(Position { x: 40, y: 25 })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Player{})
        .build();

    rltk::main_loop(context, gs);
}

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Chapter 4 - A More Interesting Map


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In this chapter, we'll make a more interesting map. It will be room-based, and look a bit like many of the earlier roguelikes such as Moria - but with less complexity. It will also provide a great starting point for placing monsters!

Cleaning up

We're going to start by cleaning up our code a bit, and utilizing separate files. As projects gain in complexity/size, it's a good idea to start keeping them as a clean set of files/modules, so we can quickly find what we're looking for (and improve compilation times, sometimes).

If you look at the source code for this chapter, you'll see that we've broken out a lot of functionality into individual files. When you make a new file in Rust, it automatically becomes a module. You then have to tell Rust to use these modules, so main.rs has gained a few mod map and similar, followed by pub use map::*. This says "import the module map, and then use - and make available to other modules - its public contents".

We've also made a bunch of struct into pub struct, and added pub to their members. If you don't do this, then the structure remains internal to that module only - and you can't use it in other parts of the code. This is the same as putting a public: C++ line in a class definition, and exporting the type in the header. Rust makes it a bit cleaner, and no need to write things twice!

Making a more interesting map

We'll start by renaming new_map (now in map.rs) to new_map_test. We'll stop using it, but keep it around for a bit - it's a decent way to test our map code! We'll also use Rust's documentation tags to publish what this function does, in case we forget later:


# #![allow(unused_variables)]
#fn main() {
/// Makes a map with solid boundaries and 400 randomly placed walls. No guarantees that it won't
/// look awful.
pub fn new_map_test() -> Vec<TileType> {
    ...
}
#}

In canonical Rust, if you prefix a function with comments starting with ///, it makes it into a function comment. Your IDE will then show you your comment text when you hover the mouse over the function header, and you can use Cargo's documentation features to make pretty documentation pages for the system you are writing. It's mostly handy if you plan on sharing your code, or working with others - but it's nice to have!

So now, in the spirit of the original libtcod tutorial, we'll start making a map. Our goal is to randomly place rooms, and join them together with corridors.

Making a couple of rectangular rooms

We'll start with a new function:


# #![allow(unused_variables)]
#fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    map
}
#}

This makes a solid 80x50 map, with walls on all tiles - you can't move! We've kept the function signature, so changing the map we want to use in main.rs just requires changing gs.ecs.insert(new_map_test()); to gs.ecs.insert(new_map_rooms_and_corridors());. Once again we're using the vec! macro to make our life easier - see the previous chapter for a discussion of how that works.

Since this algorithm makes heavy use of rectangles, and a Rect type - we'll start by making one in rect.rs. We'll include some utility functions that will be useful later on in this chapter:


# #![allow(unused_variables)]
#fn main() {
pub struct Rect {
    pub x1 : i32,
    pub x2 : i32,
    pub y1 : i32,
    pub y2 : i32
}

impl Rect {
    pub fn new(x:i32, y: i32, w:i32, h:i32) -> Rect {
        Rect{x1:x, y1:y, x2:x+w, y2:y+h}
    }

    // Returns true if this overlaps with other
    pub fn intersect(&self, other:&Rect) -> bool {
        self.x1 <= other.x2 && self.x2 >= other.x1 && self.y1 <= other.y2 && self.y2 >= other.y1
    }

    pub fn center(&self) -> (i32, i32) {
        ((self.x1 + self.x2)/2, (self.y1 + self.y2)/2)
    }
}
#}

There's nothing really new here, but lets break it down a bit:

  1. We define a struct called Rect. We added the pub tag to make it public - it's available outside of this module (by putting it into a new file, we automatically created a code module; that's a built-in Rust way to compartmentalize your code). Over in main.rs, we can add pub mod Rect to say "we use Rect, and because we put a pub in front of it anything can get Rect from us as super::rect::Rect. That's not very ergonomic to type, so a second line use rect::Rect shortens that to super::Rect.
  2. We make a new constructor, entitled new. It uses the return shorthand and returns a rectangle based on the x, y, width and height we pass in.
  3. We define a member method, intersect. It has an &self, meaning it can see into the Rect to which it is attached - but can't modify it (it's a "pure" function). It returns a bool: true if the two rectangles overlap, false otherwise.
  4. We define center, also as a pure member method. It simply returns the coordinates of the middle of the rectangle, as a tuple of x and y in val.0 and val.1.

We'll also make a new function to apply a room to a map:


# #![allow(unused_variables)]
#fn main() {
fn apply_room_to_map(room : &Rect, map: &mut [TileType]) {
    for y in room.y1 +1 ..= room.y2 {
        for x in room.x1 + 1 ..= room.x2 {
            map[xy_idx(x, y)] = TileType::Floor;
        }
    }
}
#}

Notice that we are using for y in room.y1 +1 ..= room.y2 - that's an inclusive range. We want to go all the way to the value of y2, and not y2-1! Otherwise, it's relatively straightforward: use two for loops to visit every tile inside the room's rectangle, and set that tile to be a Floor.

With these two bits of code, we can create a new rectangle anywhere with Rect::new(x, y, width, height). We can add it to the map as floors with apply_room_to_map(rect, map). That's enough to add a couple of test rooms. Our map function now looks like this:


# #![allow(unused_variables)]
#fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    let room1 = Rect::new(20, 15, 10, 15);
    let room2 = Rect::new(35, 15, 10, 15);

    apply_room_to_map(&room1, &mut map);
    apply_room_to_map(&room2, &mut map);

    map
}
#}

If you cargo run your project, you'll see that we now have two rooms - not linked together.

Making a corridor

Two disconnected rooms isn't much fun, so lets add a corridor between them. We're going to need some comparison functions, so we have to tell Rust to import them (at the top of map.rs): use std::cmp::{max, min};. min and max do what they say: they return the minimum or maximum of two values. You could use if statements to do the same thing, but some computers will optimize this into a simple (FAST) call for you; we let Rust figure that out!

Then we make two functions, for horizontal and vertical tunnels:


# #![allow(unused_variables)]
#fn main() {
fn apply_horizontal_tunnel(map: &mut [TileType], x1:i32, x2:i32, y:i32) {
    for x in min(x1,x2) ..= max(x1,x2) {
        let idx = xy_idx(x, y);
        if idx > 0 && idx < 80*50 {
            map[idx as usize] = TileType::Floor;
        }
    }
}

fn apply_vertical_tunnel(map: &mut [TileType], y1:i32, y2:i32, x:i32) {
    for y in min(y1,y2) ..= max(y1,y2) {
        let idx = xy_idx(x, y);
        if idx > 0 && idx < 80*50 {
            map[idx as usize] = TileType::Floor;
        }
    }
}
#}

Then we add a call, apply_horizontal_tunnel(&mut map, 25, 40, 23); to our map making function, and voila! We have a tunnel between the two rooms! If you run (cargo run) the project, you can walk between the two rooms - and not into walls. So our previous code is still working, but now it looks a bit more like a roguelike.

Making a simple dungeon

Now we can use that to make a random dungeon. We'll modify our function as follows:


# #![allow(unused_variables)]
#fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    let mut rooms : Vec<Rect> = Vec::new();
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for _ in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, 80 - w - 1) - 1;
        let y = rng.roll_dice(1, 50 - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&new_room, &mut map);        
            rooms.push(new_room);            
        }
    }

    map
}
#}

There's quite a bit changed there:

  • We've added const constants for the maximum number of rooms to make, and the minimum and maximum size of the rooms. This is the first time we've encountered const: it just says "setup this value at the beginning, and it can never change". It's the only easy way to have global variables in Rust; since they can never change, they often don't even exist and get baked into the functions where you use them. If they do exist, because they can't change there are no concerns when multiple threads access them. It's often cleaner to setup a named constant than to use a "magic number" - that is, a hard-coded value with no real clue as to why you picked that value.
  • We acquire a RandomNumberGenerator from RLTK (which required that we add to the use statement at the top of map.rs)
  • We're randomly building a width and height.
  • We're then placing the room randomly so that x and y are greater than 0 and less than the maximum map size minus one.
  • We iterate through existing rooms, rejecting the new room if it overlaps with one we've already placed.
  • If its ok, we apply it to the room.
  • We're keeping rooms in a vector, although we aren't using it yet.

Running the project (cargo run) at this point will give you a selection of random rooms, with no corridors between them.

Joining the rooms together

We now need to join the rooms together, with corridors. We'll add this to the if ok section of the map generator:


# #![allow(unused_variables)]
#fn main() {
if ok {
    apply_room_to_map(&new_room, &mut map);

    if !rooms.is_empty() {
        let (new_x, new_y) = new_room.center();
        let (prev_x, prev_y) = rooms[rooms.len()-1].center();
        if rng.range(0,2) == 1 {
            apply_horizontal_tunnel(&mut map, prev_x, new_x, prev_y);
            apply_vertical_tunnel(&mut map, prev_y, new_y, new_x);
        } else {
            apply_vertical_tunnel(&mut map, prev_y, new_y, prev_x);
            apply_horizontal_tunnel(&mut map, prev_x, new_x, new_y);
        }
    }

    rooms.push(new_room);
}
#}
  1. So what does this do? It starts by looking to see if the rooms list is empty. If it is, then there is no previous room to join to - so we ignore it.
  2. It gets the room's center, and stores it as new_x and new_y.
  3. It gets the previous room in the vector's center, and stores it as prev_x and prev_y.
  4. It rolls a dice, and half the time it draws a horizontal and then vertical tunnel - and half the time, the other way around.

Try cargo run now. It's really starting to look like a roguelike!

Placing the player

Currently, the player always starts in the center of the map - which with the new generator, may not be a valid starting point! We could simply move the player to the center of the first room, but it's likely that our generator will need to know where all the rooms are - so we can put things in them - rather than just the player's location. So we'll modify our new_map_rooms_and_corridors function to also return the room list. So we change the method signature to: pub fn new_map_rooms_and_corridors() -> (Vec<Rect>, Vec<TileType>) {, and the return statement to (rooms, map)

Our main.rs file also requires adjustments, to accept the new format. We change our main function in main.rs to:

fn main() {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();
    gs.ecs.register::<Player>();

    let (rooms, map) = new_map_rooms_and_corridors();
    gs.ecs.insert(map);
    let (player_x, player_y) = rooms[0].center();

    gs.ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Player{})
        .build();

    rltk::main_loop(context, gs);
}

This is mostly the same, but we are receiving both the rooms list and the map from new_map_rooms_and_corridors. We then place the player in the center of the first room.

Wrapping Up - and supporting the numpad, and Vi keys

Now you have a map that looks like a roguelike, places the player in the first room, and lets you explore with the cursor keys. Not every keyboard has cursor keys that are readily accessible (some laptops require interesting key combinations for them). Lots of players like to steer with the numpad, but not every keyboard has one of those either - so we also support the directional keys from the text editor vi. This makes both hardcore UNIX users happy, and makes regular players happier.

We're not going to worry about diagonal movement yet. In player.rs, we change player_input to look like this:


# #![allow(unused_variables)]
#fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    match ctx.key {
        None => {} // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Numpad4 => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Numpad6 => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Numpad8 => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            VirtualKeyCode::Numpad2 => try_move_player(0, 1, &mut gs.ecs),
            VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),
            _ => {}
        },
    }
}
#}

You should now get something like this when you cargo run your project:

Screenshot

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Chapter 5 - Field of View


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


We have a nicely drawn map, but it shows the whole dungeon! That reduces the usefulness of exploration - if we already know where everything is, why bother exploring? This chapter will add "field of view", and adjust rendering to show the parts of the map we've already discovered. It will also refactor the map into its own structure, rather than just a vector of tiles.

This chapter starts with the code from chapter 4.

Map refactor

We'll keep map-related functions and data together, to keep things clear as we make an ever-more-complicated game. The bulk of this is creating a new Map structure, and moving our helper functions to its implementation.


# #![allow(unused_variables)]
#fn main() {
use rltk::{ RGB, Rltk, Console, RandomNumberGenerator };
use super::{Rect};
use std::cmp::{max, min};

#[derive(PartialEq, Copy, Clone)]
pub enum TileType {
    Wall, Floor
}

pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32
}

impl Map {
    pub fn xy_idx(&self, x: i32, y: i32) -> usize {
        (y as usize * self.width as usize) + x as usize
    }

    fn apply_room_to_map(&mut self, room : &Rect) {
        for y in room.y1 +1 ..= room.y2 {
            for x in room.x1 + 1 ..= room.x2 {
                let idx = self.xy_idx(x, y);
                self.tiles[idx] = TileType::Floor;
            }
        }
    }

    fn apply_horizontal_tunnel(&mut self, x1:i32, x2:i32, y:i32) {
        for x in min(x1,x2) ..= max(x1,x2) {
            let idx = self.xy_idx(x, y);
            if idx > 0 && idx < self.width as usize * self.height as usize {
                self.tiles[idx as usize] = TileType::Floor;
            }
        }
    }

    fn apply_vertical_tunnel(&mut self, y1:i32, y2:i32, x:i32) {
        for y in min(y1,y2) ..= max(y1,y2) {
            let idx = self.xy_idx(x, y);
            if idx > 0 && idx < self.width as usize * self.height as usize {
                self.tiles[idx as usize] = TileType::Floor;
            }
        }
    }

    /// Makes a new map using the algorithm from http://rogueliketutorials.com/tutorials/tcod/part-3/
    /// This gives a handful of random rooms and corridors joining them together.
    pub fn new_map_rooms_and_corridors() -> Map {
        let mut map = Map{
            tiles : vec![TileType::Wall; 80*50],
            rooms : Vec::new(),
            width : 80,
            height: 50
        };

        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;

        let mut rng = RandomNumberGenerator::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, map.width - w - 1) - 1;
            let y = rng.roll_dice(1, map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in map.rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                map.apply_room_to_map(&new_room);

                if !map.rooms.is_empty() {
                    let (new_x, new_y) = new_room.center();
                    let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
                    if rng.range(0,2) == 1 {
                        map.apply_horizontal_tunnel(prev_x, new_x, prev_y);
                        map.apply_vertical_tunnel(prev_y, new_y, new_x);
                    } else {
                        map.apply_vertical_tunnel(prev_y, new_y, prev_x);
                        map.apply_horizontal_tunnel(prev_x, new_x, new_y);
                    }
                }

                map.rooms.push(new_room);
            }
        }

        map
    }
}
#}

There's changes in main and player, too - see the example source for all the details. This has cleaned up our code quite a bit - we can pass a Map around, instead of a vector. If we want to teach Map to do more things - we have a place to do so.

The field-of-view component

Not just the player has limited visibility! Eventually, we'll want monsters to consider what they can see, too. So, since its reusable code, we'll make a Viewshed component. (I like the word viewshed; it comes from the cartography world - literally "what can I see from here?" - and perfectly describes our problem). We'll give each entity that has a Viewshed a list of tile indices they can see. In components.rs we add:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
pub struct Viewshed {
    pub visible_tiles : Vec<rltk::Point>,
    pub range : i32
}
#}

In main.rs, we tell the system about the new component:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<Viewshed>();
#}

Lastly, also in main.rs we'll give the Player a Viewshed component:


# #![allow(unused_variables)]
#fn main() {
gs.ecs
    .create_entity()
    .with(Position { x: player_x, y: player_y })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Player{})
    .with(Viewshed{ visible_tiles : Vec::new(), range : 8 })
    .build();
#}

Player is getting quite complicated now - that's good, it shows what an ECS is good for!

A new system: generic viewsheds

We'll start by defining a system to take care of this for us. We want this to be generic, so it works for anything that can benefit from knowing what it can see. We create a new file, visibility_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Position};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( WriteStorage<'a, Viewshed>, 
                        WriteStorage<'a, Position>);

    fn run(&mut self, (mut viewshed, pos) : Self::SystemData) {
        for (viewshed,pos) in (&mut viewshed, &pos).join() {
        }
    }
}
#}

Now we have to adjust run_systems in main.rs to actually call the system:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        self.ecs.maintain();
    }
}
#}

We also have to tell main.rs to use the new module:


# #![allow(unused_variables)]
#fn main() {
mod visibility_system;
use visibility_system::VisibilitySystem;
#}

This doesn't actually do anything, yet - but we've added a system into the dispatcher, and as soon as we flesh out the code to actually plot the visibility, it will apply to every entity that has both a Viewshed and a Position component.

Asking RLTK for a Viewshed: Trait Implementation

RLTK is written to not care about how you've chosen to lay out your map: I want it to be useful for anyone, and not everyone does maps the way this tutorial does. To act as a bridge between our map implementation and RLTK, it provides some traits for us to support. For this example, we need BaseMap and Algorithm2D. Don't worry, they are simple enough to implement.

In our map.rs file, we add the following:


# #![allow(unused_variables)]
#fn main() {
impl Algorithm2D for Map {
    fn dimensions(&self) -> Point {
        Point::new(self.width, self.height)
    }
}
#}

RLTK is able to figure out a lot of other traits from the dimensions function: point indexing (and it's reciprocal), bounds-checks, and similar. We use return the dimensions we're already using, self.width and self.height.

We also need to support BaseMap. We don't need all of it yet, so we're going to let it use defaults. In map.rs:


# #![allow(unused_variables)]
#fn main() {
impl BaseMap for Map {
    fn is_opaque(&self, idx:usize) -> bool {
        self.tiles[idx as usize] == TileType::Wall
    }
}
#}

is_opaque simply returns true if the tile is a wall, and false otherwise. This will have to be expanded if/when we add more types of tile, but works for now. We'll leave the rest of the trait on defaults for now (so no need to enter anything else).

Asking RLTK for a Viewshed: The System

So going back to visibility_system.rs, we now have what we need to request a viewshed from RLTK. We extend our visibility_system.rs file to look like this:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map};
use rltk::{field_of_view, Point};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( ReadExpect<'a, Map>,
                        WriteStorage<'a, Viewshed>, 
                        WriteStorage<'a, Position>);

    fn run(&mut self, data : Self::SystemData) {
        let (map, mut viewshed, pos) = data;

        for (viewshed,pos) in (&mut viewshed, &pos).join() {
            viewshed.visible_tiles.clear();
            viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
            viewshed.visible_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );
        }
    }
}
#}

There's quite a bit here, and the viewshed is actually the simplest part:

  • We've added a ReadExpect<'a, Map> - meaning that the system should be passed our Map for use. We used ReadExpect, because not having a map is a failure.
  • In the loop, we first clear the list of visible tiles.
  • Then we call RLTK's field_of_view function, providing the starting point (the location of the entity, from pos), the range (from the viewshed), and a slightly convoluted "dereference, then get a reference" to unwrap Map from the ECS.
  • Finally we use the vector's retain method to delete any entries that don't meet the criteria we specify. This is a lambda or closure - it iterates over the vector, passing p as a parameter. If p is inside the map boundaries, we keep it. This prevents other functions from trying to access a tile outside of the working map area.

This will now run every frame (which is overkill, more on that later) - and store a list of visible tiles.

Rendering visibility - badly!

As a first try, we'll change our draw_map function to retrieve the map, and the player's viewshed. It will only draw tiles present in the viewshed:


# #![allow(unused_variables)]
#fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let mut viewsheds = ecs.write_storage::<Viewshed>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Map>();

    for (_player, viewshed) in (&mut players, &mut viewsheds).join() {
        let mut y = 0;
        let mut x = 0;
        for tile in map.tiles.iter() {
            // Render a tile depending upon the tile type
            let pt = Point::new(x,y);
            if viewshed.visible_tiles.contains(&pt) {
                match tile {
                    TileType::Floor => {
                        ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
                    }
                    TileType::Wall => {
                        ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
                    }
                }
            }

            // Move the coordinates
            x += 1;
            if x > 79 {
                x = 0;
                y += 1;
            }
        }
    }
}
#}

If you run the example now (cargo run), it will show you just what the player can see. There's no memory, and performance is quite awful - but it's there and about right.

It's clear that we're on the right track, but we need a more efficient way to do things. It would be nice if the player could remember the map as they see it, too.

Expanding map to include revealed tiles

To simulate map memory, we'll extend our Map class to include a revealed_tiles structure. It's just a bool for each tile on the map - if true, then we know what's there. Our Map definition now looks like this:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>
}
#}

We also need to extend the function that fills the map to include the new type. In new_rooms_and_corridors, we extend the Map creation to:


# #![allow(unused_variables)]
#fn main() {
let mut map = Map{
    tiles : vec![TileType::Wall; 80*50],
    rooms : Vec::new(),
    width : 80,
    height: 50,
    revealed_tiles : vec![false; 80*50]
};
#}

That adds a false value for every tile.

We change the draw_map to look at this value, rather than iterating the component each time. The function now looks like this:


# #![allow(unused_variables)]
#fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();

    let mut y = 0;
    let mut x = 0;
    for (idx,tile) in map.tiles.iter().enumerate() {
        // Render a tile depending upon the tile type
        if map.revealed_tiles[idx] {
            match tile {
                TileType::Floor => {
                    ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
                }
                TileType::Wall => {
                    ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
                }
            }
        }

        // Move the coordinates
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
#}

This will render a black screen, because we're never setting any tiles to be revealed! So now we extend the VisibilitySystem to know how to mark tiles as revealed. To do this, it has to check to see if an entity is the player - and if it is, it updates the map's revealed status:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map, Player};
use rltk::{field_of_view, Point};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>, 
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, Player>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, entities, mut viewshed, pos, player) = data;

        for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() {
            viewshed.visible_tiles.clear();
            viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
            viewshed.visible_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );

            // If this is the player, reveal what they can see
            let p : Option<&Player> = player.get(ent);
            if let Some(p) = p {
                for vis in viewshed.visible_tiles.iter() {
                    let idx = map.xy_idx(vis.x, vis.y);
                    map.revealed_tiles[idx] = true;
                }
            }
        }
    }
}
#}

The main changes here are that we're getting the Entities list along with components, and obtaining read-only access to the Players storage. We add those to the list of things to iterate in the list, and add a let p : Option<&Player> = player.get(ent); to see if this is the player. The rather cryptic if let Some(p) = p runs only if there is a Player component. Then we calculate the index, and mark it revealed.

If you run (cargo run) the project now, it is MASSIVELY faster than the previous version, and remembers where you've been.

Speeding it up even more - recalculating visibility when we need to

It's still not as efficient as it could be! Lets only update viewsheds when we need to. Lets add a dirty flag to our Viewshed component:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
pub struct Viewshed {
    pub visible_tiles : Vec<rltk::Point>,
    pub range : i32,
    pub dirty : bool
}
#}

We'll also update the initialization in main.rs to say that the viewshed is, in fact, dirty: .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }).

Our system can be extended to check if the dirty flag is true, and only recalculate if it is - and set the dirty flag to false when it is done. Now we need to set the flag when the player moves - because what they can see has changed! We update try_move_player in player.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let mut viewsheds = ecs.write_storage::<Viewshed>();
    let map = ecs.fetch::<Map>();

    for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() {
        let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map.tiles[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));

            viewshed.dirty = true;
        }
    }
}
#}

This should be pretty familiar by now: we've added viewsheds to get write storage, and included it in the list of component types we are iterating. Then one call sets the flag to true after a move.

The game now runs very fast once more, if you type cargo run.

Greying out what we remember, but can't see

One more extension: we'd like to render the parts of the map we know are there but can't currently see. So we add a list of what tiles are currently visible to Map:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>
}
#}

Our creation method also needs to know to add all false to it, just like before: visible_tiles : vec![false; 80*50]. Next, in our VisibilitySystem we clear the list of visible tiles before we begin iterating - and mark currently visible tiles as we find them. So our code to run when updating the viewshed looks like this:


# #![allow(unused_variables)]
#fn main() {
if viewshed.dirty {
    viewshed.dirty = false;
    viewshed.visible_tiles.clear();
    viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
    viewshed.visible_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );

    // If this is the player, reveal what they can see
    let _p : Option<&Player> = player.get(ent);
    if let Some(_p) = _p {
        for t in map.visible_tiles.iter_mut() { *t = false };
        for vis in viewshed.visible_tiles.iter() {
            let idx = map.xy_idx(vis.x, vis.y);
            map.revealed_tiles[idx] = true;
            map.visible_tiles[idx] = true;
        }
    }
}
#}

Now we adjust the draw_map function to handle revealed but not currently visible tiles differently. The new draw_map function looks like this:


# #![allow(unused_variables)]
#fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();

    let mut y = 0;
    let mut x = 0;
    for (idx,tile) in map.tiles.iter().enumerate() {
        // Render a tile depending upon the tile type

        if map.revealed_tiles[idx] {
            let glyph;
            let mut fg;
            match tile {
                TileType::Floor => {
                    glyph = rltk::to_cp437('.');
                    fg = RGB::from_f32(0.0, 0.5, 0.5);
                }
                TileType::Wall => {
                    glyph = rltk::to_cp437('#');
                    fg = RGB::from_f32(0., 1.0, 0.);
                }
            }
            if !map.visible_tiles[idx] { fg = fg.to_greyscale() }
            ctx.set(x, y, fg, RGB::from_f32(0., 0., 0.), glyph);
        }

        // Move the coordinates
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
#}

If you cargo run your project, you will now have visible tiles as slightly cyan floors and green walls - and grey as they move out of view. Performance should be great! Congratulations - you now have a nice, working field-of-view system.

Screenshot

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Chapter 6 - Monsters


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


A roguelike with no monsters is quite unusual, so lets add some! The good news is that we've already done some of the work for this: we can render them, and we can calculate what they can see. We'll build on the source from the previous chapter, and get some harmless monsters into play.

Rendering a monster in the center of each room

We can simply add a Renderable component for each monster (we'll also add a Viewshed since we'll use it later). In our main function (in main.rs), add the following:


# #![allow(unused_variables)]
#fn main() {
for room in map.rooms.iter().skip(1) {
    let (x,y) = room.center();
    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('g'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .build();
}

gs.ecs.insert(map);
#}

Notice the skip(1) to ignore the first room - we don't want the player starting with a mob on top of him/her/it! Running this (with cargo run) produces something like this:

Screenshot

That's a really good start! However, we're rendering monsters even if we can't see them. We probably only want to render the ones we can see. We can do this by modifying our render loop:


# #![allow(unused_variables)]
#fn main() {
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
let map = self.ecs.fetch::<Map>();

for (pos, render) in (&positions, &renderables).join() {
    let idx = map.xy_idx(pos.x, pos.y);
    if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
}
#}

We get the map from the ECS, and use it to obtain an index - and check if the tile is visible. If it is - we render the renderable. There's no need for a special case for the player - since they can generally be expected to see themselves! The result is pretty good:

Screenshot

Add some monster variety

It's rather dull to only have one monster type, so we'll amend our monster spawner to be able to create goblins and orcs.

Here's the spawner code:


# #![allow(unused_variables)]
#fn main() {
let mut rng = rltk::RandomNumberGenerator::new();
for room in map.rooms.iter().skip(1) {
    let (x,y) = room.center();

    let glyph : u8;
    let roll = rng.roll_dice(1, 2);
    match roll {
        1 => { glyph = rltk::to_cp437('g') }
        _ => { glyph = rltk::to_cp437('o') }
    }

    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: glyph,
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .build();
}
#}

Obviously, when we start adding in combat we'll want more variety - but it's a good start. Run the program (cargo run), and you'll see a roughly 50/50 split between orcs and goblins.

Making the monsters think

Now to start making the monsters think! For now, they won't actually do much, beyond pondering their lonely existence. We should start by adding a tag component to indicate that an entity is a monster. In components.rs we add a simple struct:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Monster {}
#}

Of course, we need to register it in main.rs: gs.ecs.register::<Monster>();. We should also amend our spawning code to apply it to monsters:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.create_entity()
    .with(Position{ x, y })
    .with(Renderable{
        glyph: glyph,
        fg: RGB::named(rltk::RED),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Monster{})
    .build();
#}

Now we make a system for monster thought. We'll make a new file, monster_ai_system.rs. We'll give it some basically non-existent intelligence:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map, Monster};
use rltk::{field_of_view, Point, console};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, Monster>);

    fn run(&mut self, data : Self::SystemData) {
        let (viewshed, pos, monster) = data;

        for (viewshed,pos,_monster) in (&viewshed, &pos, &monster).join() {
            console::log("Monster considers their own existence");
        }
    }
}
#}

Note that we're importing console from rltk - and printing with console::log. This is a helper provided by RLTK that detects if you are compiling to a regular program or a Web Assembly; if you are using a regular program, it calls println! and outputs to the console. If you are in WASM, it outputs to the browser console.

We'll also extend the system runner in main.rs to call it:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        let mut mob = MonsterAI{};
        mob.run_now(&self.ecs);
        self.ecs.maintain();
    }
}
#}

If you cargo run your project now, it will be very slow - and your console will fill up with "Monster considers their own existence". The AI is running - but it's running every tick!

Turn-based game, in a tick-based world

To prevent this - and make a turn-based game - we introduce a new concept to the game state. The game is either "running" or "waiting for input" - so we make an enum to handle this:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { Paused, Running }
#}

Notice the derive macro! Derivation is a way to ask Rust (and crates) to add code to your structure for you, to cut down on typing boilerplate. In this case, the enum needs a few extra features. PartialEq allows you to compare the RunState with other RunState variables to determine if they are the same (or different). Copy marks it as a "copy" type - it can safely be copied in memory (meaning it has no pointers that will be messed up doing this). Clone quietly adds a .clone() function to it, allowing you to make a memory copy that way.

Next, we need to add it into the State structure:


# #![allow(unused_variables)]
#fn main() {
pub struct State {
    pub ecs: World,
    pub runstate : RunState
}
#}

In turn, we need to amend our State creator to include a runstate: RunState::Running:


# #![allow(unused_variables)]
#fn main() {
let mut gs = State {
    ecs: World::new(),
    runstate : RunState::Running
};
#}

Now, we change our tick function to only run the simulation when the game isn't paused - and otherwise to ask for user input:


# #![allow(unused_variables)]
#fn main() {
if self.runstate == RunState::Running {
    self.run_systems();
    self.runstate = RunState::Paused;
} else {
    self.runstate = player_input(self, ctx);
}
#}

As you can see, player_input now returns a state. Here's the new code for it:


# #![allow(unused_variables)]
#fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState {
    // Player movement
    match ctx.key {
        None => { return RunState::Paused } // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Numpad4 => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Numpad6 => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Numpad8 => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            VirtualKeyCode::Numpad2 => try_move_player(0, 1, &mut gs.ecs),
            VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),
            _ => { return RunState::Paused }
        },
    }
    RunState::Running
}
#}

If you launch cargo run now, the game is back up to speed - and the monsters only think about what to do when you move. That's a basic turn-based tick loop!

Quiet monsters until they see you

You could let monsters think every time anything moves (and you probably will when you get into deeper simulation), but for now lets quiet them down a bit - and have them react if they can see the player.

It's highly likely that systems will often want to know where the player is - so lets add that as a resource. In main.rs, one line puts it in (I don't recommend doing this for non-player entities; there are only so many resources available - but the player is one we use over and over again):


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(Point::new(player_x, player_y));
#}

In player.rs, try_move_player(), update the resource when the player moves:


# #![allow(unused_variables)]
#fn main() {
let mut ppos = ecs.write_resource::<Point>();
ppos.x = pos.x;
ppos.y = pos.y;
#}

We can then use that in our monster_ai_system. Here's a working version:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster};
use rltk::{Point, console};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadExpect<'a, Point>,
                        ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Monster>);

    fn run(&mut self, data : Self::SystemData) {
        let (player_pos, viewshed, monster) = data;

        for (viewshed,_monster) in (&viewshed, &monster).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(format!("Monster shouts insults"));
            }
        }
    }
}
#}

If you cargo run this, you'll be able to move around - and your console will gain "Monster shouts insults" from time to time when a monster can see you.

Differentiating our monsters

Monsters should have names, so we know who is yelling at us! So we create a new component, Name. In components.rs, we add:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Name {
    pub name : String
}
#}

We also register it in main.rs, which you should be comfortable with by now! We'll also add some commands to add names to our monsters and the player. So our monster spawner looks like this:


# #![allow(unused_variables)]
#fn main() {
for (i,room) in map.rooms.iter().skip(1).enumerate() {
    let (x,y) = room.center();

    let glyph : u8;
    let name : String;
    let roll = rng.roll_dice(1, 2);
    match roll {
        1 => { glyph = rltk::to_cp437('g'); name = "Goblin".to_string(); }
        _ => { glyph = rltk::to_cp437('o'); name = "Orc".to_string(); }
    }

    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: glyph,
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Monster{})
        .with(Name{ name: format!("{} #{}", &name, i) })
        .build();
}
#}

Now we adjust the monster_ai_system to include the monster's name. The new AI looks like this:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster, Name};
use rltk::{Point};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadExpect<'a, Point>,
                        ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Monster>,
                        ReadStorage<'a, Name>);

    fn run(&mut self, data : Self::SystemData) {
        let (player_pos, viewshed, monster, name) = data;

        for (viewshed,_monster,name) in (&viewshed, &monster, &name).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(&format!("{} shouts insults", name.name));
            }
        }
    }
}
#}

We also need to give the player a name; we've explicitly included names in the AI's join, so we better be sure that the player has one! Otherwise, the AI will ignore the player altogether. In main.rs, we'll include one in the Player creation:


# #![allow(unused_variables)]
#fn main() {
gs.ecs
    .create_entity()
    .with(Position { x: player_x, y: player_y })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Player{})
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Name{name: "Player".to_string() })
    .build();
#}

If you cargo run the project, you now see things like Goblin #9 shouts insults - so you can tell who is shouting.

Screenshot

And that's a wrap for chapter 6; we've added a variety of foul-mouthed monsters to hurl insults at your fragile ego! In this chapter, we've begun to see some of the benefits of using an Entity Component System: it was really easy to add newly rendered monsters, with a bit of variety, and start storing names for things. The Viewshed code we wrote earlier worked with minimal modification to give visibility to monsters - and our new monster AI was able to take advantage of what we've already built to quite efficiently say bad things to the player.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Dealing Damage (and taking some!)


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Now that we have monsters, we want them to be more interesting than just yelling at you on the console! This chapter will make them chase you, and introduce some basic game stats to let you fight your way through the hordes.

Chasing the Player

The first thing we need to do is finish implementing BaseMap for our Map class. In particular, we need to support get_available_exits - which is used by the pathfinding.

In our Map implementation, we'll need a helper function:


# #![allow(unused_variables)]
#fn main() {
fn is_exit_valid(&self, x:i32, y:i32) -> bool {
    if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; }
    let idx = self.xy_idx(x, y);
    self.tiles[idx as usize] != TileType::Wall
}
#}

This takes an index, and calculates if it can be entered.

We then implement the trait, using this helper:


# #![allow(unused_variables)]
#fn main() {
fn get_available_exits(&self, idx:usize) -> Vec<(usize, f32)> {
    let mut exits : Vec<(usize, f32)> = Vec::new();
    let x = idx as i32 % self.width;
    let y = idx as i32 / self.width;
    let w = self.width as usize;

    // Cardinal directions
    if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
    if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
    if self.is_exit_valid(x, y-1) { exits.push((idx-w, 1.0)) };
    if self.is_exit_valid(x, y+1) { exits.push((idx+w, 1.0)) };

    // Diagonals
    if self.is_exit_valid(x-1, y-1) { exits.push(((idx-w)-1, 1.45)); }
    if self.is_exit_valid(x+1, y-1) { exits.push(((idx-w)+1, 1.45)); }
    if self.is_exit_valid(x-1, y+1) { exits.push(((idx+w)-1, 1.45)); }
    if self.is_exit_valid(x+1, y+1) { exits.push(((idx+w)+1, 1.45)); }

    exits
}
#}

Providing exits without a distance heuristic will lead to some horrible behaviour (and a crash on future versions of RLTK). So also implement that for your map:


# #![allow(unused_variables)]
#fn main() {
impl BaseMap for Map {
    ...
    fn get_pathing_distance(&self, idx1:usize, idx2:usize) -> f32 {
        let w = self.width as usize;
        let p1 = Point::new(idx1 % w, idx1 / w);
        let p2 = Point::new(idx2 % w, idx2 / w);
        rltk::DistanceAlg::Pythagoras.distance2d(p1, p2)
    }
#}

Pretty straight-forward: we evaluate each possible exit, and add it to the exits vector if it can be taken. Next, we modify the main loop in monster_ai_system:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster, Name, Map, Position};
use rltk::{Point, console};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadExpect<'a, Point>,
                        WriteStorage<'a, Viewshed>,
                        ReadStorage<'a, Monster>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, Position>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, player_pos, mut viewshed, monster, name, mut position) = data;

        for (mut viewshed,_monster,name,mut pos) in (&mut viewshed, &monster, &name, &mut position).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(&format!("{} shouts insults", name.name));
                let path = rltk::a_star_search(
                    map.xy_idx(pos.x, pos.y) as i32,
                    map.xy_idx(player_pos.x, player_pos.y) as i32,
                    &mut *map
                );
                if path.success && path.steps.len()>1 {
                    pos.x = path.steps[1] as i32 % map.width;
                    pos.y = path.steps[1] as i32 / map.width;
                    viewshed.dirty = true;
                }
            }
        }
    }
}
#}

We've changed a few things to allow write access, requested access to the map. We've also added an #[allow...] to tell the linter that we really did mean to use quite so much in one type! The meat is the a_star_search call; RLTK includes a high-performance A* implementation, so we're asking it for a path from the monster's position to the player. Then we check that the path succeeded, and has more than 2 steps (step 0 is always the current location). If it does, then we move the monster to that point - and set their viewshed to be dirty.

If you cargo run the project, monsters will now chase the player - and stop if they lose line-of-sight. We're not preventing monsters from standing on each other - or you - and we're not having them do anything other than yell at your console - but it's a good start. It wasn't too hard to get chase mechanics in!

Blocking access

We don't want monsters to walk on top of each other, nor do we want them to get stuck in a traffic jam hoping to find the player; we'd rather they are willing to try and flank the player! We'll accompany this by keeping track of what parts of the map are blocked.

First, we'll add another vector of bools to our Map:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>
}
#}

We'll also initialize it, just like the other vectors:


# #![allow(unused_variables)]
#fn main() {
let mut map = Map{
    tiles : vec![TileType::Wall; 80*50],
    rooms : Vec::new(),
    width : 80,
    height: 50,
    revealed_tiles : vec![false; 80*50],
    visible_tiles : vec![false; 80*50],
    blocked : vec![false; 80*50]
};
#}

Lets introduce a new function to populate whether or not a tile is blocked. In the Map implementation:


# #![allow(unused_variables)]
#fn main() {
pub fn populate_blocked(&mut self) {
    for (i,tile) in self.tiles.iter_mut().enumerate() {
        self.blocked[i] = *tile == TileType::Wall;
    }
}
#}

This function is very simple: it sets blocked for a tile to true if its a wall, false otherwise (we'll expand it when we add more tile types). While we're working with Map, lets adjust is_exit_valid to use this data:


# #![allow(unused_variables)]
#fn main() {
fn is_exit_valid(&self, x:i32, y:i32) -> bool {
    if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; }
    let idx = self.xy_idx(x, y);
    !self.blocked[idx]
}
#}

This is quite straightforward: it checks that x and y are within the map, returning false if the exit is outside of the map (this type of bounds checking is worth doing, it prevents your program from crashing because you tried to read outside of the the valid memory area). It then checks the index of the tiles array for the specified coordinates, and returns the inverse of blocked (the ! is the same as not in most languages - so read it as "not blocked at idx").

Now we'll make a new component, BlocksTile. You should know the drill by now; in Components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct BlocksTile {}
#}

Then register it in main.rs: gs.ecs.register::<BlocksTile>();

We should apply BlocksTile to NPCs - so our NPC creation code becomes:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.create_entity()
    .with(Position{ x, y })
    .with(Renderable{
        glyph,
        fg: RGB::named(rltk::RED),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Monster{})
    .with(Name{ name: format!("{} #{}", &name, i) })
    .with(BlocksTile{})
    .build();
#}

Lastly, we need to populate the blocked list. We'll probably extend this system later, so we'll go with a nice generic name map_indexing_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile};

pub struct MapIndexingSystem {}

impl<'a> System<'a> for MapIndexingSystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, position, blockers) = data;

        map.populate_blocked();
        for (position, _blocks) in (&position, &blockers).join() {
            let idx = map.xy_idx(position.x, position.y);
            map.blocked[idx] = true;
        }
    }
}
#}

This tells the map to setup blocking from the terrain, and then iterates all entities with a BlocksTile component, and applies them to the blocked list. We need to register it with run_systems; in main.rs:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        let mut mob = MonsterAI{};
        mob.run_now(&self.ecs);
        let mut mapindex = MapIndexingSystem{};
        mapindex.run_now(&self.ecs);
        self.ecs.maintain();
    }
}
#}

If you cargo run now, monsters no longer end up on top of each other - but they do end up on top of the player. We should fix that. We can make the monster only yell when it is adjacent to the player. In monster_ai_system.rs, add this above the visibility test:


# #![allow(unused_variables)]
#fn main() {
let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
if distance < 1.5 {
    // Attack goes here
    console::log(&format!("{} shouts insults", name.name));
    return;
}
#}

Lastly, we want to stop the player from walking over monsters. In player.rs, we replace the if statement that looks for walls with:


# #![allow(unused_variables)]
#fn main() {
if !map.blocked[destination_idx] {
#}

Since we already put walls into the blocked list, this should take care of the issue for now. cargo run shows that monsters now block the player. They block them perfectly - so a monster that wants to be in your way is an unpassable obstacle!

Allowing Diagonal Movement

It would be nice to be able to bypass the monsters - and diagonal movement is a mainstay of roguelikes. So lets go ahead and support it. In map.rs's get_available_exits function, we add them:


# #![allow(unused_variables)]
#fn main() {
fn get_available_exits(&self, idx:i32) -> Vec<(i32, f32)> {
    let mut exits : Vec<(i32, f32)> = Vec::new();
    let x = idx % self.width;
    let y = idx / self.width;

    // Cardinal directions
    if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
    if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
    if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, 1.0)) };
    if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, 1.0)) };

    // Diagonals
    if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1, 1.45)); }
    if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1, 1.45)); }
    if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1, 1.45)); }
    if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1, 1.45)); }

    exits
}
#}

We also modify the player.rs input code:


# #![allow(unused_variables)]
#fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState {
    // Player movement
    match ctx.key {
        None => { return RunState::Paused } // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left |
            VirtualKeyCode::Numpad4 |
            VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs),

            VirtualKeyCode::Right |
            VirtualKeyCode::Numpad6 |
            VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),

            VirtualKeyCode::Up |
            VirtualKeyCode::Numpad8 |
            VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),

            VirtualKeyCode::Down |
            VirtualKeyCode::Numpad2 |
            VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),

            // Diagonals
            VirtualKeyCode::Numpad9 |
            VirtualKeyCode::Y => try_move_player(1, -1, &mut gs.ecs),

            VirtualKeyCode::Numpad7 |
            VirtualKeyCode::U => try_move_player(-1, -1, &mut gs.ecs),

            VirtualKeyCode::Numpad3 |
            VirtualKeyCode::N => try_move_player(1, 1, &mut gs.ecs),

            VirtualKeyCode::Numpad1 |
            VirtualKeyCode::B => try_move_player(-1, 1, &mut gs.ecs),

            _ => { return RunState::Paused }
        },
    }
    RunState::Running
}
#}

You can now diagonally dodge around monsters - and they can move/attack diagonally.

Giving monsters and the player some combat stats

You probably guessed by now that the way to add stats to entities is with another component! In components.rs, we add CombatStats. Here's a simple definition:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct CombatStats {
    pub max_hp : i32,
    pub hp : i32,
    pub defense : i32,
    pub power : i32
}
#}

As usual, don't forget to register it in main.rs!

We'll give the Player 30 hit points, 2 defense, and 5 power:


# #![allow(unused_variables)]
#fn main() {
.with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
#}

Likewise, we'll give the monsters a weaker set of stats (we'll worry about monster differentiation later):


# #![allow(unused_variables)]
#fn main() {
.with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 })
#}

Indexing what is where

When traveling the map - as a player or a monster - it's really handy to know what is in a tile. You can combine it with the visibility system to make intelligent choices with what can be seen, you can use it to see if you are trying to walk into an enemy's space (and attack them), and so on. One way to do it would be to iterate the Position components and see if we hit anything; for low numbers of entities that would be plenty fast. We'll take a different approach, and make the map_indexing_system help us. We'll start by adding a field to the map:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub tile_content : Vec<Vec<Entity>>
}
#}

And we'll add a basic initializer to the new map code:


# #![allow(unused_variables)]
#fn main() {
tile_content : vec![Vec::new(); 80*50]
#}

While we're in map, there's one more function we are going to need:


# #![allow(unused_variables)]
#fn main() {
pub fn clear_content_index(&mut self) {
    for content in self.tile_content.iter_mut() {
        content.clear();
    }
}
#}

This is also quite simple: it iterates (visits) every vector in the tile_content list, mutably (the iter_mut obtains a mutable iterator). It then tells each vector to clear itself - remove all content (it doesn't actually guarantee that it will free up the memory; vectors can keep empty sections ready for more data. This is actually a good thing, because acquiring new memory is one of the slowest things a program can do - so it helps keep things running fast).

Then we'll upgrade the indexing system to index all entities by tile:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile};

pub struct MapIndexingSystem {}

impl<'a> System<'a> for MapIndexingSystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>,
                        Entities<'a>,);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, position, blockers, entities) = data;

        map.populate_blocked();
        map.clear_content_index();
        for (entity, position) in (&entities, &position).join() {
            let idx = map.xy_idx(position.x, position.y);

            // If they block, update the blocking list
            let _p : Option<&BlocksTile> = blockers.get(entity);
            if let Some(_p) = _p {
                map.blocked[idx] = true;
            }
            
            // Push the entity to the appropriate index slot. It's a Copy
            // type, so we don't need to clone it (we want to avoid moving it out of the ECS!)
            map.tile_content[idx].push(entity);
        }
    }
}
#}

Letting the player hit things

Most roguelike characters spend a lot of time hitting things, so let's implement that! Bump to attack (walking into the target) is the canonical way to do this. We want to expand try_move_player in player.rs to check to see if a tile we are trying to enter contains a target.

We'll add a reader for CombatStats to the list of data-stores, and put in a quick enemy detector:


# #![allow(unused_variables)]
#fn main() {
let combat_stats = ecs.read_storage::<CombatStats>();
let map = ecs.fetch::<Map>();

for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() {
    let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

    for potential_target in map.tile_content[destination_idx].iter() {
        let target = combat_stats.get(*potential_target);
        match target {
            None => {}
            Some(t) => {
                // Attack it
                console::log(&format!("From Hell's Heart, I stab thee!"));
                return; // So we don't move after attacking
            }
        }
    }
#}

If you cargo run this, you'll see that you can walk up to a mob and try to move onto it. From Hell's Heart, I stab thee! appears on the console. So the detection works, and the attack is in the right place.

Player attacking and killing things

We're going to do this in an ECS way, so there's a bit of boilerplate. In components.rs, we add a component indicating an intent to attack:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToMelee {
    pub target : Entity
}
#}

We also want to track incoming damage. It's possible that you will suffer damage from more than one source in a turn, and Specs doesn't like it at all when you try and have more than one component of the same type on an entity. There are two possible approaches here: make the damage an entity itself (and track the victim), or make damage a vector. The latter seems the easier approach; so we'll make a SufferDamage component to track the damage - and attach/implement a method to make using it easy:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct SufferDamage {
    pub amount : Vec<i32>
}

impl SufferDamage {
    pub fn new_damage(store: &mut WriteStorage<SufferDamage>, victim: Entity, amount: i32) {
        if let Some(suffering) = store.get_mut(victim) {
            suffering.amount.push(amount);
        } else {
            let dmg = SufferDamage { amount : vec![amount] };
            store.insert(victim, dmg).expect("Unable to insert damage");
        }
    }
}
#}

(Don't forget to register them in main.rs!). We modify the player's movement command to create a component indicating the intention to attack (attaching a wants_to_melee to the attacker):


# #![allow(unused_variables)]
#fn main() {
let entities = ecs.entities();
let mut wants_to_melee = ecs.write_storage::<WantsToMelee>();

for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
    if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return; }
    let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

    for potential_target in map.tile_content[destination_idx].iter() {
        let target = combat_stats.get(*potential_target);
        if let Some(_target) = target {
            wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed");
            return;
        }
    }
}
#}

We'll need a melee_combat_system to handle Melee. This uses the new_damage system we created to ensure that multiple sources of damage may be applied in one turn:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{CombatStats, WantsToMelee, Name, SufferDamage};

pub struct MeleeCombatSystem {}

impl<'a> System<'a> for MeleeCombatSystem {
    type SystemData = ( Entities<'a>,
                        WriteStorage<'a, WantsToMelee>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut wants_melee, names, combat_stats, mut inflict_damage) = data;

        for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
            if stats.hp > 0 {
                let target_stats = combat_stats.get(wants_melee.target).unwrap();
                if target_stats.hp > 0 {
                    let target_name = names.get(wants_melee.target).unwrap();

                    let damage = i32::max(0, stats.power - target_stats.defense);

                    if damage == 0 {
                        console::log(&format!("{} is unable to hurt {}", &name.name, &target_name.name));
                    } else {
                        console::log(&format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
                        SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage);
                    }
                }
            }
        }

        wants_melee.clear();
    }
}
#}

And we'll need a damage_system to apply the damage (we're separating it out, because damage could come from any number of sources!). We use an iterator to sum the damage, ensuring that it is all applied:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{CombatStats, SufferDamage};

pub struct DamageSystem {}

impl<'a> System<'a> for DamageSystem {
    type SystemData = ( WriteStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage> );

    fn run(&mut self, data : Self::SystemData) {
        let (mut stats, mut damage) = data;

        for (mut stats, damage) in (&mut stats, &damage).join() {
            stats.hp -= damage.amount.iter().sum::<i32>();
        }

        damage.clear();
    }
}
#}

We'll also add a method to clean up dead entities:


# #![allow(unused_variables)]
#fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let entities = ecs.entities();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { dead.push(entity); }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }
}
#}

This is called from our tick command, after the systems run: damage_system::delete_the_dead(&mut self.ecs);.

If you cargo run now, you can run around the map hitting things - and they vanish when dead!

Letting the monsters hit you back

Since we've already written systems to handle attacking and damaging, it's relatively easy to use the same code with monsters - just add a WantsToMelee component and they can attack/kill the player.

We'll start off by making the player entity into a game resource, so it can be easily referenced. Like the player's position, it's something that we're likely to need all over the place - and since entity IDs are stable, we can rely on it existing. In main.rs, we change the create_entity for the player to return the entity object:


# #![allow(unused_variables)]
#fn main() {
let player_entity = gs.ecs
    .create_entity()
    .with(Position { x: player_x, y: player_y })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Player{})
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Name{name: "Player".to_string() })
    .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
    .build();
#}

We then insert it into the world:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(player_entity);
#}

Now we modify the monster_ai_system. There's a bit of clean-up here, and the "hurl insults" code is completely replaced with a single component insert:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster, Map, Position, WantsToMelee};
use rltk::{Point};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadExpect<'a, Point>,
                        ReadExpect<'a, Entity>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>,
                        ReadStorage<'a, Monster>,
                        WriteStorage<'a, Position>,
                        WriteStorage<'a, WantsToMelee>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee) = data;

        for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() {
            let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
            if distance < 1.5 {
                wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack");
            }
            else if viewshed.visible_tiles.contains(&*player_pos) {
                // Path to the player
                let path = rltk::a_star_search(
                    map.xy_idx(pos.x, pos.y),
                    map.xy_idx(player_pos.x, player_pos.y),
                    &mut *map
                );
                if path.success && path.steps.len()>1 {
                    let mut idx = map.xy_idx(pos.x, pos.y);
                    map.blocked[idx] = false;
                    pos.x = path.steps[1] as i32 % map.width;
                    pos.y = path.steps[1] as i32 / map.width;
                    idx = map.xy_idx(pos.x, pos.y);
                    map.blocked[idx] = true;
                    viewshed.dirty = true;
                }
            }
        }
    }
}
#}

If you cargo run now, you can kill monsters - and they can attack you. If a monster kills you - the game crashes! It crashes, because delete_the_dead has deleted the player. That's obviously not what we intended. Here's a non-crashing version of delete_the_dead:


# #![allow(unused_variables)]
#fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let players = ecs.read_storage::<Player>();
        let entities = ecs.entities();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { 
                let player = players.get(entity);
                match player {
                    None => dead.push(entity),
                    Some(_) => console::log("You are dead")
                }
            }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }    
}
#}

We'll worry about ending the game in a later chapter.

Expanding the turn system

If you look closely, you'll see that enemies can fight back even after they have taken fatal damage. While that fits with some Shakespearean dramas (they really should give a speech), it's not the kind of tactical play that roguelikes encourage. The problem is that our game state is just Running and Paused - and we aren't even running the systems when the player acts. Additionally, systems don't know what phase we are in - so they can't take that into account.

Let's replace RunState with something more descriptive of each phase:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn }
#}

If you're running Visual Studio Code with RLS, half your project just turned red. That's ok, we'll refactor one step at a time. We're going to remove the RunState altogether from the main GameState:


# #![allow(unused_variables)]
#fn main() {
pub struct State {
    pub ecs: World
}
#}

This makes even more red appear! We're doing this, because we're going to make the RunState into a resource. So in main.rs where we insert other resources, we add:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(RunState::PreRun);
#}

Now to start refactoring Tick. Our new tick function looks like this:


# #![allow(unused_variables)]
#fn main() {
fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();
        let mut newrunstate;
        {
            let runstate = self.ecs.fetch::<RunState>();
            newrunstate = *runstate;
        }
        
        match newrunstate {
            RunState::PreRun => {
                self.run_systems();
                newrunstate = RunState::AwaitingInput;
            }
            RunState::AwaitingInput => {
                newrunstate = player_input(self, ctx);
            }
            RunState::PlayerTurn => {
                self.run_systems();
                newrunstate = RunState::MonsterTurn;
            }
            RunState::MonsterTurn => {
                self.run_systems();
                newrunstate = RunState::AwaitingInput;
            }
        }

        {
            let mut runwriter = self.ecs.write_resource::<RunState>();
            *runwriter = newrunstate;
        }
        damage_system::delete_the_dead(&mut self.ecs);

        draw_map(&self.ecs, ctx);

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();
        let map = self.ecs.fetch::<Map>();

        for (pos, render) in (&positions, &renderables).join() {
            let idx = map.xy_idx(pos.x, pos.y);
            if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
        }
    }
}
#}

Notice how we now have a state machine going, with a "pre-run" phase for starting the game! It's much cleaner, and quite obvious what's going on. There's a bit of scope magic in use to keep the borrow-checker happy: if you declare and use a variable inside a scope, it is dropped on scope exit (you can also manually drop things, but I think this is cleaner looking).

In player.rs we simply replace all Paused with AwaitingInput, and Running with PlayerTurn.

Lastly, we modify monster_ai_system to only run if the state is MonsterTurn (snippet):


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for MonsterAI {
#[allow(clippy::type_complexity)]
type SystemData = ( WriteExpect<'a, Map>,
                    ReadExpect<'a, Point>,
                    ReadExpect<'a, Entity>,
                    ReadExpect<'a, RunState>,
                    Entities<'a>,
                    WriteStorage<'a, Viewshed>, 
                    ReadStorage<'a, Monster>,
                    WriteStorage<'a, Position>,
                    WriteStorage<'a, WantsToMelee>);

fn run(&mut self, data : Self::SystemData) {
    let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee) = data;

    if *runstate != RunState::MonsterTurn { return; }
#}

If you cargo run the project, it now behaves as you'd expect: the player moves, and things he/she kills die before they can respond.

Wrapping Up

That was quite the chapter! We added in location indexing, damage, and killing things. The good news is that this is the hardest part; you now have a simple dungeon bash game! It's not particularly fun, and you will die (since there's no healing at all) - but the basics are there.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


User Interface


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In this chapter, we'll add a user interface to the game.

Shrinking the map

We'll start off by going to map.rs, and adding some constants: MAPWIDTH, MAPHEIGHT and MAPCOUNT:


# #![allow(unused_variables)]
#fn main() {
const MAPWIDTH : usize = 80;
const MAPHEIGHT : usize = 50;
const MAPCOUNT : usize = MAPHEIGHT * MAPWIDTH;
#}

Then we'll go through and change every reference to 80*50 to MAPCOUNT, and references to the map size to use the constants. When this is done and running, we'll change the MAPHEIGHT to 43 - to give us room at the bottom of the screen for a user interface panel.

Some minimal GUI elements

We'll create a new file, gui.rs to hold our code. We'll go with a really minimal start:


# #![allow(unused_variables)]
#fn main() {
use rltk::{ RGB, Rltk, Console };
use specs::prelude::*;

pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
    ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
}
#}

We add a mod gui to the import block at the top of main.rs, and call it at the end of tick:


# #![allow(unused_variables)]
#fn main() {
gui::draw_ui(&self.ecs, ctx);
#}

If we cargo run now, we'll see that the map has shrunk - and we have a white box in place for the panel.

Screenshot

Adding a health bar

It would help the player out to know how much health they have left. Fortunately, RLTK provides a convenient helper for this. We'll need to obtain the player's health from the ECS, and render it. This is pretty easy, and you should be comfortable with it by now. The code looks like this:


# #![allow(unused_variables)]
#fn main() {
use rltk::{ RGB, Rltk, Console };
use specs::prelude::*;
use super::{CombatStats, Player};

pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
    ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));

    let combat_stats = ecs.read_storage::<CombatStats>();
    let players = ecs.read_storage::<Player>();
    for (_player, stats) in (&players, &combat_stats).join() {
        let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp);
        ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health);

        ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK));
    }
}
#}

Adding a message log

The game log makes sense as a resource: it's available to any system that wants to tell you something, and there's very little restriction as to what might want to tell you something. We'll start by modelling the log itself. Make a new file, gamelog.rs. We'll start very simply:


# #![allow(unused_variables)]
#fn main() {
pub struct GameLog {
    pub entries : Vec<String>
}
#}

In main.rs we add a mod gamelog; line, and insert it as a resource with gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });. We're inserting a line into the log file at the start, using the vec! macro for constructing vectors. That gives us something to display - so we'll start writing the log display code in gui.rs. In our GUI drawing function, we simply add:


# #![allow(unused_variables)]
#fn main() {
let log = ecs.fetch::<GameLog>();

let mut y = 44;
for s in log.entries.iter().rev() {
    if y < 49 { ctx.print(2, y, s); }
    y += 1;
}
#}

If you cargo run the project now, you'll see something like this:

Screenshot

Logging attacks

In our melee_combat_system, we add gamelog::GameLog to our imports from super, add a read/write accessor for the log (WriteExpect<'a, GameLog>,), and extend the destructuring to include it: let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data;. Then it's just a matter of replacing the print! macros with inserting into the game log. Here's the resultant code:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog};

pub struct MeleeCombatSystem {}

impl<'a> System<'a> for MeleeCombatSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToMelee>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data;

        for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
            if stats.hp > 0 {
                let target_stats = combat_stats.get(wants_melee.target).unwrap();
                if target_stats.hp > 0 {
                    let target_name = names.get(wants_melee.target).unwrap();

                    let damage = i32::max(0, stats.power - target_stats.defense);

                    if damage == 0 {
                        log.entries.push(format!("{} is unable to hurt {}", &name.name, &target_name.name));
                    } else {
                        log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
                        SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage);                        
                    }
                }
            }
        }

        wants_melee.clear();
    }
}
#}

Now if you run the game and play a bit (cargo run, playing is up to you!), you'll see combat messages in the log:

Screenshot

Notifying of deaths

We can do the same thing with delete_the_dead to notify of deaths. Here's the finished code:


# #![allow(unused_variables)]
#fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let players = ecs.read_storage::<Player>();
        let names = ecs.read_storage::<Name>();
        let entities = ecs.entities();
        let mut log = ecs.write_resource::<GameLog>();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { 
                let player = players.get(entity);
                match player {
                    None => {
                        let victim_name = names.get(entity);
                        if let Some(victim_name) = victim_name {
                            log.entries.push(format!("{} is dead", &victim_name.name));
                        }
                        dead.push(entity)
                    }
                    Some(_) => console::log("You are dead")
                }
            }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }    
}
#}

Mouse Support and Tooltips

Let's start by looking at how we obtain mouse information from RLTK. It's really easy; add the following at the bottom of your draw_ui function:


# #![allow(unused_variables)]
#fn main() {
// Draw mouse cursor
let mouse_pos = ctx.mouse_pos();
ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::MAGENTA));
#}

This sets the background of the cell at which the mouse is pointed to magenta. As you can see, mouse information arrives from RLTK as part of the context.

Now we'll introduce a new function, draw_tooltips and call it at the end of draw_ui. New new function looks like this:


# #![allow(unused_variables)]
#fn main() {
fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();
    let names = ecs.read_storage::<Name>();
    let positions = ecs.read_storage::<Position>();

    let mouse_pos = ctx.mouse_pos();
    if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; }
    let mut tooltip : Vec<String> = Vec::new();
    for (name, position) in (&names, &positions).join() {
        if position.x == mouse_pos.0 && position.y == mouse_pos.1 {
            tooltip.push(name.name.to_string());
        }
    }

    if !tooltip.is_empty() {
        let mut width :i32 = 0;
        for s in tooltip.iter() {
            if width < s.len() as i32 { width = s.len() as i32; }
        }
        width += 3;

        if mouse_pos.0 > 40 {
            let arrow_pos = Point::new(mouse_pos.0 - 2, mouse_pos.1);
            let left_x = mouse_pos.0 - width;
            let mut y = mouse_pos.1;
            for s in tooltip.iter() {
                ctx.print_color(left_x, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s);
                let padding = (width - s.len() as i32)-1;
                for i in 0..padding {
                    ctx.print_color(arrow_pos.x - i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
                }
                y += 1;
            }
            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"->".to_string());
        } else {
            let arrow_pos = Point::new(mouse_pos.0 + 1, mouse_pos.1);
            let left_x = mouse_pos.0 +3;
            let mut y = mouse_pos.1;
            for s in tooltip.iter() {
                ctx.print_color(left_x + 1, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s);
                let padding = (width - s.len() as i32)-1;
                for i in 0..padding {
                    ctx.print_color(arrow_pos.x + 1 + i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
                }
                y += 1;
            }
            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"<-".to_string());
        }
    }
}
#}

It starts by obtaining read access to the components we need for tooltips: names and positions. It also gets read access to the map itself. Then we check that mouse cursor is actually on the map, and bail out if it isn't - no point in trying to draw tooltips for something that can never have any!

The remainder says "if we have any tooltips, look at the mouse position" - if its on the left, we'll put the tooltip to the right, otherwise to the left.

If you cargo run your project now, it looks like this:

Screenshot

Optional post-processing for that truly retro feeling

Since we're on look and feel, lets consider enabling an RLTK feature: post-processing to give scanlines and screen burn, for that truly retro feel. It's entirely up to you if you want to use this! In main.rs, the initial setup simply replaced the first init command with:


# #![allow(unused_variables)]
#fn main() {
use rltk::RltkBuilder;
    let mut context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build();
context.with_post_scanlines(true);
#}

If you choose to do this, the game looks a bit like the classic Caves of Qud:

Screenshot

Wrap up

Now that we have a GUI, it's starting to look pretty good!

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Items and Inventory


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


So far, we have maps, monsters, and bashing things! No roguelike "murder hobo" experience would be complete without items to pick up along the way. This chapter will add some basic items to the game, along with User Interface elements required to pick them up, use them and drop them.

Thinking about composing items

A major difference between object-oriented and entity-component systems is that rather than thinking about something as being located on an inheritance tree, you think about how it composes from components. Ideally, you already have some of the components ready to use!

So... what makes up an item? Thinking about it, an item can be said to have the following properties:

  • It has a Renderable - a way to draw it.
  • If its on the ground, awaiting pickup - it has a Position.
  • If its NOT on the ground - say in a backpack, it needs a way to indicate that it it is stored. We'll start with InPack
  • It's an item, which implies that it can be picked up. So it'll need an Item component of some sort.
  • If it can be used, it will need some way to indicate that it can be used - and what to do with it.

Consistently random

Computers are actually really bad at random numbers. Computers are inherently deterministic - so (without getting into cryptographic stuff) when you ask for a "random" number, you are actually getting a "really hard to predict next number in a sequence". The sequence is controlled by a seed - with the same seed, you always get the same dice rolls!

Since we have an ever-increasing number of things that use randomness, lets go ahead and make the RNG (Random Number Generator) a resource.

In main.rs, we add:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(rltk::RandomNumberGenerator::new());
#}

We can now access the RNG whenever we need it, without having to pass one around. Since we're not creating a new one, we can start it with a seed (we'd use seeded instead of new, and provide a seed). We'll worry about that later; for now, it's just going to make our code cleaner!

Improved Spawning

One monster per room, always in the middle, makes for rather boring play. We also need to support spawning items as well as monsters!

To that end, we're going to make a new file spawner.rs:


# #![allow(unused_variables)]
#fn main() {
use rltk::{ RGB, RandomNumberGenerator };
use specs::prelude::*;
use super::{CombatStats, Player, Renderable, Name, Position, Viewshed, Monster, BlocksTile};

/// Spawns the player and returns his/her entity object.
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
    ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Player{})
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Name{name: "Player".to_string() })
        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
        .build()
}

/// Spawns a random monster at a given location
pub fn random_monster(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 2);
    }
    match roll {
        1 => { orc(ecs, x, y) }
        _ => { goblin(ecs, x, y) }
    }
}

fn orc(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('o'), "Orc"); }
fn goblin(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('g'), "Goblin"); }

fn monster<S : ToString>(ecs: &mut World, x: i32, y: i32, glyph : u8, name : S) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph,
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Monster{})
        .with(Name{ name : name.to_string() })
        .with(BlocksTile{})
        .with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 })
        .build();
}
#}

As you can see, we've taken the existing code in main.rs - and wrapped it up in functions in a different module. We don't have to do this - but it helps keep things tidy. Since we're going to be expanding our spawning, it's nice to keep things separated out. Now we modify main.rs to use it:


# #![allow(unused_variables)]
#fn main() {
let player_entity = spawner::player(&mut gs.ecs, player_x, player_y);

gs.ecs.insert(rltk::RandomNumberGenerator::new());
for room in map.rooms.iter().skip(1) {
    let (x,y) = room.center();
    spawner::random_monster(&mut gs.ecs, x, y);
}
#}

That's definitely tidier! cargo run will give you exactly what we had at the end of the previous chapter.

Spawn All The Things

We're going to extend the function to spawn multiple monsters per room, with 0 being an option. First we change the Map constants which we introduced in the previous chapter to be public in order to use them in spawner.rs:


# #![allow(unused_variables)]
#fn main() {
pub const MAPWIDTH : usize = 80;
pub const MAPHEIGHT : usize = 50;
pub const MAPCOUNT : usize = MAPHEIGHT * MAPWIDTH;
#}

We want to control how many things we spawn, monsters and items. We'd like more monsters than items, to avoid too much of a "Monty Haul" dungeon! Also in spawner.rs, we'll add these constants (they can go anywhere, next to the other constants makes sense):


# #![allow(unused_variables)]
#fn main() {
const MAX_MONSTERS : i32 = 4;
const MAX_ITEMS : i32 = 2;
#}

Still in spawner.rs, we create a new function - spawn_room that uses these constants:


# #![allow(unused_variables)]
#fn main() {
/// Fills a room with stuff!
pub fn spawn_room(ecs: &mut World, room : &Rect) {
    let mut monster_spawn_points : Vec<usize> = Vec::new();

    // Scope to keep the borrow checker happy
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        let num_monsters = rng.roll_dice(1, MAX_MONSTERS + 2) - 3;

        for _i in 0 .. num_monsters {
            let mut added = false;
            while !added {
                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
                let idx = (y * MAPWIDTH) + x;
                if !monster_spawn_points.contains(&idx) {
                    monster_spawn_points.push(idx);
                    added = true;
                }
            }
        }
    }

    // Actually spawn the monsters
    for idx in monster_spawn_points.iter() {
        let x = *idx % MAPWIDTH;
        let y = *idx / MAPWIDTH;
        random_monster(ecs, x as i32, y as i32);
    }
}
#}

This obtains the RNG and the map, and rolls a dice for how many monsters it should spawn. It then keeps trying to add random positions that aren't already occupied, until sufficient monsters have been created. Each monster is then spawned at the determined location. The borrow checker isn't at all happy with the idea that we mutably access rng, and then pass the ECS itself along: so we introduce a scope to keep it happy (automatically dropping access to the RNG when we are done with it).

In main.rs, we then replace our monster spawner with:


# #![allow(unused_variables)]
#fn main() {
for room in map.rooms.iter().skip(1) {
    spawner::spawn_room(&mut gs.ecs, room);
}
#}

If you cargo run the project now, it will have between 0 and 4 monsters per room. It can get a little hairy!

Screenshot

Health Potion Entities

We'll improve the chances of surviving for a bit by adding health potions to the game! We'll start off by adding some components to help define a potion. In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Item {}

#[derive(Component, Debug)]
pub struct Potion {
    pub heal_amount : i32
}
#}

We of course need to register these in main.rs:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<Item>();
gs.ecs.register::<Potion>();
#}

In spawner.rs, we'll add a new function: health_potion:


# #![allow(unused_variables)]
#fn main() {
fn health_potion(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('¡'),
            fg: RGB::named(rltk::MAGENTA),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Name{ name : "Health Potion".to_string() })
        .with(Item{})
        .with(Potion{ heal_amount: 8 })
        .build();
}
#}

This is pretty straight-forward: we create an entity with a position, a renderable (we picked ¡ because it looks a bit like a potion, and my favorite game Dwarf Fortress uses it), a name, an Item component and a Potion component that specifies it heals 8 points of damage.

Now we can modify the spawner code to also have a chance to spawn between 0 and 2 items:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn_room(ecs: &mut World, room : &Rect) {
    let mut monster_spawn_points : Vec<usize> = Vec::new();
    let mut item_spawn_points : Vec<usize> = Vec::new();

    // Scope to keep the borrow checker happy
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        let num_monsters = rng.roll_dice(1, MAX_MONSTERS + 2) - 3;
        let num_items = rng.roll_dice(1, MAX_ITEMS + 2) - 3;

        for _i in 0 .. num_monsters {
            let mut added = false;
            while !added {
                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
                let idx = (y * MAPWIDTH) + x;
                if !monster_spawn_points.contains(&idx) {
                    monster_spawn_points.push(idx);
                    added = true;
                }
            }
        }

        for _i in 0 .. num_items {
            let mut added = false;
            while !added {
                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
                let idx = (y * MAPWIDTH) + x;
                if !item_spawn_points.contains(&idx) {
                    item_spawn_points.push(idx);
                    added = true;
                }
            }
        }
    }

    // Actually spawn the monsters
    for idx in monster_spawn_points.iter() {
        let x = *idx % MAPWIDTH;
        let y = *idx / MAPWIDTH;
        random_monster(ecs, x as i32, y as i32);
    }

    // Actually spawn the potions
    for idx in item_spawn_points.iter() {
        let x = *idx % MAPWIDTH;
        let y = *idx / MAPWIDTH;
        health_potion(ecs, x as i32, y as i32);
    }
}
#}

If you cargo run the project now, rooms now sometimes contain health potions. Tooltips and rendering "just work" - because they have the components required to use them.

Screenshot

Picking Up Items

Having potions exist is a great start, but it would be helpful to be able to pick them up! We'll create a new component in components.rs (and register it in main.rs!), to represent an item being in someone's backpack:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Clone)]
pub struct InBackpack {
    pub owner : Entity
}
#}

We also want to make item collection generic - that is, any entity can pick up an item. It would be pretty straightforward to just make it work for the player, but later on we might decide that monsters can pick up loot (introducing a whole new tactical element - bait!). So we'll also make a component indicating intent in components.rs (and register it in main.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Clone)]
pub struct WantsToPickupItem {
    pub collected_by : Entity,
    pub item : Entity
}
#}

Next, we'll put together a system to process WantsToPickupItem notices. We'll make a new file, inventory_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{WantsToPickupItem, Name, InBackpack, Position, gamelog::GameLog};

pub struct ItemCollectionSystem {}

impl<'a> System<'a> for ItemCollectionSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToPickupItem>,
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, InBackpack>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack) = data;

        for pickup in wants_pickup.join() {
            positions.remove(pickup.item);
            backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry");

            if pickup.collected_by == *player_entity {
                gamelog.entries.push(format!("You pick up the {}.", names.get(pickup.item).unwrap().name));
            }
        }

        wants_pickup.clear();
    }
}
#}

This iterates the requests to pick up an item, removes their position component, and adds an InBackpack component assigned to the collector. Don't forget to add it to the systems list in main.rs:


# #![allow(unused_variables)]
#fn main() {
let mut pickup = ItemCollectionSystem{};
pickup.run_now(&self.ecs);
#}

The next step is to add an input command to pick up an item. g is a popular key for this, so we'll go with that (we can always change it!). In player.rs, in the ever-growing match statement of inputs, we add:


# #![allow(unused_variables)]
#fn main() {
VirtualKeyCode::G => get_item(&mut gs.ecs),
#}

As you probably guessed, the next step is to implement get_item:


# #![allow(unused_variables)]
#fn main() {
fn get_item(ecs: &mut World) {
    let player_pos = ecs.fetch::<Point>();
    let player_entity = ecs.fetch::<Entity>();
    let entities = ecs.entities();
    let items = ecs.read_storage::<Item>();
    let positions = ecs.read_storage::<Position>();
    let mut gamelog = ecs.fetch_mut::<GameLog>();    

    let mut target_item : Option<Entity> = None;
    for (item_entity, _item, position) in (&entities, &items, &positions).join() {
        if position.x == player_pos.x && position.y == player_pos.y {
            target_item = Some(item_entity);
        }
    }

    match target_item {
        None => gamelog.entries.push("There is nothing here to pick up.".to_string()),
        Some(item) => {
            let mut pickup = ecs.write_storage::<WantsToPickupItem>();
            pickup.insert(*player_entity, WantsToPickupItem{ collected_by: *player_entity, item }).expect("Unable to insert want to pickup");
        }
    }
}
#}

This obtains a bunch of references/accessors from the ECS, and iterates all items with a position. If it matches the player's position, target_item is set. Then, if target_item is none - we tell the player that there is nothing to pick up. If it isn't, it adds a pickup request for the system we just added to use.

If you cargo run the project now, you can press g anywhere to be told that there's nothing to get. If you are standing on a potion, it will vanish when you press g! It's in our backpack - but we haven't any way to know that other than the log entry.

Listing your inventory

It's a good idea to be able to see your inventory list! This will be a game mode - that is, another state in which the game loop can find itself. So to start, we'll extend RunMode in main.rs to include it:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory }
#}

The i key is a popular choice for inventory (b is also popular!), so in player.rs we'll add the following to the player input code:


# #![allow(unused_variables)]
#fn main() {
VirtualKeyCode::I => return RunState::ShowInventory,
#}

In our tick function in main.rs, we'll add another matching:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowInventory => {
    if gui::show_inventory(self, ctx) == gui::ItemMenuResult::Cancel {
        newrunstate = RunState::AwaitingInput;
    }
}
#}

That naturally leads to implementing show_inventory! In gui.rs, we add:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum ItemMenuResult { Cancel, NoResponse, Selected }

pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> ItemMenuResult {
    let player_entity = gs.ecs.fetch::<Entity>();
    let names = gs.ecs.read_storage::<Name>();
    let backpack = gs.ecs.read_storage::<InBackpack>();

    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
    let count = inventory.count();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Inventory");
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");

    let mut j = 0;
    for (_pack, name) in (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ) {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &name.name.to_string());
        y += 1;
        j += 1;
    }

    match ctx.key {
        None => ItemMenuResult::NoResponse,
        Some(key) => {
            match key {
                VirtualKeyCode::Escape => { ItemMenuResult::Cancel }
                _ => ItemMenuResult::NoResponse
            }
        }
    }
}
#}

This starts out by using the filter feature of Rust iterators to count all items in your backpack. It then draws an appropriately sized box, and decorates it with a title and instructions. Next, it iterates all matching items and renders them in a menu format. Finally, it waits for keyboard input - and if you pressed ESCAPE, indicates that it is time to close the menu.

If you cargo run your project now, you can see items that you have collected:

Screenshot

Using Items

Now that we can display our inventory, lets make selecting an item actually use it. We'll extend the menu to return both an item entity and a result:


# #![allow(unused_variables)]
#fn main() {
pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let names = gs.ecs.read_storage::<Name>();
    let backpack = gs.ecs.read_storage::<InBackpack>();
    let entities = gs.ecs.entities();

    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
    let count = inventory.count();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Inventory");
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");

    let mut equippable : Vec<Entity> = Vec::new();
    let mut j = 0;
    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &name.name.to_string());
        equippable.push(entity);
        y += 1;
        j += 1;
    }

    match ctx.key {
        None => (ItemMenuResult::NoResponse, None),
        Some(key) => {
            match key {
                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
                _ => { 
                    let selection = rltk::letter_to_option(key);
                    if selection > -1 && selection < count as i32 {
                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
                    }  
                    (ItemMenuResult::NoResponse, None)
                }
            }
        }
    }
}
#}

Our call to show_inventory in main.rs is now invalid, so we'll fix it up:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowInventory => {
    let result = gui::show_inventory(self, ctx);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let item_entity = result.1.unwrap();
            let names = self.ecs.read_storage::<Name>();
            let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
            gamelog.entries.push(format!("You try to use {}, but it isn't written yet", names.get(item_entity)         .unwrap().name));
            newrunstate = RunState::AwaitingInput;
        }
    }
}
#}

If you try to use an item in your inventory now, you'll get a log entry that you try to use it, but we haven't written that bit of code yet. That's a start!

Once again, we want generic code - so that eventually monsters might use potions. We're going to cheat a little while all items are potions, and just make a potion system; we'll turn it into something more useful later. So we'll start by creating an "intent" component in components.rs (and registered in main.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct WantsToDrinkPotion {
    pub potion : Entity
}
#}

Add the following to inventory_system.rs:


# #![allow(unused_variables)]
#fn main() {
pub struct PotionUseSystem {}

impl<'a> System<'a> for PotionUseSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToDrinkPotion>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Potion>,
                        WriteStorage<'a, CombatStats>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, entities, mut wants_drink, names, potions, mut combat_stats) = data;

        for (entity, drink, stats) in (&entities, &wants_drink, &mut combat_stats).join() {
            let potion = potions.get(drink.potion);
            match potion {
                None => {}
                Some(potion) => {
                    stats.hp = i32::min(stats.max_hp, stats.hp + potion.heal_amount);
                    if entity == *player_entity {
                        gamelog.entries.push(format!("You drink the {}, healing {} hp.", names.get(drink.potion).unwrap().name, potion.heal_amount));
                    }
                    entities.delete(drink.potion).expect("Delete failed");
                }
            }
        }

        wants_drink.clear();
    }
}
#}

And register it in the list of systems to run:


# #![allow(unused_variables)]
#fn main() {
let mut potions = PotionUseSystem{};
potions.run_now(&self.ecs);
#}

Like other systems we've looked at, this iterates all of the WantsToDrinkPotion intent objects. It then heals up the drinker by the amount set in the Potion component, and deletes the potion. Since all of the placement information is attached to the potion itself, there's no need to chase around making sure it is removed from the appropriate backpack: the entity ceases to exist, and takes its components with it.

Testing this with cargo run gives a surprise: the potion isn't deleted after use! This is because the ECS simply marks entities as dead - it doesn't delete them in systems (so as to not mess up iterators and threading). So after every call to dispatch, we need to add a call to maintain. In main.ecs:


# #![allow(unused_variables)]
#fn main() {
RunState::PreRun => {
    self.run_systems();
    self.ecs.maintain();
    newrunstate = RunState::AwaitingInput;
}
#}

...


# #![allow(unused_variables)]
#fn main() {
RunState::PlayerTurn => {
    self.run_systems();
    self.ecs.maintain();
    newrunstate = RunState::MonsterTurn;
}
RunState::MonsterTurn => {
    self.run_systems();
    self.ecs.maintain();
    newrunstate = RunState::AwaitingInput;
}
#}

Finally we have to change the RunState::ShowInventory handling if an item was selected, we create a WantsToDrinkPotion intent:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowInventory => {
                let result = gui::show_inventory(self, ctx);
                match result.0 {
                    gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
                    gui::ItemMenuResult::NoResponse => {}
                    gui::ItemMenuResult::Selected => {
                        let item_entity = result.1.unwrap();
                        let mut intent = self.ecs.write_storage::<WantsToDrinkPotion>();
                        intent.insert(*self.ecs.fetch::<Entity>(), WantsToDrinkPotion{ potion: item_entity }).expect("Unable to insert intent");
                        newrunstate = RunState::PlayerTurn;
                    }
                }
            }
#}

NOW if you cargo run the project, you can pickup and drink health potions:

Screenshot

Dropping Items

You probably want to be able to drop items from your inventory, especially later when they can be used as bait. We'll follow a similar pattern for this section - create an intent component, a menu to select it, and a system to perform the drop.

So we create a component (in components.rs), and register it in main.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToDropItem {
    pub item : Entity
}
#}

We add another system to inventory_system.rs:


# #![allow(unused_variables)]
#fn main() {
pub struct ItemDropSystem {}

impl<'a> System<'a> for ItemDropSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToDropItem>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, Position>,
                        WriteStorage<'a, InBackpack>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions, mut backpack) = data;

        for (entity, to_drop) in (&entities, &wants_drop).join() {
            let mut dropper_pos : Position = Position{x:0, y:0};
            {
                let dropped_pos = positions.get(entity).unwrap();
                dropper_pos.x = dropped_pos.x;
                dropper_pos.y = dropped_pos.y;
            }
            positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position");
            backpack.remove(to_drop.item);

            if entity == *player_entity {
                gamelog.entries.push(format!("You drop the {}.", names.get(to_drop.item).unwrap().name));
            }
        }

        wants_drop.clear();
    }
}
#}

Register it in the dispatch builder in main.rs:


# #![allow(unused_variables)]
#fn main() {
let mut drop_items = ItemDropSystem{};
drop_items.run_now(&self.ecs);
#}

We'll add a new RunState in main.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem }
#}

Now in player.rs, we add d for drop to the list of commands:


# #![allow(unused_variables)]
#fn main() {
VirtualKeyCode::D => return RunState::ShowDropItem,
#}

In gui.rs, we need another menu - this time for dropping items:


# #![allow(unused_variables)]
#fn main() {
pub fn drop_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let names = gs.ecs.read_storage::<Name>();
    let backpack = gs.ecs.read_storage::<InBackpack>();
    let entities = gs.ecs.entities();

    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
    let count = inventory.count();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Drop Which Item?");
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");

    let mut equippable : Vec<Entity> = Vec::new();
    let mut j = 0;
    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &name.name.to_string());
        equippable.push(entity);
        y += 1;
        j += 1;
    }

    match ctx.key {
        None => (ItemMenuResult::NoResponse, None),
        Some(key) => {
            match key {
                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
                _ => { 
                    let selection = rltk::letter_to_option(key);
                    if selection > -1 && selection < count as i32 {
                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
                    }  
                    (ItemMenuResult::NoResponse, None)
                }
            }
        }
    }
}
#}

We also need to extend the state handler in main.rs to use it:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowDropItem => {
    let result = gui::drop_item_menu(self, ctx);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let item_entity = result.1.unwrap();
            let mut intent = self.ecs.write_storage::<WantsToDropItem>();
            intent.insert(*self.ecs.fetch::<Entity>(), WantsToDropItem{ item: item_entity }).expect("Unable to insert intent");
            newrunstate = RunState::PlayerTurn;
        }
    }
}
#}

If you cargo run the project, you can now press d to drop items! Here's a shot of rather unwisely dropping a potion while being mobbed:

Screenshot

Render order

You've probably noticed by now that when you walk over a potion, it renders over the top of you - removing the context for your player completely! We'll fix that by adding a render_order field to Renderables:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component)]
pub struct Renderable {
    pub glyph: u8,
    pub fg: RGB,
    pub bg: RGB,
    pub render_order : i32
}
#}

Your IDE is probably now highlighting lots of errors for Renderable components that were created without this information. We'll add it to various places: the player is 0 (render first), monsters 1 (second) and items 2 (last). For example, in the Player spawner, the Renderable now looks like this:


# #![allow(unused_variables)]
#fn main() {
.with(Renderable {
    glyph: rltk::to_cp437('@'),
    fg: RGB::named(rltk::YELLOW),
    bg: RGB::named(rltk::BLACK),
    render_order: 0
})
#}

To make this do something, we go to our item rendering code in main.rs and add a sort to the iterators. We referenced the Book of Specs for how to do this! Basically, we obtain the joined set of Position and Renderable components, and collect them into a vector. We then sort that vector, and iterate it to render in the appropriate order. In main.rs, replace the previous entity rendering code with:


# #![allow(unused_variables)]
#fn main() {
let mut data = (&positions, &renderables).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
for (pos, render) in data.iter() {
    let idx = map.xy_idx(pos.x, pos.y);
    if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
}
#}

Wrap Up

This chapter has shown a fair amount of the power of using an ECS: picking up, using and dropping entities is relatively simple - and once the player can do it, so can anything else (if you add it to their AI). We've also shown how to order ECS fetches, to maintain a sensible render order.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Ranged Scrolls and Targeting


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In the last chapter, we added items and inventory - and a single item type, a health potion. Now we'll add a second item type: a scroll of magic missile, that lets you zap an entity at range.

Using components to describe what an item does

In the last chapter, we pretty much wrote code to ensure that all items were healing potions. That got things going, but isn't very flexible. So we'll start by breaking down items into a few more component types. We'll start with a simple flag component, Consumable:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Consumable {}
#}

Having this item indicates that using it destroys it (consumed on use). So we replace the always-called entities.delete(useitem.item).expect("Delete failed"); in our PotionUseSystem (which we rename ItemUseSystem!) with:


# #![allow(unused_variables)]
#fn main() {
let consumable = consumables.get(useitem.item);
match consumable {
    None => {}
    Some(_) => {
        entities.delete(useitem.item).expect("Delete failed");
    }
}
#}

This is quite simple: check if the component has a Consumable tag, and destroy it if it does. Likewise, we can replace the Potion section with a ProvidesHealing to indicate that this is what the potion actually does. In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct ProvidesHealing {
    pub heal_amount : i32
}
#}

And in our ItemUseSystem:


# #![allow(unused_variables)]
#fn main() {
let item_heals = healing.get(useitem.item);
match item_heals {
    None => {}
    Some(healer) => {
        stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
        if entity == *player_entity {
            gamelog.entries.push(format!("You drink the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
        }
    }
}
#}

Drawing that together, our code for creating a potion (in spawner.rs) looks like this:


# #![allow(unused_variables)]
#fn main() {
fn health_potion(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('¡'),
            fg: RGB::named(rltk::MAGENTA),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Health Potion".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(ProvidesHealing{ heal_amount: 8 })
        .build();
}
#}

So we're describing where it is, what it looks like, its name, denoting that it is an item, consumed on use, and provides 8 points of healing. This is nice and descriptive - and future items can mix/match. As we add components, the item system will become more and more flexible.

Describing Ranged Magic Missile Scrolls

We'll want to add a few more components! In components.rs (and registered in main.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Ranged {
    pub range : i32
}

#[derive(Component, Debug)]
pub struct InflictsDamage {
    pub damage : i32
}
#}

This in turn lets us write a magic_missile_scroll function in spawner.rs, which effectively describes the scroll:


# #![allow(unused_variables)]
#fn main() {
fn magic_missile_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Magic Missile Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(InflictsDamage{ damage: 8 })
        .build();
}
#}

That neatly lays out the properties of what makes it tick: it has a position, an appearance, a name, it's an item that is destroyed on use, it has a range of 6 tiles and inflicts 8 points of damage. That's what I like about components: after a while, it sounds more like you are describing a blueprint for a device than writing many lines of code!

We'll go ahead and add them into the spawn list:


# #![allow(unused_variables)]
#fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 2);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
#}

Replace the call to health_potion in the item spawning code with a call to random_item.

If you run the program (with cargo run) now, you'll find scrolls as well as potions lying around. The components system already provides quite a bit of functionality:

  • You can see them rendered on the map (thanks to the Renderable and Position)
  • You can pick them up and drop them (thank to Item)
  • You can list them in your inventory
  • You can call use on them, and they are destroyed: but nothing happens.

Screenshot

Implementing ranged damage for items

We want magic missile to be targeted: you activate it, and then have to select a victim. This will be another input mode, so we once again extend RunState in main.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, 
    ShowTargeting { range : i32, item : Entity} }
#}

We'll extend our handler for ShowInventory in main.rs to handle items that are ranged and induce a mode switch:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowInventory => {
    let result = gui::show_inventory(self, ctx);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let item_entity = result.1.unwrap();
            let is_ranged = self.ecs.read_storage::<Ranged>();
            let is_item_ranged = is_ranged.get(item_entity);
            if let Some(is_item_ranged) = is_item_ranged {
                newrunstate = RunState::ShowTargeting{ range: is_item_ranged.range, item: item_entity };
            } else {
                let mut intent = self.ecs.write_storage::<WantsToUseItem>();
                intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item: item_entity }).expect("Unable to insert intent");
                newrunstate = RunState::PlayerTurn;
            }
        }
    }
}
#}

So now in main.rs, where we match the appropriate game mode, we can stub in:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowTargeting{range, item} => {
    let target = gui::ranged_target(self, ctx, range);
}
#}

That naturally leads to actually writing gui::ranged_target. This looks complicated, but it's actually quite straightforward:


# #![allow(unused_variables)]
#fn main() {
pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let player_pos = gs.ecs.fetch::<Point>();
    let viewsheds = gs.ecs.read_storage::<Viewshed>();

    ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:");

    // Highlight available target cells
    let mut available_cells = Vec::new();
    let visible = viewsheds.get(*player_entity);
    if let Some(visible) = visible {
        // We have a viewshed
        for idx in visible.visible_tiles.iter() {
            let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx);
            if distance <= range as f32 {
                ctx.set_bg(idx.x, idx.y, RGB::named(rltk::BLUE));
                available_cells.push(idx);
            }
        }
    } else {
        return (ItemMenuResult::Cancel, None);
    }

    // Draw mouse cursor
    let mouse_pos = ctx.mouse_pos();
    let mut valid_target = false;
    for idx in available_cells.iter() { if idx.x == mouse_pos.0 && idx.y == mouse_pos.1 { valid_target = true; } }
    if valid_target {
        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN));
        if ctx.left_click {
            return (ItemMenuResult::Selected, Some(Point::new(mouse_pos.0, mouse_pos.1)));
        }
    } else {
        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED));
        if ctx.left_click {
            return (ItemMenuResult::Cancel, None);
        }
    }

    (ItemMenuResult::NoResponse, None)
}
#}

So we start by obtaining the player's location and viewshed, and iterating cells they can see. We check the range of the cell versus the range of the item, and if it is in range - we highlight the cell in blue. We also maintain a list of what cells are possible to target. Then, we get the mouse position; if it is pointing at a valid target, we light it up in cyan - otherwise we use red. If you click a valid cell, it returns targeting information for where you are aiming - otherwise, it cancels.

Now we extend our ShowTargeting code to handle this:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowTargeting{range, item} => {
    let result = gui::ranged_target(self, ctx, range);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let mut intent = self.ecs.write_storage::<WantsToUseItem>();
            intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item, target: result.1 }).expect("Unable to insert intent");
            newrunstate = RunState::PlayerTurn;
        }
    }
}
#}

What's this target? I added another field to WantsToUseItem in components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToUseItem {
    pub item : Entity,
    pub target : Option<rltk::Point>
}
#}

So now when you receive a WantsToUseItem, you can now that the user is the owning entity, the item is the item field, and it is aimed at target - if there is one (targeting doesn't make much sense for healing potions!).

So now we can add another condition to our ItemUseSystem:


# #![allow(unused_variables)]
#fn main() {
// If it inflicts damage, apply it to the target cell
let item_damages = inflict_damage.get(useitem.item);
match item_damages {
    None => {}
    Some(damage) => {
        let target_point = useitem.target.unwrap();
        let idx = map.xy_idx(target_point.x, target_point.y);
        used_item = false;
        for mob in map.tile_content[idx].iter() {
            SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
            if entity == *player_entity {
                let mob_name = names.get(*mob).unwrap();
                let item_name = names.get(useitem.item).unwrap();
                gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
            }

            used_item = true;
        }
    }
}
#}

This checks to see if we have an InflictsDamage component on the item - and if it does, applies the damage to everyone in the targeted cell.

If you cargo run the game, you can now blast entities with your magic missile scrolls!

Introducing Area of Effect

We'll add another scroll type - Fireball. It's an old favorite, and introduces AoE - Area of Effect - damage. We'll start by adding a component to indicate our intent:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct AreaOfEffect {
    pub radius : i32
}
#}

We'll extend the random_item function in spawner.rs to offer it as an option:


# #![allow(unused_variables)]
#fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 3);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        2 => { fireball_scroll(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
#}

So now we can write a fireball_scroll function to actually spawn them. This is a lot like the other items:


# #![allow(unused_variables)]
#fn main() {
fn fireball_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::ORANGE),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Fireball Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(InflictsDamage{ damage: 20 })
        .with(AreaOfEffect{ radius: 3 })
        .build();
}
#}

Notice that it's basically the same - but we're adding an AreaOfEffect component to indicate that it is what we want. If you were to cargo run now, you'd see Fireball scrolls in the game - and they would inflict damage on a single entity. Clearly, we must fix that!

In our UseItemSystem, we'll build a new section to figure out a list of targets for an effect:


# #![allow(unused_variables)]
#fn main() {
// Targeting
let mut targets : Vec<Entity> = Vec::new();
match useitem.target {
    None => { targets.push( *player_entity ); }
    Some(target) => {
        let area_effect = aoe.get(useitem.item);
        match area_effect {
            None => {
                // Single target in tile
                let idx = map.xy_idx(target.x, target.y);
                for mob in map.tile_content[idx].iter() {
                    targets.push(*mob);
                }
            }
            Some(area_effect) => {
                // AoE
                let mut blast_tiles = rltk::field_of_view(target, area_effect.radius, &*map);
                blast_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );
                for tile_idx in blast_tiles.iter() {
                    let idx = map.xy_idx(tile_idx.x, tile_idx.y);
                    for mob in map.tile_content[idx].iter() {
                        targets.push(*mob);
                    }
                }
            }
        }
    }
}
#}

This says "if there is no target, apply it to the player". If there is a target, check to see if it is an Area of Effect event; if it is - plot a viewshed from that point of the appropriate radius, and add every entity in the target area. If it isn't, we just get the entities in the target tile.

So now we need to make the effect code generic. We don't want to assume that effects are independent; later on, we may decide that zapping something with a scroll has all manner of effects! So for healing, it looks like this:


# #![allow(unused_variables)]
#fn main() {
// If it heals, apply the healing
let item_heals = healing.get(useitem.item);
match item_heals {
    None => {}
    Some(healer) => {
        for target in targets.iter() {
            let stats = combat_stats.get_mut(*target);
            if let Some(stats) = stats {
                stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
                if entity == *player_entity {
                    gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
                }
            }                        
        }
    }
}
#}

The damage code is actually simplified, since we've already calculated targets:


# #![allow(unused_variables)]
#fn main() {
// If it inflicts damage, apply it to the target cell
let item_damages = inflict_damage.get(useitem.item);
match item_damages {
    None => {}
    Some(damage) => {
        used_item = false;
        for mob in targets.iter() {
            SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
            if entity == *player_entity {
                let mob_name = names.get(*mob).unwrap();
                let item_name = names.get(useitem.item).unwrap();
                gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
            }

            used_item = true;
        }
    }
}
#}

If you cargo run the project now, you can use magic missile scrolls, fireball scrolls and health potions.

Confusion Scrolls

Let's add another item - confusion scrolls. These will target a single entity at range, and make them Confused for a few turns - during which time they will do nothing. We'll start by describing what we want in the item spawning code:


# #![allow(unused_variables)]
#fn main() {
fn confusion_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::PINK),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Confusion Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(Confusion{ turns: 4 })
        .build();
}
#}

We'll also add it to the item choices:


# #![allow(unused_variables)]
#fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 4);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        2 => { fireball_scroll(ecs, x, y) }
        3 => { confusion_scroll(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
#}

We'll add a new component (and register it!):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug)]
pub struct Confusion {
    pub turns : i32
}
#}

That's enough to have them appear, be triggerable and cause targeting to happen - but nothing will happen when it is used. We'll add the ability to pass along confusion to the ItemUseSystem:


# #![allow(unused_variables)]
#fn main() {
// Can it pass along confusion? Note the use of scopes to escape from the borrow checker!
let mut add_confusion = Vec::new();
{
    let causes_confusion = confused.get(useitem.item);
    match causes_confusion {
        None => {}
        Some(confusion) => {
            used_item = false;
            for mob in targets.iter() {
                add_confusion.push((*mob, confusion.turns ));
                if entity == *player_entity {
                    let mob_name = names.get(*mob).unwrap();
                    let item_name = names.get(useitem.item).unwrap();
                    gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));
                }
            }
        }
    }
}
for mob in add_confusion.iter() {
    confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to insert status");
}
#}

Alright! Now we can add the Confused status to anything. We should update the monster_ai_system to use it. Replace the loop with:


# #![allow(unused_variables)]
#fn main() {
 for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() {
    let mut can_act = true;

    let is_confused = confused.get_mut(entity);
    if let Some(i_am_confused) = is_confused {
        i_am_confused.turns -= 1;
        if i_am_confused.turns < 1 {
            confused.remove(entity);
        }
        can_act = false;
    }

    if can_act {
        let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
        if distance < 1.5 {
            wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack");
        }
        else if viewshed.visible_tiles.contains(&*player_pos) {
            // Path to the player
            let path = rltk::a_star_search(
                map.xy_idx(pos.x, pos.y),
                map.xy_idx(player_pos.x, player_pos.y),
                &mut *map
            );
            if path.success && path.steps.len()>1 {
                let mut idx = map.xy_idx(pos.x, pos.y);
                map.blocked[idx] = false;
                pos.x = path.steps[1] as i32 % map.width;
                pos.y = path.steps[1] as i32 / map.width;
                idx = map.xy_idx(pos.x, pos.y);
                map.blocked[idx] = true;
                viewshed.dirty = true;
            }
        }
    }
}
#}

If this sees a Confused component, it decrements the timer. If the timer hits 0, it removes it. It then returns, making the monster skip its turn.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Loading and Saving the Game


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In the last few chapters, we've focused on getting a playable (if not massively fun) game going. You can run around, slay monsters, and make use of various items. That's a great start! Most games let you stop playing, and come back later to continue. Fortunately, Rust (and associated libraries) makes it relatively easy.

A Main Menu

If you're going to resume a game, you need somewhere from which to do so! A main menu also gives you the option to abandon your last save, possibly view credits, and generally tell the world that your game is here - and written by you. It's an important thing to have, so we'll put one together.

Being in the menu is a state - so we'll add it to the ever-expanding RunState enum. We want to include menu state inside it, so the definition winds up looking like this:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput,
    PreRun,
    PlayerTurn,
    MonsterTurn,
    ShowInventory,
    ShowDropItem,
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection }
}
#}

In gui.rs, we add a couple of enum types to handle main menu selections:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum MainMenuSelection { NewGame, LoadGame, Quit }

#[derive(PartialEq, Copy, Clone)]
pub enum MainMenuResult { NoSelection{ selected : MainMenuSelection }, Selected{ selected: MainMenuSelection } }
#}

Your GUI is probably now telling you that main.rs has errors! It's right - we need to handle the new RunState option. We'll need to change things around a bit to ensure that we aren't also rendering the GUI and map when in the menu. So we rearrange tick:


# #![allow(unused_variables)]
#fn main() {
fn tick(&mut self, ctx : &mut Rltk) {
    let mut newrunstate;
    {
        let runstate = self.ecs.fetch::<RunState>();
        newrunstate = *runstate;
    }

    ctx.cls();

    match newrunstate {
        RunState::MainMenu{..} => {}
        _ => {
            draw_map(&self.ecs, ctx);

            {
                let positions = self.ecs.read_storage::<Position>();
                let renderables = self.ecs.read_storage::<Renderable>();
                let map = self.ecs.fetch::<Map>();

                let mut data = (&positions, &renderables).join().collect::<Vec<_>>();
                data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
                for (pos, render) in data.iter() {
                    let idx = map.xy_idx(pos.x, pos.y);
                    if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
                }

                gui::draw_ui(&self.ecs, ctx);
            }
        }
    }
    ...
#}

We'll also handle the MainMenu state in our large match for RunState:


# #![allow(unused_variables)]
#fn main() {
RunState::MainMenu{ .. } => {
    let result = gui::main_menu(self, ctx);
    match result {
        gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected },
        gui::MainMenuResult::Selected{ selected } => {
            match selected {
                gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun,
                gui::MainMenuSelection::LoadGame => newrunstate = RunState::PreRun,
                gui::MainMenuSelection::Quit => { ::std::process::exit(0); }
            }
        }
    }
}
#}

We're basically updating the state with the new menu selection, and if something has been selected we change the game state. For Quit, we simply terminate the process. For now, we'll make loading/starting a game do the same thing: go into the PreRun state to setup the game.

The last thing to do is to write the menu itself. In menu.rs:

pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult {
    let runstate = gs.ecs.fetch::<RunState>();

    ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");

    if let RunState::MainMenu{ menu_selection : selection } = *runstate {
        if selection == MainMenuSelection::NewGame {
            ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
        } else {
            ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
        }

        if selection == MainMenuSelection::LoadGame {
            ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
        } else {
            ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
        }

        if selection == MainMenuSelection::Quit {
            ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
        } else {
            ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
        }

        match ctx.key {
            None => return MainMenuResult::NoSelection{ selected: selection },
            Some(key) => {
                match key {
                    VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
                    VirtualKeyCode::Up => {
                        let newselection;
                        match selection {
                            MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit,
                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame,
                            MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame
                        }
                        return MainMenuResult::NoSelection{ selected: newselection }
                    }
                    VirtualKeyCode::Down => {
                        let newselection;
                        match selection {
                            MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame,
                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit,
                            MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame
                        }
                        return MainMenuResult::NoSelection{ selected: newselection }
                    }
                    VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection },
                    _ => return MainMenuResult::NoSelection{ selected: selection }
                }
            }
        }
    }

    MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame }
}

That's a bit of a mouthful, but it displays menu options and lets you select them with the up/down keys and enter. It's very careful to not modify state itself, to keep things clear.

Including Serde

Serde is pretty much the gold-standard for serialization in Rust. It makes a lot of things easier! So the first step is to include it. In your project's Cargo.toml file, we'll expand the dependencies section to include it:

[dependencies]
rltk = { version = "0.7.0", features = ["serde"] }
specs = { version = "0.16.1", features = ["serde"] }
specs-derive = "0.4.0"
serde= { version = "1.0.93", features = ["derive"] }
serde_json = "1.0.39"

It may be worth calling cargo run now - it will take a while, downloading the new dependencies (and all of their dependencies) and building them for you. It should keep them around so you don't have to wait this long every time you build.

Adding a "SaveGame" state

We'll extend RunState once more to support game saving:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput,
    PreRun,
    PlayerTurn,
    MonsterTurn,
    ShowInventory,
    ShowDropItem,
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame
}
#}

In tick, we'll add dummy code for now:


# #![allow(unused_variables)]
#fn main() {
RunState::SaveGame => {
    newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
#}

In player.rs, we'll add another keyboard handler - escape:


# #![allow(unused_variables)]
#fn main() {
// Save and Quit
VirtualKeyCode::Escape => return RunState::SaveGame,
#}

If you cargo run now, you can start a game and press escape to quit to the menu.

Getting started with saving the game

Now that the scaffolding is in place, it's time to actually save something! Lets start simple, to get a feel for Serde. In the tick function, we extend the save system to just dump a JSON representation of the map to the console:


# #![allow(unused_variables)]
#fn main() {
RunState::SaveGame => {
    let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap();
    println!("{}", data);

    newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
#}

We'll also need to add an extern crate serde; to the top of main.rs.

This won't compile, because we need to tell Map to serialize itself! Fortunately, serde provides some helpers to make this easy. At the top of map.rs, we add use serde::{Serialize, Deserialize};. We then decorate the map to derive serialization and de-serialization code:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
#}

Note that we've decorated tile_content with directives to not serialize/de-serialize it. This prevents us from needing to store the entities, and since this data is rebuilt every frame - it doesn't matter. The game still won't compile; we need to add similar decorators to TileType and Rect:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum TileType {
    Wall, Floor
}
#}

# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub struct Rect {
    pub x1 : i32,
    pub x2 : i32,
    pub y1 : i32,
    pub y2 : i32
}
#}

Lastly, we should extend the game saving code to dump the map to the console:


# #![allow(unused_variables)]
#fn main() {
let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap();
println!("{}", data);
#}

If you cargo run the project now, when you hit escape it will dump a huge blob of JSON data to the console. That's the game map!

Saving entity state

Now that we've seen how useful serde is, we should start to use it for the game itself. This is harder than one might expect, because of how specs handles Entity structures: their ID # is purely synthetic, with no guaranty that you'll get the same one next time! Also, you may not want to save everything - so specs introduces a concept of markers to help with this. It winds up being a bit more of a mouthful than it really needs to be, but gives a pretty powerful serialization system.

Introducing Markers

First of all, in main.rs we'll tell Rust that we'd like to make use of the marker functionality:


# #![allow(unused_variables)]
#fn main() {
use specs::saveload::{SimpleMarker, SimpleMarkerAllocator};
#}

In components.rs, we'll add a marker type:


# #![allow(unused_variables)]
#fn main() {
pub struct SerializeMe;
#}

Back in main.rs, we'll add SerializeMe to the list of things that we register:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<SimpleMarker<SerializeMe>>();
#}

We'll also add an entry to the ECS resources, which gets used to determine the next identity:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
#}

Finally, in spawners.rs we tell each entity builder to include the marker. Here's the complete entry for the Player:


# #![allow(unused_variables)]
#fn main() {
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
    ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 0
        })
        .with(Player{})
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Name{name: "Player".to_string() })
        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build()
}
#}

The new line (.marked::<SimpleMarker<SerializeMe>>()) needs to be repeated for all of our spawners in this file. It's worth looking at the source for this chapter; to avoid making a huge chapter full of source code, I've omitted the repeated details.

The ConvertSaveload derive macro

The Entity class itself (provided by Specs) isn't directly serializable; it's actually a reference to an identity in a special structure called a "slot map" (basically a really efficient way to store data and keep the locations stable until you delete it, but re-use the space when it becomes available). So, in order to save and load Entity classes, it becomes necessary to convert these synthetic identities to unique ID numbers. Fortunately, Specs provides a derive macro called ConvertSaveload for this purpose. It works for most components, but not for all!

It's pretty easy to serialize a type that doesn't have an Entity in it - but does have data: mark it with #[derive(Component, ConvertSaveload, Clone)]. So we go through all the simple component types in components.rs; for example, here's Position:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, ConvertSaveload, Clone)]
pub struct Position {
    pub x: i32,
    pub y: i32,
}
#}

So what this is saying is that:

  • The structure is a Component. You can replace this with writing code specifying Specs storage if you prefer, but the macro is much easier!
  • ConvertSaveload is actually adding Serialize and Deserialize, but with extra conversion for any Entity classes it encounters.
  • Clone is saying "this structure can be copied in memory from one point to another." This is necessary for the inner-workings of Serde, and also allows you to attach .clone() to the end of any reference to a component - and get another, perfect copy of it. In most cases, clone is really fast (and occasionally the compiler can make it do nothing at all!)

When you have a component with no data, the ConvertSaveload macro doesn't work! Fortunately, these don't require any additional conversion - so you can fall back to the default Serde syntax. Here's a non-data ("tag") class:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Serialize, Deserialize, Clone)]
pub struct Player {}
#}

Actually saving something

The code for loading and saving gets large, so we've moved it into saveload_system.rs. Then include a mod saveload_system; in main.rs, and replace the SaveGame state with:


# #![allow(unused_variables)]
#fn main() {
RunState::SaveGame => {
    saveload_system::save_game(&mut self.ecs);
    newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
#}

So... onto implementing save_game. Serde and Specs work decently together, but the bridge is still pretty roughly defined. I kept running into problems like it failing to compile if I had more than 16 component types! To get around this, I build a macro. I recommend just copying the macro until you feel ready to learn Rust's (impressive) macro system.


# #![allow(unused_variables)]
#fn main() {
macro_rules! serialize_individually {
    ($ecs:expr, $ser:expr, $data:expr, $( $type:ty),*) => {
        $(
        SerializeComponents::<NoError, SimpleMarker<SerializeMe>>::serialize(
            &( $ecs.read_storage::<$type>(), ),
            &$data.0,
            &$data.1,
            &mut $ser,
        )
        .unwrap();
        )*
    };
}
#}

The short version of what it does is that it takes your ECS as the first parameter, and a tuple with your entity store and "markers" stores in it (you'll see this in a moment). Every parameter after that is a type - listing a type stored in your ECS. These are repeating rules, so it issues one SerializeComponent::serialize call per type. It's not as efficient as doing them all at once, but it works - and doesn't fall over when you exceed 16 types! The save_game function then looks like this:


# #![allow(unused_variables)]
#fn main() {
pub fn save_game(ecs : &mut World) {
    // Create helper
    let mapcopy = ecs.get_mut::<super::map::Map>().unwrap().clone();
    let savehelper = ecs
        .create_entity()
        .with(SerializationHelper{ map : mapcopy })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();

    // Actually serialize
    {
        let data = ( ecs.entities(), ecs.read_storage::<SimpleMarker<SerializeMe>>() );

        let writer = File::create("./savegame.json").unwrap();
        let mut serializer = serde_json::Serializer::new(writer);
        serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, 
            Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
            AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
            WantsToDropItem, SerializationHelper
        );
    }

    // Clean up
    ecs.delete_entity(savehelper).expect("Crash on cleanup");
}
#}

What's going on here, then?

  1. We start by creating a new component type - SerializationHelper that stores a copy of the map (see, we are using the map stuff from above!). It then creates a new entity, and gives it the new component - with a copy of the map (the clone command makes a deep copy). This is needed so we don't need to serialize the map separately.
  2. We enter a block to avoid borrow-checker issues.
  3. We set data to be a tuple, containing the Entity store and ReadStorage for SimpleMarker. These will be used by the save macro.
  4. We open a File called savegame.json in the current directory.
  5. We obtain a JSON serializer from Serde.
  6. We call the serialize_individually macro with all of our types.
  7. We delete the temporary helper entity we created.

If you cargo run and start a game, then save it - you'll find a savegame.json file has appeared - with your game state in it. Yay!

Restoring Game State

Now that we have the game data, it's time to load it!

Is there a saved game?

First, we need to know if there is a saved game to load. In saveload_system.rs, we add the following function:


# #![allow(unused_variables)]
#fn main() {
pub fn does_save_exist() -> bool {
    Path::new("./savegame.json").exists()
}
#}

Then in gui.rs, we extend the main_menu function to check for the existence of a file - and not offer to load it if it isn't there:

pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult {
    let save_exists = super::saveload_system::does_save_exist();
    let runstate = gs.ecs.fetch::<RunState>();

    ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");

    if let RunState::MainMenu{ menu_selection : selection } = *runstate {
        if selection == MainMenuSelection::NewGame {
            ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
        } else {
            ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
        }

        if save_exists {
            if selection == MainMenuSelection::LoadGame {
                ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
            } else {
                ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
            }
        }

        if selection == MainMenuSelection::Quit {
            ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
        } else {
            ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
        }

        match ctx.key {
            None => return MainMenuResult::NoSelection{ selected: selection },
            Some(key) => {
                match key {
                    VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
                    VirtualKeyCode::Up => {
                        let mut newselection;
                        match selection {
                            MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit,
                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame,
                            MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame
                        }
                        if newselection == MainMenuSelection::LoadGame && !save_exists {
                            newselection = MainMenuSelection::NewGame;
                        }
                        return MainMenuResult::NoSelection{ selected: newselection }
                    }
                    VirtualKeyCode::Down => {
                        let mut newselection;
                        match selection {
                            MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame,
                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit,
                            MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame
                        }
                        if newselection == MainMenuSelection::LoadGame && !save_exists {
                            newselection = MainMenuSelection::Quit;
                        }
                        return MainMenuResult::NoSelection{ selected: newselection }
                    }
                    VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection },
                    _ => return MainMenuResult::NoSelection{ selected: selection }
                }
            }
        }
    }

    MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame }
}

Finally, we'll modify the calling code in main.rs to call game loading:


# #![allow(unused_variables)]
#fn main() {
RunState::MainMenu{ .. } => {
    let result = gui::main_menu(self, ctx);
    match result {
        gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected },
        gui::MainMenuResult::Selected{ selected } => {
            match selected {
                gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun,
                gui::MainMenuSelection::LoadGame => {
                    saveload_system::load_game(&mut self.ecs);
                    newrunstate = RunState::AwaitingInput;
                }
                gui::MainMenuSelection::Quit => { ::std::process::exit(0); }
            }
        }
    }
}
#}

Actually loading the game

In saveload_system.rs, we're going to need another macro! This is pretty much the same as the serialize_individually macro - but reverses the process, and includes some slight changes:


# #![allow(unused_variables)]
#fn main() {
macro_rules! deserialize_individually {
    ($ecs:expr, $de:expr, $data:expr, $( $type:ty),*) => {
        $(
        DeserializeComponents::<NoError, _>::deserialize(
            &mut ( &mut $ecs.write_storage::<$type>(), ),
            &mut $data.0, // entities
            &mut $data.1, // marker
            &mut $data.2, // allocater
            &mut $de,
        )
        .unwrap();
        )*
    };
}
#}

This is called from a new function, load_game:


# #![allow(unused_variables)]
#fn main() {
pub fn load_game(ecs: &mut World) {
    {
        // Delete everything
        let mut to_delete = Vec::new();
        for e in ecs.entities().join() {
            to_delete.push(e);
        }
        for del in to_delete.iter() {
            ecs.delete_entity(*del).expect("Deletion failed");
        }
    }

    let data = fs::read_to_string("./savegame.json").unwrap();
    let mut de = serde_json::Deserializer::from_str(&data);

    {
        let mut d = (&mut ecs.entities(), &mut ecs.write_storage::<SimpleMarker<SerializeMe>>(), &mut ecs.write_resource::<SimpleMarkerAllocator<SerializeMe>>());

        deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, 
            Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
            AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
            WantsToDropItem, SerializationHelper
        );
    }

    let mut deleteme : Option<Entity> = None;
    {
        let entities = ecs.entities();
        let helper = ecs.read_storage::<SerializationHelper>();
        let player = ecs.read_storage::<Player>();
        let position = ecs.read_storage::<Position>();
        for (e,h) in (&entities, &helper).join() {
            let mut worldmap = ecs.write_resource::<super::map::Map>();
            *worldmap = h.map.clone();
            worldmap.tile_content = vec![Vec::new(); super::map::MAPCOUNT];
            deleteme = Some(e);
        }
        for (e,_p,pos) in (&entities, &player, &position).join() {
            let mut ppos = ecs.write_resource::<rltk::Point>();
            *ppos = rltk::Point::new(pos.x, pos.y);
            let mut player_resource = ecs.write_resource::<Entity>();
            *player_resource = e;
        }
    }
    ecs.delete_entity(deleteme.unwrap()).expect("Unable to delete helper");
}
#}

That's quite the mouthful, so lets step through it:

  1. Inside a block (to keep the borrow checker happy), we iterate all entities in the game. We add them to a vector, and then iterate the vector - deleting the entities. This is a two-step process to avoid invalidating the iterator in the first pass.
  2. We open the savegame.json file, and attach a JSON deserializer.
  3. Then we build the tuple for the macro, which requires mutable access to the entities store, write access to the marker store, and an allocator (from Specs).
  4. Now we pass that to the macro we just made, which calls the de-serializer for each type in turn. Since we saved in the same order, it will pick up everything.
  5. Now we go into another block, to avoid borrow conflicts with the previous code and the entity deletion.
  6. We first iterate all entities with a SerializationHelper type. If we find it, we get access to the resource storing the map - and replace it. Since we aren't serializing tile_content, we replace it with an empty set of vectors.
  7. Then we find the player, by iterating entities with a Player type and a Position type. We store the world resources for the player entity and his/her position.
  8. Finally, we delete the helper entity - so we won't have a duplicate if we save the game again.

If you cargo run now, you can load your saved game!

Just add permadeath!

It wouldn't really be a roguelike if we let you keep your save game after you reload! So we'll add one more function to saveload_system:


# #![allow(unused_variables)]
#fn main() {
pub fn delete_save() {
    if Path::new("./savegame.json").exists() { std::fs::remove_file("./savegame.json").expect("Unable to delete file"); } 
}
#}

We'll add a call to main.rs to delete the save after we load the game:


# #![allow(unused_variables)]
#fn main() {
gui::MainMenuSelection::LoadGame => {
    saveload_system::load_game(&mut self.ecs);
    newrunstate = RunState::AwaitingInput;
    saveload_system::delete_save();
}
#}

Web Assembly

The example as-is will compile and run on the web assembly (wasm32) platform: but as soon as you try to save the game, it crashes. Unfortunately (well, fortunately if you like your computer not being attacked by every website you go to!), wasm is sandboxed - and doesn't have the ability to save files locally.

Supporting saving via LocalStorage (a browser/JavaScript feature) is planned for a future version of RLTK. In the meantime, we'll add some wrappers to avoid the crash - and simply not actually save the game on wasm32.

Rust offers conditional compilation (if you are familiar with C, it's a lot like the #define madness you find in big, cross-platform libraries). In saveload_system.rs, we'll modify save_game to only compile on non-web assembly platforms:


# #![allow(unused_variables)]
#fn main() {
#[cfg(not(target_arch = "wasm32"))]
pub fn save_game(ecs : &mut World) {
#}

That # tag is scary looking, but it makes sense if you unwrap it. #[cfg()] means "only compile if the current configuration matches the contents of the parentheses. not() inverts the result of a check, so when we check that target_arch = "wasm32") (are we compiling for wasm32) the result is inverted. The end result of this is that the function only compiles if you aren't building for wasm32.

That's all well and good, but there are calls to that function - so compilation on wasm will fail. We'll add a stub function to take its place:


# #![allow(unused_variables)]
#fn main() {
#[cfg(target_arch = "wasm32")]
pub fn save_game(_ecs : &mut World) {
}
#}

The #[cfg(target_arch = "wasm32")] prefix means "only compile this for web assembly". We've kept the function signature the same, but added a _ before _ecs - telling the compiler that we intend not to use that variable. Then we keep the function empty.

The result? You can compile for wasm32 and the save_game function simply doesn't do anything at all. The rest of the structure remains, so the game correctly returns to the main menu - but with no resume function.

(Why does the check that the file exists work? Rust is smart enough to say "no filesystem, so the file can't exist". Thanks, Rust!)

Wrap-up

This has been a long chapter, with quite heavy content. The great news is that we now have a framework for loading and saving the game whenever we want to. Adding components has gained some steps: we have to register them in main, tag them for Serialize, Deserialize, and remember to add them to our component type lists in saveload_system.rs. That could be easier - but it's a very solid foundation.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Delving Deeper


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


We have all the basics of a dungeon crawler now, but only having a single level is a big limitation! This chapter will introduce depth, with a new dungeon being spawned on each level down. We'll track the player's depth, and encourage ever-deeper exploration. What could possibly go wrong for the player?

Indicating - and storing - depth

We'll start by adding the current depth to the map. In map.rs, we adjust the Map structure to include an integer for depth:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
#}

i32 is a primitive type, and automatically handled by Serde - the serialization library. So adding it here automatically adds it to our game save/load mechanism. Our map creation code also needs to indicate that we are on level 1 of the map. We want to be able to use the map generator for additional levels, so we add in a parameter also. The updated function looks like this:


# #![allow(unused_variables)]
#fn main() {
pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map {
    let mut map = Map{
        tiles : vec![TileType::Wall; MAPCOUNT],
        rooms : Vec::new(),
        width : MAPWIDTH as i32,
        height: MAPHEIGHT as i32,
        revealed_tiles : vec![false; MAPCOUNT],
        visible_tiles : vec![false; MAPCOUNT],
        blocked : vec![false; MAPCOUNT],
        tile_content : vec![Vec::new(); MAPCOUNT],
        depth: new_depth
    };
    ...
#}

We call this from the setup code in main.rs, so we need to amend the call to the dungeon builder also:


# #![allow(unused_variables)]
#fn main() {
let map : Map = Map::new_map_rooms_and_corridors(1);
#}

That's it! Our maps now know about depth. You'll want to delete any savegame.json files you have lying around, since we've changed the format - loading will fail.

Showing the player their map depth

We'll modify the player's heads-up-display to indicate the current map depth. In gui.rs, inside the draw_ui function, we add the following:


# #![allow(unused_variables)]
#fn main() {
let map = ecs.fetch::<Map>();
let depth = format!("Depth: {}", map.depth);
ctx.print_color(2, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &depth);
#}

If you cargo run the project now, you'll see that we are showing you your current depth:

Screenshot

Adding down stairs

In map.rs, we have an enumeration - TileType - that lists the available tile types. We want to add a new one: down stairs. Modify the enumeration like this:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum TileType {
    Wall, Floor, DownStairs
}
#}

We also want to be able to render the stairs. map.rs contains draw_map, and adding a tile type is a relatively simple task:


# #![allow(unused_variables)]
#fn main() {
match tile {
    TileType::Floor => {
        glyph = rltk::to_cp437('.');
        fg = RGB::from_f32(0.0, 0.5, 0.5);
    }
    TileType::Wall => {
        glyph = rltk::to_cp437('#');
        fg = RGB::from_f32(0., 1.0, 0.);
    }
    TileType::DownStairs => {
        glyph = rltk::to_cp437('>');
        fg = RGB::from_f32(0., 1.0, 1.0);
    }
}
#}

Lastly, we should place the down stairs. We place the up stairs in the center of the first room the map generates - so we'll place the stairs in the center of the last room! Going back to new_map_rooms_and_corridors in map.rs, we modify it like this:


# #![allow(unused_variables)]
#fn main() {
pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map {
    let mut map = Map{
        tiles : vec![TileType::Wall; MAPCOUNT],
        rooms : Vec::new(),
        width : MAPWIDTH as i32,
        height: MAPHEIGHT as i32,
        revealed_tiles : vec![false; MAPCOUNT],
        visible_tiles : vec![false; MAPCOUNT],
        blocked : vec![false; MAPCOUNT],
        tile_content : vec![Vec::new(); MAPCOUNT],
        depth: new_depth
    };

    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for i in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, map.width - w - 1) - 1;
        let y = rng.roll_dice(1, map.height - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in map.rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            map.apply_room_to_map(&new_room);

            if !map.rooms.is_empty() {
                let (new_x, new_y) = new_room.center();
                let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
                if rng.range(0,2) == 1 {
                    map.apply_horizontal_tunnel(prev_x, new_x, prev_y);
                    map.apply_vertical_tunnel(prev_y, new_y, new_x);
                } else {
                    map.apply_vertical_tunnel(prev_y, new_y, prev_x);
                    map.apply_horizontal_tunnel(prev_x, new_x, new_y);
                }
            }

            map.rooms.push(new_room);
        }
    }

    let stairs_position = map.rooms[map.rooms.len()-1].center();
    let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1);
    map.tiles[stairs_idx] = TileType::DownStairs;

    map
}
#}

If you cargo run the project now, and run around a bit - you can find a set of down stairs! They don't do anything yet, but they are on the map.

Screenshot

Actually going down a level

In player.rs, we have a big match statement that handles user input. Lets bind going to the next level to the period key (on US keyboards, that's > without the shift). Add this to the match:


# #![allow(unused_variables)]
#fn main() {
// Level changes
VirtualKeyCode::Period => {
    if try_next_level(&mut gs.ecs) {
        return RunState::NextLevel;
    }
}
#}

Of course, now we need to implement try_next_level:


# #![allow(unused_variables)]
#fn main() {
pub fn try_next_level(ecs: &mut World) -> bool {
    let player_pos = ecs.fetch::<Point>();
    let map = ecs.fetch::<Map>();
    let player_idx = map.xy_idx(player_pos.x, player_pos.y);
    if map.tiles[player_idx] == TileType::DownStairs {
        true
    } else {
        let mut gamelog = ecs.fetch_mut::<GameLog>();
        gamelog.entries.push("There is no way down from here.".to_string());
        false
    }
}
#}

The eagle-eyed programmer will notice that we returned a new RunState - NextLevel. Since that doesn't exist yet, we'll open main.rs and implement it:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, 
    PreRun, 
    PlayerTurn, 
    MonsterTurn, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel
}
#}

Your IDE is by now complaining that we haven't actually implemented the new RunState! So we go into our ever-growing state handler in main.rs and add:


# #![allow(unused_variables)]
#fn main() {
RunState::NextLevel => {
    self.goto_next_level();                
    newrunstate = RunState::PreRun;
}
#}

We'll add a new impl section for State, so we can attach methods to it. We're first going to create a helper method:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> {
        let entities = self.ecs.entities();
        let player = self.ecs.read_storage::<Player>();
        let backpack = self.ecs.read_storage::<InBackpack>();
        let player_entity = self.ecs.fetch::<Entity>();

        let mut to_delete : Vec<Entity> = Vec::new();
        for entity in entities.join() {
            let mut should_delete = true;

            // Don't delete the player
            let p = player.get(entity);
            if let Some(_p) = p {
                should_delete = false;
            }

            // Don't delete the player's equipment
            let bp = backpack.get(entity);
            if let Some(bp) = bp {
                if bp.owner == *player_entity {
                    should_delete = false;
                }
            }

            if should_delete { 
                to_delete.push(entity);
            }
        }

        to_delete
    }
}
#}

When we go to the next level, we want to delete all the entities - except for the player and whatever equipment the player has. This helper function queries the ECS to obtain a list of entities for deletion. It's a bit long-winded, but relatively straightforward: we make a vector, and then iterate all entities. If the entity is the player, we mark it as should_delete=false. If it is in a backpack (having the InBackpack component), we check to see if the owner is the player - and if it is, we don't delete it.

Armed with that, we go to create the goto_next_level function, also inside the State implementation:


# #![allow(unused_variables)]
#fn main() {
fn goto_next_level(&mut self) {
    // Delete entities that aren't the player or his/her equipment
    let to_delete = self.entities_to_remove_on_level_change();
    for target in to_delete {
        self.ecs.delete_entity(target).expect("Unable to delete entity");
    }

    // Build a new map and place the player
    let worldmap;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        let current_depth = worldmap_resource.depth;
        *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
        worldmap = worldmap_resource.clone();
    }

    // Spawn bad guys
    for room in worldmap.rooms.iter().skip(1) {
        spawner::spawn_room(&mut self.ecs, room);
    }

    // Place the player and update resources
    let (player_x, player_y) = worldmap.rooms[0].center();
    let mut player_position = self.ecs.write_resource::<Point>();
    *player_position = Point::new(player_x, player_y);
    let mut position_components = self.ecs.write_storage::<Position>();
    let player_entity = self.ecs.fetch::<Entity>();
    let player_pos_comp = position_components.get_mut(*player_entity);
    if let Some(player_pos_comp) = player_pos_comp {
        player_pos_comp.x = player_x;
        player_pos_comp.y = player_y;
    }

    // Mark the player's visibility as dirty
    let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
    let vs = viewshed_components.get_mut(*player_entity);
    if let Some(vs) = vs {
        vs.dirty = true;
    }        

    // Notify the player and give them some health
    let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
    gamelog.entries.push("You descend to the next level, and take a moment to heal.".to_string());
    let mut player_health_store = self.ecs.write_storage::<CombatStats>();
    let player_health = player_health_store.get_mut(*player_entity);
    if let Some(player_health) = player_health {
        player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2);
    }
}
#}

This is a long function, but does everything we need. Lets break it down step-by-step:

  1. We use the helper function we just wrote to obtain a list of entities to delete, and ask the ECS to dispose of them.
  2. We create a worldmap variable, and enter a new scope. Otherwise, we get issues with immutable vs. mutable borrowing of the ECS.
  3. In this scope, we obtain a writable reference to the resource for the current Map. We get the current level, and replace the map with a new one - with current_depth + 1 as the new depth. We then store a clone of this in the outer variable and exit the scope (avoiding any borrowing/lifetime issues).
  4. Now we use the same code we used in the initial setup to spawn bad guys and items in each room.
  5. Now we obtain the location of the first room, and update our resources for the player to set his/her location to the center of it. We also grab the player's Position component and update it.
  6. We obtain the player's Viewshed component, since it will be out of date now that the entire map has changed around him/her! We mark it as dirty - and will let the various systems take care of the rest.
  7. We give the player a log entry that they have descended to the next level.
  8. We obtain the player's health component, and if their health is less than 50% - boost it to half.

If you cargo run the project now, you can run around and descend levels. Your depth indicator goes up - telling you that you are doing something right!

Screenshot

Wrapping Up

This chapter was a bit easier than the last couple! You can now descend through an effectively infinite (it's really bounded by the size of a 32-bit integer, but good luck getting through that many levels) dungeon. We've seen how the ECS can help, and how our serialization work readily expands to include new features like this one as we add to the project.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Difficulty


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Currently, you can advance through multiple dungeon levels - but they all have the same spawns. There's no ramp-up of difficulty as you advance, and no easy-mode to get you through the beginning. This chapter aims to change that.

Adding a wait key

An important tactical element of most roguelikes is the ability to skip a turn - let the monsters come to you (and not get the first hit!). As part of turning the game into a more tactical challenge, lets quickly implement turn skipping. In player.rs (along with the rest of the input), we'll add numeric keypad 5 and space to be skip:


# #![allow(unused_variables)]
#fn main() {
// Skip Turn
VirtualKeyCode::Numpad5 => return RunState::PlayerTurn,
VirtualKeyCode::Space => return RunState::PlayerTurn,
#}

This adds a nice tactical dimension to the game: you can lure enemies towards you, and benefit from tactical placement. Another frequently found feature of roguelikes is waiting providing some healing if there are no enemies nearby. We'll only implement that for the player, since mobs suddenly healing up is disconcerting! So we'll change that to:


# #![allow(unused_variables)]
#fn main() {
// Skip Turn
VirtualKeyCode::Numpad5 => return skip_turn(&mut gs.ecs),
VirtualKeyCode::Space => return skip_turn(&mut gs.ecs),
#}

Now we implement skip_turn:


# #![allow(unused_variables)]
#fn main() {
fn skip_turn(ecs: &mut World) -> RunState {
    let player_entity = ecs.fetch::<Entity>();
    let viewshed_components = ecs.read_storage::<Viewshed>();
    let monsters = ecs.read_storage::<Monster>();

    let worldmap_resource = ecs.fetch::<Map>();

    let mut can_heal = true;
    let viewshed = viewshed_components.get(*player_entity).unwrap();
    for tile in viewshed.visible_tiles.iter() {
        let idx = worldmap_resource.xy_idx(tile.x, tile.y);
        for entity_id in worldmap_resource.tile_content[idx].iter() {
            let mob = monsters.get(*entity_id);
            match mob {
                None => {}
                Some(_) => { can_heal = false; }
            }
        }
    }

    if can_heal {
        let mut health_components = ecs.write_storage::<CombatStats>();
        let player_hp = health_components.get_mut(*player_entity).unwrap();
        player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp);
    }

    RunState::PlayerTurn
}
#}

This looks up various entities, and then iterates the player's viewshed using the tile_content system. It checks what the player can see for monsters; if no monster is present, it heals the player by 1 hp. This encourages cerebral play - and can be balanced with the inclusion of a hunger clock at a later date. It also makes the game really easy - but we're getting to that!

Increased difficulty as you delve: spawn tables

Thus far, we've been using a simple spawn system: it randomly picks a number of monsters and items, and then picks each with an equal weight. That's not much like "normal" games, which tend to make some things rare - and some things common. We'll create a generic random_table system, for use in the spawn system. Create a new file, random_table.rs and put the following in it:


# #![allow(unused_variables)]
#fn main() {
use rltk::RandomNumberGenerator;

pub struct RandomEntry {
    name : String,
    weight : i32
}

impl RandomEntry {
    pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry {
        RandomEntry{ name: name.to_string(), weight }
    }
}

#[derive(Default)]
pub struct RandomTable {
    entries : Vec<RandomEntry>,
    total_weight : i32
}

impl RandomTable {
    pub fn new() -> RandomTable {
        RandomTable{ entries: Vec::new(), total_weight: 0 }
    }

    pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable {
        self.total_weight += weight;
        self.entries.push(RandomEntry::new(name.to_string(), weight));
        self
    }

    pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String {
        if self.total_weight == 0 { return "None".to_string(); }
        let mut roll = rng.roll_dice(1, self.total_weight)-1;
        let mut index : usize = 0;

        while roll > 0 {
            if roll < self.entries[index].weight {
                return self.entries[index].name.clone();
            }

            roll -= self.entries[index].weight;
            index += 1;
        }

        "None".to_string()
    }
}
#}

So this creates a new type, random_table. It adds a new method to it, to facilitate making a new one. It also creates a vector or entries, each of which has a weight and a name (passing strings around isn't very efficient, but makes for clear example code!). It also implements an add function that lets you pass in a new name and weight, and updates the structure's total_weight. Finally, roll makes a dice roll from 0 .. total_weight - 1, and iterates through entries. If the roll is below the weight, it returns it - otherwise, it reduces the roll by the weight and tests the next entry. This gives a chance equal to the relative weight of the entry for any given item in the table. There's a bit of extra work in there to help chain methods together, for the Rust-like look of chained function calls. We'll use it in spawner.rs to create a new function, room_table:


# #![allow(unused_variables)]
#fn main() {
fn room_table() -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2)
        .add("Confusion Scroll", 2)
        .add("Magic Missile Scroll", 4)
}
#}

This contains all of the items and monsters we've added so far, with a weight attached. I wasn't very careful with these weights; we'll play with them later! It does mean that a call to room_table().roll(rng) will return a random room entry.

Now we simplify a bit. Delete the NUM_MONSTERS, random_monster and random_item functions in spawner.rs. Then we replace the room spawning code with:


# #![allow(unused_variables)]
#fn main() {
#[allow(clippy::map_entry)]
pub fn spawn_room(ecs: &mut World, room : &Rect) {
    let spawn_table = room_table();
    let mut spawn_points : HashMap<usize, String> = HashMap::new();

    // Scope to keep the borrow checker happy
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) - 3;

        for _i in 0 .. num_spawns {
            let mut added = false;
            let mut tries = 0;
            while !added && tries < 20 {
                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
                let idx = (y * MAPWIDTH) + x;
                if !spawn_points.contains_key(&idx) {
                    spawn_points.insert(idx, spawn_table.roll(&mut rng));
                    added = true;
                } else {
                    tries += 1;
                }
            }
        }
    }

    // Actually spawn the monsters
    for spawn in spawn_points.iter() {
        let x = (*spawn.0 % MAPWIDTH) as i32;
        let y = (*spawn.0 / MAPWIDTH) as i32;

        match spawn.1.as_ref() {
            "Goblin" => goblin(ecs, x, y),
            "Orc" => orc(ecs, x, y),
            "Health Potion" => health_potion(ecs, x, y),
            "Fireball Scroll" => fireball_scroll(ecs, x, y),
            "Confusion Scroll" => confusion_scroll(ecs, x, y),
            "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
            _ => {}
        }
    }
}
#}

Lets work through this:

  1. The first line tells the Rust linter that we really do like to check a HashMap for membership and then insert into it - we also set a flag, which doesn't work well with its suggestion.
  2. We obtain the global random number generator, and set the number of spawns to be 1d7-3 (for a -2 to 4 range).
  3. For each spawn above 0, we pick a random point in the room. We keep picking random points until we find an empty one (or we exceed 20 tries, in which case we give up). Once we find a point, we add it to the spawn list with a location and a roll from our random table.
  4. Then we iterate the spawn list, match on the roll result and spawn monsters and items.

This is definitely cleaner than the previous approach, and now you are less likely to run into orcs - and more likely to run into goblins and health potions.

A quick cargo run shows you the improved spawn variety.

Increasing the spawn rate as you delve

That gave a nicer distribution, but didn't solve the problem of later levels being of the same difficulty as earlier ones. A quick and dirty approach is to spawn more entities as you descend. That still doesn't solve the problem, but it's a start! We'll start by modifying the function signature of spawn_room to accept the map depth:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) {
#}

Then we'll change the number of entities that spawn to use this:


# #![allow(unused_variables)]
#fn main() {
let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3;
#}

We'll have to change a couple of calls in main.rs to pass in the depth:


# #![allow(unused_variables)]
#fn main() {
for room in map.rooms.iter().skip(1) {
    spawner::spawn_room(&mut gs.ecs, room, 1);
}
#}

# #![allow(unused_variables)]
#fn main() {
// Build a new map and place the player
let worldmap;
let current_depth;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
    worldmap = worldmap_resource.clone();
}

// Spawn bad guys
for room in worldmap.rooms.iter().skip(1) {
    spawner::spawn_room(&mut self.ecs, room, current_depth+1);
}
#}

If you cargo run now, the first level is quite quiet. Difficulty ramps up a bit as you descend, until you have veritable hordes of monsters!

Increasing the weights by depth

Let's modify the room_table function to include map depth:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
}
#}

We also change the call to it in spawn_room to use it:


# #![allow(unused_variables)]
#fn main() {
let spawn_table = room_table(map_depth);
#}

A cargo build later, and voila - you have an increasing probability of finding orcs, fireball and confusion scrolls as you descend. The total weight of goblins, health potions and magic missile scrolls remains the same - but because the others change, their total likelihood diminishes.

Wrapping Up

You now have a dungeon that increases in difficulty as you descend! In the next chapter, we'll look at giving your character some progression as well (through equipment), to balance things out.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Equipping The Player


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Now that we have a dungeon with increasing difficulty, it's time to start giving the player some ways to improve their performance! In this chapter, we'll start with the most basic of human tasks: equipping a weapon and shield.

Adding some items you can wear/wield

We already have a lot of the item system in place, so we'll build upon the foundation from previous chapters. Just using components we already have, we can start with the following in spawners.rs:


# #![allow(unused_variables)]
#fn main() {
fn dagger(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('/'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Dagger".to_string() })
        .with(Item{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}

fn shield(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('('),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Shield".to_string() })
        .with(Item{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

In both cases, we're making a new entity. We give it a Position, because it has to start somewhere on the map. We assign a Renderable, set to appropriate CP437/ASCII glyphs. We give them a name, and mark them as items. We can add them to the spawn table like this:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
}
#}

We can also include them in the system that actually spawns them quite readily:


# #![allow(unused_variables)]
#fn main() {
// Actually spawn the monsters
for spawn in spawn_points.iter() {
    let x = (*spawn.0 % MAPWIDTH) as i32;
    let y = (*spawn.0 / MAPWIDTH) as i32;

    match spawn.1.as_ref() {
        "Goblin" => goblin(ecs, x, y),
        "Orc" => orc(ecs, x, y),
        "Health Potion" => health_potion(ecs, x, y),
        "Fireball Scroll" => fireball_scroll(ecs, x, y),
        "Confusion Scroll" => confusion_scroll(ecs, x, y),
        "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
        "Dagger" => dagger(ecs, x, y),
        "Shield" => shield(ecs, x, y),
        _ => {}
    }
}
#}

If you cargo run the project now, you can run around and eventually find a dagger or shield. You might consider raising the spawn frequency from 3 to a really big number while you test! Since we've added the Item tag, you can pick up and drop these items when you find them.

Screenshot

Equipping The Item

Daggers and shields aren't too useful if you can't use them! So lets make them equippable.

Equippable Component

We need a way to indicate that an item can be equipped. You've probably guessed by now, but we add a new component! In components.rs, we add:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum EquipmentSlot { Melee, Shield }

#[derive(Component, Serialize, Deserialize, Clone)]
pub struct Equippable {
    pub slot : EquipmentSlot
}
#}

We also have to remember to register it in a few places, now that we have serialization support (from chapter 11). In main.rs, we add it to the list of registered components:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.register::<Equippable>();
#}

In saveload_system.rs, we add it to both sets of component lists:


# #![allow(unused_variables)]
#fn main() {
serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, 
    Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
    AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
    WantsToDropItem, SerializationHelper, Equippable
);
#}

# #![allow(unused_variables)]
#fn main() {
deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, 
    Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
    AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
    WantsToDropItem, SerializationHelper, Equippable
);
#}

Finally, we should add the Equippable component to our dagger and shield functions in spawner.rs:


# #![allow(unused_variables)]
#fn main() {
fn dagger(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('/'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Dagger".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Melee })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}

fn shield(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('('),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Shield".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Shield })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

Making items equippable

Generally, having a shield in your backpack doesn't help much (obvious "how did you fit it in there?" questions aside - like many games, we'll gloss over that one!) - so you have to be able to pick one to equip. We'll start by making another component, Equipped. This works in a similar fashion to InBackpack - it indicates that an entity is holding it. Unlike InBackpack, it will indicate what slot is in use. Here's the basic Equipped component, in components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, ConvertSaveload, Clone)]
pub struct Equipped {
    pub owner : Entity,
    pub slot : EquipmentSlot
}
#}

Just like before, we need to register it in main.rs, and include it in the serialization and deserialization lists in saveload_system.rs. Since this includes an Entity, we'll also have a to include wrapper/helper code to make serialization work. The wrapper is a lot like others we've written - it converts Equipped into a tuple for save, and back again for loading:


# #![allow(unused_variables)]
#fn main() {
// Equipped wrapper
#[derive(Serialize, Deserialize, Clone)]
pub struct EquippedData<M>(M, EquipmentSlot);

impl<M: Marker + Serialize> ConvertSaveload<M> for Equipped
where
    for<'de> M: Deserialize<'de>,
{
    type Data = EquippedData<M>;
    type Error = NoError;

    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
    where
        F: FnMut(Entity) -> Option<M>,
    {
        let marker = ids(self.owner).unwrap();
        Ok(EquippedData(marker, self.slot))
    }

    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
    where
        F: FnMut(M) -> Option<Entity>,
    {
        let entity = ids(data.0).unwrap();
        Ok(Equipped{owner: entity, slot : data.1})
    }
}
#}

Actually equipping the item

Now we want to make it possible to actually equip the item. Doing so will automatically unequip any item in the same slot. We'll do this through the same interface we already have for using items, so we don't have disparate menus everywhere. Open inventory_system.rs, and we'll edit ItemUseSystem. We'll start by expanding the list of systems we are referencing:


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for ItemUseSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        ReadExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToUseItem>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Consumable>,
                        ReadStorage<'a, ProvidesHealing>,
                        ReadStorage<'a, InflictsDamage>,
                        WriteStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, AreaOfEffect>,
                        WriteStorage<'a, Confusion>,
                        ReadStorage<'a, Equippable>,
                        WriteStorage<'a, Equipped>,
                        WriteStorage<'a, InBackpack>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, map, entities, mut wants_use, names, 
            consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, 
            aoe, mut confused, equippable, mut equipped, mut backpack) = data;
#}

Now, after target acquisition, add the following block:


# #![allow(unused_variables)]
#fn main() {
// If it is equippable, then we want to equip it - and unequip whatever else was in that slot
let item_equippable = equippable.get(useitem.item);
match item_equippable {
    None => {}
    Some(can_equip) => {
        let target_slot = can_equip.slot;
        let target = targets[0];

        // Remove any items the target has in the item's slot
        let mut to_unequip : Vec<Entity> = Vec::new();
        for (item_entity, already_equipped, name) in (&entities, &equipped, &names).join() {
            if already_equipped.owner == target && already_equipped.slot == target_slot {
                to_unequip.push(item_entity);
                if target == *player_entity {
                    gamelog.entries.push(format!("You unequip {}.", name.name));
                }
            }
        }
        for item in to_unequip.iter() {
            equipped.remove(*item);
            backpack.insert(*item, InBackpack{ owner: target }).expect("Unable to insert backpack entry");
        }

        // Wield the item
        equipped.insert(useitem.item, Equipped{ owner: target, slot: target_slot }).expect("Unable to insert equipped component");
        backpack.remove(useitem.item);
        if target == *player_entity {
            gamelog.entries.push(format!("You equip {}.", names.get(useitem.item).unwrap().name));
        }
    }
}
#}

This starts by matching to see if we can equip the item. If we can, it looks up the target slot for the item and looks to see if there is already an item in that slot. If there, it moves it to the backpack. Lastly, it adds an Equipped component to the item entity with the owner (the player right now) and the appropriate slot.

Lastly, you may remember that when the player moves to the next level we delete a lot of entities. We want to include Equipped by the player as a reason to keep an item in the ECS. In main.rs, we modify entities_to_remove_on_level_change as follows:


# #![allow(unused_variables)]
#fn main() {
fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> {
    let entities = self.ecs.entities();
    let player = self.ecs.read_storage::<Player>();
    let backpack = self.ecs.read_storage::<InBackpack>();
    let player_entity = self.ecs.fetch::<Entity>();
    let equipped = self.ecs.read_storage::<Equipped>();

    let mut to_delete : Vec<Entity> = Vec::new();
    for entity in entities.join() {
        let mut should_delete = true;

        // Don't delete the player
        let p = player.get(entity);
        if let Some(_p) = p {
            should_delete = false;
        }

        // Don't delete the player's equipment
        let bp = backpack.get(entity);
        if let Some(bp) = bp {
            if bp.owner == *player_entity {
                should_delete = false;
            }
        }

        let eq = equipped.get(entity);
        if let Some(eq) = eq {
            if eq.owner == *player_entity {
                should_delete = false;
            }
        }

        if should_delete { 
            to_delete.push(entity);
        }
    }

    to_delete
}
#}

If you cargo run the project now, you can run around picking up the new items - and you can equip them. They don't do anything, yet - but at least you can swap them in and out. The game log will show equipping and unequipping.

Screenshot

Granting combat bonuses

Logically, a shield should provide some protection against incoming damage - and being stabbed with a dagger should hurt more than being punched! To facilitate this, we'll add some more components (this should be a familiar song by now). In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, ConvertSaveload, Clone)]
pub struct MeleePowerBonus {
    pub power : i32
}

#[derive(Component, ConvertSaveload, Clone)]
pub struct DefenseBonus {
    pub defense : i32
}
#}

We also need to remember to register them in main.rs, and saveload_system.rs. We can then modify our code in spawner.rs to add these components to the right items:


# #![allow(unused_variables)]
#fn main() {
fn dagger(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('/'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Dagger".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Melee })
        .with(MeleePowerBonus{ power: 2 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}

fn shield(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('('),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Shield".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Shield })
        .with(DefenseBonus{ defense: 1 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

Notice how we've added the component to each? Now we need to modify the melee_combat_system to apply these bonuses. We do this by adding some additional ECS queries to our system:


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for MeleeCombatSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToMelee>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, MeleePowerBonus>,
                        ReadStorage<'a, DefenseBonus>,
                        ReadStorage<'a, Equipped>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, melee_power_bonuses, defense_bonuses, equipped) = data;

        for (entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
            if stats.hp > 0 {
                let mut offensive_bonus = 0;
                for (_item_entity, power_bonus, equipped_by) in (&entities, &melee_power_bonuses, &equipped).join() {
                    if equipped_by.owner == entity {
                        offensive_bonus += power_bonus.power;
                    }
                }

                let target_stats = combat_stats.get(wants_melee.target).unwrap();
                if target_stats.hp > 0 {
                    let target_name = names.get(wants_melee.target).unwrap();

                    let mut defensive_bonus = 0;
                    for (_item_entity, defense_bonus, equipped_by) in (&entities, &defense_bonuses, &equipped).join() {
                        if equipped_by.owner == wants_melee.target {
                            defensive_bonus += defense_bonus.defense;
                        }
                    }

                    let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus));
#}

This is a big chunk of code, so lets go through it:

  1. We've added MeleePowerBonus, DefenseBonus and Equipped readers to the system.
  2. Once we've determined that the attacker is alive, we set offensive_bonus to 0.
  3. We iterate all entities that have a MeleePowerBonus and an Equipped entry. If they are equipped by the attacker, we add their power bonus to offensive_bonus.
  4. Once we've determined that the defender is alive, we set defensive_bonus to 0.
  5. We iterate all entities that have a DefenseBonus and an Equipped entry. If they are equipped by the target, we add their defense to the defense_bonus.
  6. When we calculate damage, we add the offense bonus to the power side - and add the defense bonus to the defense side.

If you cargo run now, you'll find that using your dagger makes you hit harder - and using your shield makes you suffer less damage.

Unequipping the item

Now that you can equip items, and remove the by swapping, you may want to stop holding an item and return it to your backpack. In a game as simple as this one, this isn't strictly necessary - but it is a good option to have for the future. We'll bind the R key to remove an item, since that key is available. In player.rs, add this to the input code:


# #![allow(unused_variables)]
#fn main() {
VirtualKeyCode::R => return RunState::ShowRemoveItem,
#}

Now we add ShowRemoveItem to RunState in main.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, 
    PreRun, 
    PlayerTurn, 
    MonsterTurn, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    ShowRemoveItem
}
#}

And we add a handler for it in tick:


# #![allow(unused_variables)]
#fn main() {
RunState::ShowRemoveItem => {
    let result = gui::remove_item_menu(self, ctx);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let item_entity = result.1.unwrap();
            let mut intent = self.ecs.write_storage::<WantsToRemoveItem>();
            intent.insert(*self.ecs.fetch::<Entity>(), WantsToRemoveItem{ item: item_entity }).expect("Unable to insert intent");
            newrunstate = RunState::PlayerTurn;
        }
    }
}
#}

We'll implement a new component in components.rs (see the source code for the serialization handler; it's a cut-and-paste of the handler for wanting to drop an item, with the names changed):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToRemoveItem {
    pub item : Entity
}
#}

As usual, it has to be registered in main.rs and saveload_system.rs.

Now in gui.rs, we'll implement remove_item_menu. It's almost exactly the same as the item dropping menu, but changing what is queries and the heading (it'd be a great idea to make these into more generic functions some time!):


# #![allow(unused_variables)]
#fn main() {
pub fn remove_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let names = gs.ecs.read_storage::<Name>();
    let backpack = gs.ecs.read_storage::<Equipped>();
    let entities = gs.ecs.entities();

    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
    let count = inventory.count();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Remove Which Item?");
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");

    let mut equippable : Vec<Entity> = Vec::new();
    let mut j = 0;
    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &name.name.to_string());
        equippable.push(entity);
        y += 1;
        j += 1;
    }

    match ctx.key {
        None => (ItemMenuResult::NoResponse, None),
        Some(key) => {
            match key {
                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
                _ => { 
                    let selection = rltk::letter_to_option(key);
                    if selection > -1 && selection < count as i32 {
                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
                    }  
                    (ItemMenuResult::NoResponse, None)
                }
            }
        }
    }
}
#}

Next, we should extend inventory_system.rs to support removing items. Fortunately, this is a very simple system:


# #![allow(unused_variables)]
#fn main() {
pub struct ItemRemoveSystem {}

impl<'a> System<'a> for ItemRemoveSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( 
                        Entities<'a>,
                        WriteStorage<'a, WantsToRemoveItem>,
                        WriteStorage<'a, Equipped>,
                        WriteStorage<'a, InBackpack>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut wants_remove, mut equipped, mut backpack) = data;

        for (entity, to_remove) in (&entities, &wants_remove).join() {
            equipped.remove(to_remove.item);
            backpack.insert(to_remove.item, InBackpack{ owner: entity }).expect("Unable to insert backpack");
        }

        wants_remove.clear();
    }
}
#}

Lastly, we add it to the systems in main.rs:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        let mut mob = MonsterAI{};
        mob.run_now(&self.ecs);
        let mut mapindex = MapIndexingSystem{};
        mapindex.run_now(&self.ecs);
        let mut melee = MeleeCombatSystem{};
        melee.run_now(&self.ecs);
        let mut damage = DamageSystem{};
        damage.run_now(&self.ecs);
        let mut pickup = ItemCollectionSystem{};
        pickup.run_now(&self.ecs);
        let mut itemuse = ItemUseSystem{};
        itemuse.run_now(&self.ecs);
        let mut drop_items = ItemDropSystem{};
        drop_items.run_now(&self.ecs);
        let mut item_remove = ItemRemoveSystem{};
        item_remove.run_now(&self.ecs);

        self.ecs.maintain();
    }
}
#}

Now if you cargo run, you can pick up a dagger or shield and equip it. Then you can press R to remove it.

Adding some more powerful gear later

Lets add a couple more items, in spawner.rs:


# #![allow(unused_variables)]
#fn main() {
fn longsword(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('/'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Longsword".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Melee })
        .with(MeleePowerBonus{ power: 4 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}

fn tower_shield(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('('),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Tower Shield".to_string() })
        .with(Item{})
        .with(Equippable{ slot: EquipmentSlot::Shield })
        .with(DefenseBonus{ defense: 3 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

We're going to add a quick fix to random_table.rs to ignore entries with 0 or lower spawn chances:


# #![allow(unused_variables)]
#fn main() {
pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable {
    if weight > 0 {
        self.total_weight += weight;
        self.entries.push(RandomEntry::new(name.to_string(), weight));
    }
    self
}
#}

And back in spawner.rs, we'll add them to the loot table - with a chance of appearing later in the dungeon:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
        .add("Longsword", map_depth - 1)
        .add("Tower Shield", map_depth - 1)
}
#}

# #![allow(unused_variables)]
#fn main() {
match spawn.1.as_ref() {
    "Goblin" => goblin(ecs, x, y),
    "Orc" => orc(ecs, x, y),
    "Health Potion" => health_potion(ecs, x, y),
    "Fireball Scroll" => fireball_scroll(ecs, x, y),
    "Confusion Scroll" => confusion_scroll(ecs, x, y),
    "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
    "Dagger" => dagger(ecs, x, y),
    "Shield" => shield(ecs, x, y),
    "Longsword" => longsword(ecs, x, y),
    "Tower Shield" => tower_shield(ecs, x, y),
    _ => {}
}
#}

Now as you descend further, you can find better weapons and shields!

The game over screen

We're nearly at the end of the basic tutorial, so lets make something happen when you die - rather than locking up in a console loop. In the file damage_system.rs, we'll edit the match statement on player for delete_the_dead:


# #![allow(unused_variables)]
#fn main() {
match player {
    None => {
        let victim_name = names.get(entity);
        if let Some(victim_name) = victim_name {
            log.entries.push(format!("{} is dead", &victim_name.name));
        }
        dead.push(entity)
    }
    Some(_) => {
        let mut runstate = ecs.write_resource::<RunState>();
        *runstate = RunState::GameOver;
    }
}
#}

Of course, we now have to go to main.rs and add the new state:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, 
    PreRun, 
    PlayerTurn, 
    MonsterTurn, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    ShowRemoveItem,
    GameOver
}
#}

We'll add that to the state implementation, also in main.rs:


# #![allow(unused_variables)]
#fn main() {
RunState::GameOver => {
    let result = gui::game_over(ctx);
    match result {
        gui::GameOverResult::NoSelection => {}
        gui::GameOverResult::QuitToMenu => {
            self.game_over_cleanup();
            newrunstate = RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame };
        }
    }
}
#}

That's relatively straightforward: we call game_over to render the menu, and when you quit we delete everything in the ECS. Lastly, in gui.rs we'll implement game_over:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum GameOverResult { NoSelection, QuitToMenu }

pub fn game_over(ctx : &mut Rltk) -> GameOverResult {
    ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Your journey has ended!");
    ctx.print_color_centered(17, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "One day, we'll tell you all about how you did.");
    ctx.print_color_centered(18, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "That day, sadly, is not in this chapter..");

    ctx.print_color_centered(20, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Press any key to return to the menu.");

    match ctx.key {
        None => GameOverResult::NoSelection,
        Some(_) => GameOverResult::QuitToMenu
    }
}
#}

Lastly, we'll handle game_over_cleanup:


# #![allow(unused_variables)]
#fn main() {
fn game_over_cleanup(&mut self) {
    // Delete everything
    let mut to_delete = Vec::new();
    for e in self.ecs.entities().join() {
        to_delete.push(e);
    }
    for del in to_delete.iter() {
        self.ecs.delete_entity(*del).expect("Deletion failed");
    }

    // Build a new map and place the player
    let worldmap;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        *worldmap_resource = Map::new_map_rooms_and_corridors(1);
        worldmap = worldmap_resource.clone();
    }

    // Spawn bad guys
    for room in worldmap.rooms.iter().skip(1) {
        spawner::spawn_room(&mut self.ecs, room, 1);
    }

    // Place the player and update resources
    let (player_x, player_y) = worldmap.rooms[0].center();
    let player_entity = spawner::player(&mut self.ecs, player_x, player_y);
    let mut player_position = self.ecs.write_resource::<Point>();
    *player_position = Point::new(player_x, player_y);
    let mut position_components = self.ecs.write_storage::<Position>();
    let mut player_entity_writer = self.ecs.write_resource::<Entity>();
    *player_entity_writer = player_entity;
    let player_pos_comp = position_components.get_mut(player_entity);
    if let Some(player_pos_comp) = player_pos_comp {
        player_pos_comp.x = player_x;
        player_pos_comp.y = player_y;
    }

    // Mark the player's visibility as dirty
    let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
    let vs = viewshed_components.get_mut(player_entity);
    if let Some(vs) = vs {
        vs.dirty = true;
    }                                               
}
#}

This should look familiar from our serialization work when loading the game. It's very similar, but it generates a new player.

If you cargo run now, and die - you'll get a message informing you that the game is done, and sending you back to the menu.

Screenshot

Wrapping Up

That's it for the first section of the tutorial. It sticks relatively closely to the Python tutorial, and takes you from "hello rust" to a moderately fun Roguelike. I hope you've enjoyed it! Stay tuned, I hope to add a section 2 soon.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Section 2 - Stretch Goals


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


I've been enjoying writing this tutorial, and people are using it (thank you!) - so I decided to keep adding content. Section 2 is more of a smorgasbord of content than a structured tutorial. I'll keep adding content as we try to build a great roguelike as a community.

Please feel free to contact me (I'm @herberticus on Twitter) if you have any questions, ideas for improvements, or things you'd like me to add. Also, sorry about all the Patreon spam - hopefully someone will find this sufficiently useful to feel like throwing a coffee or two my way. :-)


Copyright (C) 2019, Herbert Wolverson.


Nicer Walls


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


So far, we've used a very traditional rendering style for the map. Hash symbols for walls, periods for floors. It looks pretty nice, but games like Dwarf Fortress do a lovely job of using codepage 437's line-drawing characters to make the walls of the dungeon look smooth. This short chapter will show how to use a bitmask to calculate appropriate walls and render them appropriately. As usual, we'll start with our previous code from the end of Section 1.

Counting neighbors to build our bitset

We have a decent map rendering system in map.rs, specifically the function draw_map. If you find the section that matches tile by type, we can start by extending the Wall selection:


# #![allow(unused_variables)]
#fn main() {
TileType::Wall => {
    glyph = wall_glyph(&*map, x, y);
    fg = RGB::from_f32(0., 1.0, 0.);
}
#}

This requires the wall_glyph function, so lets write it:


# #![allow(unused_variables)]
#fn main() {
fn wall_glyph(map : &Map, x: i32, y:i32) -> u8 {
    if x < 1 || x > map.width-2 || y < 1 || y > map.height-2 as i32 { return 35; }
    let mut mask : u8 = 0;

    if is_revealed_and_wall(map, x, y - 1) { mask +=1; }
    if is_revealed_and_wall(map, x, y + 1) { mask +=2; }
    if is_revealed_and_wall(map, x - 1, y) { mask +=4; }
    if is_revealed_and_wall(map, x + 1, y) { mask +=8; }

    match mask {
        0 => { 9 } // Pillar because we can't see neighbors
        1 => { 186 } // Wall only to the north
        2 => { 186 } // Wall only to the south
        3 => { 186 } // Wall to the north and south
        4 => { 205 } // Wall only to the west
        5 => { 188 } // Wall to the north and west
        6 => { 187 } // Wall to the south and west
        7 => { 185 } // Wall to the north, south and west
        8 => { 205 } // Wall only to the east
        9 => { 200 } // Wall to the north and east
        10 => { 201 } // Wall to the south and east
        11 => { 204 } // Wall to the north, south and east
        12 => { 205 } // Wall to the east and west
        13 => { 202 } // Wall to the east, west, and south
        14 => { 203 } // Wall to the east, west, and north
        _ => { 35 } // We missed one?
    }
}
#}

Lets step through this function:

  1. If we are at the map bounds, we aren't going to risk stepping outside of them - so we return a # symbol (ASCII 35).
  2. Now we create an 8-bit unsigned integer to act as our bitmask. We're interested in setting individual bits and only need four of them - so an 8-bit number is perfect.
  3. Next, we check each of the 4 directions and add to the mask. We're adding numbers corresponding to each of the first four bits in binary - so 1,2,4,8. This means that our final number will store whether or not we have each of the four possible neighbors. For example, a value of 3 means that we have neighbors to the north and south.
  4. Then we match on the resulting mask bit and return the appropriate line-drawing character from the codepage 437 character set

This function in turn calls is_revealed_and_wall, so we'll write that too! It's very simple:


# #![allow(unused_variables)]
#fn main() {
fn is_revealed_and_wall(map: &Map, x: i32, y: i32) -> bool {
    let idx = map.xy_idx(x, y);
    map.tiles[idx] == TileType::Wall && map.revealed_tiles[idx]
}
#}

It simply checks to see if a tile is revealed and if it is a wall. If both are true, it returns true - otherwise it returns false.

If you cargo run the project now, you get a nicer looking set of walls:

Screenshot

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Bloodstains


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Our character lives the life of a "murder-hobo", looting and slaying at will - so it only makes sense that the pristine dungeon will start to resemble a charnel house. It also gives us a bridge into a future chapter, in which we'll start to add some particle and visual effects (in ASCII/CP437) to the game.

Storing the blood

Tiles either have blood or they don't, so it makes sense to attach them to the map as a set. So at the top of map.rs, we'll include a new storage type - HashSet:


# #![allow(unused_variables)]
#fn main() {
use std::collections::HashSet;
#}

In the map definition, we'll include a HashSet of usize (to represent tile indices) types for blood:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,
    pub bloodstains : HashSet<usize>,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
#}

And in the new map generator, we'll initialize it:


# #![allow(unused_variables)]
#fn main() {
let mut map = Map{
    tiles : vec![TileType::Wall; MAPCOUNT],
    rooms : Vec::new(),
    width : MAPWIDTH as i32,
    height: MAPHEIGHT as i32,
    revealed_tiles : vec![false; MAPCOUNT],
    visible_tiles : vec![false; MAPCOUNT],
    blocked : vec![false; MAPCOUNT],
    tile_content : vec![Vec::new(); MAPCOUNT],
    depth: new_depth,
    bloodstains: HashSet::new()
};
#}

Rendering the blood

We'll indicate a bloodstain by changing a tile background to a dark red. We don't want to be too "in your face" with the effect, and we don't want to hide the tile content - so that should be sufficient. We'll also not show blood that isn't in visual range, to keep it understated. In map.rs, the render section now looks like this:


# #![allow(unused_variables)]
#fn main() {
if map.revealed_tiles[idx] {
    let glyph;
    let mut fg;
    let mut bg = RGB::from_f32(0., 0., 0.);
    match tile {
        TileType::Floor => {
            glyph = rltk::to_cp437('.');
            fg = RGB::from_f32(0.0, 0.5, 0.5);
        }
        TileType::Wall => {
            glyph = wall_glyph(&*map, x, y);
            fg = RGB::from_f32(0., 1.0, 0.);
        }
        TileType::DownStairs => {
            glyph = rltk::to_cp437('>');
            fg = RGB::from_f32(0., 1.0, 1.0);
        }
    }
    if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); }
    if !map.visible_tiles[idx] { 
        fg = fg.to_greyscale();
        bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range
    }
    ctx.set(x, y, fg, bg, glyph);
}
#}

Blood for the blood god

Now we need to add blood to the scene! We'll mark a tile as bloody whenever someone takes damage in it. We'll adjust the DamageSystem in damage_system.rs to set the bloodstain:


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for DamageSystem {
    type SystemData = ( WriteStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, Position>,
                        WriteExpect<'a, Map>,
                        Entities<'a> );

    fn run(&mut self, data : Self::SystemData) {
        let (mut stats, mut damage, positions, mut map, entities) = data;

        for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() {
            stats.hp -= damage.amount.iter().sum::<i32>();
            let pos = positions.get(entity);
            if let Some(pos) = pos {
                let idx = map.xy_idx(pos.x, pos.y);
                map.bloodstains.insert(idx);
            }
        }

        damage.clear();
    }
}
#}

If you cargo run your project, the map starts to show signs of battle!

Screenshot

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Particle Effects in ASCII


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


There's no real visual feedback for your actions - you hit something, and it either goes away, or it doesn't. Bloodstains give a good impression of what previously happened in a location - but it would be nice to give some sort of instant reaction to your actions. These need to be fast, non-blocking (so you don't have to wait for the animation to finish to keep playing), and not too intrusive. Particles are a good fit for this, so we'll implement a simple ASCII/CP437 particle system.

Particle component

As usual, we'll start out by thinking about what a particle is. Typically it has a position, something to render, and a lifetime (so it goes away). We've already written two out of three of those, so lets go ahead and create a ParticleLifetime component. In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Serialize, Deserialize, Clone)]
pub struct ParticleLifetime {
    pub lifetime_ms : f32
}
#}

We have to register this in all the usual places: main.rs and saveload_system.rs (twice).

Grouping particle code together

We'll make a new file, particle_system.rs. It won't be a regular system, because we need access to the RLTK Context object - but it will have to provide services to other systems.

The first thing to support is making particles vanish after their lifetime. So we start with the following in particle_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{ Rltk, ParticleLifetime};

pub fn cull_dead_particles(ecs : &mut World, ctx : &Rltk) {
    let mut dead_particles : Vec<Entity> = Vec::new();
    {
        // Age out particles
        let mut particles = ecs.write_storage::<ParticleLifetime>();
        let entities = ecs.entities();
        for (entity, mut particle) in (&entities, &mut particles).join() {
            particle.lifetime_ms -= ctx.frame_time_ms;
            if particle.lifetime_ms < 0.0 {
                dead_particles.push(entity);
            }
        }                    
    }
    for dead in dead_particles.iter() {
        ecs.delete_entity(*dead).expect("Particle will not die");
    } 
}
#}

Then we modify the render loop in main.rs to call it:


# #![allow(unused_variables)]
#fn main() {
ctx.cls();        
particle_system::cull_dead_particles(&mut self.ecs, ctx);
#}

Spawning particles via a service

Let's extend particle_system.rs to offer a builder system: you obtain a ParticleBuilder and add requests to it, and then create your particles as a batch together. We'll offer the particle system as a resource - so it's available anywhere. This avoids having to add much intrusive code into each system, and lets us handle the actual particle spawning as a single (fast) batch.

Our basic ParticleBuilder looks like this. We haven't done anything to actually add any particles yet, but this provides the requestor service:


# #![allow(unused_variables)]
#fn main() {
struct ParticleRequest {
    x: i32,
    y: i32,
    fg: RGB,
    bg: RGB,
    glyph: u8,
    lifetime: f32
}

pub struct ParticleBuilder {
    requests : Vec<ParticleRequest>
}

impl ParticleBuilder {
    #[allow(clippy::new_without_default)]
    pub fn new() -> ParticleBuilder {
        ParticleBuilder{ requests : Vec::new() }
    }

    pub fn request(&mut self, x:i32, y:i32, fg: RGB, bg:RGB, glyph: u8, lifetime: f32) {
        self.requests.push(
            ParticleRequest{
                x, y, fg, bg, glyph, lifetime
            }
        );
    }
}
#}

In main.rs, we'll turn it into a resource:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(particle_system::ParticleBuilder::new());
#}

Now, we'll return to particle_system.rs and build an actual system to spawn particles. The system looks like this:


# #![allow(unused_variables)]
#fn main() {
pub struct ParticleSpawnSystem {}

impl<'a> System<'a> for ParticleSpawnSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( 
                        Entities<'a>,
                        WriteStorage<'a, Position>,
                        WriteStorage<'a, Renderable>,
                        WriteStorage<'a, ParticleLifetime>,
                        WriteExpect<'a, ParticleBuilder>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut positions, mut renderables, mut particles, mut particle_builder) = data;
        for new_particle in particle_builder.requests.iter() {
            let p = entities.create();
            positions.insert(p, Position{ x: new_particle.x, y: new_particle.y }).expect("Unable to inser position");
            renderables.insert(p, Renderable{ fg: new_particle.fg, bg: new_particle.bg, glyph: new_particle.glyph, render_order: 0 }).expect("Unable to insert renderable");
            particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime }).expect("Unable to insert lifetime");
        }

        particle_builder.requests.clear();
    }
}
#}

This is a very simple service: it iterates the requests, and creates an entity for each particle with the component parameters from the request. Then it clears the builder list. The last step is to add it to the system schedule in main.rs:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        let mut mob = MonsterAI{};
        mob.run_now(&self.ecs);
        let mut mapindex = MapIndexingSystem{};
        mapindex.run_now(&self.ecs);
        let mut melee = MeleeCombatSystem{};
        melee.run_now(&self.ecs);
        let mut damage = DamageSystem{};
        damage.run_now(&self.ecs);
        let mut pickup = ItemCollectionSystem{};
        pickup.run_now(&self.ecs);
        let mut itemuse = ItemUseSystem{};
        itemuse.run_now(&self.ecs);
        let mut drop_items = ItemDropSystem{};
        drop_items.run_now(&self.ecs);
        let mut item_remove = ItemRemoveSystem{};
        item_remove.run_now(&self.ecs);
        let mut particles = particle_system::ParticleSpawnSystem{};
        particles.run_now(&self.ecs);

        self.ecs.maintain();
    }
}
#}

We've made it depend upon likely particle spawners. We'll have to be a little careful to avoid accidentally making it concurrent with anything that might add to it.

Actually spawning some particles for combat

Lets start by spawning a particle whenever someone attacks. Open up melee_combat_system.rs, and we'll add ParticleBuilder to the list of requested resources for the system. First, the includes:


# #![allow(unused_variables)]
#fn main() {
use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog, MeleePowerBonus, DefenseBonus, Equipped,
    particle_system::ParticleBuilder, Position};
#}

Then, a WriteExpect to be able to write to the resource:


# #![allow(unused_variables)]
#fn main() {
type SystemData = ( Entities<'a>,
    WriteExpect<'a, GameLog>,
    WriteStorage<'a, WantsToMelee>,
    ReadStorage<'a, Name>,
    ReadStorage<'a, CombatStats>,
    WriteStorage<'a, SufferDamage>,
    ReadStorage<'a, MeleePowerBonus>,
    ReadStorage<'a, DefenseBonus>,
    ReadStorage<'a, Equipped>,
    WriteExpect<'a, ParticleBuilder>,
    ReadStorage<'a, Position>
);
#}

And the expanded list of resources for the run method itself:


# #![allow(unused_variables)]
#fn main() {
let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, 
    melee_power_bonuses, defense_bonuses, equipped, mut particle_builder, positions) = data;
#}

Finally, we'll add the request:


# #![allow(unused_variables)]
#fn main() {
let pos = positions.get(wants_melee.target);
if let Some(pos) = pos {
    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
}

let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus));
#}

If you cargo run now, you'll see a relatively subtle particle feedback to show that melee combat occurred. This definitely helps with the feel of gameplay, and is sufficiently non-intrusive that we aren't making our other systems too confusing.

Screenshot

Adding effects to item use

It would be great to add similar effects to item use, so lets do it! In inventory_system.rs, we'll expand the ItemUseSystem introduction to include the ParticleBuilder:


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for ItemUseSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        ReadExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToUseItem>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Consumable>,
                        ReadStorage<'a, ProvidesHealing>,
                        ReadStorage<'a, InflictsDamage>,
                        WriteStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, AreaOfEffect>,
                        WriteStorage<'a, Confusion>,
                        ReadStorage<'a, Equippable>,
                        WriteStorage<'a, Equipped>,
                        WriteStorage<'a, InBackpack>,
                        WriteExpect<'a, ParticleBuilder>,
                        ReadStorage<'a, Position>
                      );

    #[allow(clippy::cognitive_complexity)]
    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, map, entities, mut wants_use, names, 
            consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, 
            aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions) = data;
#}

We'll start by showing a heart when you drink a healing potion. In the healing section:


# #![allow(unused_variables)]
#fn main() {
stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
if entity == *player_entity {
    gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
}
used_item = true;

let pos = positions.get(*target);
if let Some(pos) = pos {
    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::GREEN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('♥'), 200.0);
}
#}

We can use a similar effect for confusion - only with a magenta question mark. In the confusion section:


# #![allow(unused_variables)]
#fn main() {
gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));

let pos = positions.get(*mob);
if let Some(pos) = pos {
    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0);
}
#}

We should also use a particle to indicate that damage was inflicted. In the damage section of the system:


# #![allow(unused_variables)]
#fn main() {
gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));

let pos = positions.get(*mob);
if let Some(pos) = pos {
    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
}
#}

Lastly, if an effect hits a whole area (for example, a fireball) it would be good to indicate what the area is. In the targeting section of the system, add:


# #![allow(unused_variables)]
#fn main() {
for mob in map.tile_content[idx].iter() {
    targets.push(*mob);
}
particle_builder.request(tile_idx.x, tile_idx.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0);
#}

That wasn't too hard, was it? If you cargo run your project now, you'll see various visual effects firing.

Screenshot

Adding an indicator for missing a turn due to confusion

Lastly, we'll repeat the confused effect on monsters when it is their turn and they skip due to being confused. This should make it less confusing as to why they stand around. In monster_ai_system.rs, we first modify the system header to request the appropriate helper:


# #![allow(unused_variables)]
#fn main() {
impl<'a> System<'a> for MonsterAI {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadExpect<'a, Point>,
                        ReadExpect<'a, Entity>,
                        ReadExpect<'a, RunState>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>, 
                        ReadStorage<'a, Monster>,
                        WriteStorage<'a, Position>,
                        WriteStorage<'a, WantsToMelee>,
                        WriteStorage<'a, Confusion>,
                        WriteExpect<'a, ParticleBuilder>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, 
            monster, mut position, mut wants_to_melee, mut confused, mut particle_builder) = data;
#}

Then we add in a request at the end of the confusion test:


# #![allow(unused_variables)]
#fn main() {
can_act = false;

particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), 
                    rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0);
#}

We don't need to worry about getting the Position component here, because we already get it as part of the loop. If you cargo run your project now, and find a confusion scroll - you have visual feedback as to why a goblin isn't chasing you anymore:

Screenshot

Wrap Up

That's it for visual effects for now. We've given the game a much more visceral feel, with feedback given for actions. That's a big improvement, and goes a long way to modernizing an ASCII interface!

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Adding a hunger clock and food


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Hunger clocks are a controversial feature of a lot of roguelikes. They can really irritate the player if you are spending all of your time looking for food, but they also drive you forward - so you can't sit around without exploring more. Resting to heal becomes more of a risk/reward system, in particular. This chapter will implement a basic hunger clock for the player.

Adding a hunger clock component

We'll be adding a hunger clock to the player, so the first step is to make a component to represent it. In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Serialize, Deserialize, Copy, Clone, PartialEq)]
pub enum HungerState { WellFed, Normal, Hungry, Starving }

#[derive(Component, Serialize, Deserialize, Clone)]
pub struct HungerClock {
    pub state : HungerState,
    pub duration : i32
}
#}

As with all components, it needs to be registered in main.rs and saveload_system.rs. In spawners.rs, we'll extend the player function to add a hunger clock to the player:


# #![allow(unused_variables)]
#fn main() {
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
    ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 0
        })
        .with(Player{})
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Name{name: "Player".to_string() })
        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
        .with(HungerClock{ state: HungerState::WellFed, duration: 20 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build()
}
#}

There's now a hunger clock component in place, but it doesn't do anything!

Adding a hunger system

We'll make a new file, hunger_system.rs and implement a hunger clock system. It's quite straightforward:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{HungerClock, RunState, HungerState, SufferDamage, gamelog::GameLog};

pub struct HungerSystem {}

impl<'a> System<'a> for HungerSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( 
                        Entities<'a>,
                        WriteStorage<'a, HungerClock>,
                        ReadExpect<'a, Entity>, // The player
                        ReadExpect<'a, RunState>,
                        WriteStorage<'a, SufferDamage>,
                        WriteExpect<'a, GameLog>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut hunger_clock, player_entity, runstate, mut inflict_damage, mut log) = data;

        for (entity, mut clock) in (&entities, &mut hunger_clock).join() {
            let mut proceed = false;

            match *runstate {
                RunState::PlayerTurn => {
                    if entity == *player_entity {
                        proceed = true;
                    }
                }
                RunState::MonsterTurn => {
                    if entity != *player_entity {
                        proceed = true;
                    }
                }
                _ => proceed = false
            }

            if proceed {
                clock.duration -= 1;
                if clock.duration < 1 {
                    match clock.state {
                        HungerState::WellFed => {
                            clock.state = HungerState::Normal;
                            clock.duration = 200;
                            if entity == *player_entity {
                                log.entries.push("You are no longer well fed.".to_string());
                            }
                        }
                        HungerState::Normal => {
                            clock.state = HungerState::Hungry;
                            clock.duration = 200;
                            if entity == *player_entity {
                                log.entries.push("You are hungry.".to_string());
                            }
                        }
                        HungerState::Hungry => {
                            clock.state = HungerState::Starving;
                            clock.duration = 200;
                            if entity == *player_entity {
                                log.entries.push("You are starving!".to_string());
                            }
                        }
                        HungerState::Starving => {
                            // Inflict damage from hunger
                            if entity == *player_entity {
                                log.entries.push("Your hunger pangs are getting painful! You suffer 1 hp damage.".to_string());
                            }
                            SufferDamage::new_damage(&mut inflict_damage, entity, 1);  
                        }
                    }
                }
            }
        }
    }
}
#}

It works by iterating all entities that have a HungerClock. If they are the player, it only takes effect in the PlayerTurn state; likewise, if they are a monster, it only takes place in their turn (in case we want hungry monsters later!). The duration of the current state is reduced on each run-through. If it hits 0, it moves one state down - or if you are starving, damages you.

Now we need to add it to the list of systems running in main.rs:


# #![allow(unused_variables)]
#fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        let mut mob = MonsterAI{};
        mob.run_now(&self.ecs);
        let mut mapindex = MapIndexingSystem{};
        mapindex.run_now(&self.ecs);
        let mut melee = MeleeCombatSystem{};
        melee.run_now(&self.ecs);
        let mut damage = DamageSystem{};
        damage.run_now(&self.ecs);
        let mut pickup = ItemCollectionSystem{};
        pickup.run_now(&self.ecs);
        let mut itemuse = ItemUseSystem{};
        itemuse.run_now(&self.ecs);
        let mut drop_items = ItemDropSystem{};
        drop_items.run_now(&self.ecs);
        let mut item_remove = ItemRemoveSystem{};
        item_remove.run_now(&self.ecs);
        let mut hunger = hunger_system::HungerSystem{};
        hunger.run_now(&self.ecs);
        let mut particles = particle_system::ParticleSpawnSystem{};
        particles.run_now(&self.ecs);

        self.ecs.maintain();
    }
}
#}

If you cargo run now, and hit wait a lot - you'll starve to death.

Screenshot

Displaying the status

It would be nice to know your hunger state! We'll modify draw_ui in gui.rs to show it:


# #![allow(unused_variables)]
#fn main() {
pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
    ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));

    let combat_stats = ecs.read_storage::<CombatStats>();
    let players = ecs.read_storage::<Player>();
    let hunger = ecs.read_storage::<HungerClock>();
    for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() {
        let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp);
        ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health);

        ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK));

        match hc.state {
            HungerState::WellFed => ctx.print_color(71, 42, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"),
            HungerState::Normal => {}
            HungerState::Hungry => ctx.print_color(71, 42, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"),
            HungerState::Starving => ctx.print_color(71, 42, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"),
        }
    }
    ...
#}

If you cargo run your project, this gives quite a pleasant display: Screenshot

Adding in food

It's all well and good starving to death, but players will find it frustrating if they always start do die after 620 turns (and suffer consequences before that! 620 may sound like a lot, but it's common to use a few hundred moves on a level, and we aren't trying to make food the primary game focus). We'll introduce a new item, Rations. We have most of the components needed for this already, but we need a new one to indicate that an item ProvidesFood. In components.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct ProvidesFood {}
#}

We will, as always, need to register this in main.rs and saveload_system.rs.

Now, in spawner.rs we'll create a new function to make rations:


# #![allow(unused_variables)]
#fn main() {
fn rations(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('%'),
            fg: RGB::named(rltk::GREEN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Rations".to_string() })
        .with(Item{})
        .with(ProvidesFood{})
        .with(Consumable{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

We'll also add it to the spawn table (quite common):


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
        .add("Longsword", map_depth - 1)
        .add("Tower Shield", map_depth - 1)
        .add("Rations", 10)
}
#}

And to the spawn code:


# #![allow(unused_variables)]
#fn main() {
match spawn.1.as_ref() {
    "Goblin" => goblin(ecs, x, y),
    "Orc" => orc(ecs, x, y),
    "Health Potion" => health_potion(ecs, x, y),
    "Fireball Scroll" => fireball_scroll(ecs, x, y),
    "Confusion Scroll" => confusion_scroll(ecs, x, y),
    "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
    "Dagger" => dagger(ecs, x, y),
    "Shield" => shield(ecs, x, y),
    "Longsword" => longsword(ecs, x, y),
    "Tower Shield" => tower_shield(ecs, x, y),
    "Rations" => rations(ecs, x, y),
    _ => {}
}
#}

If you cargo run now, you will encounter rations that you can pickup and drop. You can't, however, eat them! We'll add that to inventory_system.rs. Here's the relevant portion (see the tutorial source for the full version):


# #![allow(unused_variables)]
#fn main() {
// It it is edible, eat it!
let item_edible = provides_food.get(useitem.item);
match item_edible {
    None => {}
    Some(_) => {
        used_item = true;
        let target = targets[0];
        let hc = hunger_clocks.get_mut(target);
        if let Some(hc) = hc {
            hc.state = HungerState::WellFed;
            hc.duration = 20;
            gamelog.entries.push(format!("You eat the {}.", names.get(useitem.item).unwrap().name));
        }
    }
}
#}

If you cargo run now, you can run around - find rations, and eat them to reset the hunger clock!

Screenshot

Adding a bonus for being well fed

It would be nice if being Well Fed does something! We'll give you a temporary +1 to your power when you are fed. This encourages the player to eat - even though they don't have to (sneakily making it harder to survive on lower levels as food becomes less plentiful). In melee_combat_system.rs we add:


# #![allow(unused_variables)]
#fn main() {
let hc = hunger_clock.get(entity);
if let Some(hc) = hc {
    if hc.state == HungerState::WellFed {
        offensive_bonus += 1;
    }
}
#}

And that's it! You get a +1 power bonus for being full of rations.

Preventing healing when hungry or starving

As another benefit to food, we'll prevent you from wait-healing while hungry or starving (this also balances the healing system we added earlier). In player.rs, we modify skip_turn:


# #![allow(unused_variables)]
#fn main() {
let hunger_clocks = ecs.read_storage::<HungerClock>();
let hc = hunger_clocks.get(*player_entity);
if let Some(hc) = hc {
    match hc.state {
        HungerState::Hungry => can_heal = false,
        HungerState::Starving => can_heal = false,
        _ => {}
    }
}

if can_heal {
#}

Wrap-Up

We now have a working hunger clock system. You may want to tweak the durations to suit your taste (or skip it completely if it isn't your cup of tea) - but it's a mainstay of the genre, so it's good to have it included in the tutorials.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


Magic Mapping


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


A really common item in roguelikes is the scroll of magic mapping. You read it, and the dungeon is revealed. Fancier roguelikes have nice graphics for it. In this chapter, we'll start by making it work - and then make it pretty!

Adding a magic map component

We have everything we need except for an indicator that an item is a scroll (or any other item, really) of magic mapping. So in components.rs we'll add a component for it:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct MagicMapper {}
#}

As always, we need to register it in main.rs and saveload_system.rs. We'll head over to spawners.rs and create a new function for it, as well as adding it to the loot tables:


# #![allow(unused_variables)]
#fn main() {
fn magic_mapping_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::CYAN3),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Scroll of Magic Mapping".to_string() })
        .with(Item{})
        .with(MagicMapper{})
        .with(Consumable{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

And the loot table:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
        .add("Longsword", map_depth - 1)
        .add("Tower Shield", map_depth - 1)
        .add("Rations", 10)
        .add("Magic Mapping Scroll", 400)
}
#}

Notice that we've given it a weight of 400 - absolutely ridiculous. We'll fix it later, for now we really want to spawn the scroll so that we can test it! Lastly, we add it to the actual spawn function:


# #![allow(unused_variables)]
#fn main() {
match spawn.1.as_ref() {
    "Goblin" => goblin(ecs, x, y),
    "Orc" => orc(ecs, x, y),
    "Health Potion" => health_potion(ecs, x, y),
    "Fireball Scroll" => fireball_scroll(ecs, x, y),
    "Confusion Scroll" => confusion_scroll(ecs, x, y),
    "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
    "Dagger" => dagger(ecs, x, y),
    "Shield" => shield(ecs, x, y),
    "Longsword" => longsword(ecs, x, y),
    "Tower Shield" => tower_shield(ecs, x, y),
    "Rations" => rations(ecs, x, y),
    "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
    _ => {}
}
#}

If you were to cargo run now, you'd likely find scrolls you can pick up - but they won't do anything.

Mapping the level - the simple version

We'll modify inventory_system.rs to detect if you just used a mapping scroll, and reveal the whole map:


# #![allow(unused_variables)]
#fn main() {
// If its a magic mapper...
let is_mapper = magic_mapper.get(useitem.item);
match is_mapper {
    None => {}
    Some(_) => {
        used_item = true;
        for r in map.revealed_tiles.iter_mut() {
            *r = true;
        }
        gamelog.entries.push("The map is revealed to you!".to_string());
    }
}
#}

There are some framework changes also (see the source); we've done this often enough, I don't think it needs repeating here again. If you cargo run the project now, find a scroll (they are everywhere) and use it - the map is instantly revealed:

Screenshot

Making it pretty

While the code presented there is effective, it isn't visually attractive. It's nice to include fluff in games, and let the user be pleasantly surprised by the beauty of an ASCII terminal from time to time! We'll start by modifying inventory_system.rs again:


# #![allow(unused_variables)]
#fn main() {
// If its a magic mapper...
let is_mapper = magic_mapper.get(useitem.item);
match is_mapper {
    None => {}
    Some(_) => {
        used_item = true;
        gamelog.entries.push("The map is revealed to you!".to_string());
        *runstate = RunState::MagicMapReveal{ row : 0};
    }
}
#}

Notice that instead of modifying the map, we are just changing the game state to mapping mode. We don't actually support doing that yet, so lets go into the state mapper in main.rs and modify PlayerTurn to handle it:


# #![allow(unused_variables)]
#fn main() {
RunState::PlayerTurn => {
    self.systems.dispatch(&self.ecs);
    self.ecs.maintain();
    match *self.ecs.fetch::<RunState>() {
        RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 },
        _ => newrunstate = RunState::MonsterTurn
    }                
}
#}

While we're here, lets add the state to RunState:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, 
    PreRun, 
    PlayerTurn, 
    MonsterTurn, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 }
}
#}

We also add some logic to the tick loop for the new state:


# #![allow(unused_variables)]
#fn main() {
RunState::MagicMapReveal{row} => {
    let mut map = self.ecs.fetch_mut::<Map>();
    for x in 0..MAPWIDTH {
        let idx = map.xy_idx(x as i32,row);
        map.revealed_tiles[idx] = true;
    }
    if row as usize == MAPHEIGHT-1 {
        newrunstate = RunState::MonsterTurn;
    } else {
        newrunstate = RunState::MagicMapReveal{ row: row+1 };
    }
}
#}

This is pretty straightforward: it reveals the tiles on the current row, and then if we haven't hit the bottom of the map - it adds to row. If we have, it returns to where we were - MonsterTurn. If you cargo run now, find a magic mapping scroll and use it, the map fades in nicely:

Screenshot

Remember to lower the spawn priority!

In spawners.rs we are currently spawning magic mapping scrolls everywhere. That's probably not what we want! Edit the spawn table to have a much lower priority:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
        .add("Longsword", map_depth - 1)
        .add("Tower Shield", map_depth - 1)
        .add("Rations", 10)
        .add("Magic Mapping Scroll", 2)
}
#}

Wrap Up

This was a relatively quick chapter, but we now have another staple of the roguelike genre: magic mapping.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)


Copyright (C) 2019, Herbert Wolverson.


REX Paint Main Menu


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Our main menu is really boring, and not a good way to attract players! This chapter will spice it up a bit.

REX Paint

Grid Sage Games (the amazing u/Kyzrati on Reddit) provide a lovely tool for Codepage 437 image editing called REX Paint. RLTK has built-in support for using the output from this editor. As they used to say on the BBC's old kids show Blue Peter - here's one I made earlier.

Screenshot

I cheated a bit; I found a CC0 image, resized it to 80x50 in the GIMP, and used a tool I wrote years ago to convert the PNG to a REX Paint file. Still, I like the result. You can find the REX Paint file in the resources folder.

Loading REX Assets

We'll introduce a new file, rex_assets.rs to store our REX sprites. The file looks like this:


# #![allow(unused_variables)]
#fn main() {
use rltk::{rex::XpFile};

rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");

pub struct RexAssets {
    pub menu : XpFile
}

impl RexAssets {
    #[allow(clippy::new_without_default)]
    pub fn new() -> RexAssets {
        rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");

        RexAssets{
            menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()
        }
    }
}
#}

Very simple - it defines a structure, and loads the dungeon graphic into it when new is called. We'll also insert it into Specs as a resource so we can access our sprites anywhere. There are some new concepts here:

  1. We're using rltk::embedded_resource! to include the file in our binary. This gets around having to ship the binary with your executable (and makes life easier in wasm land).
  2. #[allow(clippy::new_without_default)] tells the linter to stop telling me to write a default implementation, when we don't need one!
  3. rltk::link_resource! is the second-half the the embedded resource; the first stores it in memory, this one tells RLTK where to find it.
  4. menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap() loads the Rex paint file from memory.

In main.rs:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(rex_assets::RexAssets::new());
#}

Now we open up gui.rs and find the main_menu function. We'll add two lines before we start printing menu content:


# #![allow(unused_variables)]
#fn main() {
let assets = gs.ecs.fetch::<RexAssets>();
ctx.render_xp_sprite(&assets.menu, 0, 0);
#}

The result (cargo run to see it) is a good start at a menu!

Screenshot

Improving the look of the menu - adding a box and borders

To make it look a little snazzier, we'll work on spacing - and add a box for the menu and text. Replace the current title rendering code with:


# #![allow(unused_variables)]
#fn main() {
ctx.draw_box_double(24, 18, 31, 10, RGB::named(rltk::WHEAT), RGB::named(rltk::BLACK));
ctx.print_color_centered(20, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
ctx.print_color_centered(21, RGB::named(rltk::CYAN), RGB::named(rltk::BLACK), "by Herbert Wolverson");
ctx.print_color_centered(22, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), "Use Up/Down Arrows and Enter");
#}

If you cargo run now, your menu looks like this:

Screenshot

That's quite a bit better!

Fixing the spacing

You'll notice that if you don't have a saved game to load, there is an annoying gap between menu items. This is an easy fix, by keeping track of the y position we have used while we render the menu. Here's the new menu rendering code:


# #![allow(unused_variables)]
#fn main() {
let mut y = 24;
    if let RunState::MainMenu{ menu_selection : selection } = *runstate {
        if selection == MainMenuSelection::NewGame {
            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
        } else {
            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
        }
        y += 1;

        if save_exists {
            if selection == MainMenuSelection::LoadGame {
                ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
            } else {
                ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
            }
            y += 1;
        }

        if selection == MainMenuSelection::Quit {
            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
        } else {
            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
        }
    ...
#}

If you cargo run now, it looks better:

Screenshot

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Simple Traps


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Most roguelikes, like their D&D precursors, feature traps in the dungeon. Walk down an innocent looking hallway, and oops - an arrow flies out and hits you. This chapter will implement some simple traps, and then examine some of the game implications they bring.

What is a trap?

Most traps follow the pattern of: you might see the trap (or you might not!), you enter the tile anyway, the trap goes off and something happens (damage, teleport, etc.). So traps can be logically divided into three sections:

  • An appearance (which we already support), which may or may not be discovered (which we don't, yet).
  • A trigger - if you enter the trap's tile, something happens.
  • An effect - which we've touched on with magic items.

Let's work our way through getting components into place for these, in turn.

Rendering a basic bear trap

A lot of roguelikes use ^ for a trap, so we'll do the same. We have all the components required to render a basic object, so we'll make a new spawning function (in spawners.rs). It's pretty much the minimum to put a glyph on the map:


# #![allow(unused_variables)]
#fn main() {
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('^'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Bear Trap".to_string() })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

We'll also add it into the list of things that can spawn:


# #![allow(unused_variables)]
#fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
        .add("Dagger", 3)
        .add("Shield", 3)
        .add("Longsword", map_depth - 1)
        .add("Tower Shield", map_depth - 1)
        .add("Rations", 10)
        .add("Magic Mapping Scroll", 2)
        .add("Bear Trap", 2)
}
#}

# #![allow(unused_variables)]
#fn main() {
match spawn.1.as_ref() {
    "Goblin" => goblin(ecs, x, y),
    "Orc" => orc(ecs, x, y),
    "Health Potion" => health_potion(ecs, x, y),
    "Fireball Scroll" => fireball_scroll(ecs, x, y),
    "Confusion Scroll" => confusion_scroll(ecs, x, y),
    "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
    "Dagger" => dagger(ecs, x, y),
    "Shield" => shield(ecs, x, y),
    "Longsword" => longsword(ecs, x, y),
    "Tower Shield" => tower_shield(ecs, x, y),
    "Rations" => rations(ecs, x, y),
    "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
    "Bear Trap" => bear_trap(ecs, x, y),
    _ => {}
}
#}

If you cargo run the project now, occasionally you will run into a red ^ - and it will be labeled "Bear Trap" on the mouse-over. Not massively exciting, but a good start! Note that for testing, we'll up the spawn frequency from 2 to 100 - LOTS of traps, making debugging easier. Remember to lower it later!

But you don't always spot the trap!

It is pretty easy if you can always know that a trap awaits you! So we want to make traps hidden by default, and come up with a way to sometimes locate traps when you are near them. Like most things in an ECS driven world, analyzing the text gives a great clue as to what components you need. In this case, we need to go into components.rs and create a new component - Hidden:


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Hidden {}
#}

As usual, we need to register it in main.rs and in saveload_system.rs. We'll also give the property to our new bear trap:


# #![allow(unused_variables)]
#fn main() {
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('^'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Bear Trap".to_string() })
        .with(Hidden{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

Now, we want to modify the object renderer to not show things that are hidden. The Specs Book provides a great clue as to how to exclude a component from a join, so we do that (in main.rs):


# #![allow(unused_variables)]
#fn main() {
let mut data = (&positions, &renderables, !&hidden).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
for (pos, render, _hidden) in data.iter() {
    let idx = map.xy_idx(pos.x, pos.y);
    if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
}
#}

Notice that we've added a ! ("not" symbol) to the join - we're saying that entities must not have the Hidden component if we are to render them.

If you cargo run the project now, the bear traps are no longer visible. However, they show up in tool tips (which may be perhaps as well, we know they are there!). We'll exclude them from tool-tips also. In gui.rs, we amend the draw_tooltips function:


# #![allow(unused_variables)]
#fn main() {
fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();
    let names = ecs.read_storage::<Name>();
    let positions = ecs.read_storage::<Position>();
    let hidden = ecs.read_storage::<Hidden>();

    let mouse_pos = ctx.mouse_pos();
    if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; }
    let mut tooltip : Vec<String> = Vec::new();
    for (name, position, _hidden) in (&names, &positions, !&hidden).join() {
        if position.x == mouse_pos.0 && position.y == mouse_pos.1 {
            tooltip.push(name.name.to_string());
        }
    }
    ...
#}

Now if you cargo run, you'll have no idea that traps are present. Since they don't do anything yet - they may as well not exist!

Adding entry triggers

A trap should trigger when an entity walks onto them. So in components.rs, we'll create an EntryTrigger (as usual, we'll also register it in main.rs and saveload_system.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct EntryTrigger {}
#}

We'll give bear traps a trigger (in spawner.rs):


# #![allow(unused_variables)]
#fn main() {
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('^'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Bear Trap".to_string() })
        .with(Hidden{})
        .with(EntryTrigger{})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

We also need to have traps fire their trigger when an entity enters them. We'll add another component, EntityMoved to indicate that an entity has moved this turn. In components.rs (and remembering to register in main.rs and saveload_system.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct EntityMoved {}
#}

Now, we scour the codebase to add an EntityMoved component every time an entity moves. In player.rs, we handle player movement in the try_move_player function. At the top, we'll gain write access to the relevant component store:


# #![allow(unused_variables)]
#fn main() {
let mut entity_moved = ecs.write_storage::<EntityMoved>();
#}

Then when we've determined that the player did, in fact, move - we'll insert the EntityMoved component:


# #![allow(unused_variables)]
#fn main() {
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
#}

The other location that features movement is the Monster AI. So in monster_ai_system.rs, we do something similar. We add a WriteResource for the EntityMoved component, and insert one after the monster moves. The source code for the AI is getting a bit long, so I recommend you look at the source file directly for this one (here).

Lastly, we need a system to make triggers actually do something. We'll make a new file, trigger_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog};

pub struct TriggerSystem {}

impl<'a> System<'a> for TriggerSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Map>,
                        WriteStorage<'a, EntityMoved>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, EntryTrigger>,
                        WriteStorage<'a, Hidden>,
                        ReadStorage<'a, Name>,
                        Entities<'a>,
                        WriteExpect<'a, GameLog>);

    fn run(&mut self, data : Self::SystemData) {
        let (map, mut entity_moved, position, entry_trigger, mut hidden, names, entities, mut log) = data;

        // Iterate the entities that moved and their final position
        for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() {
            let idx = map.xy_idx(pos.x, pos.y);
            for entity_id in map.tile_content[idx].iter() {
                if entity != *entity_id { // Do not bother to check yourself for being a trap!
                    let maybe_trigger = entry_trigger.get(*entity_id);
                    match maybe_trigger {
                        None => {},
                        Some(_trigger) => {
                            // We triggered it                            
                            let name = names.get(*entity_id);
                            if let Some(name) = name {
                                log.entries.push(format!("{} triggers!", &name.name));
                            }

                            hidden.remove(*entity_id); // The trap is no longer hidden
                        }
                    }
                }
            }
        }

        // Remove all entity movement markers
        entity_moved.clear();
    }
}
#}

This is relatively straightforward if you've been through the previous chapters:

  1. We iterate all entities that have a Position and an EntityMoved component.
  2. We obtain the map index for their location.
  3. We iterate the tile_content index to see what's in the new tile.
  4. We look to see if there is a trap there.
  5. If there is, we get its name and notify the player (via the log) that a trap activated.
  6. We remove the hidden component from the trap, since we now know that it is there.

We also have to go into main.rs and insert code to run the system. It goes after the Monster AI, since monsters can move - but we might output damage, so that system needs to run later:


# #![allow(unused_variables)]
#fn main() {
...
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
let mut triggers = trigger_system::TriggerSystem{};
triggers.run_now(&self.ecs);
...
#}

Traps that hurt

So that gets us a long way: traps can be sprinkled around the level, and trigger when you enter their target tile. It would help if the trap did something! We actually have a decent number of component types to describe the effect. In spawner.rs, we'll extend the bear trap to include some damage:


# #![allow(unused_variables)]
#fn main() {
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('^'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Bear Trap".to_string() })
        .with(Hidden{})
        .with(EntryTrigger{})
        .with(InflictsDamage{ damage: 6 })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
#}

We'll also extend the trigger_system to apply the damage:


# #![allow(unused_variables)]
#fn main() {
// If the trap is damage inflicting, do it
let damage = inflicts_damage.get(*entity_id);
if let Some(damage) = damage {
    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
    SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage);
}
#}

If you cargo run now, you can move around - and walking into a trap will damage you. If a monster walks into a trap, it damages them too! It even plays the particle effect for attacking.

Bear traps only snap once

Some traps, like a bear trap (think a spring with spikes) really only fire once. That seems like a useful property to model for our trigger system, so we'll add a new component (to components.rs, main.rs and saveload_system.rs):


# #![allow(unused_variables)]
#fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct SingleActivation {}
#}

We'll also add it to the Bear Trap function in spawner.rs:


# #![allow(unused_variables)]
#fn main() {
.with(SingleActivation{})
#}

Now we modify the trigger_system to apply it. Note that we remove the entities after looping through them, to avoid confusing our iterators.


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog, 
    InflictsDamage, particle_system::ParticleBuilder, SufferDamage, SingleActivation};

pub struct TriggerSystem {}

impl<'a> System<'a> for TriggerSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Map>,
                        WriteStorage<'a, EntityMoved>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, EntryTrigger>,
                        WriteStorage<'a, Hidden>,
                        ReadStorage<'a, Name>,
                        Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        ReadStorage<'a, InflictsDamage>,
                        WriteExpect<'a, ParticleBuilder>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, SingleActivation>);

    fn run(&mut self, data : Self::SystemData) {
        let (map, mut entity_moved, position, entry_trigger, mut hidden, 
            names, entities, mut log, inflicts_damage, mut particle_builder,
            mut inflict_damage, single_activation) = data;

        // Iterate the entities that moved and their final position
        let mut remove_entities : Vec<Entity> = Vec::new();
        for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() {
            let idx = map.xy_idx(pos.x, pos.y);
            for entity_id in map.tile_content[idx].iter() {
                if entity != *entity_id { // Do not bother to check yourself for being a trap!
                    let maybe_trigger = entry_trigger.get(*entity_id);
                    match maybe_trigger {
                        None => {},
                        Some(_trigger) => {
                            // We triggered it                            
                            let name = names.get(*entity_id);
                            if let Some(name) = name {
                                log.entries.push(format!("{} triggers!", &name.name));
                            }

                            hidden.remove(*entity_id); // The trap is no longer hidden

                            // If the trap is damage inflicting, do it
                            let damage = inflicts_damage.get(*entity_id);
                            if let Some(damage) = damage {
                                particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
                                SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage);
                            }

                            // If it is single activation, it needs to be removed
                            let sa = single_activation.get(*entity_id);
                            if let Some(_sa) = sa {
                                remove_entities.push(*entity_id);
                            }
                        }
                    }
                }
            }
        }

        // Remove any single activation traps
        for trap in remove_entities.iter() {
            entities.delete(*trap).expect("Unable to delete trap");
        }

        // Remove all entity movement markers
        entity_moved.clear();
    }
}
#}

If you cargo run now (I recommend cargo run --release - it's getting slower!), you can be hit by a bear trap - take some damage, and the trap goes away.

Spotting Traps

We have a pretty functional trap system now, but it's annoying to randomly take damage for no apparent reason - because you had no way to know that a trap was there. It's also quite unfair, since there's no way to guard against it. We'll implement a chance to spot traps. At some point in the future, this might be tied to an attribute or skill - but for now, we'll go with a dice roll. That's a bit nicer than asking everyone to carry a 10 foot pole with them at all times (like some early D&D games!).

Since the visibility_system already handles revealing tiles, why not make it potentially reveal hidden things, too? Here's the code for visibility_system.rs:


# #![allow(unused_variables)]
#fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map, Player, Hidden, gamelog::GameLog};
use rltk::{field_of_view, Point};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>, 
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, Player>,
                        WriteStorage<'a, Hidden>,
                        WriteExpect<'a, rltk::RandomNumberGenerator>,
                        WriteExpect<'a, GameLog>,
                        ReadStorage<'a, Name>,);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, entities, mut viewshed, pos, player, 
            mut hidden, mut rng, mut log, names) = data;

        for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() {
            if viewshed.dirty {
                viewshed.dirty = false;
                viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
                viewshed.visible_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );

                // If this is the player, reveal what they can see
                let _p : Option<&Player> = player.get(ent);
                if let Some(_p) = _p {
                    for t in map.visible_tiles.iter_mut() { *t = false };
                    for vis in viewshed.visible_tiles.iter() {
                        let idx = map.xy_idx(vis.x, vis.y);
                        map.revealed_tiles[idx] = true;
                        map.visible_tiles[idx] = true;

                        // Chance to reveal hidden things
                        for e in map.tile_content[idx].iter() {
                            let maybe_hidden = hidden.get(*e);
                            if let Some(_maybe_hidden) = maybe_hidden {
                                if rng.roll_dice(1,24)==1 {
                                    let name = names.get(*e);
                                    if let Some(name) = name {
                                        log.entries.push(format!("You spotted a {}.", &name.name));
                                    }
                                    hidden.remove(*e);
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
#}

So why a 1 in 24 chance to spot traps? I played around until it felt about right. 1 in 6 (my first choice) was too good. Since your viewshed updates whenever you move, you have a high chance of spotting traps as you move around. Like a lot of things in game design: sometimes you just have to play with it until it feels right!

If you cargo run now, you can walk around - and sometimes spot traps. Monsters won't reveal traps, unless they fall into them.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Section 3 - Procedurally Generating Maps


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


This started out as part of section 2, but I realized it was a large, open topic. The larger roguelike games, such as Dungeon Crawl Stone Soup, Cogmind, Caves of Qud, etc. all have a variety of maps. Section 3 is all about map building, and will cover many of the available algorithms for procedurally building interesting maps.


Copyright (C) 2019, Herbert Wolverson.


Refactor: Generic Map Interface


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


So far, we've really just had one map design. It's different every time (unless you hit a repeat random seed), which is a great start - but the world of procedural generation leaves so many more possibilities. Over the next few chapters, we'll start building a few different map types.

Refactoring the builder - Defining an Interface

Up until now, all of our map generation code has sat in the map.rs file. That's fine for a single style, but what if we want to have lots of styles? This is the perfect time to create a proper builder system! If you look at the map generation code in main.rs, we have the beginnings of an interface defined:

  • We call Map::new_map_rooms_and_corridors, which builds a set of rooms.
  • We pass that to spawner::spawn_room to populate each room.
  • We then place the player in the first room.

To better organize our code, we'll make a module. Rust lets you make a directory, with a file in it called mod.rs - and that directory is now a module. Modules are exposed through mod and pub mod, and provide a way to keep parts of your code together. The mod.rs file provides an interface - that is, a list of what is provided by the module, and how to interact with it. Other files in the module can do whatever they want, safely isolated from the rest of the code.

So, we'll create a directory (off of src) called map_builders. In that directory, we'll create an empty file called mod.rs. We're trying to define an interface, so we'll start with a skeleton. In mod.rs:


# #![allow(unused_variables)]
#fn main() {
use super::Map;

trait MapBuilder {
    fn build(new_depth: i32) -> Map;
}
#}

The use of trait is new! A trait is like an interface in other languages: you are saying that any other type can implement the trait, and can then be treated as a variable of that type. Rust by Example has a great section on traits, as does The Rust Book. What we're stating is that anything can declare itself to be a MapBuilder - and that includes a promise that they will provide a build function that takes in an ECS World object, and returns a map.

Open up map.rs, and add a new function - called, appropriately enough, new:


# #![allow(unused_variables)]
#fn main() {
/// Generates an empty map, consisting entirely of solid walls
pub fn new(new_depth : i32) -> Map {
    Map{
        tiles : vec![TileType::Wall; MAPCOUNT],
        rooms : Vec::new(),
        width : MAPWIDTH as i32,
        height: MAPHEIGHT as i32,
        revealed_tiles : vec![false; MAPCOUNT],
        visible_tiles : vec![false; MAPCOUNT],
        blocked : vec![false; MAPCOUNT],
        tile_content : vec![Vec::new(); MAPCOUNT],
        depth: new_depth,
        bloodstains: HashSet::new()
    }
}
#}

We'll need this for other map generators, and it makes sense for a Map to know how to return a new one as a constructor - without having to encapsulate all the logic for map layout. The idea is that any Map will work basically the same way, irrespective of how we've decided to populate it.

Now we'll create a new file, also inside the map_builders directory. We'll call it simple_map.rs - and it'll be where we put the existing map generation system. We'll also put a skeleton in place here:


# #![allow(unused_variables)]
#fn main() {
use super::MapBuilder;
use super::Map;
use specs::prelude::*;

pub struct SimpleMapBuilder {}

impl MapBuilder for SimpleMapBuilder {
    fn build(new_depth: i32) -> Map {
        Map::new(new_depth)
    }
}
#}

This simply returns an unusable, solid map. We'll flesh out the details in a bit - lets get the interface working, first.

Now, back in map_builders/mod.rs we add a public function. For now, it just calls the builder in SimpleMapBuilder:


# #![allow(unused_variables)]
#fn main() {
pub fn build_random_map(new_depth: i32) -> Map {
    SimpleMapBuilder::build(new_depth)
}
#}

Finally, we'll tell main.rs to actually include the module:


# #![allow(unused_variables)]
#fn main() {
pub mod map_builders;
#}

Ok, so that was a fair amount of work to not actually do anything - but we've gained a clean interface offering map creation (via a single function), and setup a trait to require that our map builders work in a similar fashion. That's a good start.

Fleshing out the Simple Map Builder

Now we start moving functionality out of map.rs into our SimpleMapBuilder. We'll start by adding another file to map_builders - common.rs. This will hold functions that used to be part of the map, and are now commonly used when building.

The file looks like this:


# #![allow(unused_variables)]
#fn main() {
use super::{Map, Rect, TileType};
use std::cmp::{max, min};

pub fn apply_room_to_map(map : &mut Map, room : &Rect) {
    for y in room.y1 +1 ..= room.y2 {
        for x in room.x1 + 1 ..= room.x2 {
            let idx = map.xy_idx(x, y);
            map.tiles[idx] = TileType::Floor;
        }
    }
}

pub fn apply_horizontal_tunnel(map : &mut Map, x1:i32, x2:i32, y:i32) {
    for x in min(x1,x2) ..= max(x1,x2) {
        let idx = map.xy_idx(x, y);
        if idx > 0 && idx < map.width as usize * map.height as usize {
            map.tiles[idx as usize] = TileType::Floor;
        }
    }
}

pub fn apply_vertical_tunnel(map : &mut Map, y1:i32, y2:i32, x:i32) {
    for y in min(y1,y2) ..= max(y1,y2) {
        let idx = map.xy_idx(x, y);
        if idx > 0 && idx < map.width as usize * map.height as usize {
            map.tiles[idx as usize] = TileType::Floor;
        }
    }
}
#}

These are exactly the same as the functions from map.rs, but with map passed as a mutable reference (so you are working on the original, rather than a new one) and all vestiges of self gone. These are free functions - that is, they are functions available from anywhere, not tied to a type. The pub fn means they are public within the module - unless we add pub use to the module itself, they aren't passed out of the module to the main program. This helps keeps code organized.

Now that we have these helpers, we can start porting the map builder itself. In simple_map.rs, we start by fleshing out the build function a bit:


# #![allow(unused_variables)]
#fn main() {
impl MapBuilder for SimpleMapBuilder {
    fn build(new_depth: i32) -> Map {
        let mut map = Map::new(new_depth);
        SimpleMapBuilder::rooms_and_corridors(&mut map);
        map
    }
}
#}

We're calling a new function, rooms_and_corridors. Lets build it:


# #![allow(unused_variables)]
#fn main() {
impl SimpleMapBuilder {
    fn rooms_and_corridors(map : &mut Map) {
        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;

        let mut rng = RandomNumberGenerator::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, map.width - w - 1) - 1;
            let y = rng.roll_dice(1, map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in map.rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                apply_room_to_map(map, &new_room);

                if !map.rooms.is_empty() {
                    let (new_x, new_y) = new_room.center();
                    let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
                    if rng.range(0,2) == 1 {
                        apply_horizontal_tunnel(map, prev_x, new_x, prev_y);
                        apply_vertical_tunnel(map, prev_y, new_y, new_x);
                    } else {
                        apply_vertical_tunnel(map, prev_y, new_y, prev_x);
                        apply_horizontal_tunnel(map, prev_x, new_x, new_y);
                    }
                }

                map.rooms.push(new_room);
            }
        }

        let stairs_position = map.rooms[map.rooms.len()-1].center();
        let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1);
        map.tiles[stairs_idx] = TileType::DownStairs;
    }
}
#}

You'll notice that this is built as a method attached to the SimpleMapBuilder structure. It isn't part of the trait, so we can't define it there - but we want to keep it separated from other builders, which might have their own functions. The code itself should look eerily familiar: it's the same as the generator in map.rs, but with map as a variable rather than being generated inside the function.

This is only the first half of generation, but it's a good start! Now go to map.rs, and delete the entire new_map_rooms_and_corridors function. Also delete the ones we replicated in common.rs. The map.rs file looks much cleaner now, without any references to map building strategy! Of course, your compiler/IDE is probably telling you that we've broken a bunch of stuff. That's ok - and a normal part of "refactoring" - the process of changing code to be easier to work with.

There are three lines in main.rs that are now flagged by the compiler.

  • We can replace *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1); with *worldmap_resource = map_builders::build_random_map(current_depth + 1);.
  • *worldmap_resource = Map::new_map_rooms_and_corridors(1); can become *worldmap_resource = map_builders::build_random_map(1);.
  • let map : Map = Map::new_map_rooms_and_corridors(1); transforms to let map : Map = map_builders::build_random_map(1);.

If you cargo run now, you'll notice: the game is exactly the same! That's good: we've successfully refactored functionality out of Map and into map_builders.

Placing the Player

If you look in main.rs, pretty much every time we build a map - we then look for the first room, and use it to place the player. It's quite possible that we won't want to use the same strategy in future maps, so we should indicate where the player goes when we build the map. Lets expand our interface in map_builders/mod.rs to also return a position:


# #![allow(unused_variables)]
#fn main() {
trait MapBuilder {
    fn build(new_depth: i32) -> (Map, Position);
}

pub fn build_random_map(new_depth: i32) -> (Map, Position) {
    SimpleMapBuilder::build(new_depth)
}
#}

Notice that we're using a tuple to return two values at once. We've talked about those earlier, but this is a great example of why they are useful! We now need to go into simple_map to make the build function actually return the correct data. The definition of build in simple_map.rs now looks like this:


# #![allow(unused_variables)]
#fn main() {
fn build(new_depth: i32) -> (Map, Position) {
    let mut map = Map::new(new_depth);
    let playerpos = SimpleMapBuilder::rooms_and_corridors(&mut map);
    (map, playerpos)
}
#}

We'll update the signature of rooms_and_corridors:


# #![allow(unused_variables)]
#fn main() {
fn rooms_and_corridors(map : &mut Map) -> Position {
#}

And we'll add a last line to return the center of room 0:


# #![allow(unused_variables)]
#fn main() {
let start_pos = map.rooms[0].center();
Position{ x: start_pos.0, y: start_pos.1 }
#}

This has, of course, broken the code we updated in main.rs. We can quickly take care of that! The first error can be taken care of with the following code:


# #![allow(unused_variables)]
#fn main() {
// Build a new map and place the player
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    let (newmap, start) = map_builders::build_random_map(current_depth + 1);
    *worldmap_resource = newmap;
    player_start = start;
    worldmap = worldmap_resource.clone();
}

// Spawn bad guys
for room in worldmap.rooms.iter().skip(1) {
    spawner::spawn_room(&mut self.ecs, room, current_depth+1);
}

// Place the player and update resources
let (player_x, player_y) = (player_start.x, player_start.y);
#}

Notice how we use destructuring to retrieve both the map and the start position from the builder. We then put these in the appropriate places. Since assignment in Rust is a move operation, this is pretty efficient - and the compiler can get rid of temporary assignments for us.

We do the same again on the second error (around line 369). It's almost exactly the same code, so feel free to check the source code for this chapter if you are stuck.

Lastly, the final error can be simply replaced like this:


# #![allow(unused_variables)]
#fn main() {
let (map, player_start) = map_builders::build_random_map(1);
let (player_x, player_y) = (player_start.x, player_start.y);
#}

Alright, lets cargo run that puppy! If all went well, then... nothing has changed. We've made a significant gain, however: our map building strategy now determines the player's starting point on a level, not the map itself.

Cleaning up room spawning

It's quite possible that we won't have the concept of rooms in some map designs, so we also want to move spawning to be a function of the map builder. We'll add a generic spawner to the interface in map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
trait MapBuilder {
    fn build(new_depth: i32) -> (Map, Position);
    fn spawn(map : &Map, ecs : &mut World, new_depth: i32);
}
#}

Simple enough: it requires the ECS (since we're adding entities) and the map. We'll also add a public function, spawn to provide an external interface to layout out the monsters:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn(map : &mut Map, ecs : &mut World, new_depth: i32) {
    SimpleMapBuilder::spawn(map, ecs, new_depth);
}
#}

Now we open simple_map.rs and actually implement spawn. Fortunately, it's very simple:


# #![allow(unused_variables)]
#fn main() {
fn spawn(map : &mut Map, ecs : &mut World) {
    for room in map.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, 1);
    }
}
#}

Now, we can go into main.rs and find every time we loop through calling spawn_room and replace it with a call to map_builders::spawn.

Once again, cargo run should give you the same game we've been looking at for 22 chapters!

Maintaining builder state

If you look closely at what we have so far, there's one problem: the builder has no way of knowing what should be used for the second call to the builder (spawning things). That's because our functions are stateless - we don't actually create a builder and give it a way to remember anything. Since we want to support a wide variety of builders, we should correct that.

This introduces a new Rust concept: dynamic dispatch. The Rust Book has a good section on this if you are familiar with the concept. If you've previously used an Object Oriented Programming language, then you will have encountered this also. The basic idea is that you have a "base object" that specifies an interface - and multiple objects implement the functions from the interface. You can then, at run-time (when the program runs, rather than when it compiles) put any object that implements the interface into a variable typed by the interface - and when you call the methods from the interface, the implementation runs from the actual type. This is nice because your underlying program doesn't have to know about the actual implementations - just how to talk to the interface. That helps keep your program clean.

Dynamic dispatch does come with a cost, which is why Entity Component Systems (and Rust in general) prefer not to use it for performance-critical code. There's actually two costs:

  1. Since you don't know what type the object is up-front, you have to allocate it via a pointer. Rust makes this easy by providing the Box system (more on that in a moment), but there is a cost: rather than just jumping to a readily defined piece of memory (which your CPU/memory can generally figure out easily in advance and make sure the cache is ready) the code has to follow the pointer - and then run what it finds at the end of the pointer. That's why some C++ programmers call -> (dereference operator) the "cache miss operator". Simply by being boxed, your code is slowed down by a tiny amount.
  2. Since multiple types can implement methods, the computer needs to know which one to run. It does this with a vtable - that is, a "virtual table" of method implementations. So each call has to check the table, find out which method to run, and then run from there. That's another cache miss, and more time for your CPU to figure out what to do.

In this case, we're just generating the map - and making very few calls into the builder. That makes the slowdown acceptable, since it's really small and not being run frequently. You wouldn't want to do this in your main loop, if you can avoid it!

So - implementation. We'll start by changing our trait to be public, and have the methods accept an &mut self - which means "this method is a member of the trait, and should receive access to self - the attached object when we call it. The code looks like this:


# #![allow(unused_variables)]
#fn main() {
pub trait MapBuilder {
    fn build_map(&mut self, new_depth: i32) -> (Map, Position);
    fn spawn_entities(&mut self, map : &Map, ecs : &mut World, new_depth: i32);
}
#}

Notice that I've also taken the time to make the names a bit more descriptive! Now we replace our free function calls with a factory function: it creates a MapBuilder and returns it. The name is a bit of a lie until we have more map implementations - it claims to be random, but when there's only one choice it's not hard to guess which one it will pick (just ask Soviet election systems!):


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder() -> Box<dyn MapBuilder> {
    // Note that until we have a second map type, this isn't even slighlty random
    Box::new(SimpleMapBuilder{})
}
#}

Notice that it doesn't return a MapBuilder - rather it returns a Box<dyn MapBuilder>! That's rather convoluted (and in earlier versions of Rust, the dyn is optional). A Box is a type wrapped in a pointer, whose size may not be known at compile time. It's the same as a C++ MapBuilder * - it points to a MapBuilder rather than actually being one. The dyn is a flag to say "this should use dynamic dispatch"; the code will work without it (it will be inferred), but it's good practice to flag that you are doing something complicated/expensive here.

The function simply returns Box::new(SimpleMapBuilder{}). This is actually two calls, now: we make a box with Box::new(...), and we place an empty SimpleMapBuilder into the box.

Over in main.rs, we once again have to change all three calls to the map builder. We now need to use the following pattern:

  1. Obtain a boxed MapBuilder object, from the factory.
  2. Call build_map as a method - that is, a function attached to the object.
  3. Call spawn_entities also as a method.

The implementation from goto_next_level now reads as follows:


# #![allow(unused_variables)]
#fn main() {
// Build a new map and place the player
let mut builder = map_builders::random_builder(current_depth + 1);
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    let (newmap, start) = builder.build_map(current_depth + 1);
    *worldmap_resource = newmap;
    player_start = start;
    worldmap = worldmap_resource.clone();
}

// Spawn bad guys
builder.spawn_entities(&worldmap, &mut self.ecs, current_depth+1);
#}

It's not very different, but now we're keeping the builder object around - so subsequent calls to the builder will apply to the same implementation (sometimes called "concrete object" - the object that actually physically exists).

If we were to add 5 more map builders, the code in main.rs wouldn't care! We can add them to the factory, and the rest of the program is blissfully unaware of the workings of the map builder. This is a very good example of how dynamic dispatch can be useful: you have a clearly defined interface, and the rest of the program doesn't need to understand the inner workings.

Adding a constructor to SimpleMapBuilder

We're currently making a SimpleMapBuilder as an empty object. What if it needs to keep track of some data? In case we need it, lets add a simple constructor to it and use that instead of a blank object. In simple_map.rs, modify the struct implementation as follows:


# #![allow(unused_variables)]
#fn main() {
impl SimpleMapBuilder {
    pub fn new(new_depth : i32) -> SimpleMapBuilder {
        SimpleMapBuilder{}
    }
    ...
#}

That simply returns an empty object for now. In mod.rs, change the random_map_builder function to use it:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth : i32) -> Box<dyn MapBuilder> {
    // Note that until we have a second map type, this isn't even slighlty random
    Box::new(SimpleMapBuilder::new(new_depth))
}
#}

This hasn't gained us anything, but is a bit cleaner - when you write more maps, they may do something in their constructors!

Cleaning up the trait - simple, obvious steps and single return types

Now that we've come this far, lets extend the trait a bit to obtain the player's position in one function, the map in another, and build/spawn separately. Using small functions tends to make the code easier to read, which is a worthwhile goal in and of itself. In mod.rs, we change the interface as follows:


# #![allow(unused_variables)]
#fn main() {
pub trait MapBuilder {
    fn build_map(&mut self);
    fn spawn_entities(&mut self, ecs : &mut World);
    fn get_map(&mut self) -> Map;
    fn get_starting_position(&mut self) -> Position;
}
#}

There's a few things to note here:

  1. build_map no longer returns anything at all. We're using it as a function to build map state.
  2. spawn_entities no longer asks for a Map parameter. Since all map builders have to implement a map in order to make sense, we're going to assume that the map builder has one.
  3. get_map returns a map. Again, we're assuming that the builder implementation keeps one.
  4. get_starting_position also assumes that the builder will keep one around.

Obviously, our SimpleMapBuilder now needs to be modified to work this way. We'll start by modifying the struct to include the required variables. This is the map builder's state - and since we're doing dynamic object-oriented code, the state remains attached to the object. Here's the code from simple_map.rs:


# #![allow(unused_variables)]
#fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32
}
#}

Next, we'll implement the getter functions. These are very simple: they simply return the variables from the structure's state:


# #![allow(unused_variables)]
#fn main() {
impl MapBuilder for SimpleMapBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }
    ...
#}

We'll also update the constructor to create the state:


# #![allow(unused_variables)]
#fn main() {
pub fn new(new_depth : i32) -> SimpleMapBuilder {
    SimpleMapBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth
    }
}
#}

This also simplifies build_map and spawn_entities:


# #![allow(unused_variables)]
#fn main() {
fn build_map(&mut self) {
    SimpleMapBuilder::rooms_and_corridors();
}

fn spawn_entities(&mut self, ecs : &mut World) {
    for room in self.map.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, self.depth);
    }
}
#}

Lastly, we need to modify rooms_and_corridors to work with this interface:


# #![allow(unused_variables)]
#fn main() {
fn rooms_and_corridors(&mut self) {
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for i in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
        let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in self.map.rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&mut self.map, &new_room);

            if !self.map.rooms.is_empty() {
                let (new_x, new_y) = new_room.center();
                let (prev_x, prev_y) = self.map.rooms[self.map.rooms.len()-1].center();
                if rng.range(0,2) == 1 {
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
                } else {
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
                }
            }

            self.map.rooms.push(new_room);
        }
    }

    let stairs_position = self.map.rooms[self.map.rooms.len()-1].center();
    let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1);
    self.map.tiles[stairs_idx] = TileType::DownStairs;

    let start_pos = self.map.rooms[0].center();
    self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };
}
#}

This is very similar to what we had before, but now uses self.map to refer to its own copy of the map, and stores the player position in self.starting_position.

The calls into the new code in main.rs once again change. The call from goto_next_level now looks like this:


# #![allow(unused_variables)]
#fn main() {
let mut builder;
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    builder = map_builders::random_builder(current_depth + 1);
    builder.build_map();
    *worldmap_resource = builder.get_map();
    player_start = builder.get_starting_position();
    worldmap = worldmap_resource.clone();
}

// Spawn bad guys
builder.spawn_entities(&mut self.ecs);
#}

We basically repeat those changes for the others (see the source). We now have a pretty comfortable interface into the map builder: it exposes enough to be easy to use, without exposing the details of the magic it uses to actually build the map!

If you cargo run the project now: once again, nothing visible has changed - it still works the way it did before. When you are refactoring, that's a good thing!

So why do maps still have rooms?

Rooms don't actually do much in the game itself: they are an artifact of how we build the map. It's quite possible that later map builders won't actually care about rooms, at least not in the "here's a rectangle, we're calling a room" sense. Lets try and move that abstraction out of the map, and also out of the spawner.

As a first step, in map.rs we remove the rooms structure completely:


# #![allow(unused_variables)]
#fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,
    pub bloodstains : HashSet<usize>,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
#}

We also remove it from the new function. Take a look at your IDE, and you'll notice that you've only broken code in simple_map.rs! We weren't using the rooms anywhere else - which is a pretty big clue that they don't belong in the map we're passing around throughout the main program.

We can fix simple_map by putting rooms into the builder rather than the map. We'll put it into the structure:


# #![allow(unused_variables)]
#fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>
}
#}

This requires that we fixup the constructor:


# #![allow(unused_variables)]
#fn main() {
pub fn new(new_depth : i32) -> SimpleMapBuilder {
    SimpleMapBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        rooms: Vec::new()
    }
}
#}

The spawn function becomes:


# #![allow(unused_variables)]
#fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for room in self.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, self.depth);
    }
}
#}

And we replace every instance of map.rooms with self.rooms in rooms_and_corridors:


# #![allow(unused_variables)]
#fn main() {
fn rooms_and_corridors(&mut self) {
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for i in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
        let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in self.rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&mut self.map, &new_room);

            if !self.rooms.is_empty() {
                let (new_x, new_y) = new_room.center();
                let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center();
                if rng.range(0,2) == 1 {
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
                } else {
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
                }
            }

            self.rooms.push(new_room);
        }
    }

    let stairs_position = self.rooms[self.rooms.len()-1].center();
    let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1);
    self.map.tiles[stairs_idx] = TileType::DownStairs;

    let start_pos = self.rooms[0].center();
    self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };
}
#}

Once again, cargo run the project: and nothing should have changed.

Wrap-up

This was an interesting chapter to write, because the objective is to finish with code that operates exactly as it did before - but with the map builder cleaned into its own module, completely isolated from the rest of the code. That gives us a great starting point to start building new map builders, without having to change the game itself.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required). There isn't a lot of point, since refactoring aims to not change the visible result!

Copyright (C) 2019, Herbert Wolverson.


Map Construction Test Harness


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


As we're diving into generating new and interesting maps, it would be helpful to provide a way to see what the algorithms are doing. This chapter will build a test harness to accomplish this, and extend the SimpleMapBuilder from the previous chapter to support it. This is going to be a relatively large task, and we'll learn some new techniques along the way!

Cleaning up map creation - Do Not Repeat Yourself

In main.rs, we essentially have the same code three times. When the program starts, we insert a map into the world. When we change level, or finish the game - we do the same. The last two have different semantics (since we're updating the world rather than inserting for the first time) - but it's basically redundant repetition.

We'll start by changing the first one to insert placeholder values rather than the actual values we intend to use. This way, the World has the slots for the data - it just isn't all that useful yet. Here's a version with the old code commented out:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());

gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());

/*let mut builder = map_builders::random_builder(1);
builder.build_map();
let player_start = builder.get_starting_position();
let map = builder.get_map();
let (player_x, player_y) = (player_start.x, player_start.y);
builder.spawn_entities(&mut gs.ecs);
gs.ecs.insert(map);
gs.ecs.insert(Point::new(player_x, player_y));*/

let player_entity = spawner::player(&mut gs.ecs, 0, 0);
gs.ecs.insert(player_entity);
#}

So instead of building the map, we put a placeholder into the World resources. That's obviously not very useful for actually starting the game, so we also need a function to do the actual building and update the resources. Not entirely coincidentally, that function is the same as the other two places from which we currently update the map! In other words, we can roll those into this function, too. So in the implementation of State, we add:


# #![allow(unused_variables)]
#fn main() {
fn generate_world_map(&mut self, new_depth : i32) {
    let mut builder = map_builders::random_builder(new_depth);
    builder.build_map();
    let player_start;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        *worldmap_resource = builder.get_map();
        player_start = builder.get_starting_position();
    }

    // Spawn bad guys
    builder.spawn_entities(&mut self.ecs);

    // Place the player and update resources
    let (player_x, player_y) = (player_start.x, player_start.y);
    let mut player_position = self.ecs.write_resource::<Point>();
    *player_position = Point::new(player_x, player_y);
    let mut position_components = self.ecs.write_storage::<Position>();
    let player_entity = self.ecs.fetch::<Entity>();
    let player_pos_comp = position_components.get_mut(*player_entity);
    if let Some(player_pos_comp) = player_pos_comp {
        player_pos_comp.x = player_x;
        player_pos_comp.y = player_y;
    }

    // Mark the player's visibility as dirty
    let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
    let vs = viewshed_components.get_mut(*player_entity);
    if let Some(vs) = vs {
        vs.dirty = true;
    } 
}
#}

Now we can get rid of the commented out code, and simplify our first call quite a bit:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());
let player_entity = spawner::player(&mut gs.ecs, 0, 0);
gs.ecs.insert(player_entity);
gs.ecs.insert(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame });
gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });
gs.ecs.insert(particle_system::ParticleBuilder::new());
gs.ecs.insert(rex_assets::RexAssets::new());

gs.generate_world_map(1);
#}

We can also go to the various parts of the code that call the same code we just added to generate_world_map and greatly simplify them by using the new function. We can replace goto_next_level with:


# #![allow(unused_variables)]
#fn main() {
fn goto_next_level(&mut self) {
    // Delete entities that aren't the player or his/her equipment
    let to_delete = self.entities_to_remove_on_level_change();
    for target in to_delete {
        self.ecs.delete_entity(target).expect("Unable to delete entity");
    }

    // Build a new map and place the player
    let current_depth;
    {
        let worldmap_resource = self.ecs.fetch::<Map>();
        current_depth = worldmap_resource.depth;
    }
    self.generate_world_map(current_depth + 1);

    // Notify the player and give them some health
    let player_entity = self.ecs.fetch::<Entity>();
    let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
    gamelog.entries.push("You descend to the next level, and take a moment to heal.".to_string());
    let mut player_health_store = self.ecs.write_storage::<CombatStats>();
    let player_health = player_health_store.get_mut(*player_entity);
    if let Some(player_health) = player_health {
        player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2);
    }
}
#}

Likewise, we can clean up game_over_cleanup:


# #![allow(unused_variables)]
#fn main() {
fn game_over_cleanup(&mut self) {
    // Delete everything
    let mut to_delete = Vec::new();
    for e in self.ecs.entities().join() {
        to_delete.push(e);
    }
    for del in to_delete.iter() {
        self.ecs.delete_entity(*del).expect("Deletion failed");
    }

    // Spawn a new player
    {
        let player_entity = spawner::player(&mut self.ecs, 0, 0);
        let mut player_entity_writer = self.ecs.write_resource::<Entity>();
        *player_entity_writer = player_entity;
    }

    // Build a new map and place the player
    self.generate_world_map(1);                                          
}
#}

And there we go - cargo run gives the same game we've had for a while, and we've cut out a bunch of code. Refactors that make things smaller rock!

Making a generator

It's surprisingly difficult to combine two paradigms, sometimes:

  • The graphical "tick" nature of RLTK (and the underlying GUI environment) encourages you to do everything fast, in one fell swoop.
  • Actually visualizing progress while you generate a map encourages you to run in lots of phases as a "state machine", yielding map results along the way.

My first thought was to use coroutines, specifically Generators. They really are ideal for this type of thing: you can write code in a function that runs synchronously (in order) and "yields" values as the computation continues. I even went so far as to get a working implementation - but it required nightly support (unstable, unfinished Rust) and didn't play nicely with web assembly. So I scrapped it. There's a lesson here: sometimes the tooling isn't quite ready for what you really want!

Instead, I decided to go with a more traditional route. Maps can take a "snapshot" while they generate, and that big pile of snapshots can be played frame-by-frame in the visualizer. This isn't quite as nice as a coroutine, but it works and is stable. Those are desirable traits!

To get started, we should make sure that visualizing map generation is entirely optional. When you ship your game to players, you probably don't want to show them the whole map while they get started - but while you are working on map algorithms, it's very valuable. So towards the top of main.rs, we add a constant:


# #![allow(unused_variables)]
#fn main() {
const SHOW_MAPGEN_VISUALIZER : bool = true;
#}

A constant is just that: a variable that cannot change once the program has started. Rust makes read-only constants pretty easy, and the compiler generally optimizes them out completely since the value is known ahead of time. In this case, we're stating that a bool called SHOW_MAPGEN_VISUALIZER is true. The idea is that we can set it to false when we don't want to display our map generation progress.

With that in place, it's time to add snapshot support to our map builder interface. In map_builders/mod.rs we extend the interface a bit:


# #![allow(unused_variables)]
#fn main() {
pub trait MapBuilder {
    fn build_map(&mut self);
    fn spawn_entities(&mut self, ecs : &mut World);
    fn get_map(&self) -> Map;
    fn get_starting_position(&self) -> Position;
    fn get_snapshot_history(&self) -> Vec<Map>;
    fn take_snapshot(&mut self);
}
#}

Notice the new entries: get_snapshot_history and take_snapshot. The former will be used to ask the generator for its history of map frames; the latter tells generators to support taking snapshots (and leaves it up to them how they do it).

This is a good time to mention one major difference between Rust and C++ (and other languages that provide Object Oriented Programming support). Rust traits do not support adding variables to the trait signature. So you can't include a history : Vec<Map> within the trait, even if that's exactly what you're using to store the snapshot in all the implementations. I honestly don't know why this is the case, but it's workable - just an odd departure from OOP norms.

Inside simple_map.rs, we need to implement these methods for our SimpleMapBuilder. We start by adding supporting variables to our struct:


# #![allow(unused_variables)]
#fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>,
    history: Vec<Map>
}
#}

Notice that we've added history: Vec<Map> to the structure. It's what it says on the tin: a vector (resizable array) of Map structures. The idea is that we'll keep adding copies of the map into it for each "frame" of map generation.

Onto the trait implementations:


# #![allow(unused_variables)]
#fn main() {
fn get_snapshot_history(&self) -> Vec<Map> {
    self.history.clone()
}
#}

This is very simple: we return a copy of the history vector to the caller. We also need:


# #![allow(unused_variables)]
#fn main() {
fn take_snapshot(&mut self) {
    if SHOW_MAPGEN_VISUALIZER {
        let mut snapshot = self.map.clone();
        for v in snapshot.revealed_tiles.iter_mut() {
            *v = true;
        }
        self.history.push(snapshot);
    }
}
#}

We first check to see if we're using the snapshot feature (no point in wasting memory if we aren't!). If we are, we take a copy of the current map, iterate every revealed_tiles cell and set it to true (so the map render will display everything, including inaccessible walls), and add it to the history list.

We can now call self.take_snapshot() at any point during map generation, and it gets added as a frame to the map generator. In simple_map.rs we add a couple of calls after we add rooms or corridors:


# #![allow(unused_variables)]
#fn main() {
...
if ok {
    apply_room_to_map(&mut self.map, &new_room);
    self.take_snapshot();

    if !self.rooms.is_empty() {
        let (new_x, new_y) = new_room.center();
        let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center();
        if rng.range(0,2) == 1 {
            apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
            apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
        } else {
            apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
            apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
        }
    }

    self.rooms.push(new_room);
    self.take_snapshot();
}
...
#}

Rendering the visualizer

Visualizing map development is another game state, so we add it to our RunState enumeration in main.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, 
    PreRun, 
    PlayerTurn, 
    MonsterTurn, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 },
    MapGeneration
}
#}

Visualization actually requires a few variables, but I ran into a problem: one of the variables really should be the next state to which we transition after visualizing. We might be building a new map from one of three sources (new game, game over, next level) - and they have different states following the generation. Unfortunately, you can't put a second RunState into the first one - Rust gives you cycle errors, and it won't compile. You can use a Box<RunState> - but that doesn't work with RunState deriving from Copy! I fought this for a while, and settled on adding to State instead:


# #![allow(unused_variables)]
#fn main() {
pub struct State {
    pub ecs: World,
    mapgen_next_state : Option<RunState>,
    mapgen_history : Vec<Map>,
    mapgen_index : usize,
    mapgen_timer : f32
}
#}

We've added:

  • mapgen_next_state - which is where the game should go next.
  • mapgen_history - a copy of the map history frames to play.
  • mapgen_index - how far through the history we are during playback.
  • mapgen_timer - used for frame timing during playback.

Since we've modified State, we also have to modify our creation of the State object:


# #![allow(unused_variables)]
#fn main() {
let mut gs = State {
    ecs: World::new(),
    mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }),
    mapgen_index : 0,
    mapgen_history: Vec::new(),
    mapgen_timer: 0.0
};
#}

We've made the next state the same as the starting state we have been using: so the game will render map creation and then go to the menu. We can change our initial state to MapGeneration:


# #![allow(unused_variables)]
#fn main() {
gs.ecs.insert(RunState::MapGeneration{} );
#}

Now we need to implement the renderer. In our tick function, we add the following state:


# #![allow(unused_variables)]
#fn main() {
match newrunstate {
    RunState::MapGeneration => {
        if !SHOW_MAPGEN_VISUALIZER {
            newrunstate = self.mapgen_next_state.unwrap();
        }
        ctx.cls();                
        draw_map(&self.mapgen_history[self.mapgen_index], ctx);

        self.mapgen_timer += ctx.frame_time_ms;
        if self.mapgen_timer > 300.0 {
            self.mapgen_timer = 0.0;
            self.mapgen_index += 1;
            if self.mapgen_index >= self.mapgen_history.len() {
                newrunstate = self.mapgen_next_state.unwrap();
            }
        }
    }
    ...
#}

This is relatively straight-forward:

  1. If the visualizer isn't enabled, simply transition to the next state immediately.
  2. Clear the screen.
  3. Call draw_map, with the map history from our state - at the current frame.
  4. Add the frame duration to the mapgen_timer, and if it is greater than 300ms:
    1. Set the timer back to 0.
    2. Increment the frame counter.
    3. If the frame counter has reached the end of our history, transition to the next game state.

The eagle-eyed reader will have noticed a subtle change here. draw_map didn't used to take a map - it would pull it from the ECS! In map.rs, the beginning of draw_map changes to:


# #![allow(unused_variables)]
#fn main() {
pub fn draw_map(map : &Map, ctx : &mut Rltk) {
#}

Our regular call to draw_map in tick also changes to:


# #![allow(unused_variables)]
#fn main() {
draw_map(&self.ecs.fetch::<Map>(), ctx);
#}

This is a tiny change that allowed us to render whatever Map structure we need!

Lastly, we need to actually give the visualizer some data to render. We adjust generate_world_map to reset the various mapgen_ variables, clear the history, and retrieve the snapshot history once it has run:


# #![allow(unused_variables)]
#fn main() {
fn generate_world_map(&mut self, new_depth : i32) {
        self.mapgen_index = 0;
        self.mapgen_timer = 0.0;
        self.mapgen_history.clear();
        let mut builder = map_builders::random_builder(new_depth);
        builder.build_map();
        self.mapgen_history = builder.get_snapshot_history();
        let player_start;
        {
            let mut worldmap_resource = self.ecs.write_resource::<Map>();
            *worldmap_resource = builder.get_map();
            player_start = builder.get_starting_position();
        }
#}

If you cargo run the project now, you get to watch the simple map generator build your level before you start.

Screenshot

Wrap-Up

This finishes building the test harness - you can watch maps spawn, which should make generating maps (the topic of the next few chapters) a lot more intuitive.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


BSP Room Dungeons


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


A popular method of map generation uses "binary space partition" to sub-divide your map into rectangles of varying size, and then link the resulting rooms together into corridors. You can go a long way with this method: Nethack uses it extensively, Dungeon Crawl: Stone Soup uses it sometimes, and my project - One Knight in the Dungeon - uses it for sewer levels. This chapter will use the visualizer from the previous chapter to walk you through using this technique.

Implementing a new map - subdivided BSP, the boilerplate

We'll start by making a new file in map_builders - bsp_dungeon.rs. We start by making the basic BspDungeonBuilder struct:


# #![allow(unused_variables)]
#fn main() {
pub struct BspDungeonBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>,
    history: Vec<Map>,
    rects: Vec<Rect>
}
#}

This is basically the same as the one from SimpleMapBuilder - and we've kept the rooms vector, because this method uses a concept of rooms as well. We've added a rects vector: the algorithm uses this a lot, so it's helpful to make it available throughout the implementation. We'll see why it's needed shortly.

Now we implement the MapBuilder trait to the type:


# #![allow(unused_variables)]
#fn main() {
impl MapBuilder for BspDungeonBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        // We should do something here
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        for room in self.rooms.iter().skip(1) {
            spawner::spawn_room(ecs, room, self.depth);
        }
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}
#}

This is also pretty much the same as SimpleMapBuilder, but build_map has a comment reminding us to write some code. If you ran the generator right now, you'd get a solid blob of walls - and no content whatsoever.

We also need to implement a constructor for BspMapBuilder. Once again, it's basically the same as SimpleMapBuilder:


# #![allow(unused_variables)]
#fn main() {
impl BspDungeonBuilder {
    pub fn new(new_depth : i32) -> BspDungeonBuilder {
        BspDungeonBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            rooms: Vec::new(),
            history: Vec::new(),
            rects: Vec::new()
        }
    }
}
#}

Lastly, we'll open map_builders/mod.rs and change the random_builder function to always return our new map type:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    // Note that until we have a second map type, this isn't even slighlty random
    Box::new(BspDungeonBuilder::new(new_depth))
}
#}

Once again, this isn't in the slightest bit random - but it's far easier to develop a feature that always runs, rather than keeping trying until it picks the one we want to debug!

Building the map creator

We'll worry about swapping out map types later. Onto making the map! Note that this implementation is ported from my C++ game, One Knight in the Dungeon. We'll start with room generation. Inside our impl BspMapBuilder, we add a new function:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    self.rects.clear();
    self.rects.push( Rect::new(2, 2, self.map.width-5, self.map.height-5) ); // Start with a single map-sized rectangle
    let first_room = self.rects[0];
    self.add_subrects(first_room); // Divide the first room

    // Up to 240 times, we get a random rectangle and divide it. If its possible to squeeze a
    // room in there, we place it and add it to the rooms list.
    let mut n_rooms = 0;
    while n_rooms < 240 {
        let rect = self.get_random_rect(&mut rng);
        let candidate = self.get_random_sub_rect(rect, &mut rng);

        if self.is_possible(candidate) {
            apply_room_to_map(&mut self.map, &candidate);
            self.rooms.push(candidate);
            self.add_subrects(rect);
            self.take_snapshot();
        }

        n_rooms += 1;
    }
    let start = self.rooms[0].center();
    self.starting_position = Position{ x: start.0, y: start.1 };
}
#}

So what on Earth does this do?

  1. We clear the rects structure we created as part of the builder. This will be used to store rectangles derived from the overall map.
  2. We create the "first room" - which is really the whole map. We've trimmed a bit to add some padding to the sides of the map.
  3. We call add_subrects, passing it the rectangle list - and the first room. We'll implement that in a minute, but what it does is: it divides the rectangle into four quadrants, and adds each of the quadrants to the rectangle list.
  4. Now we setup a room counter, so we don't infinitely loop.
  5. While that counter is less than 240 (a relatively arbitrary limit that gives fun results):
    1. We call get_random_rect to retrieve a random rectangle from the rectangles list.
    2. We call get_random_sub_rect using this rectangle as an outer boundary. It creates a random room from 3 to 10 tiles in size (on each axis), somewhere within the parent rectangle.
    3. We ask is_possible if the candidate can be drawn to the map; every tile must be within the map boundaries, and not already a room. If it IS possible:
      1. We mark it on the map.
      2. We add it to the rooms list.
      3. We call add_subrects to sub-divide the rectangle we just used (not the candidate!).

There's quite a few support functions in play here, so lets go through them.


# #![allow(unused_variables)]
#fn main() {
fn add_subrects(&mut self, rect : Rect) {
    let width = i32::abs(rect.x1 - rect.x2);
    let height = i32::abs(rect.y1 - rect.y2);
    let half_width = i32::max(width / 2, 1);
    let half_height = i32::max(height / 2, 1);

    self.rects.push(Rect::new( rect.x1, rect.y1, half_width, half_height ));
    self.rects.push(Rect::new( rect.x1, rect.y1 + half_height, half_width, half_height ));
    self.rects.push(Rect::new( rect.x1 + half_width, rect.y1, half_width, half_height ));
    self.rects.push(Rect::new( rect.x1 + half_width, rect.y1 + half_height, half_width, half_height ));
}
#}

The function add_subrects is core to the BSP (Binary Space Partition) approach: it takes a rectangle, and divides the width and height in half. It then creates four new rectangles, one for each quadrant of the original. These are added to the rects list. Graphically:

###############        ###############
#             #        #  1   +   2  #
#             #        #      +      #
#      0      #   ->   #+++++++++++++#
#             #        #   3  +   4  #
#             #        #      +      #
###############        ###############

Next up is get_random_rect:


# #![allow(unused_variables)]
#fn main() {
fn get_random_rect(&mut self, rng : &mut RandomNumberGenerator) -> Rect {
    if self.rects.len() == 1 { return self.rects[0]; }
    let idx = (rng.roll_dice(1, self.rects.len() as i32)-1) as usize;
    self.rects[idx]
}
#}

This is a simple function. If there is only one rectangle in the rects list, it returns the first one. Otherwise, it rolls a dice for of 1d(size of rects list) and returns the rectangle found at the random index.

Next up is get_random_sub_rect:


# #![allow(unused_variables)]
#fn main() {
fn get_random_sub_rect(&self, rect : Rect, rng : &mut RandomNumberGenerator) -> Rect {
    let mut result = rect;
    let rect_width = i32::abs(rect.x1 - rect.x2);
    let rect_height = i32::abs(rect.y1 - rect.y2);

    let w = i32::max(3, rng.roll_dice(1, i32::min(rect_width, 10))-1) + 1;
    let h = i32::max(3, rng.roll_dice(1, i32::min(rect_height, 10))-1) + 1;

    result.x1 += rng.roll_dice(1, 6)-1;
    result.y1 += rng.roll_dice(1, 6)-1;
    result.x2 = result.x1 + w;
    result.y2 = result.y1 + h;

    result
}
#}

So this takes a rectangle as the parameter, and makes a mutable copy to use as the result. It calculates the width and height of the rectangle, and then produces a random width and height inside that rectangle - but no less than 3 tiles in size and no more than 10 on each dimension. You can tweak those numbers to change your desired room size. It then shunts the rectangle a bit, to provide some random placement (otherwise, it would always be against the sides of the sub-rectangle). Finally, it returns the result. Graphically:

###############        ########
#             #        #   1  #
#             #        #      #
#      0      #   ->   ########
#             #
#             #
###############

Finally, the is_possible function:


# #![allow(unused_variables)]
#fn main() {
fn is_possible(&self, rect : Rect) -> bool {
    let mut expanded = rect;
    expanded.x1 -= 2;
    expanded.x2 += 2;
    expanded.y1 -= 2;
    expanded.y2 += 2;

    let mut can_build = true;

    for y in expanded.y1 ..= expanded.y2 {
        for x in expanded.x1 ..= expanded.x2 {
            if x > self.map.width-2 { can_build = false; }
            if y > self.map.height-2 { can_build = false; }
            if x < 1 { can_build = false; }
            if y < 1 { can_build = false; }
            if can_build {
                let idx = self.map.xy_idx(x, y);
                if self.map.tiles[idx] != TileType::Wall { 
                    can_build = false; 
                }
            }
        }
    }

    can_build
}
#}

This is a little more complicated, but makes sense when you break it down:

  1. Take a rectangle as a target, representing the room we are looking at.
  2. Create a mutable copy of the rectangle called expanded. We then expand the rectangle out by 2 tiles in each direction, to prevent rooms from overlapping.
  3. We iterate every x and y coordinate in the rectangle:
    1. If x or y are out of the map boundaries, we mark can_build as false - this won't work.
    2. If we still can build it, we look at the existing map - if it isn't a solid wall, then we've overlapped an existing room, and mark that we can't build.
  4. We return the result of can_build.

So now that we've implemented all of these, the overall algorithm is more obvious:

  1. We start with a single rectangle covering the entire map.
  2. We sub-divide it, so now our map has 5 rectangles - one for each quadrant, one for the map as a whole.
  3. We use a counter to ensure that we don't loop forever (we'll reject a lot of rooms). While we can still add rooms, we:
    1. Obtain a random rectangle from the rectangles list. Initially, this will be one of the quadrants - or the whole map. This list will keep growing as we add subdivisions.
    2. We generate a random sub-rectangle inside this rectangle.
    3. We look to see if that's a possible room. If it is, we:
      1. Apply the room to the map (build it).
      2. Add it to the rooms list.
      3. Sub-divide the new rectangle into quadrants and add those to our rectangles list.
      4. Store a snapshot for the visualizer.

This tends to give a nice spread of rooms, and they are guaranteed not to overlap. Very Nethack like!

If you cargo run now, you will be in a room with no exits. You'll get to watch rooms appear around the map in the visualizer. That's a great start.

Screenshot

Adding in corridors

Now, we sort the rooms by left coordinate. You don't have to do this, but it helps make connected rooms line up.


# #![allow(unused_variables)]
#fn main() {
self.rooms.sort_by(|a,b| a.x1.cmp(&b.x1) );
#}

sort_by takes a closure - that is, an inline function (known as a "lambda" in other languages) as a parameter. You could specify a whole other function if you wanted to, or implement traits on Rect to make it sortable - but this is easy enough. It sorts by comparing the x1 value of each rectangle.

Now we'll add some corridors:


# #![allow(unused_variables)]
#fn main() {
// Now we want corridors
for i in 0..self.rooms.len()-1 {
    let room = self.rooms[i];
    let next_room = self.rooms[i+1];
    let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
    let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
    let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
    let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
    self.draw_corridor(start_x, start_y, end_x, end_y);
    self.take_snapshot();
}
#}

This iterates the rooms list, ignoring the last one. It fetches the current room, and the next one in the list and calculates a random location (start_x/start_y and end_x/end_y) within each room. It then calls the mysterious draw_corridor function with these coordinates. Draw corridor adds a line from the start to the end, using only north/south or east/west (it can give 90-degree bends). It won't give you a staggered, hard to navigate perfect line like Bresenham would. We also take a snapshot.

The draw_corridor function is quite simple:


# #![allow(unused_variables)]
#fn main() {
fn draw_corridor(&mut self, x1:i32, y1:i32, x2:i32, y2:i32) {
    let mut x = x1;
    let mut y = y1;

    while x != x2 || y != y2 {
        if x < x2 {
            x += 1;
        } else if x > x2 {
            x -= 1;
        } else if y < y2 {
            y += 1;
        } else if y > y2 {
            y -= 1;
        }

        let idx = self.map.xy_idx(x, y);
        self.map.tiles[idx] = TileType::Floor;
    }
}
#}

It takes a start and end point, and creates mutable x and y variables equal to the starting location. Then it keeps going until x and y match end end of the line. For each iteration, if x is less than the ending x - it goes left. If x is greater than the ending x - it goes right. Same for y, but with up and down. This gives straight corridors with a single corner.

Don't forget the stairs (I nearly did!)

Finally, we need to wrap up and create the exit:


# #![allow(unused_variables)]
#fn main() {
// Don't forget the stairs
let stairs = self.rooms[self.rooms.len()-1].center();
let stairs_idx = self.map.xy_idx(stairs.0, stairs.1);
self.map.tiles[stairs_idx] = TileType::DownStairs;
#}

We place the exit in the last room, guaranteeing that the poor player has a ways to walk.

If you cargo run now, you'll see something like this:

Screenshot.

Randomizing the dungeon per level

Rather than always using the BSP sewer algorithm, we would like to sometimes use one or the other. In map_builders/mod.rs, replace the build function:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 2);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Now when you play, it's a coin toss what type of map you encounter. The spawn functions for the types are the same - so we're not going to worry about map builder state until the next chapter.

Wrap-Up

You've refactored your map building into a new module, and built a simple BSP (Binary Space Partitioning) based map. The game randomly picks a map type, and you have more variety. The next chapter will further refactor map generation, and introduce another technique.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


BSP Interior Design


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In the last chapter, we used binary space partition (BSP) to build a dungeon with rooms. BSP is flexible, and can help you with a lot of problems; in this example, we're going to modify BSP to design an interior dungeon - completely inside a rectangular structure (for example, a castle) and with no wasted space other than interior walls.

The code for this chapter is converted from One Knight in the Dungeon's prison levels.

Scaffolding

We'll start by making a new file, map_builders/bsp_interior.rs and putting in the same initial boilerplate that we used in the previous chapter:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map, Rect, apply_room_to_map, 
    TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER};
use rltk::RandomNumberGenerator;
use specs::prelude::*;

pub struct BspInteriorBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>,
    history: Vec<Map>,
    rects: Vec<Rect>
}

impl MapBuilder for BspInteriorBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        // We should do something here
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        for room in self.rooms.iter().skip(1) {
            spawner::spawn_room(ecs, room, self.depth);
        }
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl BspInteriorBuilder {
    pub fn new(new_depth : i32) -> BspInteriorBuilder {
        BspInteriorBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            rooms: Vec::new(),
            history: Vec::new(),
            rects: Vec::new()
        }
    }
}
#}

We'll also change our random builder function in map_builders/mod.rs to once again lie to the user and always "randomly" pick the new algorithm:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 2);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(BspInteriorBuilder::new(new_depth))
}
#}

Subdividing into rooms

We're not going to achieve a perfect subdivision due to rounding issues, but we can get pretty close. Certainly good enough for a game! We put together a build function that is quite similar to the one from the previous chapter:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    self.rects.clear();
    self.rects.push( Rect::new(1, 1, self.map.width-2, self.map.height-2) ); // Start with a single map-sized rectangle
    let first_room = self.rects[0];
    self.add_subrects(first_room, &mut rng); // Divide the first room

    let rooms = self.rects.clone();
    for r in rooms.iter() {
        let room = *r;
        //room.x2 -= 1;
        //room.y2 -= 1;
        self.rooms.push(room);
        for y in room.y1 .. room.y2 {
            for x in room.x1 .. room.x2 {
                let idx = self.map.xy_idx(x, y);
                if idx > 0 && idx < ((self.map.width * self.map.height)-1) as usize {
                    self.map.tiles[idx] = TileType::Floor;
                }
            }
        }
        self.take_snapshot();
    }

    let start = self.rooms[0].center();
    self.starting_position = Position{ x: start.0, y: start.1 };
}
#}

Lets look at what this does:

  1. We create a new random number generator.
  2. We clear the rects list, and add a rectangle covering the whole map we intend to use.
  3. We call a magical function add_subrects on this rectangle. More on that in a minute.
  4. We copy the rooms list, to avoid borring issues.
  5. For each room, we add it to the rooms list - and carve it out of the map. We also take a snapshot.
  6. We start the player in the first room.

The add_subrects function in this case does all the hard work:


# #![allow(unused_variables)]
#fn main() {
fn add_subrects(&mut self, rect : Rect, rng : &mut RandomNumberGenerator) {
    // Remove the last rect from the list
    if !self.rects.is_empty() {
        self.rects.remove(self.rects.len() - 1);
    }

    // Calculate boundaries
    let width  = rect.x2 - rect.x1;
    let height = rect.y2 - rect.y1;
    let half_width = width / 2;
    let half_height = height / 2;

    let split = rng.roll_dice(1, 4);

    if split <= 2 {
        // Horizontal split
        let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height );
        self.rects.push( h1 );
        if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); }
        let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width, height );
        self.rects.push( h2 );
        if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); }
    } else {
        // Vertical split
        let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 );
        self.rects.push(v1);
        if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); }
        let v2 = Rect::new( rect.x1, rect.y1 + half_height, width, half_height );
        self.rects.push(v2);
        if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); }
    }
}
#}

Lets take a look at what this function does:

  1. If the rects list isn't empty, we remove the last item from the list. This has the effect of removing the last rectangle we added - so when we start, we are removing the rectangle covering the whole map. Later on, we are removing a rectangle because we are dividing it. This way, we won't have overlaps.
  2. We calculate the width and height of the rectangle, and well as half of the width and height.
  3. We roll a dice. There's a 50% chance of a horizontal or vertical split.
  4. If we're splitting horizontally:
    1. We make h1 - a new rectangle. It covers the left half of the parent rectangle.
    2. We add h1 to the rects list.
    3. If half_width is bigger than MIN_ROOM_SIZE, we recursively call add_subrects again, with h1 as the target rectangle.
    4. We make h2 - a new rectangle covering the right side of the parent rectangle.
    5. We add h2 to the rects list.
    6. If half_width is bigger than MIN_ROOM_SIZE, we recursively call add_subrects again, with h2 as the target rectangle.
  5. If we're splitting vertically, it's the same as (4) - but with top and bottom rectangles.

Conceptually, this starts with a rectangle:

#################################
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#################################

A horizontal split would yield the following:

#################################
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#################################

The next split might be vertical:

#################################
#              #                #
#              #                #
#              #                #
#              #                #
################                #
#              #                #
#              #                #
#              #                #
#              #                #
#################################

This repeats until we have a lot of small rooms.

You can cargo run the code right now, to see the rooms appearing.

Screenshot

Adding some doorways

It's all well and good to have rooms, but without doors connecting them it's not going to be a very fun experience! Fortunately, the exact same code from the previous chapter will work here, also.


# #![allow(unused_variables)]
#fn main() {
// Now we want corridors
for i in 0..self.rooms.len()-1 {
    let room = self.rooms[i];
    let next_room = self.rooms[i+1];
    let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
    let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
    let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
    let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
    self.draw_corridor(start_x, start_y, end_x, end_y);
    self.take_snapshot();
}
#}

This in turn calls the unchanged draw_corridor function:


# #![allow(unused_variables)]
#fn main() {
fn draw_corridor(&mut self, x1:i32, y1:i32, x2:i32, y2:i32) {
    let mut x = x1;
    let mut y = y1;

    while x != x2 || y != y2 {
        if x < x2 {
            x += 1;
        } else if x > x2 {
            x -= 1;
        } else if y < y2 {
            y += 1;
        } else if y > y2 {
            y -= 1;
        }

        let idx = self.map.xy_idx(x, y);
        self.map.tiles[idx] = TileType::Floor;
    }
}
#}

Don't forget the stairs (I nearly did, AGAIN!)

Finally, we need to wrap up and create the exit:


# #![allow(unused_variables)]
#fn main() {
// Don't forget the stairs
let stairs = self.rooms[self.rooms.len()-1].center();
let stairs_idx = self.map.xy_idx(stairs.0, stairs.1);
self.map.tiles[stairs_idx] = TileType::DownStairs;
#}

We place the exit in the last room, guaranteeing that the poor player has a ways to walk.

If you cargo run now, you'll see something like this:

Screenshot.

Restoring randomness - again

Lastly, we go back to map_builders/mod.rs and edit our random_builder to once gain provide a random dungeon per level:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 3);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Wrap Up

This type of dungeon can represent an interior, maybe of a space ship, a castle, or even a home. You can tweak dimensions, door placement, and bias the splitting as you see fit - but you'll get a map that makes most of the available space usable by the game. It's probably worth being sparing with these levels (or incorporating them into other levels) - they can lack variety, even though they are random.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Cellular Automata Maps


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Sometimes, you need a break from rectangular rooms. You might want a nice, organic looking cavern; a winding forest trail, or a spooky quarry. One Knight in the Dungeon uses cellular automata for this purpose, inspired by this excellent article. This chapter will help you create natural looking maps.

Scaffolding

Once again, we're going to take a bunch of code from the previous tutorial and re-use it for the new generator. Create a new file, map_builders/cellular_automata.rs and place the following in it:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map, Rect, apply_room_to_map, 
    TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER};
use rltk::RandomNumberGenerator;
use specs::prelude::*;

const MIN_ROOM_SIZE : i32 = 8;

pub struct CellularAutomataBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>
}

impl MapBuilder for CellularAutomataBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        //self.build(); - we should write this
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        // We need to rewrite this, too.
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl CellularAutomataBuilder {
    pub fn new(new_depth : i32) -> CellularAutomataBuilder {
        CellularAutomataBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
        }
    }
}
#}

Once again, we'll make the name random_builder a lie and only return the one we're working on:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 3);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(CellularAutomataBuilder::new(new_depth))
}
#}

Putting together the basic map

The first step is to make the map completely chaotic, with about 55% of tiles being solid. You can tweak that number for different effects, but I quite like the result. Here's the build function:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
        let mut rng = RandomNumberGenerator::new();

        // First we completely randomize the map, setting 55% of it to be floor.
        for y in 1..self.map.height-1 {
            for x in 1..self.map.width-1 {
                let roll = rng.roll_dice(1, 100);
                let idx = self.map.xy_idx(x, y);
                if roll > 55 { self.map.tiles[idx] = TileType::Floor } 
                else { self.map.tiles[idx] = TileType::Wall }
            }
        }
        self.take_snapshot();
}
#}

This makes a mess of an unusable level. Walls and floors everywhere with no rhyme or reason to them - and utterly unplayable. That's ok, because cellular automata are designed to make a level out of noise. It works by iterating each cell, counting the number of neighbors, and turning walls into floors or walls based on density. Here's a working builder:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    // First we completely randomize the map, setting 55% of it to be floor.
    for y in 1..self.map.height-1 {
        for x in 1..self.map.width-1 {
            let roll = rng.roll_dice(1, 100);
            let idx = self.map.xy_idx(x, y);
            if roll > 55 { self.map.tiles[idx] = TileType::Floor } 
            else { self.map.tiles[idx] = TileType::Wall }
        }
    }
    self.take_snapshot();

    // Now we iteratively apply cellular automata rules
    for _i in 0..15 {
        let mut newtiles = self.map.tiles.clone();

        for y in 1..self.map.height-1 {
            for x in 1..self.map.width-1 {
                let idx = self.map.xy_idx(x, y);
                let mut neighbors = 0;
                if self.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx - self.map.width as usize] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx + self.map.width as usize] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx - (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx - (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx + (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
                if self.map.tiles[idx + (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }

                if neighbors > 4 || neighbors == 0 {
                    newtiles[idx] = TileType::Wall;
                }
                else {
                    newtiles[idx] = TileType::Floor;
                }
            }
        }

        self.map.tiles = newtiles.clone();
        self.take_snapshot();
    }
}
#}

This is actually very simple:

  1. We randomize the map, as above.
  2. We count from 0 to 9, for 10 iterations of the algorithm.
  3. For each iteration:
    1. We take a copy of the map tiles, placing it into newtiles. We do this so we aren't writing to the tiles we are counting, which gives a very odd map.
    2. We iterate every cell on the map and count the number of tiles neighboring the tile that are walls.
    3. If there are more than 4, or zero, neighboring walls - then the tile (in newtiles) becomes a wall. Otherwise, it becomes a floor.
    4. We copy the newtiles back into the map.
    5. We take a snapshot.

This is a very simple algorithm - but produces quite beautiful results. Here it is in action:

Screenshot.

Picking a starting point

Picking a starting point for the player is a little more difficult than it has been in previous chapters. We don't have a list of rooms to query! Instead, we'll start in the middle and move left until we hit some open space. The code for this is quite simple:


# #![allow(unused_variables)]
#fn main() {
// Find a starting point; start at the middle and walk left until we find an open tile
self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
    self.starting_position.x -= 1;
    start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
}
#}

Placing an exit - and culling unreachable areas

We want the exit to be quite a way away from the player. We also don't want to keep areas that the player absolutely can't reach. Fortunately, the process to find an exit and the process to find orphans are quite similar. We can use a Dijkstra Map. If you haven't already read it, I recommend reading The Incredible Power of Dijkstra Maps. Fortunately, RLTK implements a very fast version of Dijkstra for you, so you won't have to fight with the algorithm. Here's the code:


# #![allow(unused_variables)]
#fn main() {
// Find all tiles we can reach from the starting point
let map_starts : Vec<usize> = vec![start_idx];
let dijkstra_map = rltk::DijkstraMap::new(self.map.width, self.map.height, &map_starts , &self.map, 200.0);
let mut exit_tile = (0, 0.0f32);
for (i, tile) in self.map.tiles.iter_mut().enumerate() {
    if *tile == TileType::Floor {
        let distance_to_start = dijkstra_map.map[i];
        // We can't get to this tile - so we'll make it a wall
        if distance_to_start == std::f32::MAX {
            *tile = TileType::Wall;
        } else {
            // If it is further away than our current exit candidate, move the exit
            if distance_to_start > exit_tile.1 {
                exit_tile.0 = i;
                exit_tile.1 = distance_to_start;
            }
        }
    }
}
self.take_snapshot();

self.map.tiles[exit_tile.0] = TileType::DownStairs;
self.take_snapshot();
#}

This is a dense piece of code that does a lot, lets walk through it:

  1. We create a vector called map_starts and give it a single value: the tile index on which the player starts. Dijkstra maps can have multiple starting points (distance 0), so this has to be a vector even though there is only one choice.
  2. We ask RLTK to make a Dijkstra Map for us. It has dimensions that match the main map, uses the starts, has read access to the map itself, and we'll stop counting at 200 steps (a safety feature in case of runaways!)
  3. We set an exit_tile tuple to 0 and 0.0. The first zero is the tile index of the exit, the second zero is the distance to the exit.
  4. We iterate the map tiles, using Rust's awesome enumerate feature. By adding .enumerate() to the end of a range iteration, it adds the cell index as the first parameter in a tuple. We then destructure to obtain both the tile and the index.
  5. If the tile is a floor,
  6. We obtain the distance to the starting point from the Dijkstra map.
  7. If the distance is the maximum value for an f32 (a marker the Dijkstra map uses for "unreachable"), then it doesn't need to be a floor at all - nobody can get there. So we turn it into a wall.
  8. If the distance is greater than the distance in our exit_tile tuple, we store both the new distance and the new tile index.
  9. Once we've visited every tile, we take a snapshot to show the removed area.
  10. We set the tile at the exit_tile (most distant reachable tile) to be a downward staircase.

If you cargo run, you actually have quite a playable map now! There's just one problem: there are no other entities on the map.

Populating our cave: freeing the spawn system from rooms.

If we were feeling lazy, we could simply iterate the map - find open spaces and have a random chance to spawn something. But that's not really very much fun. It makes more sense for monsters to be grouped together, with some "dead spaces" so you can catch your breath (and regain some health).

As a first step, we're going to revisit how we spawn entities. Right now, pretty much everything that isn't the player arrives into the world via the spawner.rs-provided spawn_room function. It has served us well up to now, but we want to be a bit more flexible; we might want to spawn in corridors, we might want to spawn in semi-open areas that don't fit a rectangle, and so on. Also, a look over spawn_room shows that it does several things in one function - which isn't the best design. A final objective is to keep the spawn_room interface available - so we can still use it, but to also offer more detailed options.

The first thing we'll do is separate out the actual spawning:


# #![allow(unused_variables)]
#fn main() {
/// Spawns a named entity (name in tuple.1) at the location in (tuple.0)
fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) {
    let x = (*spawn.0 % MAPWIDTH) as i32;
    let y = (*spawn.0 / MAPWIDTH) as i32;

    match spawn.1.as_ref() {
        "Goblin" => goblin(ecs, x, y),
        "Orc" => orc(ecs, x, y),
        "Health Potion" => health_potion(ecs, x, y),
        "Fireball Scroll" => fireball_scroll(ecs, x, y),
        "Confusion Scroll" => confusion_scroll(ecs, x, y),
        "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
        "Dagger" => dagger(ecs, x, y),
        "Shield" => shield(ecs, x, y),
        "Longsword" => longsword(ecs, x, y),
        "Tower Shield" => tower_shield(ecs, x, y),
        "Rations" => rations(ecs, x, y),
        "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
        "Bear Trap" => bear_trap(ecs, x, y),
        _ => {}
    }
}
#}

Now we can replace the last for loop in spawn_room with the following:


# #![allow(unused_variables)]
#fn main() {
// Actually spawn the monsters
for spawn in spawn_points.iter() {
    spawn_entity(ecs, &spawn);
}
#}

Now, we'll replace spawn_room with a simplified version that calls our theoretical function:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) {
    let mut possible_targets : Vec<usize> = Vec::new();
    { // Borrow scope - to keep access to the map separated
        let map = ecs.fetch::<Map>();
        for y in room.y1 + 1 .. room.y2 {
            for x in room.x1 + 1 .. room.x2 {
                let idx = map.xy_idx(x, y);
                if map.tiles[idx] == TileType::Floor {
                    possible_targets.push(idx);
                }
            }
        }
    }

    spawn_region(ecs, &possible_targets, map_depth);
}
#}

This function maintains the same interface/signature as the previous call - so our old code will still work. Instead of actually spawning anything, it builds a vector of all of the tiles in the room (checking that they are floors - something we didn't do before; monsters in walls is no longer possible!). It then calls a new function, spawn_region that accepts a similar signature - but wants a list of available tiles into which it can spawn things. Here's the new function:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn_region(ecs: &mut World, area : &[usize], map_depth: i32) {
    let spawn_table = room_table(map_depth);
    let mut spawn_points : HashMap<usize, String> = HashMap::new();
    let mut areas : Vec<usize> = Vec::from(area);

    // Scope to keep the borrow checker happy
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3);
        if num_spawns == 0 { return; }

        for _i in 0 .. num_spawns {
            let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize };
            let map_idx = areas[array_index];
            spawn_points.insert(map_idx, spawn_table.roll(&mut rng));
            areas.remove(array_index);
        }
    }

    // Actually spawn the monsters
    for spawn in spawn_points.iter() {
        spawn_entity(ecs, &spawn);
    }
}
#}

This is similar to the previous spawning code, but not quite the same (although the results are basically the same!). We'll go through it, just to be sure we understand what we're doing:

  1. We obtain a spawn table for the current map depth.
  2. We setup a HashMap called spawn_points, listing pairs of data (map index and name tag) for everything we've decided to spawn.
  3. We create a new Vector of areas, copied from the passed in slice. (A slice is a "view" of an array or vector). We're making a new one so we aren't modifying the parent area list. The caller might want to use that data for something else and it's good to avoid changing people's data without asking. Changing data without warning is called a "side effect" and it's good to avoid them in general (unless you actually want them).
  4. We make a new scope, because Rust doesn't like us using the ECS to obtain the random number generator, and then using it later to spawn entities. The scope makes Rust "forget" our first borrow as soon as it ends.
  5. We obtain a random number generator from the ECS.
  6. We calculate the number of entities to spawn. This is the same random function as we used before, but we've added an i32::min call: we want the smaller of EITHER the number of available tiles, OR the random calculation. This way, we'll never try to spawn more entities than we have room for.
  7. If the number to spawn is zero, we bail out of the function (nothing to do, here!).
  8. Repeating for zero to the number of spawns (minus 1 - we're not using an inclusive range):
    1. We pick an array_index from areas. If there is only one entry, we use it. Otherwise, we roll a dice (from 1 to the number of entries, subtract one because the array is zero-based).
    2. The map_idx (location in the map tiles array) is the value located at the array_index index of the array. So we obtain that.
    3. We insert a spawn into the spawn_points map, listing both the index and a random roll on the spawn table.
    4. We remove the entry we just used from areas - that way, we can't accidentally pick it again. Note that we're not checking to see if the array is empty: in step 6 above, we guaranteed that we won't spawn more entities than we have room for, so (at least in theory) that particular bug can't happen!

The best way to test this is to uncomment out the random_builder code (and comment the CellularAutomataBuilder entry) and give it a go. It should play just like before. Once you've tested it, go back to always spawning the map type we're working on.

Grouped placement in our map - Enter the Voronoi!

Voronoi Diagrams are a wonderfully useful piece of math. Given a group of points, it builds a diagram of regions surrounding each point (which could be random, or might mean something; that's the beauty of math, it's up to you!) - with no empty space. We'd like to do something similar for our maps: subdivide the map into random regions and spawn inside those regions. Fortunately, RLTK provides a type of noise to help with that: cellular noise.

First of all, what is noise. "Noise" in this case doesn't refer to the loud heavy metal you accidentally pipe out of your patio speakers at 2am while wondering what a stereo receiver you found in your new house does (true story...); it refers to random data - like the noise on old analog TVs if you didn't tune to a station (ok, I'm showing my age there). Like most things random, there's lots of ways to make it not-really-random and group it into useful patterns. A noise library provides lots of types of noise. Perlin/Simplex noise makes really good approximations of landscapes. White noise looks like someone randomly threw paint at a piece of paper. Cellular Noise randomly places points on a grid and then plots Voronoi diagrams around them. We're interested in the latter.

This is a somewhat complicated way to do things, so we'll take it a step at a time. Lets start by adding a structure to store generated areas into our CellularAutomataBuilder structure:


# #![allow(unused_variables)]
#fn main() {
pub struct CellularAutomataBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>
}
#}

In new, we also have to initialize it:


# #![allow(unused_variables)]
#fn main() {
impl CellularAutomataBuilder {
    pub fn new(new_depth : i32) -> CellularAutomataBuilder {
        CellularAutomataBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new()
        }
    }
    ...
#}

The idea here is that we have a HashMap (dictionary in other languages) keyed on the ID number of an area. The area consists of a vector of tile ID numbers. Ideally, we'd generate 20-30 distinct areas all with spaces to spawn entities into.

Here's the next section of the build code:


# #![allow(unused_variables)]
#fn main() {
// Now we build a noise map for use in spawning entities later
let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64);
noise.set_noise_type(rltk::NoiseType::Cellular);
noise.set_frequency(0.08);
noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);

for y in 1 .. self.map.height-1 {
    for x in 1 .. self.map.width-1 {
        let idx = self.map.xy_idx(x, y);
        if self.map.tiles[idx] == TileType::Floor {
            let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0;
            let cell_value = cell_value_f as i32;

            if self.noise_areas.contains_key(&cell_value) {
                self.noise_areas.get_mut(&cell_value).unwrap().push(idx);
            } else {
                self.noise_areas.insert(cell_value, vec![idx]);
            }
        }
    }
}
#}

Since this is quite complicated, lets walk through it:

  1. We create a new FastNoise object, from RLTK's port of Auburns' excellent FastNoise library.
  2. We specify that we want Cellular noise. That's the same as Voronoi noise in this case.
  3. We specify a frequency of 0.08. This number was found by playing with different values!
  4. We specify the Manhattan distance function. There are three to choose from, and they give differing shapes. Manhattan tends to favor elongated shapes, which I like for this purpose. Try all three and see what you like.
  5. We iterate the whole map:
    1. We get the idx of the tile, in the map's tiles vectors.
    2. We check to make sure it's a floor - and skip if it isn't.
    3. We query FastNoise for a noise value for the coordinates (converting them to f32 floating point numbers, because the library likes floats). We multiply by 10240.0 because the default is very small numbers - and this brings it up into a reasonable range.
    4. We convert the result to an integer.
    5. If the noise_areas map contains the area number we just generated, we add the tile index to the vector.
    6. If the noise_areas map DOENS'T contain the area number we just generated, we make a new vector of tile indices with the map index number in it.

This generates between 20 and 30 areas quite consistently, and they only contain valid floor tiles. So the last remaining job is to actually spawn some entities. We update our spawn_entities function:


# #![allow(unused_variables)]
#fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for area in self.noise_areas.iter() {
        spawner::spawn_region(ecs, area.1, self.depth);
    }
}
#}

This is quite simple: it iterates through each area and calls the new spawn_region with the vector of available map tiles for that region.

The game is now quite playable on these new maps:

Screenshot.

Restoring randomness

Once again, we should restore randomness to our map building. In map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 4);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Wrap-Up

We've made a pretty nice map generator and fixed our dependency upon rooms. Cellular Automata are a really flexible algorithm and can be used for all kinds of organic looking maps. With a bit of tweaking to the rules, you can make a really large variety of maps.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Drunkard's Walk Maps


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Ever wondered what would happen if an Umber Hulk (or other tunneling creature) got really drunk, and went on a dungeon carving bender? The Drunkard's Walk algorithm answers the question - or more precisely, what would happen if a whole bunch of monsters had far too much to drink. As crazy it sounds, this is a good way to make organic dungeons.

Initial scaffolding

As usual, we'll start with scaffolding from the previous map tutorials. We've done it enough that it should be old hat by now! In map_builders/drunkard.rs, build a new DrunkardsWalkBuilder class. We'll keep the zone-based placement from Cellular Automata - but remove the map building code. Here's the scaffolding:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map,  
    TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER};
use rltk::RandomNumberGenerator;
use specs::prelude::*;
use std::collections::HashMap;

pub struct DrunkardsWalkBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>
}

impl MapBuilder for DrunkardsWalkBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        self.build();
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        for area in self.noise_areas.iter() {
            spawner::spawn_region(ecs, area.1, self.depth);
        }
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl DrunkardsWalkBuilder {
    pub fn new(new_depth : i32) -> DrunkardsWalkBuilder {
        DrunkardsWalkBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new()
        }
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self) {
        let mut rng = RandomNumberGenerator::new();

        // Set a central starting point
        self.starting_position = Position{ x: self.map.width / 2, y: self.map.height / 2 };
        let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);

        // Find all tiles we can reach from the starting point
        let map_starts : Vec<usize> = vec![start_idx];
        let dijkstra_map = rltk::DijkstraMap::new(self.map.width, self.map.height, &map_starts , &self.map, 200.0);
        let mut exit_tile = (0, 0.0f32);
        for (i, tile) in self.map.tiles.iter_mut().enumerate() {
            if *tile == TileType::Floor {
                let distance_to_start = dijkstra_map.map[i];
                // We can't get to this tile - so we'll make it a wall
                if distance_to_start == std::f32::MAX {
                    *tile = TileType::Wall;
                } else {
                    // If it is further away than our current exit candidate, move the exit
                    if distance_to_start > exit_tile.1 {
                        exit_tile.0 = i;
                        exit_tile.1 = distance_to_start;
                    }
                }
            }
        }
        self.take_snapshot();

        // Place the stairs
        self.map.tiles[exit_tile.0] = TileType::DownStairs;
        self.take_snapshot();

        // Now we build a noise map for use in spawning entities later
        let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64);
        noise.set_noise_type(rltk::NoiseType::Cellular);
        noise.set_frequency(0.08);
        noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);

        for y in 1 .. self.map.height-1 {
            for x in 1 .. self.map.width-1 {
                let idx = self.map.xy_idx(x, y);
                if self.map.tiles[idx] == TileType::Floor {
                    let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0;
                    let cell_value = cell_value_f as i32;

                    if self.noise_areas.contains_key(&cell_value) {
                        self.noise_areas.get_mut(&cell_value).unwrap().push(idx);
                    } else {
                        self.noise_areas.insert(cell_value, vec![idx]);
                    }
                }
            }
        }
    }
}

#}

We've kept a lot of the work from the Cellular Automata chapter, since it can help us here also. We also go into map_builders/mod.rs and once again force the "random" system to pick our new code:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 4);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(DrunkardsWalkBuilder::new(new_depth))
}
#}

Don't Repeat Yourself (The DRY principle)

Since we're re-using the exact code from Cellular Automata, we should take the common code and put it into map_builders/common.rs. This saves typing, saves the compiler from repeatedly remaking the same code (increasing your program size). So in common.rs, we refactor the common code into some functions. In common.rs, we create a new function - remove_unreachable_areas_returning_most_distant:


# #![allow(unused_variables)]
#fn main() {
/// Searches a map, removes unreachable areas and returns the most distant tile.
pub fn remove_unreachable_areas_returning_most_distant(map : &mut Map, start_idx : usize) -> usize {
    map.populate_blocked();
    let map_starts : Vec<usize> = vec![start_idx];
    let dijkstra_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &map_starts , map, 200.0);
    let mut exit_tile = (0, 0.0f32);
    for (i, tile) in map.tiles.iter_mut().enumerate() {
        if *tile == TileType::Floor {
            let distance_to_start = dijkstra_map.map[i];
            // We can't get to this tile - so we'll make it a wall
            if distance_to_start == std::f32::MAX {
                *tile = TileType::Wall;
            } else {
                // If it is further away than our current exit candidate, move the exit
                if distance_to_start > exit_tile.1 {
                    exit_tile.0 = i;
                    exit_tile.1 = distance_to_start;
                }
            }
        }
    }

    exit_tile.0
}
#}

We'll make a second function, generate_voronoi_spawn_regions:


# #![allow(unused_variables)]
#fn main() {
/// Generates a Voronoi/cellular noise map of a region, and divides it into spawn regions.
#[allow(clippy::map_entry)]
pub fn generate_voronoi_spawn_regions(map: &Map, rng : &mut rltk::RandomNumberGenerator) -> HashMap<i32, Vec<usize>> {
    let mut noise_areas : HashMap<i32, Vec<usize>> = HashMap::new();
    let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64);
    noise.set_noise_type(rltk::NoiseType::Cellular);
    noise.set_frequency(0.08);
    noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);

    for y in 1 .. map.height-1 {
        for x in 1 .. map.width-1 {
            let idx = map.xy_idx(x, y);
            if map.tiles[idx] == TileType::Floor {
                let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0;
                let cell_value = cell_value_f as i32;

                if noise_areas.contains_key(&cell_value) {
                    noise_areas.get_mut(&cell_value).unwrap().push(idx);
                } else {
                    noise_areas.insert(cell_value, vec![idx]);
                }
            }
        }
    }

    noise_areas
}
#}

Plugging these into our build function lets us reduce the boilerplate section considerably:


# #![allow(unused_variables)]
#fn main() {
// Find all tiles we can reach from the starting point
let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
self.take_snapshot();

// Place the stairs
self.map.tiles[exit_tile] = TileType::DownStairs;
self.take_snapshot();

// Now we build a noise map for use in spawning entities later
self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);
#}

In the example, I've gone back to the cellular_automata section and done the same.

This is basically the same code we had before (hence, it isn't explained here), but wrapped in a function (and taking a mutable map reference - so it changes the map you give it, and the starting point as parameters).

Walking Drunkards

The basic idea behind the algorithm is simple:

  1. Pick a central starting point, and convert it to a floor.
  2. We count how much of the map is floor space, and iterate until we have converted a percentage (we use 50% in the example) of the map to floors.
    1. Spawn a drunkard at the starting point. The drunkard has a "lifetime" and a "position".
    2. While the drunkard is still alive:
      1. Decrement the drunkard's lifetime (I like to think that they pass out and sleep).
      2. Roll a 4-sided dice.
        1. If we rolled a 1, move the drunkard North.
        2. If we rolled a 2, move the drunkard South.
        3. If we rolled a 3, move the drunkard East.
        4. If we rolled a 4, move the drunkard West.
      3. The tile on which the drunkard landed becomes a floor.

That's really all there is to it: we keep spawning drunkards until we have sufficient map coverage. Here's an implementation:


# #![allow(unused_variables)]
#fn main() {
// Set a central starting point
self.starting_position = Position{ x: self.map.width / 2, y: self.map.height / 2 };
let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
self.map.tiles[start_idx] = TileType::Floor;

let total_tiles = self.map.width * self.map.height;
let desired_floor_tiles = (total_tiles / 2) as usize;
let mut floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
let mut digger_count = 0;
let mut active_digger_count = 0;

while floor_tile_count  < desired_floor_tiles {
    let mut did_something = false;
    let mut drunk_x = self.starting_position.x;
    let mut drunk_y = self.starting_position.y;
    let mut drunk_life = 400;

    while drunk_life > 0 {
        let drunk_idx = self.map.xy_idx(drunk_x, drunk_y);
        if self.map.tiles[drunk_idx] == TileType::Wall {
            did_something = true;
        }
        self.map.tiles[drunk_idx] = TileType::DownStairs;

        let stagger_direction = rng.roll_dice(1, 4);
        match stagger_direction {
            1 => { if drunk_x > 2 { drunk_x -= 1; } }
            2 => { if drunk_x < self.map.width-2 { drunk_x += 1; } }
            3 => { if drunk_y > 2 { drunk_y -=1; } }
            _ => { if drunk_y < self.map.height-2 { drunk_y += 1; } }
        }

        drunk_life -= 1;
    }
    if did_something { 
        self.take_snapshot(); 
        active_digger_count += 1;
    }

    digger_count += 1;
    for t in self.map.tiles.iter_mut() {
        if *t == TileType::DownStairs {
            *t = TileType::Floor;
        }
    }
    floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
}
rltk::console::log(format!("{} dwarves gave up their sobriety, of whom {} actually found a wall.", digger_count, active_digger_count));
#}

This implementation expands a lot of things out, and could be much shorter - but for clarity, we've left it large and obvious. We've also made a bunch of things into variables that could be constants - it's easier to read, and is designed to be easy to "play" with values. It also prints a status update to the console, showing what happened.

If you cargo run now, you'll get a pretty nice open map:

Screenshot.

Managing The Diggers' Alcoholism

There's a lot of ways to tweak the "drunkard's walk" algorithm to generate different map types. Since these can produce radically different maps, lets customize the interface to the algorithm to provide a few different ways to run. We'll start by creating a struct to hold the parameter sets:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum DrunkSpawnMode { StartingPoint, Random }

pub struct DrunkardSettings {
    pub spawn_mode : DrunkSpawnMode
}
#}

Now we'll modify new and the structure itself to accept it:


# #![allow(unused_variables)]
#fn main() {
pub struct DrunkardsWalkBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    settings : DrunkardSettings
}

...

impl DrunkardsWalkBuilder {
    pub fn new(new_depth : i32, settings: DrunkardSettings) -> DrunkardsWalkBuilder {
        DrunkardsWalkBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new(),
            settings
        }
    }
    ...
#}

We'll also modify the "random" builder to take settings:


# #![allow(unused_variables)]
#fn main() {
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ spawn_mode: DrunkSpawnMode::StartingPoint }))
#}

Now we have a mechanism to tune the inebriation of our diggers!

Varying the drunken rambler's starting point

We alluded to it in the previous section with the creation of DrunkSpawnMode - we're going to see what happens if we change the way drunken diggers - after the first - spawn. Change the random_builder to DrunkSpawnMode::Random, and then modify build (in drunkard.rs) to use it:


# #![allow(unused_variables)]
#fn main() {
...
while floor_tile_count  < desired_floor_tiles {
    let mut did_something = false;
    let mut drunk_x;
    let mut drunk_y;
    match self.settings.spawn_mode {
        DrunkSpawnMode::StartingPoint => {
            drunk_x = self.starting_position.x;
            drunk_y = self.starting_position.y;
        }
        DrunkSpawnMode::Random => {
            if digger_count == 0 {
                drunk_x = self.starting_position.x;
                drunk_y = self.starting_position.y;
            } else {
                drunk_x = rng.roll_dice(1, self.map.width - 3) + 1;
                drunk_y = rng.roll_dice(1, self.map.height - 3) + 1;
            }
        }
    }
    let mut drunk_life = 400;
    ...
#}

This is a relatively easy change: if we're in "random" mode, the starting position for the drunkard is the center of the map for the first digger (to ensure that we have some space around the stairs), and then a random map location for each subsequent iteration. It produces maps like this:

Screenshot.

This is a much more spread out map. Less of a big central area, and more like a sprawling cavern. A handy variation!

Modifying how long it takes for the drunkard to pass out

Another parameter to tweak is how long the drunkard stays awake. This can seriously change the character of the resultant map. We'll add it into the settings:


# #![allow(unused_variables)]
#fn main() {
pub struct DrunkardSettings {
    pub spawn_mode : DrunkSpawnMode,
    pub drunken_lifetime : i32
}
#}

We'll tell the random_builder function to use a shorter lifespan:


# #![allow(unused_variables)]
#fn main() {
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ 
    spawn_mode: DrunkSpawnMode::Random, 
    drunken_lifetime: 100 
}))
#}

And we'll modify the build code to actually use it:


# #![allow(unused_variables)]
#fn main() {
let mut drunk_life = self.settings.drunken_lifetime;
#}

That's a simple change - and drastically alters the nature of the resulting map. Each digger can only go one quarter the distance of the previous ones (stronger beer!), so they tend to carve out less of the map. That leads to more iterations, and since they start randomly you tend to see more distinct map areas forming - and hope they join up (if they don't, they will be culled at the end).

cargo run with the 100 lifespan, randomly placed drunkards produces something like this:

Screenshot.

Changing the desired fill percentage

Lastly, we'll play with how much of the map we want to cover with floors. The lower the number, the more walls (and less open areas) you generate. We'll once again modify DrunkardSettings:


# #![allow(unused_variables)]
#fn main() {
pub struct DrunkardSettings {
    pub spawn_mode : DrunkSpawnMode,
    pub drunken_lifetime : i32,
    pub floor_percent: f32
}
#}

We also change one line in our builder:


# #![allow(unused_variables)]
#fn main() {
let desired_floor_tiles = (self.settings.floor_percent * total_tiles as f32) as usize;
#}

We previously had desired_floor_tiles as total_tiles / 2 - which would be represented by 0.5 in the new system. Lets try changing that to 0.4 in random_builder:


# #![allow(unused_variables)]
#fn main() {
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ 
        spawn_mode: DrunkSpawnMode::Random, 
        drunken_lifetime: 200,
        floor_percent: 0.4
    }))
#}

If you cargo run now, you'll see that we have even fewer open areas forming:

Screenshot.

Building some preset constructors

Now that we've got these parameters to play with, lets make a few more constructors to remove the need for the caller in mod.rs to know about the algorithm details:


# #![allow(unused_variables)]
#fn main() {
pub fn new(new_depth : i32, settings: DrunkardSettings) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings
    }
}

pub fn open_area(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::StartingPoint,
            drunken_lifetime: 400,
            floor_percent: 0.5
        }
    }
}

pub fn open_halls(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::Random,
            drunken_lifetime: 400,
            floor_percent: 0.5
        }
    }
}

pub fn winding_passages(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::Random,
            drunken_lifetime: 100,
            floor_percent: 0.4
        }
    }
}
#}

Now we can modify our random_builder function to be once again random - and offer three different map types:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 7);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Wrap-Up

And we're done with drunken map building (words I never expected to type...)! It's a very flexible algorithm, and can be used to make a lot of different map types. It also combines well with other algorithms, as we'll see in future chapters.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Maze/Labyrinth Generation


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


A mainstay of dungeon crawl games is the good old-fashioned labyrinth, often featuring a Minotaur. Dungeon Crawl: Stone Soup has a literal minotaur labyrinth, Tome 4 has sand-worm mazes, One Knight has an elven hedge maze. These levels can be annoying for the player, and should be used sparingly: a lot of players don't really enjoy the tedium of exploring to find an exit. This chapter will show you how to make a labyrinth!

Scaffolding

Once again, we'll use the previous chapter as scaffolding - and set our "random" builder to use the new design. In map_builders/maze.rs, place the following code:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map,  
    TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    remove_unreachable_areas_returning_most_distant, generate_voronoi_spawn_regions};
use rltk::RandomNumberGenerator;
use specs::prelude::*;
use std::collections::HashMap;

pub struct MazeBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>
}

impl MapBuilder for MazeBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        self.build();
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        for area in self.noise_areas.iter() {
            spawner::spawn_region(ecs, area.1, self.depth);
        }
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl MazeBuilder {
    pub fn new(new_depth : i32) -> MazeBuilder {
        MazeBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new()
        }
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self) {
        let mut rng = RandomNumberGenerator::new();        

        // Find a starting point; start at the middle and walk left until we find an open tile
        self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
        let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        while self.map.tiles[start_idx] != TileType::Floor {
            self.starting_position.x -= 1;
            start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        }
        self.take_snapshot();

        // Find all tiles we can reach from the starting point
        let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
        self.take_snapshot();

        // Place the stairs
        self.map.tiles[exit_tile] = TileType::DownStairs;
        self.take_snapshot();

        // Now we build a noise map for use in spawning entities later
        self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);
    }
}
#}

And in random_builder (map_builders/mod.rs):


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 7);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(MazeBuilder::new(new_depth))
}
#}

Actually building a maze

There are lots of good maze building algorithms out there, all guaranteed to give you a perfectly solvable maze. In One Knight in the Dungeon, I based my maze building code off of a relatively standard implementation - Cyucelen's mazeGenerator. It's an interesting algorithm because - like a lot of maze algorithms - it assumes that walls are part of the tile grid, rather than having separate wall entities. That isn't going to work for the type of tile map we are using, so we generate the grid at half the resolution of the actual map, and generate walls based upon wall adjacency information in the grid.

The algorithm started as C++ code with pointers everywhere, and took a bit of time to port. The most basic structure in the algorithm: the Cell. Cells are tiles on the map:


# #![allow(unused_variables)]
#fn main() {
const TOP : usize = 0;
const RIGHT : usize = 1;
const BOTTOM : usize = 2;
const LEFT : usize = 3;

#[derive(Copy, Clone)]
struct Cell {
    row: i32,
    column: i32,
    walls: [bool; 4],
    visited: bool,
}
#}

We define four constants: TOP, RIGHT, BOTTOM and LEFT and assign them to the numbers 0..3. We use these whenever the algorithm wants to refer to a direction. Looking at Cell, it is relatively simple:

  • row and column define where the cell is on the map.
  • walls is an array, with a bool for each of the directions we've defined. Rust arrays (static, you can't resize them like a vector) are defined with the syntax [TYPE ; NUMBER_OF_ELEMENTS]. Most of the time we just use vectors because we like the dynamic sizing; in this case, the number of elements is known ahead of time, so using the lower-overhead type makes sense.
  • visited - a bool indicating whether we've previously looked at the cell.

Cell also defines some methods. The first is its constructor:


# #![allow(unused_variables)]
#fn main() {
impl Cell {
    fn new(row: i32, column: i32) -> Cell {
        Cell{
            row,
            column,
            walls: [true, true, true, true],
            visited: false
        }
    }
    ...
#}

This is a simple constructor: it makes a cell with walls in each direction, and not previously visited. Cells also define a function called remove_walls:


# #![allow(unused_variables)]
#fn main() {
fn remove_walls(&mut self, next : &mut Cell) {
    let x = self.column - next.column;
    let y = self.row - next.row;

    if x == 1 {
        self.walls[LEFT] = false;
        next.walls[RIGHT] = false;
    }
    else if x == -1 {
        self.walls[RIGHT] = false;
        next.walls[LEFT] = false;
    }
    else if y == 1 {
        self.walls[TOP] = false;
        next.walls[BOTTOM] = false;
    }
    else if y == -1 {
        self.walls[BOTTOM] = false;
        next.walls[TOP] = false;
    }
}
#}

Uh oh, there's some new stuff here:

  • We set x to be our column value, minus the column value of the next cell.
  • We do the same with y - but with row values.
  • If x is equal to 1, then the next's column must be greater than our column value. In other words, the next cell is to the right of our current location. So we remove the wall to the right.
  • Likewise, if x is -1, then we must be going left - so we remove the wall to the left.
  • Once again, if y is 1, we must be going up. So we remove the walls to the top.
  • Finally, if y is -1, we must be going down - so we remove the walls below us.

Whew! Cell is done. Now to actually use it. In our maze algorithm, Cell is part of Grid. Here's the basic Grid definition:


# #![allow(unused_variables)]
#fn main() {
struct Grid<'a> {
    width: i32,
    height: i32,
    cells: Vec<Cell>,
    backtrace: Vec<usize>,
    current: usize,
    rng : &'a mut RandomNumberGenerator
}
#}

Some commentary on Grid:

  • The <'a> is a lifetime specifier. We have to specify one so that Rust's borrow checker can ensure that the Grid will not expire before we delete the RandomNumberGenerator. Because we're passing a mutable reference to the caller's RNG, Rust needs this to ensure that the RNG doesn't go away before we're finished with it. This type of bug often affects C/C++ users, so Rust made it really hard to mess up. Unfortunately, the price of making it hard to get wrong is some ugly syntax!
  • We have a width and height defining the size of the maze.
  • Cells are just a Vector of the Cell type we defined earlier.
  • backtrace is used by the algorithm for recursively back-tracking to ensure that every cell has been processed. It's just a vector of cell indices - the index into the cells vector.
  • current is used by the algorithm to tell which Cell we're currently working with.
  • rng is the reason for the ugly lifetime stuff; we want to use the random number generator built in the build function, so we store a reference to it here. Because obtaining a random number changes the content of the variable, we have to store a mutable reference. The really ugly &'a mut indicates that it is a reference, with the lifetime 'a (defined above) and is mutable/changeable.

Grid implements quite a few methods. First up, the constructor:


# #![allow(unused_variables)]
#fn main() {
impl<'a> Grid<'a> {
    fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) -> Grid {
        let mut grid = Grid{
            width,
            height,
            cells: Vec::new(),
            backtrace: Vec::new(),
            current: 0,
            rng
        };

        for row in 0..height {
            for column in 0..width {
                grid.cells.push(Cell::new(row, column));
            }
        }

        grid
    }
    ...
#}

Notice that once again we had to use some ugly syntax for the lifetime! The constructor itself is quite simple: it makes a new Grid structure with the specified width and height, a new vector of cells, a new (empty) backtrace vector, sets current to 0 and stores the random number generator reference. Then it iterates the rows and columns of the grid, pushing new Cell structures to the cells vector, numbered by their location.

The Grid also implements calculate_index:


# #![allow(unused_variables)]
#fn main() {
fn calculate_index(&self, row: i32, column: i32) -> i32 {
    if row < 0 || column < 0 || column > self.width-1 || row > self.height-1 {
        -1
    } else {
        column + (row * self.width)
    }
}
#}

This is very similar to our map's xy_idx function: it takes a row and column coordinate, and returns the array index at which one can find the cell. It also does some bounds checking, and returns -1 if the coordinates are invalid. Next, we provide get_available_neighbors:


# #![allow(unused_variables)]
#fn main() {
fn get_available_neighbors(&self) -> Vec<usize> {
    let mut neighbors : Vec<usize> = Vec::new();

    let current_row = self.cells[self.current].row;
    let current_column = self.cells[self.current].column;

    let neighbor_indices : [i32; 4] = [
        self.calculate_index(current_row -1, current_column),
        self.calculate_index(current_row, current_column + 1),
        self.calculate_index(current_row + 1, current_column),
        self.calculate_index(current_row, current_column - 1)
    ];

    for i in neighbor_indices.iter() {
        if *i != -1 && !self.cells[*i as usize].visited {
            neighbors.push(*i as usize);
        }
    }

    neighbors
}
#}

This function provides the available exits from the current cell. It works by obtaining the row and column coordinates of the current cell, and then puts a call to calculate_index into an array (corresponding to the directions we defined with Cell). It finally iterates the array, and if the values are valid (greater than -1), and we haven't been there before (the visited check) it pushes them into the neighbors list. It then returns neighbors. A call to this for any cell address will return a vector listing all of the adjacent cells to which we can travel (ignoring walls). We first use this in find_next_cell:


# #![allow(unused_variables)]
#fn main() {
fn find_next_cell(&mut self) -> Option<usize> {
    let neighbors = self.get_available_neighbors();
    if !neighbors.is_empty() {
        if neighbors.len() == 1 {
            return Some(neighbors[0]);
        } else {
            return Some(neighbors[(self.rng.roll_dice(1, neighbors.len() as i32)-1) as usize]);
        }
    }
    None
}
#}

This function is interesting in that it returns an Option. It's possible that there is nowhere to go from the current cell - in which case it returns None. Otherwise, it returns Some with the array index of the next destination. It works by:

  • Obtain a list of neighbors for the current cell.
  • If there are neighbors:
    • If there is only one neighbor, return it.
    • If there are multiple neighbors, pick one and random and return it.
  • If there are no neighbors, return None.

We use this from generate_maze:


# #![allow(unused_variables)]
#fn main() {
fn generate_maze(&mut self, generator : &mut MazeBuilder) {
    loop {
        self.cells[self.current].visited = true;
        let next = self.find_next_cell();

        match next {
            Some(next) => {
                self.cells[next].visited = true;
                self.backtrace.push(self.current);
                //   __lower_part__      __higher_part_
                //   /            \      /            \
                // --------cell1------ | cell2-----------
                let (lower_part, higher_part) =
                    self.cells.split_at_mut(std::cmp::max(self.current, next));
                let cell1 = &mut lower_part[std::cmp::min(self.current, next)];
                let cell2 = &mut higher_part[0];
                cell1.remove_walls(cell2);
                self.current = next;
            }
            None => {
                if !self.backtrace.is_empty() {
                    self.current = self.backtrace[0];
                    self.backtrace.remove(0);
                } else {
                    break;
                }
            }
        }

        self.copy_to_map(&mut generator.map);
        generator.take_snapshot();    
    }
}
#}

So now we're onto the actual algorithm! Lets step through it to understand how it works:

  1. We start with a loop. We haven't used one of these before (you can read about them here). Basically, a loop runs forever - until it hits a break statement.
  2. We set the value of visited in the current cell to true.
  3. We add the current cell to the beginning of the backtrace list.
  4. We call find_next_cell and set its index in the variable next. If this is our first run, we'll get a random direction from the starting cell. Otherwise, we get an exit from the current cell we're visiting.
  5. If next has a value, then:
    1. Split cells to two mutable references. We will need two mutable references to the same slice, Rust normally doesn't allow this, but we can split our slice to two non-overlapping parts. This is a common use case and Rust provides a safe function to do exactly that.
    2. Get mutable reference to the cell with lower index from first part and to the second from start of second part.
    3. We call remove_walls on the cell1 cell, referencing the cell2 cell.
  6. If next does not have a value (it's equal to None), we:
    1. If backtrace isn't empty, we set current to the first value in the backtrace list.
    2. If backtrace is empty, we've finished - so we break out of the loop.
  7. Finally, we call copy_to_map - which copies the maze to the map (more on that below), and take a snapshot for the iterative map generation renderer.

So why does that work?

  • The first few iterations will get a non-visited neighbor, carving a clear path through the maze. Each step along the way, the cell we've visited is added to backtrace. This is effectively a drunken walk through the maze, but ensuring that we cannot return to a cell.
  • When we hit a point at which we have no neighbors (we've hit the end of the maze), the algorithm will change current to the first entry in our backtrace list. It will then randomly walk from there, filling in more cells.
  • If that point can't go anywhere, it works back up the backtrace list.
  • This repeats until every cell has been visited, meaning that backtrace and neighbors are both empty. We're done!

The best way to understand this is to watch it in action:

Screenshot.

Finally, there's the copy_to_map function:


# #![allow(unused_variables)]
#fn main() {
fn copy_to_map(&self, map : &mut Map) {
    // Clear the map
    for i in map.tiles.iter_mut() { *i = TileType::Wall; }

    for cell in self.cells.iter() {
        let x = cell.column + 1;
        let y = cell.row + 1;
        let idx = map.xy_idx(x * 2, y * 2);

        map.tiles[idx] = TileType::Floor;
        if !cell.walls[TOP] { map.tiles[idx - map.width as usize] = TileType::Floor }
        if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor }
        if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] = TileType::Floor }
        if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor }
    }
}
#}

This is where the mismatch between Grid/Cell and our map format is resolved: each Cell in the maze structure can have walls in any of the four major directions. Our map doesn't work that way: walls aren't part of a tile, they are a tile. So we double the size of the Grid, and write carve floors where walls aren't present. Lets walk through this function:

  1. We set all cells in the map to be a solid wall.
  2. For each cell in the grid, we:
    1. Calculate x as the cell's column value, plus one.
    2. Calculate y as the cell's row value, plus one.
    3. Set idx to map.xy_idx of DOUBLE the x and y values: so spread each cell out.
    4. We set the map tile at idx to be a floor.
    5. If the Cell we're referencing does not have a TOP wall, we set the map tile above our idx tile to be a floor.
    6. We repeat that for the other directions.

Speeding up the generator

We're wasting a lot of time by snapshotting at every iteration - we're building a huge list of snapshot maps. That was great for learning the algorithm, but simply takes too long when playing the game. We'll modify our generate_maze function to count iterations, and only log every 10th:


# #![allow(unused_variables)]
#fn main() {
fn generate_maze(&mut self, generator : &mut MazeBuilder) {
    let mut i = 0;
    loop {
        self.cells[self.current].visited = true;
        let next = self.find_next_cell();

        match next {
            Some(next) => {
                self.cells[next].visited = true;
                self.backtrace.push(self.current);
                unsafe {
                    let next_cell : *mut Cell = &mut self.cells[next];
                    let current_cell = &mut self.cells[self.current];
                    current_cell.remove_walls(next_cell);
                }
                self.current = next;
            }
            None => {
                if !self.backtrace.is_empty() {
                    self.current = self.backtrace[0];
                    self.backtrace.remove(0);
                } else {
                    break;
                }
            }
        }

        if i % 50 == 0 {
            self.copy_to_map(&mut generator.map);
            generator.take_snapshot();    
        }
        i += 1;
    }
}
#}

This brings the generator up to a reasonable speed, and you can still watch the maze develop.

Finding the exit

Fortunately, our current algorithm will start you at Cell (1,1) - which corresponds to map location (2,2). So in build, we can easily specify a starting point:


# #![allow(unused_variables)]
#fn main() {
self.starting_position = Position{ x: 2, y : 2 };
let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
self.take_snapshot();
#}

We can then use the same code we've used in the last two examples to find an exit:


# #![allow(unused_variables)]
#fn main() {
// Find all tiles we can reach from the starting point
let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
self.take_snapshot();

// Place the stairs
self.map.tiles[exit_tile] = TileType::DownStairs;
self.take_snapshot();

// Now we build a noise map for use in spawning entities later
self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);
#}

This is also a great test of the library's Dijkstra map code. It can solve a maze very quickly!

Restoring the randomness

Once again, we should restore random_builder to be random:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 8);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(MazeBuilder::new(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }    
}
#}

Wrap-Up

In this chapter, we've built a maze. It's a guaranteed solvable maze, so there's no risk of a level that you can't beat. You still have to use this type of map with caution: they make good one-off maps, and can really annoy players!

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Diffusion-Limited Aggregation


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Diffusion-Limited Aggregation (DLA) is a fancy name for a constrained form of the drunken walk. It makes organic looking maps, with more of an emphasis on a central area and "arms" coming out of it. With some tricks, it can be made to look quite alien - or quite real. See this excellent article on Rogue Basin.

Scaffolding

We'll create a new file, map_builders/dla.rs and put the scaffolding in from previous projects. We'll name the builder DLABuilder. We'll also keep the voronoi spawn code, it will work fine for this application. Rather than repeat the scaffolding code blocks from previous chapters, we'll jump straight in. If you get stuck, you can check the source code for this chapter here.

Algorithm Tuning Knobs

In the last chapter, we introduced the idea of adding parameters to our builder. We'll do the same again for DLA - there's a few algorithm variants that can produce different map styles. We'll introduce the following enumerations:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum DLAAlgorithm { WalkInwards, WalkOutwards, CentralAttractor }

#[derive(PartialEq, Copy, Clone)]
pub enum DLASymmetry { None, Horizontal, Vertical, Both }
#}

Our builder will include one more, brush size:


# #![allow(unused_variables)]
#fn main() {
pub struct DLABuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    algorithm : DLAAlgorithm,
    brush_size: i32,
    symmetry: DLASymmetry,
    floor_percent: f32
}
#}

This should be pretty self-explanatory by now if you've been through the other chapters:

  • We're supporting three algorithms, WalkInwards, WalkOutwards, CentralAttractor. We'll cover these in detail shortly.
  • We've added symmetry, which can be either None, Horizontal, Vertical or Both. Symmetry can be used to make some beautiful results with this algorithm, and we'll cover that later in the article.
  • We've also added brush_size, which specifies how many floor tiles we "paint" onto the map in one go. We'll look at this at the end of the chapter.
  • We've included floor_percent from the Drunkard's Walk chapter.

Our new function needs to include these parameters:


# #![allow(unused_variables)]
#fn main() {
pub fn new(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::WalkInwards,
        brush_size: 1,
        symmetry: DLASymmetry::None,
        floor_percent: 0.25
    }
}
#}

We'll make some type constructors once we've mastered the algorithms and their variants!

Walking Inwards

The most basic form of Diffusion-Limited Aggregation works like this:

  1. Dig a "seed" area around your central starting point.
  2. While the number of floor tiles is less than your desired total:
    1. Select a starting point at random for your digger.
    2. Use the "drunkard's walk" algorithm to move randomly.
    3. If the digger hit a floor tile, then the previous tile they were in also becomes a floor and the digger stops.

Very simple, and not too hard to implement:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    // Carve a starting seed
    self.starting_position = Position{ x: self.map.width/2, y : self.map.height/2 };
    let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
    self.take_snapshot();
    self.map.tiles[start_idx] = TileType::Floor;
    self.map.tiles[start_idx-1] = TileType::Floor;
    self.map.tiles[start_idx+1] = TileType::Floor;
    self.map.tiles[start_idx-self.map.width as usize] = TileType::Floor;
    self.map.tiles[start_idx+self.map.width as usize] = TileType::Floor;

    // Random walker
    let total_tiles = self.map.width * self.map.height;
    let desired_floor_tiles = (self.floor_percent * total_tiles as f32) as usize;
    let mut floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
    while floor_tile_count  < desired_floor_tiles {

        match self.algorithm {
            DLAAlgorithm::WalkInwards => {
                let mut digger_x = rng.roll_dice(1, self.map.width - 3) + 1;
                let mut digger_y = rng.roll_dice(1, self.map.height - 3) + 1;
                let mut prev_x = digger_x;
                let mut prev_y = digger_y;
                let mut digger_idx = self.map.xy_idx(digger_x, digger_y);
                while self.map.tiles[digger_idx] == TileType::Wall {
                    prev_x = digger_x;
                    prev_y = digger_y;
                    let stagger_direction = rng.roll_dice(1, 4);
                    match stagger_direction {
                        1 => { if digger_x > 2 { digger_x -= 1; } }
                        2 => { if digger_x < self.map.width-2 { digger_x += 1; } }
                        3 => { if digger_y > 2 { digger_y -=1; } }
                        _ => { if digger_y < self.map.height-2 { digger_y += 1; } }
                    }
                    digger_idx = self.map.xy_idx(digger_x, digger_y);
                }
                self.paint(prev_x, prev_y);
            }
            _ => {}
            ...
#}

The only new thing here is the call to paint. We'll be extending it later (to handle brush sizes), but here's a temporary implementation:


# #![allow(unused_variables)]
#fn main() {
fn paint(&mut self, x: i32, y: i32) {
    let digger_idx = self.map.xy_idx(x, y);
    self.map.tiles[digger_idx] = TileType::Floor;
}
#}

If you cargo run this, you will get a pretty cool looking dungeon:

Screenshot.

Walking outwards

A second variant of this algorithm reverses part of the process:

  1. Dig a "seed" area around your central starting point.
  2. While the number of floor tiles is less than your desired total:
    1. Set the digger to the starting central location.
    2. Use the "drunkard's walk" algorithm to move randomly.
    3. If the digger hit a wall tile, then that tile becomes a floor - and the digger stops.

So instead of marching inwards, our brave diggers are marching outwards. Implementing this is quite simple, and can be added to the match sequence of algorithms in build:


# #![allow(unused_variables)]
#fn main() {
...
DLAAlgorithm::WalkOutwards => {
    let mut digger_x = self.starting_position.x;
    let mut digger_y = self.starting_position.y;
    let mut digger_idx = self.map.xy_idx(digger_x, digger_y);
    while self.map.tiles[digger_idx] == TileType::Floor {
        let stagger_direction = rng.roll_dice(1, 4);
        match stagger_direction {
            1 => { if digger_x > 2 { digger_x -= 1; } }
            2 => { if digger_x < self.map.width-2 { digger_x += 1; } }
            3 => { if digger_y > 2 { digger_y -=1; } }
            _ => { if digger_y < self.map.height-2 { digger_y += 1; } }
        }
        digger_idx = self.map.xy_idx(digger_x, digger_y);
    }
    self.paint(digger_x, digger_y);
}
_ => {}
#}

There aren't any new concepts in this code, and if you understood Drunkard's Walk - it should be pretty self explanatory. If you adjust the constructor to use it, and call cargo run it looks pretty good:

Screenshot.

Central Attractor

This variant is again very similar, but slightly different. Instead of moving randomly, your particles path from a random point towards the middle:

  1. Dig a "seed" area around your central starting point.
  2. While the number of floor tiles is less than your desired total:
    1. Select a starting point at random for your digger.
    2. Plot a line to the center of the map, and keep it.
    3. Traverse the line. If the digger hit a floor tile, then the previous tile they were in also becomes a floor and the digger stops.

Again, this is relatively easy to implement:


# #![allow(unused_variables)]
#fn main() {
...
DLAAlgorithm::CentralAttractor => {
    let mut digger_x = rng.roll_dice(1, self.map.width - 3) + 1;
    let mut digger_y = rng.roll_dice(1, self.map.height - 3) + 1;
    let mut prev_x = digger_x;
    let mut prev_y = digger_y;
    let mut digger_idx = self.map.xy_idx(digger_x, digger_y);

    let mut path = rltk::line2d(
        rltk::LineAlg::Bresenham, 
        rltk::Point::new( digger_x, digger_y ), 
        rltk::Point::new( self.starting_position.x, self.starting_position.y )
    );

    while self.map.tiles[digger_idx] == TileType::Wall && !path.is_empty() {
        prev_x = digger_x;
        prev_y = digger_y;
        digger_x = path[0].x;
        digger_y = path[0].y;
        path.remove(0);
        digger_idx = self.map.xy_idx(digger_x, digger_y);
    }
    self.paint(prev_x, prev_y);
}
#}

If you adjust the constructor to use this algorithm, and cargo run the project you get a map that is more focused around a central point:

Screenshot.

Implementing Symmetry

Tyger Tyger, burning bright, 
In the forests of the night; 
What immortal hand or eye, 
Could frame thy fearful symmetry?

(William Blake, The Tyger)

Symmetry can transform a random map into something that looks designed - but quite alien. It often looks quite insectoid or reminiscent of a Space Invaders enemy. This can make for some fun-looking levels!

Lets modify the paint function to handle symmetry:


# #![allow(unused_variables)]
#fn main() {
fn paint(&mut self, x: i32, y:i32) {
    match self.symmetry {
        DLASymmetry::None => self.apply_paint(x, y),
        DLASymmetry::Horizontal => {
            let center_x = self.map.width / 2;
            if x == center_x {
                self.apply_paint(x, y);                    
            } else {
                let dist_x = i32::abs(center_x - x);
                self.apply_paint(center_x + dist_x, y);
                self.apply_paint(center_x - dist_x, y);
            }
        }
        DLASymmetry::Vertical => {
            let center_y = self.map.height / 2;
            if y == center_y {
                self.apply_paint(x, y);
            } else {
                let dist_y = i32::abs(center_y - y);
                self.apply_paint(x, center_y + dist_y);
                self.apply_paint(x, center_y - dist_y);
            }
        }
        DLASymmetry::Both => {
            let center_x = self.map.width / 2;
            let center_y = self.map.height / 2;
            if x == center_x && y == center_y {
                self.apply_paint(x, y);
            } else {
                let dist_x = i32::abs(center_x - x);
                self.apply_paint(center_x + dist_x, y);
                self.apply_paint(center_x - dist_x, y);
                let dist_y = i32::abs(center_y - y);
                self.apply_paint(x, center_y + dist_y);
                self.apply_paint(x, center_y - dist_y);
            }
        }
    }
#}

This is a longer function that it really needs to be, in the name of clarity. Here's how it works:

  1. We match on the current symmetry setting.
  2. If it is None, we simply call apply_paint with the destination tile.
  3. If it is Horizontal:
    1. We check to see if we are on the tile - if we are, just apply the paint once.
    2. Otherwise, obtain the horizontal distance from the center.
    3. Paint at center_x - distance and center_x + distance to paint symmetrically on the x axis.
  4. If it is Vertical:
    1. We check to see if we are on the tile - if we are, just apply the paint once (this helps with odd numbers of tiles by reducing rounding issues).
    2. Otherwise, obtain the vertical distance from the center.
    3. Paint at center_y - distance and center_y + distance.
  5. If it is Both - then do both steps.

You'll notice that we're calling apply_paint rather than actually painting. That's because we've also implemented brush_size:


# #![allow(unused_variables)]
#fn main() {
fn apply_paint(&mut self, x: i32, y: i32) {
    match self.brush_size {
        1 => {
            let digger_idx = self.map.xy_idx(x, y);
            self.map.tiles[digger_idx] = TileType::Floor;
        }

        _ => {
            let half_brush_size = self.brush_size / 2;
            for brush_y in y-half_brush_size .. y+half_brush_size {
                for brush_x in x-half_brush_size .. x+half_brush_size {
                    if brush_x > 1 && brush_x < self.map.width-1 && brush_y > 1 && brush_y < self.map.height-1 {
                        let idx = self.map.xy_idx(brush_x, brush_y);
                        self.map.tiles[idx] = TileType::Floor;
                    }
                }
            }
        }
    }
}
#}

This is quite simple:

  1. If brush size is 1, we just paint a floor tile.
  2. Otherwise, we loop through the brush size - and paint, performing bounds-checking to ensure we aren't painting off the map.

In your constructor, use the CentralAttractor algorithm - and enable symmetry with Horizontal. If you cargo run now, you get a map not unlike a cranky insectoid:

Screenshot.

Playing with Brush Sizes

Using a larger brush is ensures that you don't get too many 1x1 areas (that can be fiddly to navigate), and gives a more planned look to the map. Now that we've already implemented brush size, modify your constructor like this:


# #![allow(unused_variables)]
#fn main() {
pub fn new(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::WalkInwards,
        brush_size: 2,
        symmetry: DLASymmetry::None,
        floor_percent: 0.25
    }
}
#}

With this simple change, our map looks much more open:

Screenshot.

Providing a few constructors

Rather than pollute the random_builder function with algorithm details, we'll make constructors for each of the major algorithms we used in this chapter:


# #![allow(unused_variables)]
#fn main() {
pub fn walk_inwards(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::WalkInwards,
        brush_size: 1,
        symmetry: DLASymmetry::None,
        floor_percent: 0.25
    }
}

pub fn walk_outwards(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::WalkOutwards,
        brush_size: 2,
        symmetry: DLASymmetry::None,
        floor_percent: 0.25
    }
}

pub fn central_attractor(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::CentralAttractor,
        brush_size: 2,
        symmetry: DLASymmetry::None,
        floor_percent: 0.25
    }
}

pub fn insectoid(new_depth : i32) -> DLABuilder {
    DLABuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        algorithm: DLAAlgorithm::CentralAttractor,
        brush_size: 2,
        symmetry: DLASymmetry::Horizontal,
        floor_percent: 0.25
    }
}
#}

Randomizing the map builder, once again

Now we can modify random_builder in map_builders/mod.rs to actually be random once more - and offer even more types of map!


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 12);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(MazeBuilder::new(new_depth)),
        8 => Box::new(DLABuilder::walk_inwards(new_depth)),
        9 => Box::new(DLABuilder::walk_outwards(new_depth)),
        10 => Box::new(DLABuilder::central_attractor(new_depth)),
        11 => Box::new(DLABuilder::insectoid(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Wrap-up

This chapter has introduced another, very flexible, map builder for your arsenal. Great for making maps that feel like they were carved from the rock (or hewn from the forest, mined from the asteroid, etc.), it's another great way to introduce variety into your game.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Adding Symmetry and Brush Size as Library Functions


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In the previous chapter on Diffusion-Limited Aggregation, we introduced two new concepts for map building: symmetry and brush size. These readily apply to other algorithms, so we're going to take a moment to move them into library functions (in map_builders/common.rs), make them generic, and demonstrate how they can alter the Drunkard's Walk.

Building the library versions

We'll start by moving the DLASymmetry enumeration out of dla.rs and into common.rs. We'll also change its name, since we are no longer binding it to a specific algorithm:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum Symmetry { None, Horizontal, Vertical, Both }
#}

At the end of common.rs, we can add the following:


# #![allow(unused_variables)]
#fn main() {
pub fn paint(map: &mut Map, mode: Symmetry, brush_size: i32, x: i32, y:i32) {
    match mode {
        Symmetry::None => apply_paint(map, brush_size, x, y),
        Symmetry::Horizontal => {
            let center_x = map.width / 2;
            if x == center_x {
                apply_paint(map, brush_size, x, y);                    
            } else {
                let dist_x = i32::abs(center_x - x);
                apply_paint(map, brush_size, center_x + dist_x, y);
                apply_paint(map, brush_size, center_x - dist_x, y);
            }
        }
        Symmetry::Vertical => {
            let center_y = map.height / 2;
            if y == center_y {
                apply_paint(map, brush_size, x, y);
            } else {
                let dist_y = i32::abs(center_y - y);
                apply_paint(map, brush_size, x, center_y + dist_y);
                apply_paint(map, brush_size, x, center_y - dist_y);
            }
        }
        Symmetry::Both => {
            let center_x = map.width / 2;
            let center_y = map.height / 2;
            if x == center_x && y == center_y {
                apply_paint(map, brush_size, x, y);
            } else {
                let dist_x = i32::abs(center_x - x);
                apply_paint(map, brush_size, center_x + dist_x, y);
                apply_paint(map, brush_size, center_x - dist_x, y);
                let dist_y = i32::abs(center_y - y);
                apply_paint(map, brush_size, x, center_y + dist_y);
                apply_paint(map, brush_size, x, center_y - dist_y);
            }
        }
    }
}

fn apply_paint(map: &mut Map, brush_size: i32, x: i32, y: i32) {
    match brush_size {
        1 => {
            let digger_idx = map.xy_idx(x, y);
            map.tiles[digger_idx] = TileType::Floor;
        }

        _ => {
            let half_brush_size = brush_size / 2;
            for brush_y in y-half_brush_size .. y+half_brush_size {
                for brush_x in x-half_brush_size .. x+half_brush_size {
                    if brush_x > 1 && brush_x < map.width-1 && brush_y > 1 && brush_y < map.height-1 {
                        let idx = map.xy_idx(brush_x, brush_y);
                        map.tiles[idx] = TileType::Floor;
                    }
                }
            }
        }
    }
}
#}

This shouldn't be a surprise: it's the exact same code we had in dla.rs - but with the &mut self removed and instead taking parameters.

Modifying dla.rs to use it

It's relatively simple to modify dla.rs to use it. Replace all DLASymmetry references with Symmetry. Replace all calls to self.paint(x, y) with paint(&mut self.map, self.symmetry, self.brush_size, x, y);. You can check the source code to see the changes - no need to repeat them all here. Make sure to include paint and Symmetry in the list of included functions at the top, too.

Like a lot of refactoring, the proof of the pudding is that if you cargo run your code - nothing has changed! We won't bother with a screenshot to show that it's the same as last time!

Modifying Drunkard's Walk to use it

We'll start by modifying the DrunkardSettings struct to accept the two new features:


# #![allow(unused_variables)]
#fn main() {
pub struct DrunkardSettings {
    pub spawn_mode : DrunkSpawnMode,
    pub drunken_lifetime : i32,
    pub floor_percent: f32,
    pub brush_size: i32,
    pub symmetry: Symmetry
}
#}

The compiler will complain that we aren't setting these in our constructors, so we'll add some default values:


# #![allow(unused_variables)]
#fn main() {
pub fn open_area(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::StartingPoint,
            drunken_lifetime: 400,
            floor_percent: 0.5,
            brush_size: 1,
            symmetry: Symmetry::None
        }
    }
}
#}

We need to make similar changes to the other constructors - just adding brush_size and symmetry to each of the DrunkardSettings builders.

We also need to replace the line:


# #![allow(unused_variables)]
#fn main() {
self.map.tiles[drunk_idx] = TileType::DownStairs;
#}

With:


# #![allow(unused_variables)]
#fn main() {
paint(&mut self.map, self.settings.symmetry, self.settings.brush_size, drunk_x, drunk_y);
self.map.tiles[drunk_idx] = TileType::DownStairs;
#}

The double-draw retains the function of adding > symbols to show you the walker's path, while retaining the overdraw of the paint function.

Making a wider-carving drunk

To test this out, we'll add a new constructor to drunkard.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn fat_passages(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::Random,
            drunken_lifetime: 100,
            floor_percent: 0.4,
            brush_size: 2,
            symmetry: Symmetry::None
        }
    }
}
#}

We'll also quickly modify random_builder in map_builders/mod.rs to showcase this one:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 12);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(MazeBuilder::new(new_depth)),
        8 => Box::new(DLABuilder::walk_inwards(new_depth)),
        9 => Box::new(DLABuilder::walk_outwards(new_depth)),
        10 => Box::new(DLABuilder::central_attractor(new_depth)),
        11 => Box::new(DLABuilder::insectoid(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(DrunkardsWalkBuilder::fat_passages(new_depth))
}
#}

This shows an immediate change in the map generation:

Screenshot.

Notice how the "fatter" digging area gives more open halls. It also runs in half the time, since we exhaust the desired floor count much more quickly.

Adding Symmetry

Like DLA, symmetrical drunkards can make interesting looking maps. We'll add one more constructor:


# #![allow(unused_variables)]
#fn main() {
pub fn fearful_symmetry(new_depth : i32) -> DrunkardsWalkBuilder {
    DrunkardsWalkBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        settings : DrunkardSettings{
            spawn_mode: DrunkSpawnMode::Random,
            drunken_lifetime: 100,
            floor_percent: 0.4,
            brush_size: 1,
            symmetry: Symmetry::Both
        }
    }
}
#}

We also modify our random_builder function to use it:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 12);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(MazeBuilder::new(new_depth)),
        8 => Box::new(DLABuilder::walk_inwards(new_depth)),
        9 => Box::new(DLABuilder::walk_outwards(new_depth)),
        10 => Box::new(DLABuilder::central_attractor(new_depth)),
        11 => Box::new(DLABuilder::insectoid(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth))
}
#}

cargo run will render results something like these:

Screenshot.

Notice how the symmetry is applied (really fast - we're blasting out the floor tiles, now!) - and then unreachable areas are culled, getting rid of part of the map. This is quite a nice map!

Restoring Randomness Once More

Once again, we add our new algorithms to the random_builder function in map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 14);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)),
        8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)),
        9 => Box::new(MazeBuilder::new(new_depth)),
        10 => Box::new(DLABuilder::walk_inwards(new_depth)),
        11 => Box::new(DLABuilder::walk_outwards(new_depth)),
        12 => Box::new(DLABuilder::central_attractor(new_depth)),
        13 => Box::new(DLABuilder::insectoid(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

We're up to 14 algorithms, now! We have an increasingly varied game!

Wrap-Up

This chapter has demonstrated a very useful tool for the game programmer: finding a handy algorithm, making it generic, and using it in other parts of your code. It's rare to guess exactly what you need up-front (and there's a lot to be said for "you won't need it" - implementing things when you do need them), so it's a valuable weapon in our arsenal to be able to quickly refactor our code for reuse.

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Voronoi Hive/Cell Maps


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


We've touched on Voronoi diagrams before, in our spawn placement. In this section, we'll use them to make a map. The algorithm basically subdivides the map into regions, and places walls between them. The result is a bit like a hive. You can play with the distance/adjacency algorithm to adjust the results.

Scaffolding

We'll make scaffolding like in the previous chapters, making voronoi.rs with the structure VoronoiBuilder in it. We'll also adjust our random_builder function to only return VoronoiBuilder for now.

Building a Voronoi Diagram

In previous usages, we've skimmed over how to actually make a Voronoi diagram - and relied on the FastNoise library inside rltk. That's all well and good, but it doesn't really show us how it works - and gives very limited opportunities to tweak it. So - we'll make our own.

The first step in making some Voronoi noise it to populate a set of "seeds". These are randomly chosen (but not duplicate) points on the map. We'll make the number of seeds a variable so it can be tweaked later. Here's the code:


# #![allow(unused_variables)]
#fn main() {
let n_seeds = 64;
let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new();

while voronoi_seeds.len() < n_seeds {
    let vx = rng.roll_dice(1, self.map.width-1);
    let vy = rng.roll_dice(1, self.map.height-1);
    let vidx = self.map.xy_idx(vx, vy);
    let candidate = (vidx, rltk::Point::new(vx, vy));
    if !voronoi_seeds.contains(&candidate) {
        voronoi_seeds.push(candidate);
    }
}
#}

This makes a vector, each entry containing a tuple. Inside that tuple, we're storing an index to the map location, and a Point with the x and y coordinates in it (we could skip saving those and calculate from the index if we wanted, but I feel that this is clearer). Then we randomly determine a position, check to see that we haven't already rolled that location, and add it. We repeat the process until we have the desired number of seeds. 64 is quite a lot, but will give a relatively dense hive-like structure.

The next step is to determine each cell's Voronoi membership:


# #![allow(unused_variables)]
#fn main() {
let mut voronoi_distance = vec![(0, 0.0f32) ; n_seeds];
let mut voronoi_membership : Vec<i32> = vec![0 ; self.map.width as usize * self.map.height as usize];
for (i, vid) in voronoi_membership.iter_mut().enumerate() {
    let x = i as i32 % self.map.width;
    let y = i as i32 / self.map.width;

    for (seed, pos) in voronoi_seeds.iter().enumerate() {
        let distance = rltk::DistanceAlg::PythagorasSquared.distance2d(
            rltk::Point::new(x, y), 
            pos.1
        );
        voronoi_distance[seed] = (seed, distance);
    }

    voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());

    *vid = voronoi_distance[0].0 as i32;
}
#}

In this block of code, we:

  1. Create a new vector, called voronoi_distance. It contains tuples of a usize and a f32 (float), and is pre-made with n_seeds entries. We could make this for every iteration, but it's a lot faster to reuse the same one. We create it zeroed.
  2. We create a new voronoi_membership vector, containing one entry per tile on the map. We set them all to 0. We'll use this to store which Voronoi cell the tile belongs to.
  3. For every tile in voronoi_membership, we obtain an enumerator (index number) and the value. We have this mutably, so we can make changes.
    1. We calculate the x and y position of the tile from the enumerator (i).
    2. For each entry in the voronoi_seeds structure, we obtain the index (via enumerate()) and the position tuple.
      1. We calculate the distance from the seed to the current tile, using the PythagorasSquared algorithm.
      2. We set voronoi_distance[seed] to the seed index and the distance.
    3. We sort the voronoi_distance vector by the distance, so the closest seed will be the first entry.
    4. We set the tile's vid (Voronoi ID) to the first entry in the voronoi_distance list.

You can summarize that in English more easily: each tile is given membership of the Voronoi group to whom's seed it is physically closest.

Next, we use this to draw the map:


# #![allow(unused_variables)]
#fn main() {
for y in 1..self.map.height-1 {
    for x in 1..self.map.width-1 {
        let mut neighbors = 0;
        let my_idx = self.map.xy_idx(x, y);
        let my_seed = voronoi_membership[my_idx];
        if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; }
        if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; }
        if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; }
        if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; }

        if neighbors < 2 {
            self.map.tiles[my_idx] = TileType::Floor;
        }
    }
    self.take_snapshot();
}  
#}

In this code, we visit every tile except for the very outer edges. We count how many neighboring tiles are in a different Voronoi group. If the answer is 0, then it is entirely in the group: so we can place a floor. If the answer is 1, it only borders 1 other group - so we can also place a floor (to ensure we can walk around the map). Otherwise, we leave the tile as a wall.

Then we run the same culling and placement code we've used before. If you cargo run the project now, you will see a pleasant structure:

Screenshot.

Tweaking the Hive

There are two obvious variables to expose to the builder: the number of seeds, and the distance algorithm to use. We'll update the structure signature to include these:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum DistanceAlgorithm { Pythagoras, Manhattan, Chebyshev }

pub struct VoronoiCellBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    n_seeds: usize,
    distance_algorithm: DistanceAlgorithm
}
#}

Then we'll update the Voronoi code to use them:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    // Make a Voronoi diagram. We'll do this the hard way to learn about the technique!
    let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new();

    while voronoi_seeds.len() < self.n_seeds {
        let vx = rng.roll_dice(1, self.map.width-1);
        let vy = rng.roll_dice(1, self.map.height-1);
        let vidx = self.map.xy_idx(vx, vy);
        let candidate = (vidx, rltk::Point::new(vx, vy));
        if !voronoi_seeds.contains(&candidate) {
            voronoi_seeds.push(candidate);
        }
    }

    let mut voronoi_distance = vec![(0, 0.0f32) ; self.n_seeds];
    let mut voronoi_membership : Vec<i32> = vec![0 ; self.map.width as usize * self.map.height as usize];
    for (i, vid) in voronoi_membership.iter_mut().enumerate() {
        let x = i as i32 % self.map.width;
        let y = i as i32 / self.map.width;

        for (seed, pos) in voronoi_seeds.iter().enumerate() {
            let distance;
            match self.distance_algorithm {           
                DistanceAlgorithm::Pythagoras => {
                    distance = rltk::DistanceAlg::PythagorasSquared.distance2d(
                        rltk::Point::new(x, y), 
                        pos.1
                    );
                }
                DistanceAlgorithm::Manhattan => {
                    distance = rltk::DistanceAlg::Manhattan.distance2d(
                        rltk::Point::new(x, y), 
                        pos.1
                    );
                }
                DistanceAlgorithm::Chebyshev => {
                    distance = rltk::DistanceAlg::Chebyshev.distance2d(
                        rltk::Point::new(x, y), 
                        pos.1
                    );
                }
            }
            voronoi_distance[seed] = (seed, distance);
        }

        voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());

        *vid = voronoi_distance[0].0 as i32;
    }

    for y in 1..self.map.height-1 {
        for x in 1..self.map.width-1 {
            let mut neighbors = 0;
            let my_idx = self.map.xy_idx(x, y);
            let my_seed = voronoi_membership[my_idx];
            if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; }
            if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; }
            if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; }
            if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; }

            if neighbors < 2 {
                self.map.tiles[my_idx] = TileType::Floor;
            }
        }
        self.take_snapshot();
    }
    ...
#}

As a test, lets change the constructor to use Manhattan distance. The results will look something like this:

Screenshot.

Notice how the lines are straighter, and less organic looking. That's what Manhattan distance does: it calculates distance like a Manhattan Taxi Driver - number of rows plus number of columns, rather than a straight line distance.

Restoring Randomness

So we'll put a couple of constructors in for each of the noise types:


# #![allow(unused_variables)]
#fn main() {
pub fn pythagoras(new_depth : i32) -> VoronoiCellBuilder {
    VoronoiCellBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        n_seeds: 64,
        distance_algorithm: DistanceAlgorithm::Pythagoras
    }
}

pub fn manhattan(new_depth : i32) -> VoronoiCellBuilder {
    VoronoiCellBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history: Vec::new(),
        noise_areas : HashMap::new(),
        n_seeds: 64,
        distance_algorithm: DistanceAlgorithm::Manhattan
    }
}
#}

Then we'll restore the random_builder to once again be random:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)),
        8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)),
        9 => Box::new(MazeBuilder::new(new_depth)),
        10 => Box::new(DLABuilder::walk_inwards(new_depth)),
        11 => Box::new(DLABuilder::walk_outwards(new_depth)),
        12 => Box::new(DLABuilder::central_attractor(new_depth)),
        13 => Box::new(DLABuilder::insectoid(new_depth)),
        14 => Box::new(VoronoiCellBuilder::pythagoras(new_depth)),
        15 => Box::new(VoronoiCellBuilder::manhattan(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }
}
#}

Wrap-Up

That's another algorithm under our belts! We really have enough to write a pretty good roguelike now, but there are still more to come!

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Wave Function Collapse


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


A few years ago, Wave Function Collapse (WFC) exploded onto the procedural generation scene. Apparently magical, it took images in - and made a similar image. Demos showed it spitting out great looking game levels, and the amazing Caves of Qud started using it for generating fun levels. The canonical demonstrations - along with the original algorithm in C# and various explanatory links/ports - may be found here.

In this chapter, we're going to implement Wave Function Collapse from scratch - and apply it to making fun Roguelike levels. Note that there is a crate with the original algorithm available (wfc, accompanied by wfc-image); it seemed pretty good in testing, but I had problems making it work with Web Assembly. I also didn't feel that I was really teaching the algorithm by saying "just import this". It's a longer chapter, but by the end you should feel comfortable with the algorithm.

So what does WFC really do?

Wave Function Collapse is unlike the map generation algorithms we've used so far in that it doesn't actually make maps. It takes source data in (we'll use other maps!), scans them, and builds a new map featuring elements made exclusively from the source data. It operates in a few phases:

  1. It reads the incoming data. In the original implementation, this was a PNG file. In our implementation, this is a Map structure like others we've worked with; we'll also implement a REX Paint reader to load maps.
  2. It divides the source image into "tiles", and optionally makes more tiles by mirroring the tiles it reads along one or two axes.
  3. It either loads or builds a "constraints" graph. This is a set of rules specifying which tiles can go next to each other. In an image, this may be derived from tile adjacency. In a Roguelike map, connectivity of exits is a good metric. For a tile-based game, you might carefully build a layout of what can go where.
  4. It then divides the output image into tile-sized chunks, and sets them all to "empty". The first tile placed will be pretty random, and then it selects areas and examines tile data that is already known - placing down tiles that are compatible with what is already there. Eventually, it's placed all of the tiles - and you have a map/image!

The name "Wave Function Collapse" refers to the Quantum Physics idea that a particle may have not actually have a state until you look at it. In the algorithm, tiles don't really coalesce into being until you pick one to examine. So there is a slight similarity to Quantum Physics. In reality, though - the name is a triumph of marketing. The algorithm is what is known as a solver - given a set of constraints, it iterates through possible solutions until the constraints are solved. This isn't a new concept - Prolog is an entire programming language based around this idea, and it first hit the scene in 1972. So in a way, it's older than me!

Getting started: Rust support for complex modules

All our previous algorithms were small enough to fit into one source code file, without too much paging around to find the relevant bit of code. Wave Function Collapse is complicated enough that it deserves to be broken into multiple files - in much the same was as the map_builders module was broken into a module - WFC will be divided into its own module. The module will still live inside map_builders - so in a way it's really a sub-module.

Rust makes it pretty easy to break any module into multiple files: you create a directory inside the parent module, and put a file in it called mod.rs. You can then put more files in the folder, and so long as you enable them (with mod myfile) and use the contents (with use myfile::MyElement) it works just like a single file.

So to get started, inside your map_builders directory - make a new directory called waveform_collapse. Add a file, mod.rs into it. You should have a source tree like this:

\ src
   \ map_builders
      \ waveform_collapse
         + mod.rs
      bsp_dungeon.rs
      (etc)
   main.rs
   (etc)

We'll populate mod.rs with a skeletal implementation similar to previous chapters:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    generate_voronoi_spawn_regions, remove_unreachable_areas_returning_most_distant};
use rltk::RandomNumberGenerator;
use specs::prelude::*;

pub struct WaveformCollapseBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>
}

impl MapBuilder for WaveformCollapseBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        self.build();
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
        for area in self.noise_areas.iter() {
            spawner::spawn_region(ecs, area.1, self.depth);
        }
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl WaveformCollapseBuilder {
    pub fn new(new_depth : i32) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new()
        }
    }    

    fn build(&mut self) {
        let mut rng = RandomNumberGenerator::new();

        // TODO: Builder goes here

        // Find a starting point; start at the middle and walk left until we find an open tile
        self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
        /*let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        while self.map.tiles[start_idx] != TileType::Floor {
            self.starting_position.x -= 1;
            start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        }*/
        self.take_snapshot();

        // Find all tiles we can reach from the starting point
        let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
        self.take_snapshot();

        // Place the stairs
        self.map.tiles[exit_tile] = TileType::DownStairs;
        self.take_snapshot();

        // Now we build a noise map for use in spawning entities later
        self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);
    }

}
#}

We'll also modify map_builders/mod.rs's random_builder function to always return the algorithm we're currently working with:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    match builder {
        1 => Box::new(BspDungeonBuilder::new(new_depth)),
        2 => Box::new(BspInteriorBuilder::new(new_depth)),
        3 => Box::new(CellularAutomataBuilder::new(new_depth)),
        4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)),
        5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)),
        6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)),
        7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)),
        8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)),
        9 => Box::new(MazeBuilder::new(new_depth)),
        10 => Box::new(DLABuilder::walk_inwards(new_depth)),
        11 => Box::new(DLABuilder::walk_outwards(new_depth)),
        12 => Box::new(DLABuilder::central_attractor(new_depth)),
        13 => Box::new(DLABuilder::insectoid(new_depth)),
        14 => Box::new(VoronoiCellBuilder::pythagoras(new_depth)),
        15 => Box::new(VoronoiCellBuilder::manhattan(new_depth)),
        _ => Box::new(SimpleMapBuilder::new(new_depth))
    }*/
    Box::new(WaveformCollapseBuilder::new(new_depth))
}
#}

This will give you an empty map (all walls) if you cargo run it - but it's a good starting point.

Loading the source image - REX Paint

You may remember back in section 2 we loaded a REX Paint file to use as the main menu screen. We're going to do similar here, but we're going to turn it into a playable map. It's a deliberately odd map to help illustrate what you can do with this algorithm. Here's the original in REX Paint:

Screenshot.

I've tried to include some interesting shapes, a silly face, and plenty of corridors and different sized rooms. Here's a second REX Paint file, designed to be more like the old board game The Sorcerer's Cave, of which the algorithm reminds me - tiles with 1 exit, 2 exits, 3 exits and 4. It would be easy to make these prettier, but we'll keep it simple for demonstration purposes.

Screenshot.

These files are found in the resources directory, as wfc-demo1.xp and wfc-demo2.xp. One thing I love about REX Paint: the files are tiny (102k and 112k respectively). To make accessing them easier - and avoid having to ship them with the executable when you publish your finished game, we'll embed them into our game. We did this previously for the main menu. Modify rex_assets.xp to include the new files:


# #![allow(unused_variables)]
#fn main() {
use rltk::{rex::XpFile};

rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-demo2.xp");

pub struct RexAssets {
    pub menu : XpFile
}

impl RexAssets {
    #[allow(clippy::new_without_default)]
    pub fn new() -> RexAssets {
        rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
        rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
        rltk::link_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-demo2.xp");

        RexAssets{
            menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()
        }
    }
}
#}

Finally, we should load the map itself! Inside the waveform_collapse directory, make a new file: image_loader.rs:


# #![allow(unused_variables)]
#fn main() {
use rltk::rex::XpFile;
use super::{Map, TileType};

/// Loads a RexPaint file, and converts it into our map format
pub fn load_rex_map(new_depth: i32, xp_file : &XpFile) -> Map {
    let mut map : Map = Map::new(new_depth);

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < map.width as usize && y < map.height as usize {
                    let idx = map.xy_idx(x as i32, y as i32);
                    match cell.ch {
                        32 => map.tiles[idx] = TileType::Floor, // #
                        35 => map.tiles[idx] = TileType::Wall, // #
                        _ => {}
                    }
                }
            }
        }
    }

    map
}
#}

This is really simple, and if you remember the main menu graphic tutorial it should be quite self-explanatory. This function:

  1. Accepts arguments for new_depth (because maps want it) and a reference to an XpFile - a REX Paint map. It will be made completely solid, walls everywhere by the constructor.
  2. It creates a new map, using the new_depth parameter.
  3. For each layer in the REX Paint file (there should be only one at this point):
    1. For each y and x on that layer:
      1. Load the tile information for that coordinate.
      2. Ensure that we're within the map boundaries (in case we have a mismatch in sizes).
      3. Calculate the tiles index for the cell.
      4. Match on the cell glyph; if its a # (35) we place a wall, if its a space (32) we place a floor.

Now we can modify our build function (in mod.rs) to load the map:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo1.xp").unwrap());
    self.take_snapshot();

    // Find a starting point; start at the middle and walk left until we find an open tile
    self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
    ...
#}

At the top, we have to tell it to use the new image_loader file:


# #![allow(unused_variables)]
#fn main() {
mod image_loader;
use image_loader::*;
#}

Note that we're not putting pub in front of these: we're using them, but not exposing them outside of the module. This helps us keep our code clean, and our compile times short!

In and of itself, this is cool - we can now load any REX Paint designed level and play it! If you cargo run now, you'll find that you can play the new map:

Screenshot.

We'll make use of this in later chapters for vaults, prefabs and pre-designed levels - but for now, we'll just use it as source data for later in the Wave Function Collapse implementation.

Carving up our map into tiles

We discussed earlier that WFC works by carving the original image into chunks/tiles, and optionally flipping them in different directions. It does this as the first part of building constraints - how the map can be laid out. So now we need to start carving up our image.

We'll start by picking a tile size (we're going to call it chunk_size). We'll make it a constant for now (it'll become tweakable later), and start with a size of 7 - because that was the size of the tiles in our second REX demo file. We'll also call a function we'll write in a moment:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    let mut rng = RandomNumberGenerator::new();

    const CHUNK_SIZE :i32 = 7;

    self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo2.xp").unwrap());
    self.take_snapshot();

    let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true);
    ...
#}

Since we're dealing with constraints, we'll make a new file in our map_builders/waveform_collapse directory - constraints.rs. We're going to make a function called build_patterns:


# #![allow(unused_variables)]
#fn main() {
use super::{TileType, Map};
use std::collections::HashSet;

pub fn build_patterns(map : &Map, chunk_size: i32, include_flipping: bool, dedupe: bool) -> Vec<Vec<TileType>> {
    let chunks_x = map.width / chunk_size;
    let chunks_y = map.height / chunk_size;
    let mut patterns = Vec::new();

    for cy in 0..chunks_y {
        for cx in 0..chunks_x {
            // Normal orientation
            let mut pattern : Vec<TileType> = Vec::new();
            let start_x = cx * chunk_size;
            let end_x = (cx+1) * chunk_size;
            let start_y = cy * chunk_size;
            let end_y = (cy+1) * chunk_size;

            for y in start_y .. end_y {
                for x in start_x .. end_x {
                    let idx = map.xy_idx(x, y);
                    pattern.push(map.tiles[idx]);
                }
            }
            patterns.push(pattern);

            if include_flipping {
                // Flip horizontal
                pattern = Vec::new();
                for y in start_y .. end_y {
                    for x in start_x .. end_x {
                        let idx = map.xy_idx(end_x - (x+1), y);
                        pattern.push(map.tiles[idx]);
                    }
                }
                patterns.push(pattern);

                // Flip vertical
                pattern = Vec::new();
                for y in start_y .. end_y {
                    for x in start_x .. end_x {
                        let idx = map.xy_idx(x, end_y - (y+1));
                        pattern.push(map.tiles[idx]);
                    }
                }
                patterns.push(pattern);

                // Flip both
                pattern = Vec::new();
                for y in start_y .. end_y {
                    for x in start_x .. end_x {
                        let idx = map.xy_idx(end_x - (x+1), end_y - (y+1));
                        pattern.push(map.tiles[idx]);
                    }
                }
                patterns.push(pattern);
            }
        }
    }

    // Dedupe
    if dedupe {
        rltk::console::log(format!("Pre de-duplication, there are {} patterns", patterns.len()));
        let set: HashSet<Vec<TileType>> = patterns.drain(..).collect(); // dedup
        patterns.extend(set.into_iter());
        rltk::console::log(format!("There are {} patterns", patterns.len()));
    }

    patterns
}
#}

That's quite the mouthful of a function, so let's walk through it:

  1. At the top, we're importing some items from elsewhere in the project: Map, TileType, and the built-in collection HashMap.
  2. We declare our build_patterns function, with parameters for a reference to the source map, the chunk_size to use (tile size), and flags (bool variables) for include_flipping and dedupe. These indicate which features we'd like to use when reading the source map. We're returning a vector, containing a series of vectors of different TileTypes. The outer container holds each pattern. The inner vector holds the TileTypes that make up the pattern itself.
  3. We determine how many chunks there are in each direction and store it in chunks_x and chunks_y.
  4. We create a new vector called patterns. This will hold the result of the function; we don't declare it's type, because Rust is smart enough to see that we're returning it at the end of the function - and can figure out what type it is for us.
  5. We iterate every vertical chunk in the variable cy:
    1. We iterate every horizontal chunk in the variable cx:
      1. We make a new vector to hold this pattern.
      2. We calculate start_x, end_x, start_y and end_y to hold the four corner coordinates of this chunk - on the original map.
      3. We iterate the pattern in y/x order (to match our map format), read in the TileType of each map tile within the chunk, and add it to the pattern.
      4. We push the pattern to the patterns result vector.
      5. If include_flipping is set to true (because we'd like to flip our tiles, making more tiles!):
        1. Repeat iterating y/x in different orders, giving 3 more tiles. Each is added to the patterns result vector.
  6. If dedupe is set, then we are "de-duplicating" the pattern buffer. Basically, removing any pattern that occurs more than once. This is good for a map with lots of wasted space, if you don't want to make an equally sparse result map. We de-duplicate by adding the patterns into a HashMap (which can only store one of each entry) and then reading it back out again.

For this to compile, we have to make TileType know how to convert itself into a hash. HashMap uses "hashes" (basically a checksum of the contained values) to determine if an entry is unique, and to help find it. In map.rs, we can simply add one more derived attribute to the TileType enumeration:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
pub enum TileType {
    Wall, Floor, DownStairs
}
#}

This code should get you every 7x7 tile within your source file - but it'd be great to be able to prove that it works! As Reagan's speech-writer once wrote, Trust - But Verify. In constraints.rs, we'll add another function: render_pattern_to_map:


# #![allow(unused_variables)]
#fn main() {
fn render_pattern_to_map(map : &mut Map, pattern: &Vec<TileType>, chunk_size: i32, start_x : i32, start_y: i32) {
    let mut i = 0usize;
    for tile_y in 0..chunk_size {
        for tile_x in 0..chunk_size {
            let map_idx = map.xy_idx(start_x + tile_x, start_y + tile_y);
            map.tiles[map_idx] = pattern[i];
            map.visible_tiles[map_idx] = true;
            i += 1;
        }
    }
}
#}

This is pretty simple: iterate the pattern, and copy to a location on the map - offset by the start_x and start_y coordinates. Note that we're also marking the tile as visible - this will make the renderer display our tiles in color.

Now we just need to display our tiles as part of the snapshot system. In waveform_collapse/mod.rs add a new function as part of the implementation of WaveformCollapseBuilder (underneath build). It's a member function because it needs access to the take_snapshot command:


# #![allow(unused_variables)]
#fn main() {
fn render_tile_gallery(&mut self, patterns: &Vec<Vec<TileType>>, chunk_size: i32) {
    self.map = Map::new(0);
    let mut counter = 0;
    let mut x = 1;
    let mut y = 1;
    while counter < patterns.len() {
        render_pattern_to_map(&mut self.map, &patterns[counter], chunk_size, x, y);

        x += chunk_size + 1;
        if x + chunk_size > self.map.width {
            // Move to the next row
            x = 1;
            y += chunk_size + 1;

            if y + chunk_size > self.map.height {
                // Move to the next page
                self.take_snapshot();
                self.map = Map::new(0);

                x = 1;
                y = 1;
            }
        }

        counter += 1;
    }
    self.take_snapshot();
}
#}

Now, we need to call it. In build:


# #![allow(unused_variables)]
#fn main() {
let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true);
self.render_tile_gallery(&patterns, CHUNK_SIZE);
#}

Also, comment out some code so that it doesn't crash from not being able to find a starting point:


# #![allow(unused_variables)]
#fn main() {
let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
/*while self.map.tiles[start_idx] != TileType::Floor {
    self.starting_position.x -= 1;
    start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
}*/
#}

If you cargo run now, it'll show you the tile patterns from map sample 2:

Screenshot.

Notice how flipping has given us multiple variants of each tile. If we change the image loading code to load wfc-demo1 (by changing the loader to self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo1.xp").unwrap());), we get chunks of our hand-drawn map:

Screenshot.

Building the constraints matrix

Now we need to begin to tell the algorithm how it can place tiles next to one another. We could go for a simple "what's next to it on the original image?" algorithm, but that would ignore a key factor in roguelike maps: connectivity. We're far more interested in the ability to go from point A to point B than we are in overall aesthetics! So we need to write a constraint builder that takes into account connectivity.

We'll start by extending builder in mod.rs to call a hypothetical function we'll implement in a second:


# #![allow(unused_variables)]
#fn main() {
let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true);
self.render_tile_gallery(&patterns, CHUNK_SIZE);
let constraints = patterns_to_constraints(patterns, CHUNK_SIZE);
#}

This gives us the signature of a new method, patterns_to_constraints to add to constraints.rs. We're also going to need a new type and a helper function. We'll use these in other places, so we're going to add a new file to the waveform_collapse folder - common.rs.


# #![allow(unused_variables)]
#fn main() {
use super::TileType;

#[derive(PartialEq, Eq, Hash, Clone)]
pub struct MapChunk {
    pub pattern : Vec<TileType>,
    pub exits: [Vec<bool>; 4],
    pub has_exits: bool,
    pub compatible_with: [Vec<usize>; 4]
}

pub fn tile_idx_in_chunk(chunk_size: i32, x:i32, y:i32) -> usize {
    ((y * chunk_size) + x) as usize
}
#}

We're defining MapChunk to be a structure, containing the actual pattern, a structure of exits (more on that in a moment), a bool to say we have any exits, and a structure called compatible_with (more on that in a second, too). We're also defining tile_idx_in_chunk - which is just like map.xy_idx - but constrained to a small tile type.

Now we'll write patterns_to_constraints in constraints.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn patterns_to_constraints(patterns: Vec<Vec<TileType>>, chunk_size : i32) -> Vec<MapChunk> {
    // Move into the new constraints object
    let mut constraints : Vec<MapChunk> = Vec::new();
    for p in patterns {
        let mut new_chunk = MapChunk{
            pattern: p,
            exits: [ Vec::new(), Vec::new(), Vec::new(), Vec::new() ],
            has_exits : true,
            compatible_with: [ Vec::new(), Vec::new(), Vec::new(), Vec::new() ]
        };
        for exit in new_chunk.exits.iter_mut() {
            for _i in 0..chunk_size {
                exit.push(false);
            }
        }

        let mut n_exits = 0;
        for x in 0..chunk_size {
            // Check for north-bound exits            
            let north_idx = tile_idx_in_chunk(chunk_size, x, 0);
            if new_chunk.pattern[north_idx] == TileType::Floor {
                new_chunk.exits[0][x as usize] = true;
                n_exits += 1;
            }

            // Check for south-bound exits
            let south_idx = tile_idx_in_chunk(chunk_size, x, chunk_size-1);
            if new_chunk.pattern[south_idx] == TileType::Floor {
                new_chunk.exits[1][x as usize] = true;
                n_exits += 1;
            }

            // Check for west-bound exits
            let west_idx = tile_idx_in_chunk(chunk_size, 0, x);
            if new_chunk.pattern[west_idx] == TileType::Floor {
                new_chunk.exits[2][x as usize] = true;
                n_exits += 1;
            }

            // Check for east-bound exits
            let east_idx = tile_idx_in_chunk(chunk_size, chunk_size-1, x);
            if new_chunk.pattern[east_idx] == TileType::Floor {
                new_chunk.exits[3][x as usize] = true;
                n_exits += 1;
            }
        }

        if n_exits == 0 {
            new_chunk.has_exits = false;
        }

        constraints.push(new_chunk);
    }

    // Build compatibility matrix
    let ch = constraints.clone();
    for c in constraints.iter_mut() {
        for (j,potential) in ch.iter().enumerate() {
            // If there are no exits at all, it's compatible
            if !c.has_exits || !potential.has_exits {
                for compat in c.compatible_with.iter_mut() {
                    compat.push(j);
                }
            } else {
                // Evaluate compatibilty by direction
                for (direction, exit_list) in c.exits.iter_mut().enumerate() {
                    let opposite = match direction {
                        0 => 1, // Our North, Their South
                        1 => 0, // Our South, Their North
                        2 => 3, // Our West, Their East
                        _ => 2 // Our East, Their West
                    };

                    let mut it_fits = false;
                    let mut has_any = false;
                    for (slot, can_enter) in exit_list.iter().enumerate() {
                        if *can_enter {
                            has_any = true;
                            if potential.exits[opposite][slot] {
                                it_fits = true;
                            }
                        }
                    }
                    if it_fits {
                        c.compatible_with[direction].push(j);
                    }
                    if !has_any {
                        // There's no exits on this side, we don't care what goes there
                        for compat in c.compatible_with.iter_mut() {
                            compat.push(j);
                        }
                    }
                }
            }
        }
    }

    constraints
}
#}

This is a really big function, but clearly broken down into sections. Let's take the time to walk through what it actually does:

  1. It accepts a first parameter, patterns as Vec<Vec<TileType>> - the type we used to build our patterns. A second parameter, chunk_size is the same as we've used before. It returns a vector of the new MapChunk type. A MapChunk is a pattern, but with additional exit and compatibility information added to it. So we're promising that given a set of pattern graphics, we're going to add all the navigation information to it and return the patterns as a set of chunks.
  2. It makes a new Vec of type MapChunk called constraints. This is our result - we'll be adding to it, and returning it to the caller at the end.
  3. Now we iterate every pattern in patterns, calling it p (to save typing). For each pattern:
    1. We make a new MapChunk. The pattern field gets a copy of our pattern. exits is an array (fixed size set; in this case of size 4) of vectors, so we insert 4 empty vectors into it. compatible_with is also an array of vectors, so we set those to new - empty - vectors. We set has_exits to true - we'll set that later.
    2. We iterate from 0 to chunk_size, and add false into each exits field of the new map chunk. The exits structure represents one entry per possible direction (North, South, West, East) - so it needs one entry per size of the chunk to represent each possible exit tile in that direction. We'll check for actual connectivity later - for now, we just want placeholders for each direction.
    3. We set n_exits to 0, and make it mutable - so we can add to it later. We'll be counting the total number of exits on the way through.
    4. We iterate x from 0 to chunk_size, and for each value of x:
      1. We check for north-bound exits. These are always at the location (x, 0) within the chunk - so we calculate the tile index to check as tile_idx_in_chunk(chunk_size, x, 0). If that tile is a floor, we add one to n_exits and set new_chunk.exits[0][x] to true.
      2. We do the same for south-bound exits. These are always at the location (x, chunk_size-1), so we calculate the chunk index to be tile_idx_in_chunk(chunk_size, x, chunk_size-1). If that tile is a floor, we add one to n_exits and set new_chunks.exits[1][x] to true.
      3. We do the same again for west-bound, which are at location (0,x).
      4. We do the same again for east-bound, which are at location (chunk_size-1,0).
    5. If n_exits is 0, we set new_chunk.has_exits to 0 - there's no way in or out of this chunk!
    6. We push new_chunk to the constraints result vector.
  4. Now it's time to build a compatibility matrix! The idea here is to match which tiles can be placed to which other tiles, by matching exits on adjacent edges.
  5. To avoid borrow-checker issues, we take a copy of the existing constraints with let ch = constraints.clone();. Rust isn't a big fan of both reading from and writing to the same vector at once - so this avoids us having to do a dance to keep it separated.
  6. For each constraint in or results vector constraints, named c we:
    1. Iterate every constraint in ch, our copy of the constraints vector, as potential. We add an enumerator, j to tell us how it is indexed.
      1. If neither c (the constraint we are editing) or potential (the constraint we are examining) has exits, then we make it compatible with everything. We do this to increase the chances of a map being successfully resolved and still featuring these tiles (otherwise, they would never be chosen). To add compatibility with everything, we add j to the compatibile_with structure for all four directions. So c can be placed next to potential in any direction.
      2. Otherwise, we iterate through all four exit directions on c:
        1. We set opposite to the reciprocal of the direction we're evaluating; so North goes to South, East to West, etc.
        2. We setup two mutable variables, it_fits and has_any - and set both to false. We'll use these in the next steps. it_fits means that there are one or more matching exits between c's exit tiles and potential's entry tiles. has_any means that c has any exits at all in this direction. We distinguish between the two because if there are no exits in that direction, we don't care what the neighbor is - we can't affect it. If there are exits, then we only want to be compatible with tiles you can actually visit.
        3. We iterate c's exits, keeping both a slot (the tile number we are evaluating) and the value of the exit tile (can_enter). You'll remember that we've set these to true if they are a floor - and false otherwise - so we're iterating possible exits.
          1. If can_enter is true, then we set has_any to true - it has an exit in that direction.
          2. We check potential_exits.exits[opposite][slot] - that is that matching exit on the other tile, in the opposite direction to the way we're going. If there is a match-up, then you can go from tile c to tile potential in our current direction! That lets us set it_fits to true.
        4. If it_fits is true, then there is a compatibility between the tiles: we add j to c's compatible_with vector for the current direction.
        5. If has_any is false, then we don't care about adjacency in this direction - so we add j to the compatibility matrix for all directions, just like we did for a tile with no exits.
  7. Finally, we return our constraints results vector.

That's quite a complicated algorithm, so we don't really want to trust that I got it right. We'll verify exit detection by adjusting our tile gallery code to show exits. In build, tweak the rendering order and what we're passing to render_tile_gallery:


# #![allow(unused_variables)]
#fn main() {
let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true);
let constraints = patterns_to_constraints(patterns, CHUNK_SIZE);
self.render_tile_gallery(&constraints, CHUNK_SIZE);
#}

We also need to modify render_tile_gallery:


# #![allow(unused_variables)]
#fn main() {
fn render_tile_gallery(&mut self, constraints: &Vec<MapChunk>, chunk_size: i32) {
    self.map = Map::new(0);
    let mut counter = 0;
    let mut x = 1;
    let mut y = 1;
    while counter < constraints.len() {
        render_pattern_to_map(&mut self.map, &constraints[counter], chunk_size, x, y);

        x += chunk_size + 1;
        if x + chunk_size > self.map.width {
            // Move to the next row
            x = 1;
            y += chunk_size + 1;

            if y + chunk_size > self.map.height {
                // Move to the next page
                self.take_snapshot();
                self.map = Map::new(0);

                x = 1;
                y = 1;
            }
        }

        counter += 1;
    }
    self.take_snapshot();
}
#}

This requires that we modify our render_pattern_to_map function, also:


# #![allow(unused_variables)]
#fn main() {
pub fn render_pattern_to_map(map : &mut Map, chunk: &MapChunk, chunk_size: i32, start_x : i32, start_y: i32) {
    let mut i = 0usize;
    for tile_y in 0..chunk_size {
        for tile_x in 0..chunk_size {
            let map_idx = map.xy_idx(start_x + tile_x, start_y + tile_y);
            map.tiles[map_idx] = chunk.pattern[i];
            map.visible_tiles[map_idx] = true;
            i += 1;
        }
    }

    for (x,northbound) in chunk.exits[0].iter().enumerate() {
        if *northbound {
            let map_idx = map.xy_idx(start_x + x as i32, start_y);
            map.tiles[map_idx] = TileType::DownStairs;
        }
    }
    for (x,southbound) in chunk.exits[1].iter().enumerate() {
        if *southbound {
            let map_idx = map.xy_idx(start_x + x as i32, start_y + chunk_size -1);
            map.tiles[map_idx] = TileType::DownStairs;
        }
    }
    for (x,westbound) in chunk.exits[2].iter().enumerate() {
        if *westbound {
            let map_idx = map.xy_idx(start_x, start_y + x as i32);
            map.tiles[map_idx] = TileType::DownStairs;
        }
    }
    for (x,eastbound) in chunk.exits[3].iter().enumerate() {
        if *eastbound {
            let map_idx = map.xy_idx(start_x + chunk_size - 1, start_y + x as i32);
            map.tiles[map_idx] = TileType::DownStairs;
        }
    }
}
#}

Now that we have the demo framework running, we can cargo run the project - and see the tiles from wfc-demo2.xp correctly highlighting the exits:

Screenshot.

The wfc-demo1.xp exits are also highlighted:

Screenshot.

That's great! Our exit finder is working correctly.

Building the Solver

Do you remember the old books of logic problems you used to be able to buy for long trips? "Fred is a lawyer, Mary is a doctor, and Jim is unemployed. Fred can't sit next to unemployed people, because he's snooty. Mary likes everyone. How should you arrange their seating?" This is an example of the type of constrained problem a solver is designed to help with. Building our map is no different - we're reading the constraints matrix (which we built above) to determine which tiles we can place in any given area. Because it's a roguelike, and we want something different every time, we want to inject some randomness - and get a different but valid map every time.

Let's extend our build function to call a hypothetical solver:


# #![allow(unused_variables)]
#fn main() {
let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true);
let constraints = patterns_to_constraints(patterns, CHUNK_SIZE);
self.render_tile_gallery(&constraints, CHUNK_SIZE);
        
self.map = Map::new(self.depth);
loop {
    let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE, &self.map);
    while !solver.iteration(&mut self.map, &mut rng) {
        self.take_snapshot();
    }
    self.take_snapshot();
    if solver.possible { break; } // If it has hit an impossible condition, try again
}
#}

We make a freshly solid map (since we've been using it for rendering tile demos, and don't want to pollute the final map with a demo gallery!). Then we loop (the Rust loop that runs forever until something calls break). Inside that loop, we create a solver for a copy of the constraints matrix (we copy it in case we have to go through repeatedly; otherwise, we'd have to move it in and move it out again). We repeatedly call the solver's iteration function, taking a snapshot each time - until it reports that it is done. If the solver gave up and said it wasn't possible, we try again.

We'll start by adding solver.rs to our waveform_collapse directory. The solver needs to keep its own state: that is, as it iterates through, it needs to know how far it has come. We'll support this by making Solver into a struct:


# #![allow(unused_variables)]
#fn main() {
pub struct Solver {
    constraints: Vec<MapChunk>,
    chunk_size : i32,
    chunks : Vec<Option<usize>>,
    chunks_x : usize,
    chunks_y : usize,
    remaining : Vec<(usize, i32)>, // (index, # neighbors)
    pub possible: bool
}
#}

It stores the constraints we've been building, the chunk_size we're using, the chunks we're resolving (more on that in a second), the number of chunks it can fit onto the target map (chunks_x, and chunks_y), a remaining vector (more on that, too), and a possible indicator to indicate whether or not it gave up.

chunks is a vector of Option<usize>. The usize value is the index of the chunk. It's an option because we may not have filled it in, yet - so it might be None or Some(usize). This nicely represents the "quantum waveform collapse" nature of the problem - it either exists or it doesn't, and we don't know until we look at it!

remaining is a vector of all of the chunks, with their index. It's a tuple - we store the chunk index in the first entry, and the number of existing neighbors in the second. We'll use that to help decide which chunk to fill in next, and remove it from the remaining list when we've added one.

We'll need to implement methods for Solver, too. new is a basic constructor:


# #![allow(unused_variables)]
#fn main() {
impl Solver {
    pub fn new(constraints: Vec<MapChunk>, chunk_size: i32, map : &Map) -> Solver {
        let chunks_x = (map.width / chunk_size) as usize;
        let chunks_y = (map.height / chunk_size) as usize;
        let mut remaining : Vec<(usize, i32)> = Vec::new();
        for i in 0..(chunks_x*chunks_y) {
            remaining.push((i, 0));
        }

        Solver {
            constraints,
            chunk_size,
            chunks: vec![None; chunks_x * chunks_y],
            chunks_x,
            chunks_y,
            remaining,
            possible: true
        }
    }
    ...
#}

It calculates the size (for chunks_x and chunks_y), fills remaining with every tile and no neighbors, and chunks with None values. This sets us up for our solving run! We also need a helper function called chunk_idx:


# #![allow(unused_variables)]
#fn main() {
fn chunk_idx(&self, x:usize, y:usize) -> usize {
    ((y * self.chunks_x) + x) as usize
}
#}

This is a lot like xy_idx in map, or tile_idx_in_chunk in common - but is constrained by the number of chunks we can fit onto our map. We'll also rely on count_neighbors:


# #![allow(unused_variables)]
#fn main() {
fn count_neighbors(&self, chunk_x:usize, chunk_y:usize) -> i32 {
    let mut neighbors = 0;

    if chunk_x > 0 {
        let left_idx = self.chunk_idx(chunk_x-1, chunk_y);
        match self.chunks[left_idx] {
            None => {}
            Some(_) => {
                neighbors += 1;
            }
        }
    }

    if chunk_x < self.chunks_x-1 {
        let right_idx = self.chunk_idx(chunk_x+1, chunk_y);
        match self.chunks[right_idx] {
            None => {}
            Some(_) => {
                neighbors += 1;
            }
        }
    }

    if chunk_y > 0 {
        let up_idx = self.chunk_idx(chunk_x, chunk_y-1);
        match self.chunks[up_idx] {
            None => {}
            Some(_) => {
                neighbors += 1;
            }
        }
    }

    if chunk_y < self.chunks_y-1 {
        let down_idx = self.chunk_idx(chunk_x, chunk_y+1);
        match self.chunks[down_idx] {
            None => {}
            Some(_) => {
                neighbors += 1;
            }
        }
    }
    neighbors
}
#}

This function could be a lot smaller, but I've left it spelling out every step for clarity. It looks at a chunk, and determines if it has a created (not set to None) chunk to the North, South, East and West.

Finally, we get to the iteration function - which does the hard work:


# #![allow(unused_variables)]
#fn main() {
pub fn iteration(&mut self, map: &mut Map, rng : &mut super::RandomNumberGenerator) -> bool {
    if self.remaining.is_empty() { return true; }

    // Populate the neighbor count of the remaining list
    let mut remain_copy = self.remaining.clone();
    let mut neighbors_exist = false;
    for r in remain_copy.iter_mut() {
        let idx = r.0;
        let chunk_x = idx % self.chunks_x;
        let chunk_y = idx / self.chunks_x;
        let neighbor_count = self.count_neighbors(chunk_x, chunk_y);
        if neighbor_count > 0 { neighbors_exist = true; }
        *r = (r.0, neighbor_count);
    }
    remain_copy.sort_by(|a,b| b.1.cmp(&a.1));
    self.remaining = remain_copy;

    // Pick a random chunk we haven't dealt with yet and get its index, remove from remaining list
    let remaining_index = if !neighbors_exist { 
        (rng.roll_dice(1, self.remaining.len() as i32)-1) as usize
    } else {
        0usize
    };
    let chunk_index = self.remaining[remaining_index].0;
    self.remaining.remove(remaining_index);

    let chunk_x = chunk_index % self.chunks_x;
    let chunk_y = chunk_index / self.chunks_x;

    let mut neighbors = 0;
    let mut options : Vec<Vec<usize>> = Vec::new();

    if chunk_x > 0 {
        let left_idx = self.chunk_idx(chunk_x-1, chunk_y);
        match self.chunks[left_idx] {
            None => {}
            Some(nt) => {
                neighbors += 1;
                options.push(self.constraints[nt].compatible_with[3].clone());
            }
        }
    }

    if chunk_x < self.chunks_x-1 {
        let right_idx = self.chunk_idx(chunk_x+1, chunk_y);
        match self.chunks[right_idx] {
            None => {}
            Some(nt) => {
                neighbors += 1;
                options.push(self.constraints[nt].compatible_with[2].clone());
            }
        }
    }

    if chunk_y > 0 {
        let up_idx = self.chunk_idx(chunk_x, chunk_y-1);
        match self.chunks[up_idx] {
            None => {}
            Some(nt) => {
                neighbors += 1;
                options.push(self.constraints[nt].compatible_with[1].clone());
            }
        }
    }

    if chunk_y < self.chunks_y-1 {
        let down_idx = self.chunk_idx(chunk_x, chunk_y+1);
        match self.chunks[down_idx] {
            None => {}
            Some(nt) => {
                neighbors += 1;
                options.push(self.constraints[nt].compatible_with[0].clone());
            }
        }
    }

    if neighbors == 0 {
        // There is nothing nearby, so we can have anything!
        let new_chunk_idx = (rng.roll_dice(1, self.constraints.len() as i32)-1) as usize;
        self.chunks[chunk_index] = Some(new_chunk_idx);
        let left_x = chunk_x as i32 * self.chunk_size as i32;
        let right_x = (chunk_x as i32+1) * self.chunk_size as i32;
        let top_y = chunk_y as i32 * self.chunk_size as i32;
        let bottom_y = (chunk_y as i32+1) * self.chunk_size as i32;


        let mut i : usize = 0;
        for y in top_y .. bottom_y {
            for x in left_x .. right_x {
                let mapidx = map.xy_idx(x, y);
                let tile = self.constraints[new_chunk_idx].pattern[i];
                map.tiles[mapidx] = tile;
                i += 1;
            }
        }
    }
    else {
        // There are neighbors, so we try to be compatible with them
        let mut options_to_check : HashSet<usize> = HashSet::new();
        for o in options.iter() {
            for i in o.iter() {
                options_to_check.insert(*i);
            }
        }

        let mut possible_options : Vec<usize> = Vec::new();
        for new_chunk_idx in options_to_check.iter() {
            let mut possible = true;
            for o in options.iter() {
                if !o.contains(new_chunk_idx) { possible = false; }
            }
            if possible {
                possible_options.push(*new_chunk_idx);
            }
        }

        if possible_options.is_empty() {
            rltk::console::log("Oh no! It's not possible!");
            self.possible = false;
            return true;
        } else {
            let new_chunk_idx = if possible_options.len() == 1 { 0 } 
                else { rng.roll_dice(1, possible_options.len() as i32)-1 };

            self.chunks[chunk_index] = Some(new_chunk_idx as usize);
            let left_x = chunk_x as i32 * self.chunk_size as i32;
            let right_x = (chunk_x as i32+1) * self.chunk_size as i32;
            let top_y = chunk_y as i32 * self.chunk_size as i32;
            let bottom_y = (chunk_y as i32+1) * self.chunk_size as i32;


            let mut i : usize = 0;
            for y in top_y .. bottom_y {
                for x in left_x .. right_x {
                    let mapidx = map.xy_idx(x, y);
                    let tile = self.constraints[new_chunk_idx as usize].pattern[i];
                    map.tiles[mapidx] = tile;
                    i += 1;
                }
            }
        }
    }

    false
}
#}

This is another really big function, but once again that's because I tried to keep it easy to read. Let's walk through the algorithm:

  1. If there is nothing left in remaining, we return that we have completed the map. possible is true, because we actually finished the problem.
  2. We take a clone of remaining to avoid borrow checker issues.
  3. We iterate our copy of remaining, and for each remaining chunk:
    1. We determine it's x and y location from the chunk index.
    2. We call count_neighbors to determine how many (if any) neighboring chunks have been resolved.
    3. If any neighbors were found, we set neighbors_exist to true - telling the algorithm that it has run at least once.
    4. We update the copy of the remaining list to include the same index as before, and the new neighbor count.
  4. We sort our copy of remaining by the number of neighbors, descending - so the chunk with the most neighbors is first.
  5. We copy our clone of remaining back to our actual remaining list.
  6. We want to create a new variable, remaining_index - to indicate which chunk we're going to work on, and where it is in the remaining vector. If we haven't made any tiles yet, we pick our starting point at random. Otherwise, we pick the first entry in the remaining list - which will be the one with the most neighbors.
  7. We obtain chunk_idx from the remaining list at the selected index, and remove that chunk from the list.
  8. Now we calculate chunk_x and chunk_y to tell us where it is on the new map.
  9. We set a mutable variable, neighbors to 0; we'll be counting neighbors again.
  10. We create a mutable variable called Options. It has the rather strange type Vec<Vec<usize>> - it is a vector of vectors, each of which contains an array index (usize). We'll be storing compatible options for each direction in here - so we need the outer vector for directions, and the inner vector for options. These index the constraints vector.
  11. If it isn't the left-most chunk on the map, it may have a chunk to the west - so we calculate the index of that chunk. If a chunk to the west exists (isn't None), then we add it's east bound compatible_with list to our Options vector. We increment neighbors to indicate that we found a neighbor.
  12. We repeat for the east - if it isn't the right-most chunk on the map. We increment neighbors to indicate that we found a neighbor.
  13. We repeat for the south - if it isn't the bottom chunk on the map. We increment neighbors to indicate that we found a neighbor.
  14. We repeat for the north - if it isn't the top chunk on the map. We increment neighbors to indicate that we found a neighbor.
  15. If there are no neighbors, we:
    1. Find a random tile from constraints.
    2. Figure out the bounds of where we are placing the tile in left_x, right_x, top_y, and bottom_y.
    3. Copy the selected tile to the map.
  16. If there are neighbors, we:
    1. Insert all of the options from each direction into a HashSet. We used HashSet to de-duplicate our tiles earlier, and this is what we're doing here: we're removing all duplicate options, so we don't evaluate them repeatedly.
    2. We make a new vector called possible_options. For each option in the HashSet:
      1. Set a mutable variable called possible to true.
      2. Check each directions' options, and if it is compatible with its neighbors preferences - add it to possible_options.
    3. If possible_options is empty - then we've hit a brick wall, and can't add any more tiles. We set possible to false in the parent structure and bail out!
    4. Otherwise, we pick a random entry from possible_options and draw it to the map.

So while it's a long function, it isn't a really complicated one. It looks for possible combinations for each iteration, and tries to apply them - giving up and returning failure if it can't find one.

The caller is already taking snapshots of each iteration, so if we cargo run the project with our wfc-test1.xp file we get something like this:

Screenshot.

Not the greatest map, but you can watch the solver chug along - placing tiles one at a time. Now lets try it with wfc-test2.xmp, a set of tiles designed for tiling:

Screenshot.

This is kind-of fun - it lays it out like a jigsaw, and eventually gets a map! The map isn't as well connected as one might hope, the edges with no exit lead to a smaller play area (which is culled at the end). It's still a good start!

Reducing the chunk size

We can significantly improve the resulting map in this case by reducing our CHUNK_SIZE constant to 3. Running it with test map 1 produces something like this:

Screenshot.

That's a much more interesting map! You can try it with wfc-test2.xp as well:

Screenshot.

Once again, it's an interesting and playable map! The problem is that we've got such a small chunk size that there really aren't all that many interesting options for adjacency - 3x3 grids really limits the amount of variability you can have on your map! So we'll try wfc-test1.xp with a chunk size of 5:

Screenshot.

That's more like it! It's not dissimilar from a map we might try and generate in another fashion.

Taking advantage of the ability to read other map types

Rather than loading one of our .xp files, lets feed in the results of a CellularAutomata run, and use that as the seed with a large (8) chunk. This is surprisingly easy with the structure we have! In our build function:


# #![allow(unused_variables)]
#fn main() {
const CHUNK_SIZE :i32 = 8;

let mut ca = super::CellularAutomataBuilder::new(0);
ca.build_map();
self.map = ca.get_map();
for t in self.map.tiles.iter_mut() {
    if *t == TileType::DownStairs { *t = TileType::Floor; }
}
#}

Notice that we're removing down stairs - the Cellular Automata generator will place one, and we don't want stairs everywhere! This gives a very pleasing result:

Screenshot.

Improving adjacency - and increasing the risk of rejection!

What we have already is quite a workable solution - you can make decent maps with it, especially when you use other generators as the seed. On winding jigsaw maps, it's not generating the adjacency we'd like. There's a small risk by making the matcher more specific that we will see some failures, but lets give it a go anyway. In our code that builds a compatibility matrix, find the comment There's no exits on this side and replace the section with this code:


# #![allow(unused_variables)]
#fn main() {
if !has_any {
    // There's no exits on this side, let's match only if 
    // the other edge also has no exits
    let matching_exit_count = potential.exits[opposite].iter().filter(|a| !**a).count();
    if matching_exit_count == 0 {
        c.compatible_with[direction].push(j);
    }
}
#}

Run against the our cellular automata example, we see a bit of a change:

Screenshot.

It also looks pretty good with our map test 1:

Screenshot.

Overall, that change is a winner! It doesn't look very good with our jigsaw puzzle anymore; there just aren't enough tiles to make good patterns.

Offering different build options to the game

We're going to offer three modes to our random_builder function: TestMap (just the REX Paint map), and Derived (run on an existing algorithm). So, in mod.rs we add an enumeration and extend our structure to hold some related data:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum WaveformMode { TestMap, Derived }

pub struct WaveformCollapseBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    mode : WaveformMode,
    derive_from : Option<Box<dyn MapBuilder>>
}
#}

We'll extend our new constructor to include these:


# #![allow(unused_variables)]
#fn main() {
impl WaveformCollapseBuilder {
    pub fn new(new_depth : i32, mode : WaveformMode, derive_from : Option<Box<dyn MapBuilder>>) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new(),
            mode,
            derive_from
        }
    }  
#}

Then we'll add some functionality into the top of our build function:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    if self.mode == WaveformMode::TestMap {
        self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo1.xp").unwrap());
        self.take_snapshot();
        return;
    }

    let mut rng = RandomNumberGenerator::new();

    const CHUNK_SIZE :i32 = 8;

    let prebuilder = &mut self.derive_from.as_mut().unwrap();
    prebuilder.build_map();
    self.map = prebuilder.get_map();
    for t in self.map.tiles.iter_mut() {
        if *t == TileType::DownStairs { *t = TileType::Floor; }
    }
    self.take_snapshot();
    ...
#}

Now we'll add a couple of constructors to make it easier for random_builder to not have to know about the innards of the WFC algorithm:


# #![allow(unused_variables)]
#fn main() {
pub fn test_map(new_depth: i32) -> WaveformCollapseBuilder {
    WaveformCollapseBuilder::new(new_depth, WaveformMode::TestMap, None)
}

pub fn derived_map(new_depth: i32, builder: Box<dyn MapBuilder>) -> WaveformCollapseBuilder {
    WaveformCollapseBuilder::new(new_depth, WaveformMode::Derived, Some(builder))
}
#}

Lastly, we'll modify our random_builder (in map_builders/mod.rs) to sometimes return the test map - and sometimes run WFC on whatever map we've created:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 17);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        16 => { result = Box::new(WaveformCollapseBuilder::test_map(new_depth)); }
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    result
}
#}

That's quite a change. We roll a 17-sided dice (wouldn't it be nice if those really existed?), and pick a builder - as before, but with the option to use the .xp file from wfc_test1.xp. We store it in result. Then we roll 1d3; if it comes up 1, we wrap the builder in the WaveformCollapseBuilder in derived mode - so it will take the original map and rebuild it with WFC. Effectively, we just added another 17 options!

Cleaning Up Dead Code Warnings

Let's take a moment to do a little housekeeping on our code.

There are quite a few warnings in the project when you compile. They are almost all "this function is never used" (or equivalent). Since we're building a library of map builders, it's ok to not always call the constructors. You can add an annotation above a function definition - #[allow(dead_code)] to tell the compiler to stop worrying about this. For example, in drunkard.rs:


# #![allow(unused_variables)]
#fn main() {
impl DrunkardsWalkBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32, settings: DrunkardSettings) -> DrunkardsWalkBuilder {
#}

I've gone through and applied these where necessary in the example code to silence the compiler.

Cleaning Up Unused Embedded Files

We're not using wfc-test2.xp anymore, so lets remove it from rex-assets.rs:


# #![allow(unused_variables)]
#fn main() {
use rltk::{rex::XpFile};

rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");

pub struct RexAssets {
    pub menu : XpFile
}

impl RexAssets {
    #[allow(clippy::new_without_default)]
    pub fn new() -> RexAssets {
        rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
        rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");

        RexAssets{
            menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()
        }
    }
}
#}

This saves a little bit of space in the resulting binary (never a bad thing: smaller binaries fit into your CPU's cache better, and generally run faster).

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Prefabricated Levels and Level Sections


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Despite being essentially pseudorandom (that is, random - but constrained in a way that makes for a fun, cohesive game), many roguelikes feature some hand-crafted content. Typically, these can be divided into a few categories:

  • Hand-crafted levels - the whole level is premade, the content static. These are typically used very sparingly, for big set-piece battles essential to the story.
  • Hand-crafted level sections - some of the level is randomly created, but a large part is pre-made. For example, a fortress might be a "set piece", but the dungeon leading up to it is random. Dungeon Crawl Stone Soup uses these extensively - you sometimes run into areas that you recognize because they are prefabricated - but the dungeon around them is clearly random. Cogmind uses these for parts of the caves (I'll avoid spoilers). Caves of Qud has a few set-piece levels that appear to be built around a number of prefabricated parts. Some systems call this mechanism "vaults" - but the name can also apply to the third category.
  • Hand-crafted rooms (also called Vaults in some cases). The level is largely random, but when sometimes a room fits a vault - so you put one there.

The first category is special and should be used sparingly (otherwise, your players will just learn an optimal strategy and power on through it - and may become bored from lack of variety). The other categories benefit from either providing lots of vaults (so there's a ton of content to sprinkle around, meaning the game doesn't feel too similar each time you play) or being rare - so you only occasionally see them (for the same reason).

Some Clean Up

In the Wave Function Collapse chapter, we loaded a pre-made level - without any entities (those are added later). It's not really very nice to hide a map loader inside WFC - since that isn't it's primary purpose - so we'll start by removing it:

We'll start by deleting the file map_builders/waveform_collapse/image_loader.rs. We'll be building a better one in a moment.

Now we edit the start of mod.rs in ``map_builders/waveform_collapse`:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    generate_voronoi_spawn_regions, remove_unreachable_areas_returning_most_distant};
use rltk::RandomNumberGenerator;
use specs::prelude::*;
use std::collections::HashMap;
mod common;
use common::*;
mod constraints;
use constraints::*;
mod solver;
use solver::*;

/// Provides a map builder using the Wave Function Collapse algorithm.
pub struct WaveformCollapseBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    derive_from : Option<Box<dyn MapBuilder>>
}
...

impl WaveformCollapseBuilder {
    /// Generic constructor for waveform collapse.
    /// # Arguments
    /// * new_depth - the new map depth
    /// * derive_from - either None, or a boxed MapBuilder, as output by `random_builder`
    pub fn new(new_depth : i32, derive_from : Option<Box<dyn MapBuilder>>) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new(),
            derive_from
        }
    }
    
    /// Derives a map from a pre-existing map builder.
    /// # Arguments
    /// * new_depth - the new map depth
    /// * derive_from - either None, or a boxed MapBuilder, as output by `random_builder`
    pub fn derived_map(new_depth: i32, builder: Box<dyn MapBuilder>) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder::new(new_depth, Some(builder))
    }
    ...
#}

We've removed all references to image_loader, removed the test map constructor, and removed the ugly mode enumeration. WFC is now exactly what it says on the tin, and nothing else. Lastly, we'll modify random_builder to not use the test map anymore:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    result
}
#}

Skeletal Builder

We'll start with a very basic skeleton, similar to those used before. We'll make a new file, prefab_builder.rs in map_builders:


# #![allow(unused_variables)]
#fn main() {
use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    remove_unreachable_areas_returning_most_distant};
use rltk::RandomNumberGenerator;
use specs::prelude::*;

pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
}

impl MapBuilder for PrefabBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        self.build();
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
    }

    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}

impl PrefabBuilder {
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new()
        }
    }

    fn build(&mut self) {
    }
}
#}

Prefab builder mode 1 - hand-crafted levels

We're going to support multiple modes for the prefab-builder, so lets bake that in at the beginning. In prefab_builder.rs:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Clone)]
#[allow(dead_code)]
pub enum PrefabMode { 
    RexLevel{ template : &'static str }
}

pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode
}
#}

This is new - an enum with variables? This works because under the hood, Rust enumerations are actually unions. They can hold whatever you want to put in there, and the type is sized to hold the largest of the options. It's best used sparingly in tight code, but for things like configuration it is a very clean way to pass in data. We should also update the constructor to create the new types:


# #![allow(unused_variables)]
#fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::RexLevel{ template : "../../resources/wfc-demo1.xp" }
        }
    }
    ...
#}

Including the map template path in the mode makes for easier reading, even if it is slightly more complicated. We're not filling the PrefabBuilder with variables for all of the options we might use - we're keeping them separated. That's generally good practice - it makes it much more obvious to someone who reads your code what's going on.

Now we'll re-implement the map reader we previously deleted from image_loader.rs - only we'll add it as a member function for PrefabBuilder, and use the enclosing class features rather than passing Map and new_depth in and out:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    match (cell.ch as u8) as char {
                        ' ' => self.map.tiles[idx] = TileType::Floor, // space
                        '#' => self.map.tiles[idx] = TileType::Wall, // #
                        _ => {}
                    }
                }
            }
        }
    }
}
#}

That's pretty straightforward, more or less a direct port of the one form the Wave Function Collapse chapter. Now lets start making our build function:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template)
    }

    // Find a starting point; start at the middle and walk left until we find an open tile
    self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
    let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
    while self.map.tiles[start_idx] != TileType::Floor {
        self.starting_position.x -= 1;
        start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
    }
    self.take_snapshot();
}
#}

Notice that we've copied over the find starting point code; we'll improve that at some point, but for now it ensures you can play your level. We haven't spawned anything - so you will be alone in the level. There's also a slightly different usage of match here - we're using the variable in the enum. The code PrefabMode::RexLevel{template} says "match RexLevel, but with any value of template - and make that value available via the name template in the match scope". You could use _ to match any value if you didn't want to access it. Rust's pattern matching system is really impressive - you can do a lot with it!

Lets modify our random_builder function to always call this type of map (so we don't have to test over and over in the hopes of getting the one we want!). In map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    result*/

    Box::new(PrefabBuilder::new(new_depth))
}
#}

If you cargo run your project now, you can run around the (otherwise deserted) demo map:

Screenshot.

Populating the test map with prefabbed entities

Let's pretend that our test map is some sort of super-duper end-game map. We'll take a copy and call it wfc-populated.xp. Then we'll splat a bunch of monster and item glyphs around it:

Screenshot.

The color coding is completely optional, but I put it in for clarity. You'll see we have an @ to indicate the player start, a > to indicate the exit, and a bunch of g goblins, o orcs, ! potions, % rations and ^ traps. Not too bad a map, really.

We'll add wfc-populated.xp to our resources folder, and extend rex_assets.rs to load it:


# #![allow(unused_variables)]
#fn main() {
use rltk::{rex::XpFile};

rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
rltk::embedded_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp");

pub struct RexAssets {
    pub menu : XpFile
}

impl RexAssets {
    #[allow(clippy::new_without_default)]
    pub fn new() -> RexAssets {
        rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
        rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
        rltk::link_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp");

        RexAssets{
            menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()
        }
    }
}
#}

We also want to be able to list out spawns that are required by the map. Looking in spawner.rs, we have an established tuple format for how we pass spawns - so we'll use it in the struct:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode,
    spawns: Vec<(usize, String)>
}
#}

Now we'll modify our constructor to use the new map, and initialize spawns:


# #![allow(unused_variables)]
#fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::RexLevel{ template : "../../resources/wfc-populated.xp" },
            spawns: Vec::new()
        }
    }
    ...
#}

To make use of the function in spawner.rs that accepts this type of data, we need to make it public. So we open up the file, and add the word pub to the function signature:


# #![allow(unused_variables)]
#fn main() {
/// Spawns a named entity (name in tuple.1) at the location in (tuple.0)
pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) {
    ...
#}

We'll then modify our PrefabBuilder's spawn_entities function to make use of this data:


# #![allow(unused_variables)]
#fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for entity in self.spawns.iter() {
        spawner::spawn_entity(ecs, &(&entity.0, &entity.1));
    }
}
#}

We do a bit of a dance with references just to work with the previous function signature (and not have to change it, which would change lots of other code). So far, so good - it reads the spawn list, and requests that everything in the list be placed onto the map. Now would be a good time to add something to the list! We'll want to modify our load_rex_map to handle the new data:


# #![allow(unused_variables)]
#fn main() {
 #[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    // We're doing some nasty casting to make it easier to type things like '#' in the match
                    match (cell.ch as u8) as char {
                        ' ' => self.map.tiles[idx] = TileType::Floor,
                        '#' => self.map.tiles[idx] = TileType::Wall,
                        '@' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.starting_position = Position{ x:x as i32, y:y as i32 };
                        }
                        '>' => self.map.tiles[idx] = TileType::DownStairs,
                        'g' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Goblin".to_string()));
                        }
                        'o' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Orc".to_string()));
                        }
                        '^' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Bear Trap".to_string()));
                        }
                        '%' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Rations".to_string()));
                        }
                        '!' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Health Potion".to_string()));
                        }
                        _ => {
                            rltk::console::log(format!("Unknown glyph loading map: {}", (cell.ch as u8) as char));
                        }
                    }
                }
            }
        }
    }
}
#}

This recognizes the extra glyphs, and prints a warning to the console if we've loaded one we forgot to handle. Note that for entities, we're setting the tile to Floor and then adding the entity type. That's because we can't overlay two glyphs on the same tile - but it stands to reason that the entity is standing on a floor.

Lastly, we need to modify our build function to not move the exit and the player. We simply wrap the fallback code in an if statement to detect if we've set a starting_position (we're going to require that if you set a start, you also set an exit):


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template)
    }
    self.take_snapshot();

    // Find a starting point; start at the middle and walk left until we find an open tile
    if self.starting_position.x == 0 {
        self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
        let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        while self.map.tiles[start_idx] != TileType::Floor {
            self.starting_position.x -= 1;
            start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        }
        self.take_snapshot();

        // Find all tiles we can reach from the starting point
        let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
        self.take_snapshot();

        // Place the stairs
        self.map.tiles[exit_tile] = TileType::DownStairs;
        self.take_snapshot();
    }
}
#}

If you cargo run the project now, you start in the specified location - and entities spawn around you.

Screenshot.

Rex-free prefabs

It's possible that you don't like Rex Paint (don't worry, I won't tell Kyzrati!), maybe you are on a platform that doesn't support it - or maybe you'd just like to not have to rely on an external tool. We'll extend our reader to also support string output for maps. This will be handy later when we get to small room prefabs/vaults.

I cheated a bit, and opened the wfc-populated.xp file in Rex and typed ctrl-t to save in TXT format. That gave me a nice Notepad friendly map file:

Screenshot.

I also realized that prefab_builder was going to outgrow a single file! Fortunately, Rust makes it pretty easy to turn a module into a multi-file monster. In map_builders, I made a new directory called prefab_builder. I then moved prefab_builder.rs into it, and renamed it mod.rs. The game compiles and runs exactly as before.

Make a new file in your prefab_builder folder, and name it prefab_levels.rs. We'll paste in the map definition, and decorate it a bit:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabLevel {
    pub template : &'static str,
    pub width : usize,
    pub height: usize
}

pub const WFC_POPULATED : PrefabLevel = PrefabLevel{
    template : LEVEL_MAP,
    width: 80,
    height: 43
};

const LEVEL_MAP : &str = 
"
################################################################################
#          ########################################################    #########
#    @     ######    #########       ####     ###################        #######
#          ####   g  #                          ###############            #####
#          #### #    # #######       ####       #############                ###
##### ######### #    # #######       #########  ####    #####                ###
##### ######### ###### #######   o   #########  #### ## #####                ###
##                        ####       #########   ### ##         o            ###
##### ######### ###       ####       #######         ## #####                ###
##### ######### ###       ####       ####### #   ### ## #####                ###
##### ######### ###       ####       ####### #######    #####     o          ###
###          ## ###       ####       ####### ################                ###
###          ## ###   o   ###### ########### #   ############                ###
###          ## ###       ###### ###########     ###                         ###
###    %                  ###### ########### #   ###   !   ##                ###
###          ## ###              ######   ## #######       ##                ###
###          ## ###       ## ### #####     # ########################      #####
###          ## ###       ## ### #####     # #   ######################    #####
#### ## ####### ###### ##### ### ####          o ###########     ######    #####
#### ## ####### ###### ####   ## ####        #   #########         ###### ######
#    ## ####### ###### ####   ## ####        ############           ##### ######
# g  ## ####### ###### ####   ##        %    ###########   o      o  #### #    #
#    ## ###            ####   ## ####        #   #######   ##    ##  ####   g  #
#######                  ####### ####            ######     !    !    ### #    #
######                     ##### ####        #   ######               ### ######
#####                            #####     # ##########               ### ######
#####           !           ### ######     # ##########      o##o     ### #   ##
#####                       ### #######   ## #   ######               ###   g ##
#   ##                     #### ######## ###   o #######  ^########^ #### #   ##
# g    #                 ###### ######## #####   #######  ^        ^ #### ######
#   ##g####           ######    ######## ################           ##### ######
#   ## ########## ##########    ######## #################         ######      #
#####   ######### ########## %  ######## ###################     ######## ##   #
#### ### ######## ##########    ######## #################### ##########   #   #
### ##### ######   #########    ########          ########### #######   # g#   #
### #####           ###############      ###      ########### #######   ####   #
### ##### ####       ############## ######## g  g ########### ####         # ^ #
#### ###^####         ############# ########      #####       ####      # g#   #
#####   ######       ###            ########      ##### g     ####   !  ####^^ #
#!%^## ###  ##           ########## ########  gg                 g         # > #
#!%^   ###  ###     ############### ########      ##### g     ####      # g#   #
# %^##  ^   ###     ############### ########      #####       ##################
################################################################################
";
#}

So we start by defining a new struct type: PrefabLevel. This holds a map template, a width and a height. Then we make a constant, WFC_POPULATED and create an always-available level definition in it. Lastly, we paste our Notepad file into a new constant, currently called MY_LEVEL. This is a big string, and will be stored like any other string.

Lets modify the mode to also allow this type:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode { 
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel }
}
#}

We'll modify our build function to also handle this match pattern:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template),
        PrefabMode::Constant{level} => self.load_ascii_map(&level)
    }
    self.take_snapshot();
    ...
#}

And modify our constructor to use it:


# #![allow(unused_variables)]
#fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::Constant{level : prefab_levels::WFC_POPULATED},
            spawns: Vec::new()
        }
    }
#}

Now we need to create a loader that can handle it. We'll modify our load_rex_map to share some code with it, so we aren't typing everything repeatedly - and make our new load_ascii_map function:


# #![allow(unused_variables)]
#fn main() {
fn char_to_map(&mut self, ch : char, idx: usize) {
    match ch {
        ' ' => self.map.tiles[idx] = TileType::Floor,
        '#' => self.map.tiles[idx] = TileType::Wall,
        '@' => {
            let x = idx as i32 % self.map.width;
            let y = idx as i32 / self.map.width;
            self.map.tiles[idx] = TileType::Floor;
            self.starting_position = Position{ x:x as i32, y:y as i32 };
        }
        '>' => self.map.tiles[idx] = TileType::DownStairs,
        'g' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Goblin".to_string()));
        }
        'o' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Orc".to_string()));
        }
        '^' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Bear Trap".to_string()));
        }
        '%' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Rations".to_string()));
        }
        '!' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Health Potion".to_string()));
        }
        _ => {
            rltk::console::log(format!("Unknown glyph loading map: {}", (ch as u8) as char));
        }
    }
}

#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    // We're doing some nasty casting to make it easier to type things like '#' in the match
                    self.char_to_map(cell.ch as u8 as char, idx);
                }
            }
        }
    }
}

#[allow(dead_code)]
fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel) {
    // Start by converting to a vector, with newlines removed
    let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect();
    for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } }

    let mut i = 0;
    for ty in 0..level.height {
        for tx in 0..level.width {
            if tx < self.map.width as usize && ty < self.map.height as usize {
                let idx = self.map.xy_idx(tx as i32, ty as i32);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
}
#}

The first thing to notice is that the giant match in load_rex_map is now a function - char_to_map. Since we're using the functionality more than once, this is good practice: now we only have to fix it once if we messed it up! Otherwise, load_rex_map is pretty much the same. Our new function is load_ascii_map. It starts with some ugly code that bears explanation:

  1. let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect(); is a common Rust pattern, but isn't really self-explanatory at all. It chains methods together, in left-to-right order. So it's really a big collection of instructions glued together:
    1. let mut string_vec : Vec<char> is just saying "make a variable named string_vec, or the type Vec<char> and let me edit it.
    2. level.template is the string in which our level template lives.
    3. .chars() turns the string into an iterator - the same as when we've previously typed myvector.iter().
    4. .filter(|a| *a != '\r' && *a !='\n') is interesting. Filters take a lambda function in, and keep any entries that return true. So in this case, we're stripping out \r and \n - the two newline characters. We'll keep everything else.
    5. .collect() says "take the results of everything before me, and put them into a vector."
  2. We then mutably iterate the string vector, and turn the character 160 into spaces. I honestly have no idea why the text is reading spaces as character 160 and not 32, but we'll roll with it and just convert it.
  3. We then iterate y from 0 to the specified height.
    1. We then iterate x from 0 to the specified width.
      1. If the x and y values are within the map we're creating, we calculate the idx for the map tile - and call our char_to_map function to translate it.

If you cargo run now, you'll see exactly the same as before - but instead of loading the Rex Paint file, we've loaded it from the constant ASCII in prefab_levels.rs.

Building a level section

Your brave adventurer emerges from the twisting tunnels, and comes across the walls of an ancient underground fortification! That's the stuff of great D&D stories, and also an occasional occurrence in games such as Dungeon Crawl: Stone Soup. It's quite likely that what actually happened is your brave adventurer emerges from a procedurally generated map and finds a level section prefab!

We'll extend our mapping system to explicitly support this: a regular builder makes a map, and then a sectional prefab replaces part of the map with your exciting premade content. We'll start by making a new file (in map_builders/prefab_builder) called prefab_sections.rs, and place a description of what we want:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum HorizontalPlacement { Left, Center, Right }

#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum VerticalPlacement { Top, Center, Bottom }

#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabSection {
    pub template : &'static str,
    pub width : usize,
    pub height: usize,
    pub placement : (HorizontalPlacement, VerticalPlacement)
}

#[allow(dead_code)]
pub const UNDERGROUND_FORT : PrefabSection = PrefabSection{
    template : RIGHT_FORT,
    width: 15,
    height: 43,
    placement: ( HorizontalPlacement::Right, VerticalPlacement::Top )
};

#[allow(dead_code)]
const RIGHT_FORT : &str = "
     #         
  #######      
  #     #      
  #     #######
  #  g        #
  #     #######
  #     #      
  ### ###      
    # #        
    # #        
    # ##       
    ^          
    ^          
    # ##       
    # #        
    # #        
    # #        
    # #        
  ### ###      
  #     #      
  #     #      
  #  g  #      
  #     #      
  #     #      
  ### ###      
    # #        
    # #        
    # #        
    # ##       
    ^          
    ^          
    # ##       
    # #        
    # #        
    # #        
  ### ###      
  #     #      
  #     #######
  #  g        #
  #     #######
  #     #      
  #######      
     #         
";
#}

So we have RIGHT_FORT as a string, describing a fortification we might encounter. We've built a structure, PrefabSection which includes placement hints, and a constant for our actual fort (UNDERGROUND_FORT) specifying that we'd like to be at the right of the map, at the top (the vertical doesn't really matter in this example, because it is the full size of the map).

Level sections are different from builders we've made before, because they take a completed map - and replace part of it. We've done something similar with Wave Function Collapse, so we'll adopt a similar pattern. We'll start by modifying our PrefabBuilder to know about the new type of map decoration:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode { 
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel },
    Sectional{ section : prefab_sections::PrefabSection }
}

#[allow(dead_code)]
pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode,
    spawns: Vec<(usize, String)>,
    previous_builder : Option<Box<dyn MapBuilder>>
}
#}

As much as I'd love to put the previous_builder into the enum, I kept running into lifetime problems. Perhaps there's a way to do it (and some kind reader will help me out?), but for now I've put it into PrefabBuilder. The requested map section is in the parameter, however. We also update our constructor to use this type of map:


# #![allow(unused_variables)]
#fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32, previous_builder : Option<Box<dyn MapBuilder>>) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::Sectional{ section: prefab_sections::UNDERGROUND_FORT },
            spawns: Vec::new(),
            previous_builder
        }
    }
    ...
#}

Over in map_builders/mod.rs's random_builder, we'll modify the builder to first run a Cellular Automata map, and then apply the sectional:


# #![allow(unused_variables)]
#fn main() {
Box::new(
    PrefabBuilder::new(
        new_depth, 
        Some(
            Box::new(
                CellularAutomataBuilder::new(new_depth)
            )
        )
    )
)
#}

This could be one line, but I've separated it out due to the sheer number of parentheses.

Next, we update our match statement (in build()) to actually call the builder:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template),
        PrefabMode::Constant{level} => self.load_ascii_map(&level),
        PrefabMode::Sectional{section} => self.apply_sectional(&section)
    }
    self.take_snapshot();
    ...
#}

Now, we'll write apply_sectional:


# #![allow(unused_variables)]
#fn main() {
pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) {
    // Build the map
    let prev_builder = self.previous_builder.as_mut().unwrap();
    prev_builder.build_map();
    self.starting_position = prev_builder.get_starting_position();
    self.map = prev_builder.get_map().clone();
    self.take_snapshot();

    use prefab_sections::*;

    let string_vec = PrefabBuilder::read_ascii_to_vec(section.template);
    
    // Place the new section
    let chunk_x;
    match section.placement.0 {
        HorizontalPlacement::Left => chunk_x = 0,
        HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2),
        HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32
    }

    let chunk_y;
    match section.placement.1 {
        VerticalPlacement::Top => chunk_y = 0,
        VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2),
        VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32
    }
    println!("{},{}", chunk_x, chunk_y);

    let mut i = 0;
    for ty in 0..section.height {
        for tx in 0..section.width {
            if tx < self.map.width as usize && ty < self.map.height as usize {
                let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
    self.take_snapshot();
}
#}

This a lot like other code we've written, but lets step through it anyway:

  1. let prev_builder = self.previous_builder.as_mut().unwrap(); is quite the mouthful. The previous builder is an Option - but if we're calling this code, it has to have a value. So we want to unwrap it (which will panic and crash if there is no value), but we can't! The borrow checker will complain if we just call previous_builder.unwrap - so we have to inject an as_mut() in there, which Option provides for just this purpose.
  2. We call build_map on the previous builder, to construct the base map.
  3. We copy the starting position from the previous builder to our new builder.
  4. We copy the map from the previous builder to our self (the new builder).
  5. We call read_ascii_to_vec, which is the same as the string-to-vector code from the level example; we've actually updated the level example to use it also, in the source code.
  6. We create two variables, chunk_x and chunk_y and query the section's placement preference to determine where to put the new chunk.
  7. We iterate the section just like when we were iterating a level earlier - but adding chunk_x to tx and chunk_y to ty to offset the section inside the level.

If you cargo run the example now, you'll see a map built with a cave - and a fortification to the right.

Screenshot.

You may also notice that there aren't any entities at all, outside of the prefab area!

Adding entities to sectionals

Spawning and determining spawn points have been logically separated, to help keep the map generation code clean. Different maps can have their own strategies for placing entities, so there isn't a straightforward method to simply suck in the data from the previous algorithms and add to it. There should be, and it should enable filtering and all manner of tweaking with later "meta-map builders" (such as WFC or this one). We've stumbled upon a clue for a good interface in the code that places entities in prefabs: the spawn system already supports tuples of (position, type string). We'll use that as the basis for the new setup.

We'll start by opening up map_builders/mod.rs and editing the MapBuilder trait:


# #![allow(unused_variables)]
#fn main() {
pub trait MapBuilder {
    fn build_map(&mut self);
    fn get_map(&self) -> Map;
    fn get_starting_position(&self) -> Position;
    fn get_snapshot_history(&self) -> Vec<Map>;
    fn take_snapshot(&mut self);
    fn get_spawn_list(&self) -> &Vec<(usize, String)>;

    fn spawn_entities(&mut self, ecs : &mut World) {
        for entity in self.get_spawn_list().iter() {
            spawner::spawn_entity(ecs, &(&entity.0, &entity.1));
        }
    }
}
#}

Congratulations, half your source code just turned red in your IDE. That's the danger of changing a base interface - you wind up implementing it everywhere. Also, the setup of spawn_entities has changed - there is now a default implementation. Implementers of the trait can override it if they want to - but otherwise they don't actually need to write it anymore. Since everything should be available via the get_spawn_list function, the trait has everything it needs to provide that implementation.

We'll go back to simple_map and update it to obey the new trait rules. We'll extend the SimpleMapBuiler structure to feature a spawn list:


# #![allow(unused_variables)]
#fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>,
    history: Vec<Map>,
    spawn_list: Vec<(usize, String)>
}
#}

The get_spawn_list implementation is trivial:


# #![allow(unused_variables)]
#fn main() {
fn get_spawn_list(&self) -> &Vec<(usize, String)> {
    &self.spawn_list
}
#}

Now for the fun part. Previously, we didn't consider spawning until the call to spawn_entities. Lets remind ourselves what it does (it's been a while!):


# #![allow(unused_variables)]
#fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for room in self.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, self.depth);
    }
}
#}

It iterates all the rooms, and spawns entities inside the rooms. We're using that pattern a lot, so it's time to visit spawn_room in spawner.rs. We'll modify it to spawn into a spawn_list rather than directly onto the map. So we open up spawner.rs, and modify spawn_room and spawn_region (since they are intertwined, we'll fix them together):


# #![allow(unused_variables)]
#fn main() {
/// Fills a room with stuff!
pub fn spawn_room(map: &Map, rng: &mut RandomNumberGenerator, room : &Rect, map_depth: i32, spawn_list : &mut Vec<(usize, String)>) {
    let mut possible_targets : Vec<usize> = Vec::new();
    { // Borrow scope - to keep access to the map separated
        for y in room.y1 + 1 .. room.y2 {
            for x in room.x1 + 1 .. room.x2 {
                let idx = map.xy_idx(x, y);
                if map.tiles[idx] == TileType::Floor {
                    possible_targets.push(idx);
                }
            }
        }
    }

    spawn_region(map, rng, &possible_targets, map_depth, spawn_list);
}

/// Fills a region with stuff!
pub fn spawn_region(map: &Map, rng: &mut RandomNumberGenerator, area : &[usize], map_depth: i32, spawn_list : &mut Vec<(usize, String)>) {
    let spawn_table = room_table(map_depth);
    let mut spawn_points : HashMap<usize, String> = HashMap::new();
    let mut areas : Vec<usize> = Vec::from(area);

    // Scope to keep the borrow checker happy
    {
        let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3);
        if num_spawns == 0 { return; }

        for _i in 0 .. num_spawns {
            let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize };

            let map_idx = areas[array_index];
            spawn_points.insert(map_idx, spawn_table.roll(rng));
            areas.remove(array_index);
        }
    }

    // Actually spawn the monsters
    for spawn in spawn_points.iter() {
        spawn_list.push((*spawn.0, spawn.1.to_string()));
    }
}
#}

You'll notice that the biggest change is taking a mutable reference to the spawn_list in each function, and instead of actually spawning the entity - we defer the operation by pushing the spawn information into the spawn_list vector at the end. Instead of passing in the ECS, we're passing in the Map and RandomNumberGenerator.

Going back to simple_map.rs, we move the spawning code into the end of build:


# #![allow(unused_variables)]
#fn main() {
...
self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };

// Spawn some entities
for room in self.rooms.iter().skip(1) {
    spawner::spawn_room(&self.map, &mut rng, room, self.depth, &mut self.spawn_list);
}
#}

We can now delete SimpleMapBuilder's implementation of spawn_entities - the default will work fine.

The same changes can be made to all of the builders that rely on room spawning; for brevity, I won't spell them all out here - you can find them in the source code. The various builders that use Voronoi diagrams are similarly simple to update. For example, Cellular Automata. Add the spawn_list to the builder structure, and add a spawn_list : Vec::new() into the constructor. Move the monster spawning from spawn_entities into the end of build and delete the function. Copy the get_spawn_list from the other implementations. We changed the region spawning code a little, so here's the implementation from cellular_automata.rs:


# #![allow(unused_variables)]
#fn main() {
// Now we build a noise map for use in spawning entities later
self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);

// Spawn the entities
for area in self.noise_areas.iter() {
    spawner::spawn_region(&self.map, &mut rng, area.1, self.depth, &mut self.spawn_list);
}
#}

Once again, it's rinse and repeat on the other Voronoi spawn algorithms. I've done the work in the source code for you, if you'd like to take a peek.

Jump to here if refactoring is boring!

SO - now that we've refactored our spawn system, how do we use it inside our PrefabBuilder? We can add one line to our apply_sectional function and get all of the entities from the previous map. You could simply copy it, but that's probably not what you want; you need to filter out entities inside the new prefab, both to make room for new ones and to ensure that the spawning makes sense. We'll also need to rearrange a little to keep the borrow checker happy. Here's the function now:


# #![allow(unused_variables)]
#fn main() {
pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) {
    use prefab_sections::*;

    let string_vec = PrefabBuilder::read_ascii_to_vec(section.template);
    
    // Place the new section
    let chunk_x;
    match section.placement.0 {
        HorizontalPlacement::Left => chunk_x = 0,
        HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2),
        HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32
    }

    let chunk_y;
    match section.placement.1 {
        VerticalPlacement::Top => chunk_y = 0,
        VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2),
        VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32
    }

    // Build the map
    let prev_builder = self.previous_builder.as_mut().unwrap();
    prev_builder.build_map();
    self.starting_position = prev_builder.get_starting_position();
    self.map = prev_builder.get_map().clone();        
    for e in prev_builder.get_spawn_list().iter() {
        let idx = e.0;
        let x = idx as i32 % self.map.width;
        let y = idx as i32 / self.map.width;
        if x < chunk_x || x > (chunk_x + section.width as i32) ||
            y < chunk_y || y > (chunk_y + section.height as i32) {
                self.spawn_list.push(
                    (idx, e.1.to_string())
                )
            }
    }        
    self.take_snapshot();        

    let mut i = 0;
    for ty in 0..section.height {
        for tx in 0..section.width {
            if tx > 0 && tx < self.map.width as usize -1 && ty < self.map.height as usize -1 && ty > 0 {
                let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
    self.take_snapshot();
}
#}

If you cargo run now, you'll face enemies in both sections of the map.

Screenshot.

Wrap Up

In this chapter, we've covered quite a bit of ground:

  • We can load Rex Paint levels, complete with hand-placed entities and play them.
  • We can define ASCII premade maps in our game, and play them (removing the requirement to use Rex Paint).
  • We can load level sectionals, and apply them to the level.
  • We can adjust the spawns from previous levels in the builder chain.

...

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Room Vaults


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


The last chapter was getting overly long, so it was broken into two. In the previous chapter, we learned how to load prefabricated maps and map sections, modified the spawn system so that meta-builders could affect the spawn patterns from the previous builder, and demonstrated integration of whole map chunks into levels. In this chapter, we'll explode room vaults - prefabricated content that integrates itself into your level. So you might hand-craft some rooms, and have them seamlessly fit into your existing map.

Designing a room: Totally Not A Trap

The life of a roguelike developer is part programmer, part interior decorator (in a weirdly Gnome Mad Scientist fashion). We've already designed whole levels and level sections, so it isn't a huge leap to designing rooms. Lets go ahead and build a few pre-designed rooms.

We'll make a new file in map_builders/prefab_builders called prefab_rooms.rs. We'll insert a relatively iconic map feature into it:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabRoom {
    pub template : &'static str,
    pub width : usize,
    pub height: usize,
    pub first_depth: i32,
    pub last_depth: i32
}

#[allow(dead_code)]
pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{
    template : TOTALLY_NOT_A_TRAP_MAP,
    width: 5,
    height: 5,
    first_depth: 0,
    last_depth: 100
};

#[allow(dead_code)]
const TOTALLY_NOT_A_TRAP_MAP : &str = "
     
 ^^^ 
 ^!^ 
 ^^^ 
     
";
#}

If you look at the ASCII, you'll see a classic piece of map design: a health potion completely surrounded by traps. Since the traps are hidden by default, we're relying on the player to think "well, that doesn't look suspicious at all"! Not that there are spaces all around the content - there's a 1-tile gutter all around it. This ensures that any 5x5 room into which the vault is placed will still be traversable. We're also introducing first_depth and last_depth - these are the levels at which the vault might be applied; for the sake of introduction, we'll pick 0..100 - which should be every level, unless you are a really dedicated play-tester!

Placing the not-a-trap room

We'll start by adding another mode to the PrefabBuiler system:


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode { 
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel },
    Sectional{ section : prefab_sections::PrefabSection },
    RoomVaults
}
#}

We're not going to add any parameters yet - by the end of the chapter, we'll have it integrated into a broader system for placing vaults. We'll update our constructor to use this type of placement:


# #![allow(unused_variables)]
#fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32, previous_builder : Option<Box<dyn MapBuilder>>) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::RoomVaults,
            previous_builder,
            spawn_list : Vec::new()
        }
    }
    ...
#}

And we'll teach our match function in build to use it:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template),
        PrefabMode::Constant{level} => self.load_ascii_map(&level),
        PrefabMode::Sectional{section} => self.apply_sectional(&section),
        PrefabMode::RoomVaults => self.apply_room_vaults()
    }
    self.take_snapshot();
    ...
#}

That leaves the next logical step being to write apply_room_vaults. Our objective is to scan the incoming map (from a different builder, even a previous iteration of this one!) for appropriate places into which we can place a vault, and add it to the map. We'll also want to remove any spawned creatures from the vault area - so the vaults remain hand-crafted and aren't interfered with by random spawning.

We'll be re-using our "create previous iteration" code from apply_sectional - so lets rewrite it into a more generic form:


# #![allow(unused_variables)]
#fn main() {
fn apply_previous_iteration<F>(&mut self, mut filter: F) 
    where F : FnMut(i32, i32, &(usize, String)) -> bool
{
    // Build the map
    let prev_builder = self.previous_builder.as_mut().unwrap();
    prev_builder.build_map();
    self.starting_position = prev_builder.get_starting_position();
    self.map = prev_builder.get_map().clone();   
    for e in prev_builder.get_spawn_list().iter() {
        let idx = e.0;
        let x = idx as i32 % self.map.width;
        let y = idx as i32 / self.map.width;
        if filter(x, y, e) {
            self.spawn_list.push(
                (idx, e.1.to_string())
            )
        }
    }        
    self.take_snapshot(); 
}
#}

There's a lot of new Rust here! Lets walk through it:

  1. You'll notice that we've added a template type to the function. fn apply_previous_iteration<F>. This specifies that we don't know exactly what F is when we write the function.
  2. The second parameter (mut filter: F) is also of type F. So we're telling the function signature to accept the template type as the parameter.
  3. Before the opening curly bracket, we've added a where clause. This type of clause can be used to limit what it accepted by the generic type. In this case, we're saying that F must be an FnMut. An FnMut is a function pointer that is allowed to change state (mutable; if it were immutable it'd be an Fn). We then specify the parameters of the function, and its return type. Inside the function, we can now treat filter like a function - even though we haven't actually written one. We're requiring that function accept two i32 (integers), and a tuple of (usize, String). The latter should look familiar - its our spawn list format. The first two are the x and y coordinates of the spawn - we're passing that to save the caller from doing the math each time.
  4. We then run the prev_builder code we wrote in the previous chapter - it builds the map and obtains the map data itself, along with the spawn_list from the previous algorithm.
  5. We then iterate through the spawn list, and calculate the x/y coordinates and map index for each entity. We call filter with this information, and if it returns true we add it to our spawn_list.
  6. Lastly, we take a snapshot of the map so you can see the step in action.

That sounds really complicated, but most of what it as done is allow us to replace the following code in apply_sectional:


# #![allow(unused_variables)]
#fn main() {
// Build the map
let prev_builder = self.previous_builder.as_mut().unwrap();
prev_builder.build_map();
self.starting_position = prev_builder.get_starting_position();
self.map = prev_builder.get_map().clone();        
for e in prev_builder.get_spawn_list().iter() {
    let idx = e.0;
    let x = idx as i32 % self.map.width;
    let y = idx as i32 / self.map.width;
    if x < chunk_x || x > (chunk_x + section.width as i32) ||
        y < chunk_y || y > (chunk_y + section.height as i32) {
            self.spawn_list.push(
                (idx, e.1.to_string())
            )
        }
}        
self.take_snapshot();
#}

We can replace it with a more generic call:


# #![allow(unused_variables)]
#fn main() {
// Build the map
self.apply_previous_iteration(|x,y,e| {
    x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32)
}); 
#}

This is interesting: we're passing in a closure - a lambda function to the filter. It receives x, y, and e from the previous map's spawn_list for each entity. In this case, we're checking against chunk_x, chunk_y, section.width and section.height to see if the entity is inside our sectional. You've probably noticed that we didn't declare these anywhere in the lambda function; we are relying on capture - you can call a lambda and reference other variables that are in its scope - and it can reference them as if they were its own. This is a very powerful feature, and you can learn about it here.

Room Vaults

Let's start building apply_room_vaults. We'll take it step-by-step, and work our way through. We'll start with the function signature:


# #![allow(unused_variables)]
#fn main() {
fn apply_room_vaults(&mut self) {
    use prefab_rooms::*;
    let mut rng = RandomNumberGenerator::new();
#}

Simple enough: no parameters other than mutable membership of the builder. It is going to be referring to types in prefab_rooms, so rather than type that every time an in-function using statement imports the names to the local namespace to save your fingers. We'll also need a random number generator, so we make one as we have before. Next up:


# #![allow(unused_variables)]
#fn main() {
// Apply the previous builder, and keep all entities it spawns (for now)
self.apply_previous_iteration(|_x,_y,_e| true);
#}

We use the code we just wrote to apply the previous map. The filter we're passing in this time always returns true: keep all the entities for now. Next:


# #![allow(unused_variables)]
#fn main() {
// Note that this is a place-holder and will be moved out of this function
let master_vault_list = vec![TOTALLY_NOT_A_TRAP];

// Filter the vault list down to ones that are applicable to the current depth
let possible_vaults : Vec<&PrefabRoom> = master_vault_list
    .iter()
    .filter(|v| { self.depth >= v.first_depth && self.depth <= v.last_depth })
    .collect();

if possible_vaults.is_empty() { return; } // Bail out if there's nothing to build

let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize };
let vault = possible_vaults[vault_index];
#}

We make a vector of all possible vault types - there's currently only one, but when we have more they go in here. This isn't really ideal, but we'll worry about making it a global resource in a future chapter. We then make a possible_vaults list by taking the master_vault_list and filtering it to only include those whose first_depth and last_depth line up with the requested dungeon depth. The iter().filter(...).collect() pattern has been described before, and it's a very powerful way to quickly extract what you need from a vector. If there are no possible vaults, we return out of the function - nothing to do here! Finally, we use another pattern we've used before: we pick a vault to create by selecting a random member of the possible_vaults vector.

Next up:


# #![allow(unused_variables)]
#fn main() {
// We'll make a list of places in which the vault could fit
let mut vault_positions : Vec<Position> = Vec::new();

let mut idx = 0usize;
loop {
    let x = (idx % self.map.width as usize) as i32;
    let y = (idx / self.map.width as usize) as i32;

    // Check that we won't overflow the map
    if x > 1 
        && (x+vault.width as i32) < self.map.width-2
        && y > 1 
        && (y+vault.height as i32) < self.map.height-2
    {

        let mut possible = true;
        for ty in 0..vault.height as i32 {
            for tx in 0..vault.width as i32 {

                let idx = self.map.xy_idx(tx + x, ty + y);
                if self.map.tiles[idx] != TileType::Floor {
                    possible = false;
                }
            }
        }

        if possible {
            vault_positions.push(Position{ x,y });
            break;
        }

    }

    idx += 1;
    if idx >= self.map.tiles.len()-1 { break; }
}
#}

There's quite a bit of code in this section (which determines all the places a the vault might fit). Lets walk through it:

  1. We make a new vector of Positions. This will contain all the possible places in which we could spawn our vault.
  2. We set idx to 0 - we plan to iterate through the whole map.
  3. We start a loop - the Rust loop type that doesn't exit until you call break.
    1. We calculate x and y to know where we are on the map.
    2. We do an overflow check; x needs to be greater than 1, and x+1 needs to be less than the map width. We check the same with y and the map height. If we're within the bounds:
      1. We set possible to true.
      2. We iterate every tile on the map in the range (x .. x+vault width), (y .. y + vault height) - if any tile isn't a floor, we set possible to false.
      3. If it is possible to place the vault here, we add the position to our vault_positions vector from step 1.
    3. We increment idx by 1.
    4. If we've run out of map, we break out of the loop.

In other words, we quickly scan the whole map for everywhere we could put the vault - and make a list of possible placements. We then:


# #![allow(unused_variables)]
#fn main() {
if !vault_positions.is_empty() {
    let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
    let pos = &vault_positions[pos_idx];

    let chunk_x = pos.x;
    let chunk_y = pos.y;

    let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template);
    let mut i = 0;
    for ty in 0..vault.height {
        for tx in 0..vault.width {
            let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
            self.char_to_map(string_vec[i], idx);
            i += 1;
        }
    }
    self.take_snapshot();
}
#}

So if there are any valid positions for the vault, we:

  1. Pick a random entry in the vault_positions vector - this is where we will place the vault.
  2. Use read_ascii_to_vec to read in the ASCII, just like we did in prefabs and sectionals.
  3. Iterate the vault data and use char_to_map to place it - just like we did before.

Putting it all together, you have the following function:


# #![allow(unused_variables)]
#fn main() {
fn apply_room_vaults(&mut self) {
    use prefab_rooms::*;
    let mut rng = RandomNumberGenerator::new();

    // Apply the previous builder, and keep all entities it spawns (for now)
    self.apply_previous_iteration(|_x,_y,_e| true);

    // Note that this is a place-holder and will be moved out of this function
    let master_vault_list = vec![TOTALLY_NOT_A_TRAP];

    // Filter the vault list down to ones that are applicable to the current depth
    let possible_vaults : Vec<&PrefabRoom> = master_vault_list
        .iter()
        .filter(|v| { self.depth >= v.first_depth && self.depth <= v.last_depth })
        .collect();

    if possible_vaults.is_empty() { return; } // Bail out if there's nothing to build

    let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize };
    let vault = possible_vaults[vault_index];

    // We'll make a list of places in which the vault could fit
    let mut vault_positions : Vec<Position> = Vec::new();

    let mut idx = 0usize;
    loop {
        let x = (idx % self.map.width as usize) as i32;
        let y = (idx / self.map.width as usize) as i32;

        // Check that we won't overflow the map
        if x > 1 
            && (x+vault.width as i32) < self.map.width-2
            && y > 1 
            && (y+vault.height as i32) < self.map.height-2
        {

            let mut possible = true;
            for ty in 0..vault.height as i32 {
                for tx in 0..vault.width as i32 {

                    let idx = self.map.xy_idx(tx + x, ty + y);
                    if self.map.tiles[idx] != TileType::Floor {
                        possible = false;
                    }
                }
            }

            if possible {
                vault_positions.push(Position{ x,y });
                break;
            }

        }

        idx += 1;
        if idx >= self.map.tiles.len()-1 { break; }
    }

    if !vault_positions.is_empty() {
        let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
        let pos = &vault_positions[pos_idx];

        let chunk_x = pos.x;
        let chunk_y = pos.y;

        let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template);
        let mut i = 0;
        for ty in 0..vault.height {
            for tx in 0..vault.width {
                let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                self.char_to_map(string_vec[i], idx);
                i += 1;
            }
        }
        self.take_snapshot();
    }
}
#}

It's more likely that a square vault will fit in rectangular rooms, so we'll pop over to map_builders/mod.rs and slightly adjust the random_builder to use the original simple map algorithm for the base map:


# #![allow(unused_variables)]
#fn main() {
Box::new(
    PrefabBuilder::new(
        new_depth, 
        Some(
            Box::new(
                SimpleMapBuilder::new(new_depth)
            )
        )
    )
)
#}

If you cargo run now, the vault will probably be placed on your map. Here's a screenshot of a run in which I found it:

Screenshot.

Filtering out entities

We probably don't want to keep entities that are inside our new vault from the previous map iteration. You might have a cunningly placed trap and spawn a goblin on top of it! (While fun, probably not what you had in mind). So we'll extend apply_room_vaults to do some filtering when it places the vault. We want to filter before we spawn new stuff, and then spawn more stuff with the room. Enter the retain feature:


# #![allow(unused_variables)]
#fn main() {
...
let chunk_y = pos.y;

let width = self.map.width; // The borrow checker really doesn't like it
let height = self.map.height; // when we access `self` inside the `retain`
self.spawn_list.retain(|e| {
    let idx = e.0 as i32;
    let x = idx % width;
    let y = idx / height;
    x < chunk_x || x > chunk_x + vault.width as i32 || y < chunk_y || y > chunk_y + vault.height as i32
});
...
#}

Calling retain on a vector iterates through every entry, and calls the passed closure/lambda function. If it returns true, then the element is retained (kept) - otherwise it is removed. So here we're catching width and height (to avoid borrowing self), and then calculate the location for each entry. If it is outside of the new vault - we keep it.

I want more than one vault!

Having only one vault is pretty dull - albeit a good start in terms of proving the functionality works. In prefab_rooms.rs we'll go ahead and write a couple more. These aren't intended to be seminal examples of level design, but they illustrate the process. We'll add some more room prefabs:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabRoom {
    pub template : &'static str,
    pub width : usize,
    pub height: usize,
    pub first_depth: i32,
    pub last_depth: i32
}

#[allow(dead_code)]
pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{
    template : TOTALLY_NOT_A_TRAP_MAP,
    width: 5,
    height: 5,
    first_depth: 0,
    last_depth: 100
};

#[allow(dead_code)]
const TOTALLY_NOT_A_TRAP_MAP : &str = "
     
 ^^^ 
 ^!^ 
 ^^^ 
     
";

#[allow(dead_code)]
pub const SILLY_SMILE : PrefabRoom = PrefabRoom{
    template : SILLY_SMILE_MAP,
    width: 6,
    height: 6,
    first_depth: 0,
    last_depth: 100
};

#[allow(dead_code)]
const SILLY_SMILE_MAP : &str = "
      
 ^  ^ 
  ##  
      
 #### 
      
";

#[allow(dead_code)]
pub const CHECKERBOARD : PrefabRoom = PrefabRoom{
    template : CHECKERBOARD_MAP,
    width: 6,
    height: 6,
    first_depth: 0,
    last_depth: 100
};

#[allow(dead_code)]
const CHECKERBOARD_MAP : &str = "
      
 #^#  
 g#%# 
 #!#  
 ^# # 
      
";
#}

We've added CHECKERBOARD (a grid of walls and spaces with traps, a goblin and goodies in it), and SILLY_SMILE which just looks like a silly wall feature. Now open up apply_room_vaults in map_builders/prefab_builder/mod.rs and add these to the master vector:


# #![allow(unused_variables)]
#fn main() {
// Note that this is a place-holder and will be moved out of this function
let master_vault_list = vec![TOTALLY_NOT_A_TRAP, CHECKERBOARD, SILLY_SMILE];
#}

If you cargo run now, you'll most likely encounter one of the three vaults. Each time you advance a depth, you will probably encounter one of the three. My test ran into the checkerboard almost immediately:

Screenshot.

That's a great start, and gives a bit of flair to maps as you descend - but it may not be quite what you were asking for when you said you wanted more than one vault! How about more than one vault on a level? Back to apply_room_vaults! It's easy enough to come up with a number of vaults to spawn:


# #![allow(unused_variables)]
#fn main() {
let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32);
#}

This sets n_vaults to the minimum value of a dice roll (1d3) and the number of possible vaults - so it'll never exceed the number of options, but can vary a bit. It's also pretty easy to wrap the creation function in a for loop:


# #![allow(unused_variables)]
#fn main() {
if possible_vaults.is_empty() { return; } // Bail out if there's nothing to build

        let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32);

        for _i in 0..n_vaults {

            let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize };
            let vault = possible_vaults[vault_index];

            ...

                self.take_snapshot();

                possible_vaults.remove(vault_index);
            }
        }
#}

Notice that at the end of the loop, we're removing the vault we added from possible_vaults. We have to change the declaration to be able to do that: let mut possible_vaults : Vec<&PrefabRoom> = ... - we add the mut to allow us to change the vector. This way, we won't keep adding the same vault - they only get spawned once.

Now for the more difficult part: making sure that our new vaults don't overlap the previously spawned ones. We'll create a new HashSet of tiles we've consumed:


# #![allow(unused_variables)]
#fn main() {
let mut used_tiles : HashSet<usize> = HashSet::new();
#}

Hash sets have the advantage of offering a quick way to say if they contain a value, so they are ideal for what we need. We'll insert the tile idx into the set when we add a tile:


# #![allow(unused_variables)]
#fn main() {
for ty in 0..vault.height {
    for tx in 0..vault.width {
        let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
        self.char_to_map(string_vec[i], idx);
        used_tiles.insert(idx);
        i += 1;
    }
}
#}

Lastly, in our possibility checking we want to do a check against used_tiles to ensure we aren't overlapping:


# #![allow(unused_variables)]
#fn main() {
let idx = self.map.xy_idx(tx + x, ty + y);
if self.map.tiles[idx] != TileType::Floor {
    possible = false;
}
if used_tiles.contains(&idx) {
    possible = false;
}
#}

Now if you cargo run your project, you might encounter several vaults. Here's a case where we encountered two vaults:

Screenshot.

I don't always want a vault!

If you offer all of your vaults on every level, the game will be a bit more predictable than you probably want (unless you make a lot of vaults!). We'll modify apply_room_vaults to only sometimes have any vaults, with an increasing probability as you descend into the dungeon:


# #![allow(unused_variables)]
#fn main() {
// Apply the previous builder, and keep all entities it spawns (for now)
self.apply_previous_iteration(|_x,_y,_e| true);

// Do we want a vault at all?
let vault_roll = rng.roll_dice(1, 6) + self.depth;
if vault_roll < 4 { return; }
#}

This is very simple: we roll a six-sided dice and add the current depth. If we rolled less than 4, we bail out and just provide the previously generated map. If you cargo run your project now, you'll sometimes encounter vaults - and sometimes you won't.

Finishing up: offering some constructors other than just new

We should offer some more friendly ways to build our PrefabBuilder, so it's obvious what we're doing when we construct our builder chain. Add the following constructors to prefab_builder/mod.rs:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
pub fn rex_level(new_depth : i32, template : &'static str) -> PrefabBuilder {
    PrefabBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history : Vec::new(),
        mode : PrefabMode::RexLevel{ template },
        previous_builder : None,
        spawn_list : Vec::new()
    }
}

#[allow(dead_code)]
pub fn constant(new_depth : i32, level : prefab_levels::PrefabLevel) -> PrefabBuilder {
    PrefabBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history : Vec::new(),
        mode : PrefabMode::Constant{ level },
        previous_builder : None,
        spawn_list : Vec::new()
    }
}

#[allow(dead_code)]
pub fn sectional(new_depth : i32, section : prefab_sections::PrefabSection, previous_builder : Box<dyn MapBuilder>) -> PrefabBuilder {
    PrefabBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history : Vec::new(),
        mode : PrefabMode::Sectional{ section },
        previous_builder : Some(previous_builder),
        spawn_list : Vec::new()
    }
}

#[allow(dead_code)]
pub fn vaults(new_depth : i32, previous_builder : Box<dyn MapBuilder>) -> PrefabBuilder {
    PrefabBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        history : Vec::new(),
        mode : PrefabMode::RoomVaults,
        previous_builder : Some(previous_builder),
        spawn_list : Vec::new()
    }
}
#}

We now have a decent interface for creating our meta-builder!

It's Turtles (Or Meta-Builders) All The Way Down

The last few chapters have all created meta builders - they aren't really builders in that they don't create an entirely new map, they modify the results of another algorithm. The really interesting thing here is that you can keep chaining them together to achieve the results you want. For example, lets make a map by starting with a Cellular Automata map, feeding it through Wave Function Collapse, possibly adding a castle wall, and then searching for vaults!

The syntax for this is currently quite ugly (that will be a future chapter topic). In map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
Box::new(
    PrefabBuilder::vaults(
        new_depth,
        Box::new(PrefabBuilder::sectional(
            new_depth,
            prefab_builder::prefab_sections::UNDERGROUND_FORT,
            Box::new(WaveformCollapseBuilder::derived_map(
                new_depth, 
                Box::new(CellularAutomataBuilder::new(new_depth))
            ))
        ))
    )
)
#}

Also in map_builders/prefab_builder/mod.rs make sure that you are publicly sharing the map modules:


# #![allow(unused_variables)]
#fn main() {
pub mod prefab_levels;
pub mod prefab_sections;
pub mod prefab_rooms;
#}

If you cargo run this, you get to watch it cycle through the layered building:

Screenshot.

Restoring Randomness

Now that we've completed a two-chapter marathon of prefabricated, layered map building - it's time to restore the random_builder function to provide randomness once more. Here's the new function from map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 17);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        16 => { result = Box::new(PrefabBuilder::constant(new_depth, prefab_builder::prefab_levels::WFC_POPULATED)) },
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    if rng.roll_dice(1, 20)==1 {
        result = Box::new(PrefabBuilder::sectional(new_depth, prefab_builder::prefab_sections::UNDERGROUND_FORT ,result));
    }

    result = Box::new(PrefabBuilder::vaults(new_depth, result));

    result
}
#}

We're taking full advantage of the composability of our layers system now! Our random builder now:

  1. In the first layer, we roll 1d17 and pick a map type; we've included our pre-made level as one of the options.
  2. Next, we roll 1d3 - and on a 1, we run the WaveformCollapse algorithm on that builder.
  3. We roll 1d20, and on a 1 - we apply a PrefabBuilder sectional, and add our fortress. That way, you'll only occasionally run into it.
  4. We run whatever builder we came up with against our PrefabBuilder's Room Vault system (the focus of this chapter!), to add premade rooms to the mix.

Wrap-Up

In this chapter, we've gained the ability to prefabricate rooms and include them if they fit into our level design. We've also explored the ability to add algorithms together, giving even more layers of randomness.

...

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Layering/Builder Chaining


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


The last few chapters have introduced an important concept in procedural generation: chained builders. We're happily building a map, calling Wave Function Collapse to mutate the map, calling our PrefabBuilder to change it again, and so on. This chapter will formalize this process a bit, expand upon it, and leave you with a framework that lets you clearly build new maps by chaining concepts together.

A builder-based interface

Builder chaining is a pretty profound approach to procedurally generating maps, and gives us an opportunity to clean up a lot of the code we've built thus far. We want an interface similar to the way we build entities with Specs: a builder, onto which we can keep chaining builders and return it as an "executor" - ready to build the maps. We also want to stop builders from doing more than one thing - they should do one thing, and do it well (that's a good principle of design; it makes debugging easier, and reduces duplication).

There are two major types of builders: those that just make a map (and only make sense to run once), and those that modify an existing map. We'll name those InitialMapBuilder and MetaMapBuilder respectively.

This gives us an idea of the syntax we want to employ:

  • Our Builder should have:
    • ONE Initial Builder.
    • n Meta Builders, that run in order.

It makes sense then that the builder should have a start_with method that accepts the first map, and additional with methods to chain builders. The builders should be stored in a container that preserves the order in which they were added - a vector being the obvious choice.

It would also make sense to no longer make individual builders responsible for setting up their predecessors; ideally, a builder shouldn't have to know anything about the process beyond what it does. So we need to abstract the process, and support snapshotting (so you can view your procedural generation progress) along the way.

Shared map state - the BuilderMap

Rather than each builder defining their own copies of shared data, it would make sense to put the shared data in one place - and pass it around the chain as needed. So we'll start by defining some new structures and interfaces. First of all, we'll make BuilderMap in map_builders/mod.rs:


# #![allow(unused_variables)]
#fn main() {
pub struct BuilderMap {
    pub spawn_list : Vec<(usize, String)>,
    pub map : Map,
    pub starting_position : Option<Position>,
    pub rooms: Option<Vec<Rect>>,
    pub history : Vec<Map>
}
#}

You'll notice that this has all of the data we've been building into each map builder - and nothing else. It's intentionally generic - we'll be passing it to builders, and letting them work on it. Notice that all the fields are public - that's because we're passing it around, and there's a good chance that anything that touches it will need to access any/all of its contents.

The BuilderMap also needs to facilitate the task of taking snapshots for debugger viewing of maps as we work on algorithms. We're going to put one function into BuilderMap - to handle snapshotting development:


# #![allow(unused_variables)]
#fn main() {
impl BuilderMap {
    fn take_snapshot(&mut self) {
        if SHOW_MAPGEN_VISUALIZER {
            let mut snapshot = self.map.clone();
            for v in snapshot.revealed_tiles.iter_mut() {
                *v = true;
            }
            self.history.push(snapshot);
        }
    }
}
#}

This is the same as the take_snapshot code we've been mixing into our builders. Since we're using a central repository of map building knowledge, we can promote it to apply to all our builders.

The BuilderChain - master builder to manage map creation

Previously, we've passed MapBuilder classes around, each capable of building previous maps. Since we've concluded that this is a poor idea, and defined the syntax we want, we'll make a replacement. The BuilderChain is a master builder - it controls the whole build process. To this end, we'll add the BuilderChain type:


# #![allow(unused_variables)]
#fn main() {
pub struct BuilderChain {
    starter: Option<Box<dyn InitialMapBuilder>>,
    builders: Vec<Box<dyn MetaMapBuilder>>,
    pub build_data : BuilderMap
}
#}

This is a more complicated structure, so let's go through it:

  • starter is an Option, so we know if there is one. Not having a first step (a map that doesn't refer to other maps) would be an error condition, so we'll track it. We're referencing a new trait, InitialMapBuilder; we'll get to that in a moment.
  • builders is a vector of MetaMapBuilders, another new trait (and again - we'll get to it in a moment). These are builders that operate on the results of previous maps.
  • build_data is a public variable (anyone can read/write it), containing the BuilderMap we just made.

We'll implement some functions to support it. First up, a constructor:


# #![allow(unused_variables)]
#fn main() {
impl BuilderChain {
    pub fn new(new_depth : i32) -> BuilderChain {
        BuilderChain{
            starter: None,
            builders: Vec::new(),
            build_data : BuilderMap {
                spawn_list: Vec::new(),
                map: Map::new(new_depth),
                starting_position: None,
                rooms: None,
                history : Vec::new()
            }
        }
    }
    ...
#}

This is pretty simple: it makes a new BuilderChain with default values for everything. Now, lets permit our users to add a starting map to the chain. (A starting map is a first step that doesn't require a previous map as input, and results in a usable map structure which we may then modify):


# #![allow(unused_variables)]
#fn main() {
...
pub fn start_with(&mut self, starter : Box<dyn InitialMapBuilder>) {
    match self.starter {
        None => self.starter = Some(starter),
        Some(_) => panic!("You can only have one starting builder.")
    };
}
...
#}

There's one new concept in here: panic!. If the user tries to add a second starting builder, we'll crash - because that doesn't make any sense. You'd simply be overwriting your previous steps, which is a giant waste of time! We'll also permit the user to add meta-builders:


# #![allow(unused_variables)]
#fn main() {
...
pub fn with(&mut self, metabuilder : Box<dyn MetaMapBuilder>) {
    self.builders.push(metabuilder);
}
...
#}

This is very simple: we simply add the meta-builder to the builder vector. Since vectors remain in the order in which you add to them, your operations will remain sorted appropriately. Finally, we'll implement a function to actually construct the map:


# #![allow(unused_variables)]
#fn main() {
pub fn build_map(&mut self, rng : &mut rltk::RandomNumberGenerator) {
    match &mut self.starter {
        None => panic!("Cannot run a map builder chain without a starting build system"),
        Some(starter) => {
            // Build the starting map
            starter.build_map(rng, &mut self.build_data);
        }
    }

    // Build additional layers in turn
    for metabuilder in self.builders.iter_mut() {
        metabuilder.build_map(rng, &mut self.build_data);
    }
}
#}

Let's walk through the steps here:

  1. We match on our starting map. If there isn't one, we panic - and crash the program with a message that you have to set a starting builder.
  2. We call build_map on the starting map.
  3. For each meta-builder, we call build_map on it - in the order specified.

That's not a bad syntax! It should enable us to chain builders together, and provide the required overview for constructing complicated, layered maps.

New Traits - InitialMapBuilder and MetaMapBuilder

Lets look at the two trait interfaces we've defined, InitialMapBuilder and MetaMapBuilder. We made them separate types to force the user to only pick one starting builder, and not try to put any starting builders in the list of modification layers. The implementation for them is the same:


# #![allow(unused_variables)]
#fn main() {
pub trait InitialMapBuilder {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap);
}

pub trait MetaMapBuilder {    
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap);
}
#}

build_map takes a random-number generator (so we stop creating new ones everywhere!), and a mutable reference to the BuilderMap we are working on. So instead of each builder optionally calling the previous one, we're passing along state as we work on it.

Spawn Function

We'll also want to implement our spawning system:


# #![allow(unused_variables)]
#fn main() {
pub fn spawn_entities(&mut self, ecs : &mut World) {
    for entity in self.build_data.spawn_list.iter() {
        spawner::spawn_entity(ecs, &(&entity.0, &entity.1));
    }
}
#}

This is almost exactly the same code as our previous spawner in MapBuilder, but instead we're spawning from the spawn_list in our build_data structure. Otherwise, it's identical.

Random Builder - Take 1

Finally, we'll modify random_builder to use our SimpleMapBuilder with some new types to break out the creation steps:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    let mut builder = BuilderChain::new(new_depth);
    builder.start_with(SimpleMapBuilder::new());
    builder.with(RoomBasedSpawner::new());
    builder.with(RoomBasedStartingPosition::new());
    builder.with(RoomBasedStairs::new());
    builder
}
#}

Notice that we're now taking a RandomNumberGenerator parameter. That's because we'd like to use the global RNG, rather than keep making new ones. This way, if the caller sets a "seed" - it will apply to world generation. This is intended to be the topic of a future chapter. We're also now returning a BuilderChain rather than a boxed trait - we're hiding the messy boxing/dynamic dispatch inside the implementation, so the caller doesn't have to worry about it. There's also two new types here: RoomBasedSpawner and RoomBasedStartingPosition - as well as a changed constructor for SimpleMapBuilder (it no longer accepts a depth parameter). We'll be looking at that in a second - but first, lets deal with the changes to the main program resulting from the new interface.

Nice looking interface - but you broke stuff!

We now have the interface we want - a good map of how the system interacts with the world. Unfortunately, the world is still expecting the setup we had before - so we need to fix it. In main.rs, we need to update our generate_world_map function to use the new interface:


# #![allow(unused_variables)]
#fn main() {
fn generate_world_map(&mut self, new_depth : i32) {
    self.mapgen_index = 0;
    self.mapgen_timer = 0.0;
    self.mapgen_history.clear();
    let mut rng = self.ecs.write_resource::<rltk::RandomNumberGenerator>();
    let mut builder = map_builders::random_builder(new_depth, &mut rng);
    builder.build_map(&mut rng);
    std::mem::drop(rng);
    self.mapgen_history = builder.build_data.history.clone();
    let player_start;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        *worldmap_resource = builder.build_data.map.clone();
        player_start = builder.build_data.starting_position.as_mut().unwrap().clone();
    }

    // Spawn bad guys
    builder.spawn_entities(&mut self.ecs);
#}
  1. We reset mapgen_index, mapgen_timer and the mapgen_history so that the progress viewer will run from the beginning.
  2. We obtain the RNG from the ECS World.
  3. We create a new random_builder with the new interface, passing along the random number generator.
  4. We tell it to build the new maps from the chain, also utilizing the RNG.
  5. We call std::mem::drop on the RNG. This stops the "borrow" on it - so we're no longer borrowing self either. This prevents borrow-checker errors on the next phases of code.
  6. We clone the map builder history into our own copy of the world's history. We copy it so we aren't destroying the builder, yet.
  7. We set player_start to a clone of the builder's determined starting position. Note that we are calling unwrap - so the Option for a starting position must have a value at this point, or we'll crash. That's deliberate: we'd rather crash knowing that we forgot to set a starting point than have the program run in an unknown/confusing state.
  8. We call spawn_entities to populate the map.

Modifying SimpleMapBuilder

We can simplify SimpleMapBuilder (making it worthy of the name!) quite a bit. Here's the new code:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, Rect, apply_room_to_map, 
    apply_horizontal_tunnel, apply_vertical_tunnel };
use rltk::RandomNumberGenerator;

pub struct SimpleMapBuilder {}

impl InitialMapBuilder for SimpleMapBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.rooms_and_corridors(rng, build_data);
    }
}

impl SimpleMapBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<SimpleMapBuilder> {
        Box::new(SimpleMapBuilder{})
    }

    fn rooms_and_corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;
        let mut rooms : Vec<Rect> = Vec::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, build_data.map.width - w - 1) - 1;
            let y = rng.roll_dice(1, build_data.map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                apply_room_to_map(&mut build_data.map, &new_room);
                build_data.take_snapshot();

                if !rooms.is_empty() {
                    let (new_x, new_y) = new_room.center();
                    let (prev_x, prev_y) = rooms[i as usize -1].center();
                    if rng.range(0,2) == 1 {
                        apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y);
                        apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x);
                    } else {
                        apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x);
                        apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y);
                    }
                }

                rooms.push(new_room);
                build_data.take_snapshot();
            }
        }
        build_data.rooms = Some(rooms);
    }
}
#}

This is basically the same as the old SimpleMapBuilder, but there's a number of changes:

  • Notice that we're only applying the InitialMapBuilder trait - MapBuilder is no more.
  • We're also not setting a starting position, or spawning entities - those are now the purview of other builders in the chain. We've basically distilled it down to just the room building algorithm.
  • We set build_data.rooms to Some(rooms). Not all algorithms support rooms - so our trait leaves the Option set to None until we fill it. Since the SimpleMapBuilder is all about rooms - we fill it in.

Room-based spawning

Create a new file, room_based_spawner.rs in the map_builders directory. We're going to apply just the room populating system from the old SimpleMapBuilder here:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, spawner};
use rltk::RandomNumberGenerator;

pub struct RoomBasedSpawner {}

impl MetaMapBuilder for RoomBasedSpawner {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomBasedSpawner {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomBasedSpawner> {
        Box::new(RoomBasedSpawner{})
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        if let Some(rooms) = &build_data.rooms {
            for room in rooms.iter().skip(1) {
                spawner::spawn_room(&build_data.map, rng, room, build_data.map.depth, &mut build_data.spawn_list);
            }
        } else {
            panic!("Room Based Spawning only works after rooms have been created");
        }
    }
}
#}

In this sub-module, we're implementing MetaMapBuilder: this builder requires that you already have a map. In build, we've copied the old room-based spawning code from SimpleMapBuilder, and modified it to operate on the builder's rooms structure. To that end, if we if let to obtain the inner value of the Option; if there isn't one, then we panic! and the program quits stating that room-based spawning is only going to work if you have rooms.

We've reduced the functionality to just one task: if there are rooms, we spawn monsters in them.

Room-based starting position

This is very similar to room-based spawning, but places the player in the first room - just like it used to in SimpleMapBuilder. Create a new file, room_based_starting_position in map_builders:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, Position};
use rltk::RandomNumberGenerator;

pub struct RoomBasedStartingPosition {}

impl MetaMapBuilder for RoomBasedStartingPosition {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomBasedStartingPosition {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomBasedStartingPosition> {
        Box::new(RoomBasedStartingPosition{})
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        if let Some(rooms) = &build_data.rooms {
            let start_pos = rooms[0].center();
            build_data.starting_position = Some(Position{ x: start_pos.0, y: start_pos.1 });
        } else {
            panic!("Room Based Staring Position only works after rooms have been created");
        }
    }
}
#}

Room-based stairs

This is also very similar to how we generated exit stairs in SimpleMapBuilder. Make a new file, room_based_stairs.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

pub struct RoomBasedStairs {}

impl MetaMapBuilder for RoomBasedStairs {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomBasedStairs {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomBasedStairs> {
        Box::new(RoomBasedStairs{})
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        if let Some(rooms) = &build_data.rooms {
            let stairs_position = rooms[rooms.len()-1].center();
            let stairs_idx = build_data.map.xy_idx(stairs_position.0, stairs_position.1);
            build_data.map.tiles[stairs_idx] = TileType::DownStairs;
            build_data.take_snapshot();
        } else {
            panic!("Room Based Stairs only works after rooms have been created");
        }
    }
}
#}

Putting it together to make a simple map with the new framework

Let's take another look at random_builder:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(SimpleMapBuilder::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStartingPosition::new());
builder.with(RoomBasedStairs::new());
builder
#}

Now that we've made all of the steps, this should make sense:

  1. We start with a map generated with the SimpleMapBuilder generator.
  2. We modify the map with the meta-builder RoomBasedSpawner to spawn entities in rooms.
  3. We again modify the map with the meta-builder RoomBasedStartingPosition to start in the first room.
  4. Once again, we modify the map with the meta-builder RoomBasedStairs to place a down staircase in the last room.

If you cargo run the project now, you'll let lots of warnings about unused code - but the game should play with just the simple map from our first section. You may be wondering why we've taken so much effort to keep things the same; hopefully, it will become clear as we clean up more builders!

Cleaning up the BSP Dungeon Builder

Once again, we can seriously clean-up a map builder! Here's the new version of bsp_dungeon.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, Map, Rect, apply_room_to_map, 
    TileType, draw_corridor};
use rltk::RandomNumberGenerator;

pub struct BspDungeonBuilder {
    rects: Vec<Rect>,
}

impl InitialMapBuilder for BspDungeonBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl BspDungeonBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<BspDungeonBuilder> {
        Box::new(BspDungeonBuilder{
            rects: Vec::new(),
        })
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let mut rooms : Vec<Rect> = Vec::new();
        self.rects.clear();
        self.rects.push( Rect::new(2, 2, build_data.map.width-5, build_data.map.height-5) ); // Start with a single map-sized rectangle
        let first_room = self.rects[0];
        self.add_subrects(first_room); // Divide the first room

        // Up to 240 times, we get a random rectangle and divide it. If its possible to squeeze a
        // room in there, we place it and add it to the rooms list.
        let mut n_rooms = 0;
        while n_rooms < 240 {
            let rect = self.get_random_rect(rng);
            let candidate = self.get_random_sub_rect(rect, rng);

            if self.is_possible(candidate, &build_data.map) {
                apply_room_to_map(&mut build_data.map, &candidate);
                rooms.push(candidate);
                self.add_subrects(rect);
                build_data.take_snapshot();
            }

            n_rooms += 1;
        }

        // Now we sort the rooms
        rooms.sort_by(|a,b| a.x1.cmp(&b.x1) );

        // Now we want corridors
        for i in 0..rooms.len()-1 {
            let room = rooms[i];
            let next_room = rooms[i+1];
            let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
            let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
            let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
            let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
            draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y);
            build_data.take_snapshot();
        }
        build_data.rooms = Some(rooms);
    }

    fn add_subrects(&mut self, rect : Rect) {
        let width = i32::abs(rect.x1 - rect.x2);
        let height = i32::abs(rect.y1 - rect.y2);
        let half_width = i32::max(width / 2, 1);
        let half_height = i32::max(height / 2, 1);

        self.rects.push(Rect::new( rect.x1, rect.y1, half_width, half_height ));
        self.rects.push(Rect::new( rect.x1, rect.y1 + half_height, half_width, half_height ));
        self.rects.push(Rect::new( rect.x1 + half_width, rect.y1, half_width, half_height ));
        self.rects.push(Rect::new( rect.x1 + half_width, rect.y1 + half_height, half_width, half_height ));
    }

    fn get_random_rect(&mut self, rng : &mut RandomNumberGenerator) -> Rect {
        if self.rects.len() == 1 { return self.rects[0]; }
        let idx = (rng.roll_dice(1, self.rects.len() as i32)-1) as usize;
        self.rects[idx]
    }

    fn get_random_sub_rect(&self, rect : Rect, rng : &mut RandomNumberGenerator) -> Rect {
        let mut result = rect;
        let rect_width = i32::abs(rect.x1 - rect.x2);
        let rect_height = i32::abs(rect.y1 - rect.y2);

        let w = i32::max(3, rng.roll_dice(1, i32::min(rect_width, 10))-1) + 1;
        let h = i32::max(3, rng.roll_dice(1, i32::min(rect_height, 10))-1) + 1;

        result.x1 += rng.roll_dice(1, 6)-1;
        result.y1 += rng.roll_dice(1, 6)-1;
        result.x2 = result.x1 + w;
        result.y2 = result.y1 + h;

        result
    }

    fn is_possible(&self, rect : Rect, map : &Map) -> bool {
        let mut expanded = rect;
        expanded.x1 -= 2;
        expanded.x2 += 2;
        expanded.y1 -= 2;
        expanded.y2 += 2;

        let mut can_build = true;

        for y in expanded.y1 ..= expanded.y2 {
            for x in expanded.x1 ..= expanded.x2 {
                if x > map.width-2 { can_build = false; }
                if y > map.height-2 { can_build = false; }
                if x < 1 { can_build = false; }
                if y < 1 { can_build = false; }
                if can_build {
                    let idx = map.xy_idx(x, y);
                    if map.tiles[idx] != TileType::Wall { 
                        can_build = false; 
                    }
                }
            }
        }

        can_build
    }
}
#}

Just like SimpleMapBuilder, we've stripped out all the non-room building code for a much cleaner piece of code. We're referencing the build_data struct from the builder, rather than making our own copies of everything - and the meat of the code is largely the same.

Now you can modify random_builder to make this map type:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStartingPosition::new());
builder.with(RoomBasedStairs::new());
builder
#}

If you cargo run now, you'll get a dungeon based on the BspDungeonBuilder. See how you are reusing the spawner, starting position and stairs code? That's definitely an improvement over the older versions - if you change one, it can now help on multiple builders!

Same again for BSP Interior

Yet again, we can greatly clean up a builder - this time the BspInteriorBuilder. Here's the code for bsp_interior.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, Rect, TileType, draw_corridor};
use rltk::RandomNumberGenerator;

const MIN_ROOM_SIZE : i32 = 8;

pub struct BspInteriorBuilder {
    rects: Vec<Rect>
}

impl InitialMapBuilder for BspInteriorBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl BspInteriorBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<BspInteriorBuilder> {
        Box::new(BspInteriorBuilder{
            rects: Vec::new()
        })
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let mut rooms : Vec<Rect> = Vec::new();
        self.rects.clear();
        self.rects.push( Rect::new(1, 1, build_data.map.width-2, build_data.map.height-2) ); // Start with a single map-sized rectangle
        let first_room = self.rects[0];
        self.add_subrects(first_room, rng); // Divide the first room

        let rooms_copy = self.rects.clone();
        for r in rooms_copy.iter() {
            let room = *r;
            //room.x2 -= 1;
            //room.y2 -= 1;
            rooms.push(room);
            for y in room.y1 .. room.y2 {
                for x in room.x1 .. room.x2 {
                    let idx = build_data.map.xy_idx(x, y);
                    if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize {
                        build_data.map.tiles[idx] = TileType::Floor;
                    }
                }
            }
            build_data.take_snapshot();
        }

        // Now we want corridors
        for i in 0..rooms.len()-1 {
            let room = rooms[i];
            let next_room = rooms[i+1];
            let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
            let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
            let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
            let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
            draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y);
            build_data.take_snapshot();
        }

        build_data.rooms = Some(rooms);
    }

    fn add_subrects(&mut self, rect : Rect, rng : &mut RandomNumberGenerator) {
        // Remove the last rect from the list
        if !self.rects.is_empty() {
            self.rects.remove(self.rects.len() - 1);
        }

        // Calculate boundaries
        let width  = rect.x2 - rect.x1;
        let height = rect.y2 - rect.y1;
        let half_width = width / 2;
        let half_height = height / 2;

        let split = rng.roll_dice(1, 4);

        if split <= 2 {
            // Horizontal split
            let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height );
            self.rects.push( h1 );
            if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); }
            let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width, height );
            self.rects.push( h2 );
            if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); }
        } else {
            // Vertical split
            let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 );
            self.rects.push(v1);
            if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); }
            let v2 = Rect::new( rect.x1, rect.y1 + half_height, width, half_height );
            self.rects.push(v2);
            if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); }
        }
    }
}
#}

You may test it by modifying random_builder:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspInteriorBuilder::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStartingPosition::new());
builder.with(RoomBasedStairs::new());
builder
#}

cargo run will now take you around an interior builder.

Cellular Automata

You should understand the basic idea here, now - we're breaking up builders into small chunks, and implementing the appropriate traits for the map type. Looking at Cellular Automata maps, you'll see that we do things a little differently:

  • We make a map as usual. This obviously belongs in CellularAutomataBuilder.
  • We search for a starting point close to the middle. This looks like it should be a separate step.
  • We search the map for unreachable areas and cull them. This also looks like a separate step.
  • We place the exit far from the starting position. That's also a different algorithm step.

The good news is that the last three of those are used in lots of other builders - so implementing them will let us reuse the code, and not keep repeating ourselves. The bad news is that if we run our cellular automata builder with the existing room-based steps, it will crash - we don't have rooms!

So we'll start by constructing the basic map builder. Like the others, this is mostly just rearranging code to fit with the new trait scheme. Here's the new cellular_automata.rs file:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

pub struct CellularAutomataBuilder {}

impl InitialMapBuilder for CellularAutomataBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl CellularAutomataBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<CellularAutomataBuilder> {
        Box::new(CellularAutomataBuilder{})
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        // First we completely randomize the map, setting 55% of it to be floor.
        for y in 1..build_data.map.height-1 {
            for x in 1..build_data.map.width-1 {
                let roll = rng.roll_dice(1, 100);
                let idx = build_data.map.xy_idx(x, y);
                if roll > 55 { build_data.map.tiles[idx] = TileType::Floor } 
                else { build_data.map.tiles[idx] = TileType::Wall }
            }
        }
        build_data.take_snapshot();

        // Now we iteratively apply cellular automata rules
        for _i in 0..15 {
            let mut newtiles = build_data.map.tiles.clone();

            for y in 1..build_data.map.height-1 {
                for x in 1..build_data.map.width-1 {
                    let idx = build_data.map.xy_idx(x, y);
                    let mut neighbors = 0;
                    if build_data.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx - (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx - (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx + (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
                    if build_data.map.tiles[idx + (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }

                    if neighbors > 4 || neighbors == 0 {
                        newtiles[idx] = TileType::Wall;
                    }
                    else {
                        newtiles[idx] = TileType::Floor;
                    }
                }
            }

            build_data.map.tiles = newtiles.clone();
            build_data.take_snapshot();
        }
    }
}
#}

Non-Room Starting Points

It's entirely possible that we don't actually want to start in the middle of the map. Doing so presents lots of opportunities (and helps ensure connectivity), but maybe you would rather the player trudge through lots of map with less opportunity to pick the wrong direction. Maybe your story makes more sense if the player arrives at one end of the map and leaves via another. Lets implement a starting position system that takes a preferred starting point, and picks the closest valid tile. Create area_starting_points.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, Position, TileType};
use rltk::RandomNumberGenerator;

#[allow(dead_code)]
pub enum XStart { LEFT, CENTER, RIGHT }

#[allow(dead_code)]
pub enum YStart { TOP, CENTER, BOTTOM }

pub struct AreaStartingPosition {
    x : XStart, 
    y : YStart
}

impl MetaMapBuilder for AreaStartingPosition {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl AreaStartingPosition {
    #[allow(dead_code)]
    pub fn new(x : XStart, y : YStart) -> Box<AreaStartingPosition> {
        Box::new(AreaStartingPosition{
            x, y
        })
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let seed_x;
        let seed_y;

        match self.x {
            XStart::LEFT => seed_x = 1,
            XStart::CENTER => seed_x = build_data.map.width / 2,
            XStart::RIGHT => seed_x = build_data.map.width - 2
        }

        match self.y {
            YStart::TOP => seed_y = 1,
            YStart::CENTER => seed_y = build_data.map.height / 2,
            YStart::BOTTOM => seed_y = build_data.map.height - 2
        }

        let mut available_floors : Vec<(usize, f32)> = Vec::new();
        for (idx, tiletype) in build_data.map.tiles.iter().enumerate() {
            if *tiletype == TileType::Floor {
                available_floors.push(
                    (
                        idx,
                        rltk::DistanceAlg::PythagorasSquared.distance2d(
                            rltk::Point::new(idx as i32 % build_data.map.width, idx as i32 / build_data.map.width),
                            rltk::Point::new(seed_x, seed_y)
                        )
                    )
                );
            }
        }
        if available_floors.is_empty() {
            panic!("No valid floors to start on");
        }

        available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());

        let start_x = available_floors[0].0 as i32 % build_data.map.width;
        let start_y = available_floors[0].0 as i32 / build_data.map.width;

        build_data.starting_position = Some(Position{x : start_x, y: start_y});
    }
}
#}

We've covered the boilerplate enough to not need to go over it again - so lets step through the build function:

  1. We are taking in a couple of enum types: preferred position on the X and Y axes.
  2. So we set seed_x and seed_y to a point closest to the specified locations.
  3. We iterate through the whole map, adding floor tiles to available_floors - and calculating the distance to the preferred starting point.
  4. We sort the available tile list, so the lower distances are first.
  5. We pick the first one on the list.

Note that we also panic! if there are no floors at all.

The great part here is that this will work for any map type - it searches for floors to stand on, and tries to find the closest starting point.

Culling Unreachable Areas

We've previously had good luck with culling areas that can't be reached from the starting point. So lets formalize that into its own meta-builder. Create cull_unreachable.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

pub struct CullUnreachable {}

impl MetaMapBuilder for CullUnreachable {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl CullUnreachable {
    #[allow(dead_code)]
    pub fn new() -> Box<CullUnreachable> {
        Box::new(CullUnreachable{})
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let starting_pos = build_data.starting_position.as_ref().unwrap().clone();
        let start_idx = build_data.map.xy_idx(
            starting_pos.x, 
            starting_pos.y
        );
        build_data.map.populate_blocked();
        let map_starts : Vec<usize> = vec![start_idx];
        let dijkstra_map = rltk::DijkstraMap::new(build_data.map.width as usize, build_data.map.height as usize, &map_starts , &build_data.map, 1000.0);
        for (i, tile) in build_data.map.tiles.iter_mut().enumerate() {
            if *tile == TileType::Floor {
                let distance_to_start = dijkstra_map.map[i];
                // We can't get to this tile - so we'll make it a wall
                if distance_to_start == std::f32::MAX {
                    *tile = TileType::Wall;
                }
            }
        }
    }
}
#}

You'll notice this is almost exactly the same as remove_unreachable_areas_returning_most_distant from common.rs, but without returning a Dijkstra map. That's the intent: we remove areas the player can't get to, and only do that.

Voronoi-based spawning

We also need to replicate the functionality of Voronoi-based spawning. Create voronoi_spawning.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType, spawner};
use rltk::RandomNumberGenerator;
use std::collections::HashMap;

pub struct VoronoiSpawning {}

impl MetaMapBuilder for VoronoiSpawning {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl VoronoiSpawning {
    #[allow(dead_code)]
    pub fn new() -> Box<VoronoiSpawning> {
        Box::new(VoronoiSpawning{})
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let mut noise_areas : HashMap<i32, Vec<usize>> = HashMap::new();
        let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64);
        noise.set_noise_type(rltk::NoiseType::Cellular);
        noise.set_frequency(0.08);
        noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);

        for y in 1 .. build_data.map.height-1 {
            for x in 1 .. build_data.map.width-1 {
                let idx = build_data.map.xy_idx(x, y);
                if build_data.map.tiles[idx] == TileType::Floor {
                    let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0;
                    let cell_value = cell_value_f as i32;

                    if noise_areas.contains_key(&cell_value) {
                        noise_areas.get_mut(&cell_value).unwrap().push(idx);
                    } else {
                        noise_areas.insert(cell_value, vec![idx]);
                    }
                }
            }
        }

        // Spawn the entities
        for area in noise_areas.iter() {
            spawner::spawn_region(&build_data.map, rng, area.1, build_data.map.depth, &mut build_data.spawn_list);
        }
    }
}
#}

This is almost exactly the same as the code from common.rs we were calling in various builders, just modified to work within the builder chaining/builder map framework.

Spawning a distant exit

Another commonly used piece of code generated a Dijkstra map of the level, starting at the player's entry point - and used that map to place the exit at the most distant location from the player. This was in common.rs, and we called it a lot. We'll turn this into a map building step; create map_builders/distant_exit.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

pub struct DistantExit {}

impl MetaMapBuilder for DistantExit {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl DistantExit {
    #[allow(dead_code)]
    pub fn new() -> Box<DistantExit> {
        Box::new(DistantExit{})
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let starting_pos = build_data.starting_position.as_ref().unwrap().clone();
        let start_idx = build_data.map.xy_idx(
            starting_pos.x, 
            starting_pos.y
        );
        build_data.map.populate_blocked();
        let map_starts : Vec<usize> = vec![start_idx];
        let dijkstra_map = rltk::DijkstraMap::new(build_data.map.width as usize, build_data.map.height as usize, &map_starts , &build_data.map, 1000.0);
        let mut exit_tile = (0, 0.0f32);
        for (i, tile) in build_data.map.tiles.iter_mut().enumerate() {
            if *tile == TileType::Floor {
                let distance_to_start = dijkstra_map.map[i];
                if distance_to_start != std::f32::MAX {
                    // If it is further away than our current exit candidate, move the exit
                    if distance_to_start > exit_tile.1 {
                        exit_tile.0 = i;
                        exit_tile.1 = distance_to_start;
                    }
                }
            }
        }

        // Place a staircase
        let stairs_idx = exit_tile.0;
        build_data.map.tiles[stairs_idx] = TileType::DownStairs;
        build_data.take_snapshot();
    }
}
#}

Again, this is the same code we've used previously - just tweaked to match the new interface, so we won't go over it in detail.

Testing Cellular Automata

We've finally got all the pieces together, so lets give it a test. In random_builder, we'll use the new builder chains:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(CellularAutomataBuilder::new());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

If you cargo run now, you'll get to play in a Cellular Automata generated map.

Updating Drunkard's Walk

You should have a pretty good picture of what we're doing now, so we'll gloss over the changes to drunkard.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, TileType, Position, paint, Symmetry};
use rltk::RandomNumberGenerator;

#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum DrunkSpawnMode { StartingPoint, Random }

pub struct DrunkardSettings {
    pub spawn_mode : DrunkSpawnMode,
    pub drunken_lifetime : i32,
    pub floor_percent: f32,
    pub brush_size: i32,
    pub symmetry: Symmetry
}

pub struct DrunkardsWalkBuilder {
    settings : DrunkardSettings
}

impl InitialMapBuilder for DrunkardsWalkBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl DrunkardsWalkBuilder {
    #[allow(dead_code)]
    pub fn new(settings: DrunkardSettings) -> DrunkardsWalkBuilder {
        DrunkardsWalkBuilder{
            settings
        }
    }

    #[allow(dead_code)]
    pub fn open_area() -> Box<DrunkardsWalkBuilder> {
        Box::new(DrunkardsWalkBuilder{
            settings : DrunkardSettings{
                spawn_mode: DrunkSpawnMode::StartingPoint,
                drunken_lifetime: 400,
                floor_percent: 0.5,
                brush_size: 1,
                symmetry: Symmetry::None
            }
        })
    }

    #[allow(dead_code)]
    pub fn open_halls() -> Box<DrunkardsWalkBuilder> {
        Box::new(DrunkardsWalkBuilder{
            settings : DrunkardSettings{
                spawn_mode: DrunkSpawnMode::Random,
                drunken_lifetime: 400,
                floor_percent: 0.5,
                brush_size: 1,
                symmetry: Symmetry::None
            },
        })
    }

    #[allow(dead_code)]
    pub fn winding_passages() -> Box<DrunkardsWalkBuilder> {
        Box::new(DrunkardsWalkBuilder{
            settings : DrunkardSettings{
                spawn_mode: DrunkSpawnMode::Random,
                drunken_lifetime: 100,
                floor_percent: 0.4,
                brush_size: 1,
                symmetry: Symmetry::None
            },
        })
    }

    #[allow(dead_code)]
    pub fn fat_passages() -> Box<DrunkardsWalkBuilder> {
        Box::new(DrunkardsWalkBuilder{
            settings : DrunkardSettings{
                spawn_mode: DrunkSpawnMode::Random,
                drunken_lifetime: 100,
                floor_percent: 0.4,
                brush_size: 2,
                symmetry: Symmetry::None
            },
        })
    }

    #[allow(dead_code)]
    pub fn fearful_symmetry() -> Box<DrunkardsWalkBuilder> {
        Box::new(DrunkardsWalkBuilder{
            settings : DrunkardSettings{
                spawn_mode: DrunkSpawnMode::Random,
                drunken_lifetime: 100,
                floor_percent: 0.4,
                brush_size: 1,
                symmetry: Symmetry::Both
            },
        })
    }
    
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        // Set a central starting point
        let starting_position = Position{ x: build_data.map.width / 2, y: build_data.map.height / 2 };
        let start_idx = build_data.map.xy_idx(starting_position.x, starting_position.y);
        build_data.map.tiles[start_idx] = TileType::Floor;

        let total_tiles = build_data.map.width * build_data.map.height;
        let desired_floor_tiles = (self.settings.floor_percent * total_tiles as f32) as usize;
        let mut floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
        let mut digger_count = 0;
        while floor_tile_count  < desired_floor_tiles {
            let mut did_something = false;
            let mut drunk_x;
            let mut drunk_y;
            match self.settings.spawn_mode {
                DrunkSpawnMode::StartingPoint => {
                    drunk_x = starting_position.x;
                    drunk_y = starting_position.y;
                }
                DrunkSpawnMode::Random => {
                    if digger_count == 0 {
                        drunk_x = starting_position.x;
                        drunk_y = starting_position.y;
                    } else {
                        drunk_x = rng.roll_dice(1, build_data.map.width - 3) + 1;
                        drunk_y = rng.roll_dice(1, build_data.map.height - 3) + 1;
                    }
                }
            }
            let mut drunk_life = self.settings.drunken_lifetime;

            while drunk_life > 0 {
                let drunk_idx = build_data.map.xy_idx(drunk_x, drunk_y);
                if build_data.map.tiles[drunk_idx] == TileType::Wall {
                    did_something = true;
                }
                paint(&mut build_data.map, self.settings.symmetry, self.settings.brush_size, drunk_x, drunk_y);
                build_data.map.tiles[drunk_idx] = TileType::DownStairs;

                let stagger_direction = rng.roll_dice(1, 4);
                match stagger_direction {
                    1 => { if drunk_x > 2 { drunk_x -= 1; } }
                    2 => { if drunk_x < build_data.map.width-2 { drunk_x += 1; } }
                    3 => { if drunk_y > 2 { drunk_y -=1; } }
                    _ => { if drunk_y < build_data.map.height-2 { drunk_y += 1; } }
                }

                drunk_life -= 1;
            }
            if did_something { 
                build_data.take_snapshot(); 
            }

            digger_count += 1;
            for t in build_data.map.tiles.iter_mut() {
                if *t == TileType::DownStairs {
                    *t = TileType::Floor;
                }
            }
            floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
        }
    }
}
#}

Once again, you can test it by adjusting random_builder to:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(DrunkardsWalkBuilder::fearful_symmetry());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

You can cargo run and see it in action.

Update Diffusion-Limited Aggregation

This is more of the same, so we'll again just provide the code for dla.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, TileType, Position, Symmetry, paint};
use rltk::RandomNumberGenerator;

#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum DLAAlgorithm { WalkInwards, WalkOutwards, CentralAttractor }

pub struct DLABuilder {
    algorithm : DLAAlgorithm,
    brush_size: i32,
    symmetry: Symmetry,
    floor_percent: f32,
}


impl InitialMapBuilder for DLABuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl DLABuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<DLABuilder> {
        Box::new(DLABuilder{
            algorithm: DLAAlgorithm::WalkInwards,
            brush_size: 2,
            symmetry: Symmetry::None,
            floor_percent: 0.25,
        })
    }

    #[allow(dead_code)]
    pub fn walk_inwards() -> Box<DLABuilder> {
        Box::new(DLABuilder{
            algorithm: DLAAlgorithm::WalkInwards,
            brush_size: 1,
            symmetry: Symmetry::None,
            floor_percent: 0.25,
        })
    }

    #[allow(dead_code)]
    pub fn walk_outwards() -> Box<DLABuilder> {
        Box::new(DLABuilder{
            algorithm: DLAAlgorithm::WalkOutwards,
            brush_size: 2,
            symmetry: Symmetry::None,
            floor_percent: 0.25,
        })
    }

    #[allow(dead_code)]
    pub fn central_attractor() -> Box<DLABuilder> {
        Box::new(DLABuilder{
            algorithm: DLAAlgorithm::CentralAttractor,
            brush_size: 2,
            symmetry: Symmetry::None,
            floor_percent: 0.25,
        })
    }

    #[allow(dead_code)]
    pub fn insectoid() -> Box<DLABuilder> {
        Box::new(DLABuilder{
            algorithm: DLAAlgorithm::CentralAttractor,
            brush_size: 2,
            symmetry: Symmetry::Horizontal,
            floor_percent: 0.25,
        })
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        // Carve a starting seed
        let starting_position = Position{ x: build_data.map.width/2, y : build_data.map.height/2 };
        let start_idx = build_data.map.xy_idx(starting_position.x, starting_position.y);
        build_data.take_snapshot();
        build_data.map.tiles[start_idx] = TileType::Floor;
        build_data.map.tiles[start_idx-1] = TileType::Floor;
        build_data.map.tiles[start_idx+1] = TileType::Floor;
        build_data.map.tiles[start_idx-build_data.map.width as usize] = TileType::Floor;
        build_data.map.tiles[start_idx+build_data.map.width as usize] = TileType::Floor;

        // Random walker
        let total_tiles = build_data.map.width * build_data.map.height;
        let desired_floor_tiles = (self.floor_percent * total_tiles as f32) as usize;
        let mut floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
        while floor_tile_count  < desired_floor_tiles {

            match self.algorithm {
                DLAAlgorithm::WalkInwards => {
                    let mut digger_x = rng.roll_dice(1, build_data.map.width - 3) + 1;
                    let mut digger_y = rng.roll_dice(1, build_data.map.height - 3) + 1;
                    let mut prev_x = digger_x;
                    let mut prev_y = digger_y;
                    let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y);
                    while build_data.map.tiles[digger_idx] == TileType::Wall {
                        prev_x = digger_x;
                        prev_y = digger_y;
                        let stagger_direction = rng.roll_dice(1, 4);
                        match stagger_direction {
                            1 => { if digger_x > 2 { digger_x -= 1; } }
                            2 => { if digger_x < build_data.map.width-2 { digger_x += 1; } }
                            3 => { if digger_y > 2 { digger_y -=1; } }
                            _ => { if digger_y < build_data.map.height-2 { digger_y += 1; } }
                        }
                        digger_idx = build_data.map.xy_idx(digger_x, digger_y);
                    }
                    paint(&mut build_data.map, self.symmetry, self.brush_size, prev_x, prev_y);
                }

                DLAAlgorithm::WalkOutwards => {
                    let mut digger_x = starting_position.x;
                    let mut digger_y = starting_position.y;
                    let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y);
                    while build_data.map.tiles[digger_idx] == TileType::Floor {
                        let stagger_direction = rng.roll_dice(1, 4);
                        match stagger_direction {
                            1 => { if digger_x > 2 { digger_x -= 1; } }
                            2 => { if digger_x < build_data.map.width-2 { digger_x += 1; } }
                            3 => { if digger_y > 2 { digger_y -=1; } }
                            _ => { if digger_y < build_data.map.height-2 { digger_y += 1; } }
                        }
                        digger_idx = build_data.map.xy_idx(digger_x, digger_y);
                    }
                    paint(&mut build_data.map, self.symmetry, self.brush_size, digger_x, digger_y);
                }

                DLAAlgorithm::CentralAttractor => {
                    let mut digger_x = rng.roll_dice(1, build_data.map.width - 3) + 1;
                    let mut digger_y = rng.roll_dice(1, build_data.map.height - 3) + 1;
                    let mut prev_x = digger_x;
                    let mut prev_y = digger_y;
                    let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y);

                    let mut path = rltk::line2d(
                        rltk::LineAlg::Bresenham, 
                        rltk::Point::new( digger_x, digger_y ), 
                        rltk::Point::new( starting_position.x, starting_position.y )
                    );

                    while build_data.map.tiles[digger_idx] == TileType::Wall && !path.is_empty() {
                        prev_x = digger_x;
                        prev_y = digger_y;
                        digger_x = path[0].x;
                        digger_y = path[0].y;
                        path.remove(0);
                        digger_idx = build_data.map.xy_idx(digger_x, digger_y);
                    }
                    paint(&mut build_data.map, self.symmetry, self.brush_size, prev_x, prev_y);
                }
            }

            build_data.take_snapshot();

            floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count();
        }
    }    
}
#}

Updating the Maze Builder

Once again, here's the code for maze.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{Map,  InitialMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

pub struct MazeBuilder {}

impl InitialMapBuilder for MazeBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl MazeBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<MazeBuilder> {
        Box::new(MazeBuilder{})
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        // Maze gen
        let mut maze = Grid::new((build_data.map.width / 2)-2, (build_data.map.height / 2)-2, rng);
        maze.generate_maze(build_data);
    }
}

/* Maze code taken under MIT from https://github.com/cyucelen/mazeGenerator/ */

const TOP : usize = 0;
const RIGHT : usize = 1;
const BOTTOM : usize = 2;
const LEFT : usize = 3;

#[derive(Copy, Clone)]
struct Cell {
    row: i32,
    column: i32,
    walls: [bool; 4],
    visited: bool,
}

impl Cell {
    fn new(row: i32, column: i32) -> Cell {
        Cell{
            row,
            column,
            walls: [true, true, true, true],
            visited: false
        }
    }

    fn remove_walls(&mut self, next : &mut Cell) {
        let x = self.column - next.column;
        let y = self.row - next.row;

        if x == 1 {
            self.walls[LEFT] = false;
            next.walls[RIGHT] = false;
        }
        else if x == -1 {
            self.walls[RIGHT] = false;
            next.walls[LEFT] = false;
        }
        else if y == 1 {
            self.walls[TOP] = false;
            next.walls[BOTTOM] = false;
        }
        else if y == -1 {
            self.walls[BOTTOM] = false;
            next.walls[TOP] = false;
        }
    }
}

struct Grid<'a> {
    width: i32,
    height: i32,
    cells: Vec<Cell>,
    backtrace: Vec<usize>,
    current: usize,
    rng : &'a mut RandomNumberGenerator
}

impl<'a> Grid<'a> {
    fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) -> Grid {
        let mut grid = Grid{
            width,
            height,
            cells: Vec::new(),
            backtrace: Vec::new(),
            current: 0,
            rng
        };

        for row in 0..height {
            for column in 0..width {
                grid.cells.push(Cell::new(row, column));
            }
        }

        grid
    }

    fn calculate_index(&self, row: i32, column: i32) -> i32 {
        if row < 0 || column < 0 || column > self.width-1 || row > self.height-1 {
            -1
        } else {
            column + (row * self.width)
        }
    }

    fn get_available_neighbors(&self) -> Vec<usize> {
        let mut neighbors : Vec<usize> = Vec::new();

        let current_row = self.cells[self.current].row;
        let current_column = self.cells[self.current].column;

        let neighbor_indices : [i32; 4] = [
            self.calculate_index(current_row -1, current_column),
            self.calculate_index(current_row, current_column + 1),
            self.calculate_index(current_row + 1, current_column),
            self.calculate_index(current_row, current_column - 1)
        ];

        for i in neighbor_indices.iter() {
            if *i != -1 && !self.cells[*i as usize].visited {
                neighbors.push(*i as usize);
            }
        }

        neighbors
    }

    fn find_next_cell(&mut self) -> Option<usize> {
        let neighbors = self.get_available_neighbors();
        if !neighbors.is_empty() {
            if neighbors.len() == 1 {
                return Some(neighbors[0]);
            } else {
                return Some(neighbors[(self.rng.roll_dice(1, neighbors.len() as i32)-1) as usize]);
            }
        }
        None
    }

    fn generate_maze(&mut self, build_data : &mut BuilderMap) {
        let mut i = 0;
        loop {
            self.cells[self.current].visited = true;
            let next = self.find_next_cell();

            match next {
                Some(next) => {
                    self.cells[next].visited = true;
                    self.backtrace.push(self.current);
                    //   __lower_part__      __higher_part_
                    //   /            \      /            \
                    // --------cell1------ | cell2-----------
                    let (lower_part, higher_part) =
                        self.cells.split_at_mut(std::cmp::max(self.current, next));
                    let cell1 = &mut lower_part[std::cmp::min(self.current, next)];
                    let cell2 = &mut higher_part[0];
                    cell1.remove_walls(cell2);
                    self.current = next;
                }
                None => {
                    if !self.backtrace.is_empty() {
                        self.current = self.backtrace[0];
                        self.backtrace.remove(0);
                    } else {
                        break;
                    }
                }
            }

            if i % 50 == 0 {
                self.copy_to_map(&mut build_data.map);
                build_data.take_snapshot();    
            }
            i += 1;
        }
    }

    fn copy_to_map(&self, map : &mut Map) {
        // Clear the map
        for i in map.tiles.iter_mut() { *i = TileType::Wall; }

        for cell in self.cells.iter() {
            let x = cell.column + 1;
            let y = cell.row + 1;
            let idx = map.xy_idx(x * 2, y * 2);

            map.tiles[idx] = TileType::Floor;
            if !cell.walls[TOP] { map.tiles[idx - map.width as usize] = TileType::Floor }
            if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor }
            if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] = TileType::Floor }
            if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor }
        }
    }
}
#}

Updating Voronoi Maps

Here's the updated code for the Voronoi builder (in voronoi.rs):


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, TileType};
use rltk::RandomNumberGenerator;

#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum DistanceAlgorithm { Pythagoras, Manhattan, Chebyshev }

pub struct VoronoiCellBuilder {
    n_seeds: usize,
    distance_algorithm: DistanceAlgorithm
}


impl InitialMapBuilder for VoronoiCellBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl VoronoiCellBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<VoronoiCellBuilder> {
        Box::new(VoronoiCellBuilder{
            n_seeds: 64,
            distance_algorithm: DistanceAlgorithm::Pythagoras,
        })
    }

    #[allow(dead_code)]
    pub fn pythagoras() -> Box<VoronoiCellBuilder> {
        Box::new(VoronoiCellBuilder{
            n_seeds: 64,
            distance_algorithm: DistanceAlgorithm::Pythagoras,
        })
    }

    #[allow(dead_code)]
    pub fn manhattan() -> Box<VoronoiCellBuilder> {
        Box::new(VoronoiCellBuilder{
            n_seeds: 64,
            distance_algorithm: DistanceAlgorithm::Manhattan,
        })
    }

    #[allow(clippy::map_entry)]
    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        // Make a Voronoi diagram. We'll do this the hard way to learn about the technique!
        let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new();

        while voronoi_seeds.len() < self.n_seeds {
            let vx = rng.roll_dice(1, build_data.map.width-1);
            let vy = rng.roll_dice(1, build_data.map.height-1);
            let vidx = build_data.map.xy_idx(vx, vy);
            let candidate = (vidx, rltk::Point::new(vx, vy));
            if !voronoi_seeds.contains(&candidate) {
                voronoi_seeds.push(candidate);
            }
        }

        let mut voronoi_distance = vec![(0, 0.0f32) ; self.n_seeds];
        let mut voronoi_membership : Vec<i32> = vec![0 ; build_data.map.width as usize * build_data.map.height as usize];
        for (i, vid) in voronoi_membership.iter_mut().enumerate() {
            let x = i as i32 % build_data.map.width;
            let y = i as i32 / build_data.map.width;

            for (seed, pos) in voronoi_seeds.iter().enumerate() {
                let distance;
                match self.distance_algorithm {           
                    DistanceAlgorithm::Pythagoras => {
                        distance = rltk::DistanceAlg::PythagorasSquared.distance2d(
                            rltk::Point::new(x, y), 
                            pos.1
                        );
                    }
                    DistanceAlgorithm::Manhattan => {
                        distance = rltk::DistanceAlg::Manhattan.distance2d(
                            rltk::Point::new(x, y), 
                            pos.1
                        );
                    }
                    DistanceAlgorithm::Chebyshev => {
                        distance = rltk::DistanceAlg::Chebyshev.distance2d(
                            rltk::Point::new(x, y), 
                            pos.1
                        );
                    }
                }
                voronoi_distance[seed] = (seed, distance);
            }

            voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());

            *vid = voronoi_distance[0].0 as i32;
        }

        for y in 1..build_data.map.height-1 {
            for x in 1..build_data.map.width-1 {
                let mut neighbors = 0;
                let my_idx = build_data.map.xy_idx(x, y);
                let my_seed = voronoi_membership[my_idx];
                if voronoi_membership[build_data.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; }
                if voronoi_membership[build_data.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; }
                if voronoi_membership[build_data.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; }
                if voronoi_membership[build_data.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; }

                if neighbors < 2 {
                    build_data.map.tiles[my_idx] = TileType::Floor;
                }
            }
            build_data.take_snapshot();
        }
    }    
}
#}

Updating Wave Function Collapse

Wave Function Collapse is a slightly different one to port, because it already had a concept of a "previous builder". That's gone now (chaining is automatic), so there's a bit more to update. Wave Function Collapse is a meta-builder, so it implements that trait, rather than the initial map builder. Overall, these changes make it a lot more simple! The changes all take place in waveform_collapse/mod.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, Map, TileType};
use rltk::RandomNumberGenerator;
mod common;
use common::*;
mod constraints;
use constraints::*;
mod solver;
use solver::*;

/// Provides a map builder using the Wave Function Collapse algorithm.
pub struct WaveformCollapseBuilder {}

impl MetaMapBuilder for WaveformCollapseBuilder {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl WaveformCollapseBuilder {
    /// Constructor for waveform collapse.
    #[allow(dead_code)]
    pub fn new() -> Box<WaveformCollapseBuilder> {
        Box::new(WaveformCollapseBuilder{})
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        const CHUNK_SIZE :i32 = 8;
        build_data.take_snapshot();

        let patterns = build_patterns(&build_data.map, CHUNK_SIZE, true, true);
        let constraints = patterns_to_constraints(patterns, CHUNK_SIZE);
        self.render_tile_gallery(&constraints, CHUNK_SIZE, build_data);
                
        build_data.map = Map::new(build_data.map.depth);
        loop {
            let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE, &build_data.map);
            while !solver.iteration(&mut build_data.map, rng) {
                build_data.take_snapshot();
            }
            build_data.take_snapshot();
            if solver.possible { break; } // If it has hit an impossible condition, try again
        }
        build_data.spawn_list.clear();
    }

    fn render_tile_gallery(&mut self, constraints: &[MapChunk], chunk_size: i32, build_data : &mut BuilderMap) {
        build_data.map = Map::new(0);
        let mut counter = 0;
        let mut x = 1;
        let mut y = 1;
        while counter < constraints.len() {
            render_pattern_to_map(&mut build_data.map, &constraints[counter], chunk_size, x, y);

            x += chunk_size + 1;
            if x + chunk_size > build_data.map.width {
                // Move to the next row
                x = 1;
                y += chunk_size + 1;

                if y + chunk_size > build_data.map.height {
                    // Move to the next page
                    build_data.take_snapshot();
                    build_data.map = Map::new(0);

                    x = 1;
                    y = 1;
                }
            }

            counter += 1;
        }
        build_data.take_snapshot();
    }
}
#}

You can test this with the following code:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(VoronoiCellBuilder::pythagoras());
builder.with(WaveformCollapseBuilder::new());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

Updating the Prefab Builder

So here's a fun one. The PrefabBuilder is both an InitialMapBuilder and a MetaMapBuilder - with shared code between the two. Fortunately, the traits are identical - so we can implement them both and call into the main build function from each! Rust is smart enough to figure out which one we're calling based on the trait we are storing - so PrefabBuilder can be placed in either an initial or a meta map builder.

The changes all take place in prefab_builder/mod.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, MetaMapBuilder, BuilderMap, TileType, Position};
use rltk::RandomNumberGenerator;
pub mod prefab_levels;
pub mod prefab_sections;
pub mod prefab_rooms;
use std::collections::HashSet;

#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode { 
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel },
    Sectional{ section : prefab_sections::PrefabSection },
    RoomVaults
}

#[allow(dead_code)]
pub struct PrefabBuilder {
    mode: PrefabMode
}

impl MetaMapBuilder for PrefabBuilder {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl InitialMapBuilder for PrefabBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}

impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<PrefabBuilder> {
        Box::new(PrefabBuilder{
            mode : PrefabMode::RoomVaults,
        })
    }

    #[allow(dead_code)]
    pub fn rex_level(template : &'static str) -> Box<PrefabBuilder> {
        Box::new(PrefabBuilder{
            mode : PrefabMode::RexLevel{ template },
        })
    }

    #[allow(dead_code)]
    pub fn constant(level : prefab_levels::PrefabLevel) -> Box<PrefabBuilder> {
        Box::new(PrefabBuilder{
            mode : PrefabMode::Constant{ level },
        })
    }

    #[allow(dead_code)]
    pub fn sectional(section : prefab_sections::PrefabSection) -> Box<PrefabBuilder> {
        Box::new(PrefabBuilder{
            mode : PrefabMode::Sectional{ section },
        })
    }

    #[allow(dead_code)]
    pub fn vaults() -> Box<PrefabBuilder> {
        Box::new(PrefabBuilder{
            mode : PrefabMode::RoomVaults,
        })
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        match self.mode {
            PrefabMode::RexLevel{template} => self.load_rex_map(&template, build_data),
            PrefabMode::Constant{level} => self.load_ascii_map(&level, build_data),
            PrefabMode::Sectional{section} => self.apply_sectional(&section, rng, build_data),
            PrefabMode::RoomVaults => self.apply_room_vaults(rng, build_data)
        }
        build_data.take_snapshot();    
    }

    fn char_to_map(&mut self, ch : char, idx: usize, build_data : &mut BuilderMap) {
        match ch {
            ' ' => build_data.map.tiles[idx] = TileType::Floor,
            '#' => build_data.map.tiles[idx] = TileType::Wall,
            '@' => {
                let x = idx as i32 % build_data.map.width;
                let y = idx as i32 / build_data.map.width;
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.starting_position = Some(Position{ x:x as i32, y:y as i32 });
            }
            '>' => build_data.map.tiles[idx] = TileType::DownStairs,
            'g' => {
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.spawn_list.push((idx, "Goblin".to_string()));
            }
            'o' => {
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.spawn_list.push((idx, "Orc".to_string()));
            }
            '^' => {
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.spawn_list.push((idx, "Bear Trap".to_string()));
            }
            '%' => {
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.spawn_list.push((idx, "Rations".to_string()));
            }
            '!' => {
                build_data.map.tiles[idx] = TileType::Floor;
                build_data.spawn_list.push((idx, "Health Potion".to_string()));
            }
            _ => {
                rltk::console::log(format!("Unknown glyph loading map: {}", (ch as u8) as char));
            }
        }
    }

    #[allow(dead_code)]
    fn load_rex_map(&mut self, path: &str, build_data : &mut BuilderMap) {
        let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

        for layer in &xp_file.layers {
            for y in 0..layer.height {
                for x in 0..layer.width {
                    let cell = layer.get(x, y).unwrap();
                    if x < build_data.map.width as usize && y < build_data.map.height as usize {
                        let idx = build_data.map.xy_idx(x as i32, y as i32);
                        // We're doing some nasty casting to make it easier to type things like '#' in the match
                        self.char_to_map(cell.ch as u8 as char, idx, build_data);
                    }
                }
            }
        }
    }

    fn read_ascii_to_vec(template : &str) -> Vec<char> {
        let mut string_vec : Vec<char> = template.chars().filter(|a| *a != '\r' && *a !='\n').collect();
        for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } }
        string_vec
    }

    #[allow(dead_code)]
    fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel, build_data : &mut BuilderMap) {
        let string_vec = PrefabBuilder::read_ascii_to_vec(level.template);

        let mut i = 0;
        for ty in 0..level.height {
            for tx in 0..level.width {
                if tx < build_data.map.width as usize && ty < build_data.map.height as usize {
                    let idx = build_data.map.xy_idx(tx as i32, ty as i32);
                    if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); }
                }
                i += 1;
            }
        }
    }

    fn apply_previous_iteration<F>(&mut self, mut filter: F, _rng: &mut RandomNumberGenerator, build_data : &mut BuilderMap)
        where F : FnMut(i32, i32) -> bool
    {
        let width = build_data.map.width;
        build_data.spawn_list.retain(|(idx, _name)| {
            let x = *idx as i32 % width;
            let y = *idx as i32 / width;
            filter(x, y)
        });
        build_data.take_snapshot();
    }

    #[allow(dead_code)]
    fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection, rng: &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        use prefab_sections::*;

        let string_vec = PrefabBuilder::read_ascii_to_vec(section.template);
        
        // Place the new section
        let chunk_x;
        match section.placement.0 {
            HorizontalPlacement::Left => chunk_x = 0,
            HorizontalPlacement::Center => chunk_x = (build_data.map.width / 2) - (section.width as i32 / 2),
            HorizontalPlacement::Right => chunk_x = (build_data.map.width-1) - section.width as i32
        }

        let chunk_y;
        match section.placement.1 {
            VerticalPlacement::Top => chunk_y = 0,
            VerticalPlacement::Center => chunk_y = (build_data.map.height / 2) - (section.height as i32 / 2),
            VerticalPlacement::Bottom => chunk_y = (build_data.map.height-1) - section.height as i32
        }

        // Build the map
        self.apply_previous_iteration(|x,y| {
            x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32)
        }, rng, build_data);       

        let mut i = 0;
        for ty in 0..section.height {
            for tx in 0..section.width {
                if tx > 0 && tx < build_data.map.width as usize -1 && ty < build_data.map.height as usize -1 && ty > 0 {
                    let idx = build_data.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                    if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); }
                }
                i += 1;
            }
        }
        build_data.take_snapshot();
    }

    fn apply_room_vaults(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        use prefab_rooms::*;

        // Apply the previous builder, and keep all entities it spawns (for now)
        self.apply_previous_iteration(|_x,_y| true, rng, build_data);

        // Do we want a vault at all?
        let vault_roll = rng.roll_dice(1, 6) + build_data.map.depth;
        if vault_roll < 4 { return; }

        // Note that this is a place-holder and will be moved out of this function
        let master_vault_list = vec![TOTALLY_NOT_A_TRAP, CHECKERBOARD, SILLY_SMILE];

        // Filter the vault list down to ones that are applicable to the current depth
        let mut possible_vaults : Vec<&PrefabRoom> = master_vault_list
            .iter()
            .filter(|v| { build_data.map.depth >= v.first_depth && build_data.map.depth <= v.last_depth })
            .collect();

        if possible_vaults.is_empty() { return; } // Bail out if there's nothing to build

        let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32);
        let mut used_tiles : HashSet<usize> = HashSet::new();

        for _i in 0..n_vaults {

            let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize };
            let vault = possible_vaults[vault_index];

            // We'll make a list of places in which the vault could fit
            let mut vault_positions : Vec<Position> = Vec::new();

            let mut idx = 0usize;
            loop {
                let x = (idx % build_data.map.width as usize) as i32;
                let y = (idx / build_data.map.width as usize) as i32;

                // Check that we won't overflow the map
                if x > 1 
                    && (x+vault.width as i32) < build_data.map.width-2
                    && y > 1 
                    && (y+vault.height as i32) < build_data.map.height-2
                {

                    let mut possible = true;
                    for ty in 0..vault.height as i32 {
                        for tx in 0..vault.width as i32 {

                            let idx = build_data.map.xy_idx(tx + x, ty + y);
                            if build_data.map.tiles[idx] != TileType::Floor {
                                possible = false;
                            }
                            if used_tiles.contains(&idx) {
                                possible = false;
                            }
                        }
                    }

                    if possible {
                        vault_positions.push(Position{ x,y });
                        break;
                    }

                }

                idx += 1;
                if idx >= build_data.map.tiles.len()-1 { break; }
            }

            if !vault_positions.is_empty() {
                let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
                let pos = &vault_positions[pos_idx];

                let chunk_x = pos.x;
                let chunk_y = pos.y;

                let width = build_data.map.width; // The borrow checker really doesn't like it
                let height = build_data.map.height; // when we access `self` inside the `retain`
                build_data.spawn_list.retain(|e| {
                    let idx = e.0 as i32;
                    let x = idx % width;
                    let y = idx / height;
                    x < chunk_x || x > chunk_x + vault.width as i32 || y < chunk_y || y > chunk_y + vault.height as i32
                });

                let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template);
                let mut i = 0;
                for ty in 0..vault.height {
                    for tx in 0..vault.width {
                        let idx = build_data.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                        if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); }
                        used_tiles.insert(idx);
                        i += 1;
                    }
                }
                build_data.take_snapshot();

                possible_vaults.remove(vault_index);
            }
        }
    }
}
#}

You can test our recent changes with the following code in random_builder (in map_builders/mod.rs):


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(VoronoiCellBuilder::pythagoras());
builder.with(WaveformCollapseBuilder::new());
builder.with(PrefabBuilder::vaults());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT));
builder.with(DistantExit::new());
builder
#}

This demonstrates the power of our approach - we're putting a lot of functionality together from small building blocks. In this example we are:

  1. Starting with a map generated with the VoronoiBuilder in Pythagoras mode.
  2. Modifying the map with a WaveformCollapseBuilder run, which will rearrange the map like a jigsaw puzzle.
  3. Modifying the map by placing vaults, via the PrefabBuilder (in Vaults mode).
  4. Modifying the map with AreaStartingPositions indicating that we'd like to start near the middle of the map.
  5. Modifying the map to cull unreachable areas.
  6. Modifying the map to spawn entities using a Voronoi spawning method.
  7. Modifying the map to add an underground fortress, again using the PrefabBuilder.
  8. Modifying the map to add an exit staircase, in the most distant location.

Delete the MapBuilder Trait and bits from common

Now that we have the builder mechanism in place, there's some old code we can delete. From common.rs, we can delete remove_unreachable_areas_returning_most_distant and generate_voronoi_spawn_regions; we've replaced them with builder steps.

We can also open map_builders/mod.rs and delete the MapBuilder trait and its implementation: we've completely replaced it now.

Randomize

As usual, we'd like to go back to having map generation be random. We're going to break the process up into two steps. We'll make a new function, random_initial_builder that rolls a dice and picks the starting builder. It also returns a bool, indicating whether or not we picked an algorithm that provides room data. The basic function should look familiar, but we've got rid of all the Box::new calls - the constructors make boxes for us, now:


# #![allow(unused_variables)]
#fn main() {
fn random_initial_builder(rng: &mut rltk::RandomNumberGenerator) -> (Box<dyn InitialMapBuilder>, bool) {
    let builder = rng.roll_dice(1, 17);
    let result : (Box<dyn InitialMapBuilder>, bool);
    match builder {
        1 => result = (BspDungeonBuilder::new(), true),
        2 => result = (BspInteriorBuilder::new(), true),
        3 => result = (CellularAutomataBuilder::new(), false),
        4 => result = (DrunkardsWalkBuilder::open_area(), false),
        5 => result = (DrunkardsWalkBuilder::open_halls(), false),
        6 => result = (DrunkardsWalkBuilder::winding_passages(), false),
        7 => result = (DrunkardsWalkBuilder::fat_passages(), false),
        8 => result = (DrunkardsWalkBuilder::fearful_symmetry(), false),
        9 => result = (MazeBuilder::new(), false),
        10 => result = (DLABuilder::walk_inwards(), false),
        11 => result = (DLABuilder::walk_outwards(), false),
        12 => result = (DLABuilder::central_attractor(), false),
        13 => result = (DLABuilder::insectoid(), false),
        14 => result = (VoronoiCellBuilder::pythagoras(), false),
        15 => result = (VoronoiCellBuilder::manhattan(), false),
        16 => result = (PrefabBuilder::constant(prefab_builder::prefab_levels::WFC_POPULATED), false),
        _ => result = (SimpleMapBuilder::new(), true)
    }
    result
}
#}

This is a pretty straightforward function - we roll a dice, match on the result table and return the builder and room information we picked. Now we'll modify our random_builder function to use it:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    let mut builder = BuilderChain::new(new_depth);
    let (random_starter, has_rooms) = random_initial_builder(rng);
    builder.start_with(random_starter);
    if has_rooms {
        builder.with(RoomBasedSpawner::new());
        builder.with(RoomBasedStairs::new());
        builder.with(RoomBasedStartingPosition::new());
    } else {
        builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
        builder.with(CullUnreachable::new());
        builder.with(VoronoiSpawning::new());
        builder.with(DistantExit::new());
    }

    if rng.roll_dice(1, 3)==1 {
        builder.with(WaveformCollapseBuilder::new());
    }

    if rng.roll_dice(1, 20)==1 {
        builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT));
    }

    builder.with(PrefabBuilder::vaults());

    builder
}
#}

This should look familiar. This function:

  1. Selects a random room using the function we just created.
  2. If the builder provides room data, we chain RoomBasedSpawner, RoomBasedStairs and RoomBasedStartingPositiosn - the three important steps required for room data.
  3. If the builder doesn't provide room information, we chain AreaStartingPosition, CullUnreachable, VoronoiSpawning and DistantExit - the defaults we used to apply inside each builder.
  4. We roll a 3-sided die; if it comes up 1 - we apply a WaveformCollapseBuilder to rearrange the map.
  5. We roll a 20-sided die; if it comes up 1 - we apply our Underground Fort prefab.
  6. We apply vault creation to the final map, giving a chance for pre-made rooms to appear.

Wrap-Up

This has been an enormous chapter, but we've accomplished a lot:

  • We now have a consistent builder interface for chaining as many meta-map modifiers as we want to our build chain. This should let us build the maps we want.
  • Each builder now does just one task - so it's much more obvious where to go if you need to fix/debug them.
  • Builders are no longer responsible for making other builders - so we've culled a swathe of code and moved the opportunity for bugs to creep in to just one (simple) control flow.

This sets the stage for the next chapter, which will look at more ways to use filters to modify your map.

...

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Fun With Layers


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


Now that we have a nice, clean layering system we'll take the opportunity to play with it a bit. This chapter is a collection of fun things you can do with layers, and will introduce a few new layer types. It's meant to whet your appetite to write more: the sky really is the limit!

Existing Algorithms as Meta-Builders

Let's start by adjusting some of our existing algorithms to be useful as filters.

Applying Cellular Automata as a meta-builder

When we wrote the Cellular Automata system, we aimed for a generic cavern builder. The algorithm is capable of quite a bit more than that - each iteration is basically a "meta builder" running on the previous iteration. A simple tweak allows it to also be a meta-builder that only runs a single iteration.

We'll start by moving the code for a single iteration into its own function:


# #![allow(unused_variables)]
#fn main() {
fn apply_iteration(&mut self, build_data : &mut BuilderMap) {
    let mut newtiles = build_data.map.tiles.clone();

    for y in 1..build_data.map.height-1 {
        for x in 1..build_data.map.width-1 {
            let idx = build_data.map.xy_idx(x, y);
            let mut neighbors = 0;
            if build_data.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx - (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx - (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx + (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; }
            if build_data.map.tiles[idx + (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; }

            if neighbors > 4 || neighbors == 0 {
                newtiles[idx] = TileType::Wall;
            }
            else {
                newtiles[idx] = TileType::Floor;
            }
        }
    }

    build_data.map.tiles = newtiles.clone();
    build_data.take_snapshot();
}
#}

The build function is easily modified to call this on each iteration:


# #![allow(unused_variables)]
#fn main() {
// Now we iteratively apply cellular automata rules
for _i in 0..15 {
    self.apply_iteration(build_data);
}
#}

Finally, we'll add an implementation of MetaMapBuilder to the mix:


# #![allow(unused_variables)]
#fn main() {
impl MetaMapBuilder for CellularAutomataBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, _rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.apply_iteration(build_data);
    }
}
#}

See how we're calling a single iteration, instead of replacing the whole map? This shows how we can apply the cellular automata rules to the map - and change the resultant character quite a bit.

Now lets modify map_builders/mod.rs's random_builder to force it to use this as an example:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    let mut builder = BuilderChain::new(new_depth);
    builder.start_with(VoronoiCellBuilder::pythagoras());
    builder.with(CellularAutomataBuilder::new());
    builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
    builder.with(CullUnreachable::new());
    builder.with(VoronoiSpawning::new());
    builder.with(DistantExit::new());
    builder
}
#}

If you cargo run the project now, you'll see something like this:

Screenshot.

Eroding a boxy map with drunken dwarves

The Drunken Walk algorithm can also make a nice post-processing effect, with very minimal modification. In drunkard.rs, simply add the following:


# #![allow(unused_variables)]
#fn main() {
impl MetaMapBuilder for DrunkardsWalkBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}
#}

You can test it by once again modifying random_builder:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(SimpleMapBuilder::new());
builder.with(DrunkardsWalkBuilder::winding_passages());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

If you cargo run the project, you'll see something like this:

Screenshot.

Notice how the initial boxy design now looks a bit more natural, because drunken dwarves have carved out sections of the map!

Attacking your boxy map with Diffusion-Limited Aggregation

DLA can also be modified to erode an existing, boxy map. Simply add the MetaBuilder trait to dla.rs:


# #![allow(unused_variables)]
#fn main() {
impl MetaMapBuilder for DLABuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build(rng, build_data);
    }
}
#}

We'll also add a new mode, heavy_erosion - it's the same as "walk inwards", but wants a greater percentage of floor space:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
pub fn heavy_erosion() -> Box<DLABuilder> {
    Box::new(DLABuilder{
        algorithm: DLAAlgorithm::WalkInwards,
        brush_size: 2,
        symmetry: Symmetry::None,
        floor_percent: 0.35,
    })
}
#}

And modify your random_builder test harness:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(SimpleMapBuilder::new());
builder.with(DLABuilder::heavy_erosion());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

If you cargo run the project, you'll see something like this:

Screenshot.

Some New Meta-Builders

There's also plenty of scope to write new map filters. We'll explore a few of the more interesting ones in this section. Pretty much anything you might use as an image filter in a program like Photoshop (or the GIMP!) could be adapted for this purpose. How useful a given filter is remains an open/interesting question!

Eroding rooms

Nethack-style boxy rooms make for very early-D&D type play, but people often remark that they aren't all that visually pleasing or interesting. One way to keep the basic room style, but get a more organic look, is to run drunkard's walk inside each room. I like to call this "exploding the room" - because it looks a bit like you set off dynamite in each room. In map_builders/, make a new file room_exploder.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType, paint, Symmetry, Rect};
use rltk::RandomNumberGenerator;

pub struct RoomExploder {}

impl MetaMapBuilder for RoomExploder {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomExploder {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomExploder> {
        Box::new(RoomExploder{})
    }

    fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let rooms : Vec<Rect>;
        if let Some(rooms_builder) = &build_data.rooms {
            rooms = rooms_builder.clone();
        } else {
            panic!("Room Explosions require a builder with room structures");
        }

        for room in rooms.iter() {
            let start = room.center();
            let n_diggers = rng.roll_dice(1, 20)-5;
            if n_diggers > 0 {
                for _i in 0..n_diggers {
                    let mut drunk_x = start.0;
                    let mut drunk_y = start.1;

                    let mut drunk_life = 20;
                    let mut did_something = false;

                    while drunk_life > 0 {
                        let drunk_idx = build_data.map.xy_idx(drunk_x, drunk_y);
                        if build_data.map.tiles[drunk_idx] == TileType::Wall {
                            did_something = true;
                        }
                        paint(&mut build_data.map, Symmetry::None, 1, drunk_x, drunk_y);
                        build_data.map.tiles[drunk_idx] = TileType::DownStairs;

                        let stagger_direction = rng.roll_dice(1, 4);
                        match stagger_direction {
                            1 => { if drunk_x > 2 { drunk_x -= 1; } }
                            2 => { if drunk_x < build_data.map.width-2 { drunk_x += 1; } }
                            3 => { if drunk_y > 2 { drunk_y -=1; } }
                            _ => { if drunk_y < build_data.map.height-2 { drunk_y += 1; } }
                        }

                        drunk_life -= 1;
                    }
                    if did_something { 
                        build_data.take_snapshot();
                    }

                    for t in build_data.map.tiles.iter_mut() {
                        if *t == TileType::DownStairs {
                            *t = TileType::Floor;
                        }
                    }
                }
            }
        }
    }
}
#}

There's nothing too surprising in this code: it takes the rooms list from the parent build data, and then iterates each room. A random number (which can be zero) of drunkards is then run from the center of each room, with a short lifespan, carving out the edges of each room. You can test this with the following random_builder code:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(RoomExploder::new());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

Screenshot.

Rounding Room Corners

Another quick and easy way to make a boxy map look less rectangular is to smooth the corners a bit. Add room_corner_rounding.rs to map_builders/:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType, Rect};
use rltk::RandomNumberGenerator;

pub struct RoomCornerRounder {}

impl MetaMapBuilder for RoomCornerRounder {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomCornerRounder {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomCornerRounder> {
        Box::new(RoomCornerRounder{})
    }

    fn fill_if_corner(&mut self, x: i32, y: i32, build_data : &mut BuilderMap) {
        let w = build_data.map.width;
        let h = build_data.map.height;
        let idx = build_data.map.xy_idx(x, y);
        let mut neighbor_walls = 0;
        if x > 0 && build_data.map.tiles[idx-1] == TileType::Wall { neighbor_walls += 1; }
        if y > 0 && build_data.map.tiles[idx-w as usize] == TileType::Wall { neighbor_walls += 1; }
        if x < w-2 && build_data.map.tiles[idx+1] == TileType::Wall { neighbor_walls += 1; }
        if y < h-2 && build_data.map.tiles[idx+w as usize] == TileType::Wall { neighbor_walls += 1; }

        if neighbor_walls == 2 {
            build_data.map.tiles[idx] = TileType::Wall;
        }
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let rooms : Vec<Rect>;
        if let Some(rooms_builder) = &build_data.rooms {
            rooms = rooms_builder.clone();
        } else {
            panic!("Room Rounding require a builder with room structures");
        }

        for room in rooms.iter() {
            self.fill_if_corner(room.x1+1, room.y1+1, build_data);
            self.fill_if_corner(room.x2, room.y1+1, build_data);
            self.fill_if_corner(room.x1+1, room.y2, build_data);
            self.fill_if_corner(room.x2, room.y2, build_data);

            build_data.take_snapshot();
        }
    }
}
#}

The boilerplate (repeated code) should look familiar by now, so we'll focus on the algorithm in build:

  1. We obtain a list of rooms, and panic! if there aren't any.
  2. For each of the 4 corners of the room, we call a new function fill_if_corner.
  3. fill_if_corner counts each of the neighboring tiles to see if it is a wall. If there are exactly 2 walls, then this tile is eligible to become a corner - so we fill in a wall.

You can try it out with the following random_builder code:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(RoomCornerRounder::new());
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::new());
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
builder
#}

The result (if you cargo run) should be something like this:

Screenshot.

Decoupling Rooms and Corridors

There's a fair amount of shared code between BSP room placement and "simple map" room placement - but with different corridor decision-making. What if we were to de-couple the stages - so the room algorithms decide where the rooms go, another algorithm draws them (possibly changing how they are drawn), and a third algorithm places corridors? Our improved framework supports this with just a bit of algorithm tweaking.

Here's simple_map.rs with the corridor code removed:


# #![allow(unused_variables)]
#fn main() {
use super::{InitialMapBuilder, BuilderMap, Rect, apply_room_to_map, 
    apply_horizontal_tunnel, apply_vertical_tunnel };
use rltk::RandomNumberGenerator;

pub struct SimpleMapBuilder {}

impl InitialMapBuilder for SimpleMapBuilder {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.build_rooms(rng, build_data);
    }
}

impl SimpleMapBuilder {
    #[allow(dead_code)]
    pub fn new() -> Box<SimpleMapBuilder> {
        Box::new(SimpleMapBuilder{})
    }

    fn build_rooms(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;
        let mut rooms : Vec<Rect> = Vec::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, build_data.map.width - w - 1) - 1;
            let y = rng.roll_dice(1, build_data.map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                apply_room_to_map(&mut build_data.map, &new_room);
                build_data.take_snapshot();

                rooms.push(new_room);
                build_data.take_snapshot();
            }
        }
        build_data.rooms = Some(rooms);
    }
}
#}

Other than renaming rooms_and_corridors to just build_rooms, the only change is removing the dice roll to place corridors.

Lets make a new file, map_builders/rooms_corridors_dogleg.rs. This is where we place the corridors. For now, we'll use the same algorithm we just removed from SimpleMapBuilder:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, Rect, apply_horizontal_tunnel, apply_vertical_tunnel };
use rltk::RandomNumberGenerator;

pub struct DoglegCorridors {}

impl MetaMapBuilder for DoglegCorridors {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.corridors(rng, build_data);
    }
}

impl DoglegCorridors {
    #[allow(dead_code)]
    pub fn new() -> Box<DoglegCorridors> {
        Box::new(DoglegCorridors{})
    }

    fn corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let rooms : Vec<Rect>;
        if let Some(rooms_builder) = &build_data.rooms {
            rooms = rooms_builder.clone();
        } else {
            panic!("Dogleg Corridors require a builder with room structures");
        }

        for (i,room) in rooms.iter().enumerate() {
            if i > 0 {
                let (new_x, new_y) = room.center();
                let (prev_x, prev_y) = rooms[i as usize -1].center();
                if rng.range(0,2) == 1 {
                    apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y);
                    apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x);
                } else {
                    apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x);
                    apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y);
                }
                build_data.take_snapshot();
            }
        }
    }
}
#}

Again - this is the code we just removed, but placed into a new builder by itself. So there's really nothing new. We can adjust random_builder to test this code:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(SimpleMapBuilder::new());
builder.with(DoglegCorridors::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder
#}

Testing it with cargo run should show you that rooms are built, and then corridors:

Screenshot.

Same again with BSP Dungeons

It's easy to do the same to our BSPDungeonBuilder. In bsp_dungeon.rs, we also trim out the corridor code. We'll just include the build function for brevity:


# #![allow(unused_variables)]
#fn main() {
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let mut rooms : Vec<Rect> = Vec::new();
        self.rects.clear();
        self.rects.push( Rect::new(2, 2, build_data.map.width-5, build_data.map.height-5) ); // Start with a single map-sized rectangle
        let first_room = self.rects[0];
        self.add_subrects(first_room); // Divide the first room

        // Up to 240 times, we get a random rectangle and divide it. If its possible to squeeze a
        // room in there, we place it and add it to the rooms list.
        let mut n_rooms = 0;
        while n_rooms < 240 {
            let rect = self.get_random_rect(rng);
            let candidate = self.get_random_sub_rect(rect, rng);

            if self.is_possible(candidate, &build_data.map) {
                apply_room_to_map(&mut build_data.map, &candidate);
                rooms.push(candidate);
                self.add_subrects(rect);
                build_data.take_snapshot();
            }

            n_rooms += 1;
        }

        build_data.rooms = Some(rooms);
    }
#}

We'll also move our BSP corridor code into a new builder, without the room sorting (we'll be touching on sorting in the next heading!). Create the new file map_builders/rooms_corridors_bsp.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, Rect, draw_corridor };
use rltk::RandomNumberGenerator;

pub struct BspCorridors {}

impl MetaMapBuilder for BspCorridors {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.corridors(rng, build_data);
    }
}

impl BspCorridors {
    #[allow(dead_code)]
    pub fn new() -> Box<BspCorridors> {
        Box::new(BspCorridors{})
    }

    fn corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let rooms : Vec<Rect>;
        if let Some(rooms_builder) = &build_data.rooms {
            rooms = rooms_builder.clone();
        } else {
            panic!("BSP Corridors require a builder with room structures");
        }

        for i in 0..rooms.len()-1 {
            let room = rooms[i];
            let next_room = rooms[i+1];
            let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
            let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
            let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
            let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
            draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y);
            build_data.take_snapshot();
        }
    }
}
#}

Again, this is the corridor code from BspDungeonBuilder - just fitted into its own builder stage. You can prove that it works by modifying random_builder once again:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(BspCorridors::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder
#}

If you cargo run it, you'll see something like this:

Screenshot.

That looks like it works - but if you pay close attention, you'll see why we sorted the rooms in the original algorithm: there's lots of overlap between rooms/corridors, and corridors don't trend towards the shortest path. This was deliberate - we need to make a RoomSorter builder, to give us some more map-building options. Lets create map_builders/room_sorter.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap };
use rltk::RandomNumberGenerator;

pub struct RoomSorter {}

impl MetaMapBuilder for RoomSorter {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.sorter(rng, build_data);
    }
}

impl RoomSorter {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomSorter> {
        Box::new(RoomSorter{})
    }

    fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) );
    }
}
#}

This is exactly the same sorting we used before, and we can test it by inserting it into our builder sequence:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(RoomSorter::new());
builder.with(BspCorridors::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder
#}

If you cargo run it, you'll see something like this:

Screenshot.

That's better - we've restored the look and feel of our BSP Dungeon Builder!

More Room Sorting Options

Breaking the sorter into its own step is only really useful if we're going to come up with some different ways to sort the rooms! We're currently sorting by the left-most entry - giving a map that gradually works its way East, but jumps around.

Lets add an enum to give us more sorting options:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap };
use rltk::RandomNumberGenerator;

pub enum RoomSort { LEFTMOST }

pub struct RoomSorter {
    sort_by : RoomSort
}

impl MetaMapBuilder for RoomSorter {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.sorter(rng, build_data);
    }
}

impl RoomSorter {
    #[allow(dead_code)]
    pub fn new(sort_by : RoomSort) -> Box<RoomSorter> {
        Box::new(RoomSorter{ sort_by })
    }

    fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        match self.sort_by {
            RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) )
        }
    }
}
#}

Simple enough: we store the sorting algorithm we wish to use in the structure, and match on it when it comes time to execute.

Lets add RIGHTMOST - which will simply reverse the sort:


# #![allow(unused_variables)]
#fn main() {
pub enum RoomSort { LEFTMOST, RIGHTMOST }
...
fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    match self.sort_by {
        RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ),
        RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) )
    }
}
#}

That's so simple it's basically cheating! Lets add TOPMOST and BOTTOMMOST as well, for completeness of this type of sort:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST }
...
fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    match self.sort_by {
        RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ),
        RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ),
        RoomSort::TOPMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ),
        RoomSort::BOTTOMMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) )
    }
}
#}

Here's BOTTOMMOST in action:

Screenshot.

See how that changes the character of the map without really changing the structure? It's amazing what you can do with little tweaks!

We'll add another sort, CENTRAL. This time, we're sorting by distance from the map center:


# #![allow(unused_variables)]
#fn main() {
#[allow(dead_code)]
pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST, CENTRAL }
...
fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    match self.sort_by {
        RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ),
        RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ),
        RoomSort::TOPMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ),
        RoomSort::BOTTOMMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) ),
        RoomSort::CENTRAL => {
            let map_center = rltk::Point::new( build_data.map.width / 2, build_data.map.height / 2 );
            let center_sort = |a : &Rect, b : &Rect| {
                let a_center = a.center();
                let a_center_pt = rltk::Point::new(a_center.0, a_center.1);
                let b_center = b.center();
                let b_center_pt = rltk::Point::new(b_center.0, b_center.1);
                let distance_a = rltk::DistanceAlg::Pythagoras.distance2d(a_center_pt, map_center);
                let distance_b = rltk::DistanceAlg::Pythagoras.distance2d(b_center_pt, map_center);
                distance_a.partial_cmp(&distance_b).unwrap()
            };

            build_data.rooms.as_mut().unwrap().sort_by(center_sort);
        }
    }
}
#}

You can modify your random_builder function to use this:


# #![allow(unused_variables)]
#fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspDungeonBuilder::new());
builder.with(RoomSorter::new(RoomSort::CENTRAL));
builder.with(BspCorridors::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder
#}

And cargo run will give you something like this:

Screenshot.

Notice how all roads now lead to the middle - for a very connected map!

Cleaning up our random builder

Now that we're getting towards the end of this section (not there yet!), lets take the time to really take advantage of what we've built so far. We're going to completely restructure the way we're selecting a random build pattern.

Room-based spawning isn't as embarrassingly predictable as it used to be, now. So lets make a function that exposes all of the room variety we've built so far:


# #![allow(unused_variables)]
#fn main() {
fn random_room_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) {
    let build_roll = rng.roll_dice(1, 3);
    match build_roll {
        1 => builder.start_with(SimpleMapBuilder::new()),
        2 => builder.start_with(BspDungeonBuilder::new()),
        _ => builder.start_with(BspInteriorBuilder::new())
    }

    // BSP Interior still makes holes in the walls
    if build_roll != 3 {
        // Sort by one of the 5 available algorithms
        let sort_roll = rng.roll_dice(1, 5);
        match sort_roll {
            1 => builder.with(RoomSorter::new(RoomSort::LEFTMOST)),
            2 => builder.with(RoomSorter::new(RoomSort::RIGHTMOST)),
            3 => builder.with(RoomSorter::new(RoomSort::TOPMOST)),
            4 => builder.with(RoomSorter::new(RoomSort::BOTTOMMOST)),
            _ => builder.with(RoomSorter::new(RoomSort::CENTRAL)),
        }

        let corridor_roll = rng.roll_dice(1, 2);
        match corridor_roll {
            1 => builder.with(DoglegCorridors::new()),
            _ => builder.with(BspCorridors::new())
        }

        let modifier_roll = rng.roll_dice(1, 6);
        match modifier_roll {
            1 => builder.with(RoomExploder::new()),
            2 => builder.with(RoomCornerRounder::new()),
            _ => {}
        }
    }

    let start_roll = rng.roll_dice(1, 2);
    match start_roll {
        1 => builder.with(RoomBasedStartingPosition::new()),
        _ => {
            let (start_x, start_y) = random_start_position(rng);
            builder.with(AreaStartingPosition::new(start_x, start_y));
        }
    }

    let exit_roll = rng.roll_dice(1, 2);
    match exit_roll {
        1 => builder.with(RoomBasedStairs::new()),
        _ => builder.with(DistantExit::new())
    }

    let spawn_roll = rng.roll_dice(1, 2);
    match spawn_roll {
        1 => builder.with(RoomBasedSpawner::new()),
        _ => builder.with(VoronoiSpawning::new())
    }
}
#}

That's a big function, so we'll step through it. It's quite simple, just really spread out and full of branches:

  1. We roll 1d3, and pick from BSP Interior, Simple and BSP Dungeon map builders.
  2. If we didn't pick BSP Interior (which does a lot of stuff itself), we:
    1. Randomly pick a room sorting algorithm.
    2. Randomly pick one of the two corridor algorithms we now have.
    3. Randomly pick (or ignore) a room exploder or corner-rounder.
  3. We randomly choose between a Room-based starting position, and an area-based starting position. For the latter, call random_start_position to pick between 3 X-axis and 3 Y-axis starting positions to favor.
  4. We randomly choose between a Room-based stairs placement and a "most distant from the start" exit.
  5. We randomly choose between Voronoi-area spawning and room-based spawning.

So that function is all about rolling dice, and making a map! It's a lot of combinations, even ignoring the thousands of possible layouts that can come from each starting builder. There are:

2 <starting rooms with options> * 5 <sort> * 2 <corridor> * 3 <modifier> = 60 basic room options.
+1 for BSP Interior Dungeons = 61 room options.
*2 <starting position options> = 122 room options.
*2 <exit placements> = 244 room options.
*2 <spawn options> = 488 room options!

So this function is offering 488 possible builder combinations!.

Now we'll create a function for the non-room spawners:


# #![allow(unused_variables)]
#fn main() {
fn random_shape_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) {
    let builder_roll = rng.roll_dice(1, 16);
    match builder_roll {
        1 => builder.start_with(CellularAutomataBuilder::new()),
        2 => builder.start_with(DrunkardsWalkBuilder::open_area()),
        3 => builder.start_with(DrunkardsWalkBuilder::open_halls()),
        4 => builder.start_with(DrunkardsWalkBuilder::winding_passages()),
        5 => builder.start_with(DrunkardsWalkBuilder::fat_passages()),
        6 => builder.start_with(DrunkardsWalkBuilder::fearful_symmetry()),
        7 => builder.start_with(MazeBuilder::new()),
        8 => builder.start_with(DLABuilder::walk_inwards()),
        9 => builder.start_with(DLABuilder::walk_outwards()),
        10 => builder.start_with(DLABuilder::central_attractor()),
        11 => builder.start_with(DLABuilder::insectoid()),
        12 => builder.start_with(VoronoiCellBuilder::pythagoras()),
        13 => builder.start_with(VoronoiCellBuilder::manhattan()),
        _ => builder.start_with(PrefabBuilder::constant(prefab_builder::prefab_levels::WFC_POPULATED)),
    }

    // Set the start to the center and cull
    builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
    builder.with(CullUnreachable::new());

    // Now set the start to a random starting area
    let (start_x, start_y) = random_start_position(rng);
    builder.with(AreaStartingPosition::new(start_x, start_y));

    // Setup an exit and spawn mobs
    builder.with(VoronoiSpawning::new());
    builder.with(DistantExit::new());
}
#}

This is similar to what we've done before, but with a twist: we now place the player centrally, cull unreachable areas, and then place the player in a random location. It's likely that the middle of a generated map is quite connected - so this gets rid of dead space, and minimizes the likelihood of starting in an "orphaned" section and culling the map down to just a few tiles.

This also provides a lot of combinations, but not quite as many.

14 basic room options
*1 Spawn option
*1 Exit option
*6 Starting options
= 84 options.

So this function is offering 84 room builder combinations.

Finally, we pull it all together in random_builder:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    let mut builder = BuilderChain::new(new_depth);
    let type_roll = rng.roll_dice(1, 2);
    match type_roll {
        1 => random_room_builder(rng, &mut builder),
        _ => random_shape_builder(rng, &mut builder)
    }

    if rng.roll_dice(1, 3)==1 {
        builder.with(WaveformCollapseBuilder::new());
    }

    if rng.roll_dice(1, 20)==1 {
        builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT));
    }

    builder.with(PrefabBuilder::vaults());

    builder
}
#}

This is relatively straightforward. We randomly pick either a room or a shape builder, as defined above. There's a 1 in 3 chance we'll then run Wave Function Collapse on it, and a 1 in 20 chance that we'll add a sectional to it. Finally, we try to spawn any vaults we might want to use.

So how does our total combinatorial explosion look? Pretty good at this point:

488 possible room builders +
84 possible shape builders =
572 builder combinations.

We might run Wave Function Collapse, giving another 2 options:
*2 = 1,144

We might add a sectional:
*2 = 2,288

So we now have 2,288 possible builder combinations, just from the last few chapters. Combine that with a random seed, and it's increasingly unlikely that a player will see the exact same combination of maps on a run twice.

...

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.


Improved room building


About this tutorial

This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.


In the last chapter, we abstracted out room layout - but kept the actual placement of the rooms the same: they are always rectangles, although this can be mitigated with room explosion and corner rounding. This chapter will add the ability to use rooms of different shapes.

Rectangle Room Builder

First, we'll make a builder that accepts a set of rooms as input, and outputs those rooms as rectangles on the map - exactly like the previous editions. We'll also modify SimpleMapBuilder and BspDungeonBuilder to not duplicate the functionality.

We'll make a new file, map_builders/room_draw.rs:


# #![allow(unused_variables)]
#fn main() {
use super::{MetaMapBuilder, BuilderMap, TileType, Rect};
use rltk::RandomNumberGenerator;

pub struct RoomDrawer {}

impl MetaMapBuilder for RoomDrawer {
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap)  {
        self.build(rng, build_data);
    }
}

impl RoomDrawer {
    #[allow(dead_code)]
    pub fn new() -> Box<RoomDrawer> {
        Box::new(RoomDrawer{})
    }

    fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
        let rooms : Vec<Rect>;
        if let Some(rooms_builder) = &build_data.rooms {
            rooms = rooms_builder.clone();
        } else {
            panic!("Room Rounding require a builder with room structures");
        }

        for room in rooms.iter() {
            for y in room.y1 +1 ..= room.y2 {
                for x in room.x1 + 1 ..= room.x2 {
                    let idx = build_data.map.xy_idx(x, y);
                    if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize {
                        build_data.map.tiles[idx] = TileType::Floor;
                    }
                }
            }
            build_data.take_snapshot();
        }
    }
}
#}

This is the same drawing functionality found in common.rs's apply_room_to_map - wrapped in the same meta-builder functionality we've used in the last couple of chapters. Nothing too surprising here!

In bsp_dungeon.rs, simply remove the line referencing apply_room_to_map. You can also remove take_snapshot - since we aren't applying anything to the map yet:


# #![allow(unused_variables)]
#fn main() {
if self.is_possible(candidate, &build_data.map, &rooms) {
    rooms.push(candidate);
    self.add_subrects(rect);
}
#}

We'll also have to update is_possible to check the rooms list rather than reading the live map (to which we haven't written anything):


# #![allow(unused_variables)]
#fn main() {
fn is_possible(&self, rect : Rect, build_data : &BuilderMap, rooms: &Vec<Rect>) -> bool {
    let mut expanded = rect;
    expanded.x1 -= 2;
    expanded.x2 += 2;
    expanded.y1 -= 2;
    expanded.y2 += 2;

    let mut can_build = true;

    for r in rooms.iter() {
        if r.intersect(&rect) { can_build = false; }
    }

    for y in expanded.y1 ..= expanded.y2 {
        for x in expanded.x1 ..= expanded.x2 {
            if x > build_data.map.width-2 { can_build = false; }
            if y > build_data.map.height-2 { can_build = false; }
            if x < 1 { can_build = false; }
            if y < 1 { can_build = false; }
            if can_build {
                let idx = build_data.map.xy_idx(x, y);
                if build_data.map.tiles[idx] != TileType::Wall { 
                    can_build = false; 
                }
            }
        }
    }

    can_build
}
#}

Likewise, in simple_map.rs - just remove the apply_room_to_map and take_snapshot calls:


# #![allow(unused_variables)]
#fn main() {
if ok {
    rooms.push(new_room);
}
#}

Nothing is using apply_room_to_map in common.rs anymore - so we can delete that too!

Lastly, modify random_builder in map_builders/mod.rs to test our code:


# #![allow(unused_variables)]
#fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    /*let mut builder = BuilderChain::new(new_depth);
    let type_roll = rng.roll_dice(1, 2);
    match type_roll {
        1 => random_room_builder(rng, &mut builder),
        _ => random_shape_builder(rng, &mut builder)
    }

    if rng.roll_dice(1, 3)==1 {
        builder.with(WaveformCollapseBuilder::new());
    }

    if rng.roll_dice(1, 20)==1 {
        builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT));
    }

    builder.with(PrefabBuilder::vaults());

    builder*/

    let mut builder = BuilderChain::new(new_depth);
    builder.start_with(SimpleMapBuilder::new());
    builder.with(RoomDrawer::new());
    builder.with(RoomSorter::new(RoomSort::LEFTMOST));
    builder.with(BspCorridors::new());
    builder.with(RoomBasedSpawner::new());
    builder.with(RoomBasedStairs::new());
    builder.with(RoomBasedStartingPosition::new());
    builder
}
#}

If you cargo run the project, you'll see our simple map builder run - just like before.

Circular Rooms

Simply moving the draw code out of the algorithm cleans things up, but doesn't gain us anything new. So we'll look at adding a few shape options for rooms. We'll start by moving the draw code out of the main loop and into its own function. Modify room_draw.rs as follows:


# #![allow(unused_variables)]
#fn main() {
fn rectangle(&mut self, build_data : &mut BuilderMap, room : &Rect) {
    for y in room.y1 +1 ..= room.y2 {
        for x in room.x1 + 1 ..= room.x2 {
            let idx = build_data.map.xy_idx(x, y);
            if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize {
                build_data.map.tiles[idx] = TileType::Floor;
            }
        }
    }
}

fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    let rooms : Vec<Rect>;
    if let Some(rooms_builder) = &build_data.rooms {
        rooms = rooms_builder.clone();
    } else {
        panic!("Room Drawing require a builder with room structures");
    }

    for room in rooms.iter() {
        self.rectangle(build_data, room);
        build_data.take_snapshot();
    }
}
#}

Once again, if you feel like testing it - cargo run will give you similar results to last time. Lets add a second room shape - circular rooms: