This is the first in what I hope to be a series of tutorials and articles covering important points in the architecture and design of Sulis. While Sulis is used as the primary example, the post and example code below should be applicable to pretty much any game or similar project.
Sulis is a turn based tactical RPG written in Rust.
Motivation
In developing Sulis, one of the primary goals is easy and powerful modding capabilities. To that end, virtually all resources are defined via simple YAML files. The idea is that anyone with a text editor can create new resources or edit existing ones easily. However, this immediately brings up the question of how to manage all these resources within the game's state. In Sulis, this is handled via a central resource manager.
Resources
For the purposes of this discussion, each resource has its own single YAML file to define it. (In actuality, Sulis allows multiple files from different sources to merge to create one resource - hopefully I'll cover this in a future Post).
For example, here is pretty much the simplest possible example of an in-game item:
id: craft_gem01
name: Amethyst
icon: inventory/craft_gem01
weight: 5
value: 100
And now a corresponding Rust Struct. All the non-relevant fields for this example have been removed:
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
pub id: String,
pub name: String,
pub icon: String,
value: u32,
weight: u32,
}
By adding serde and serde-yaml as dependencies in our project, we can very easily Deserialize the YAML file into the Rust struct at runtime:
fn read_resource<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, Error> {
let data = std::fs::read_to_string(path)?;
let resource: T = match serde_yaml::from_str(&data) {
Ok(resource) => Ok(resource),
Err(_) => Err(Error::new(ErrorKind::Other, "Unable to parse YAML")),
}?;
Ok(resource)
}
The conversion from a serde_yaml::Error to a std::io::Error makes our otherwise clean example a bit messy. But, in our real code we will need much better error handling anyway.
Multiple Resources
In Sulis, we typically have a whole bunch of each type of resource to store. So, we find all the resources in a given directory, and store them in a HashMap by ID.
fn read_resources<T: serde::de::DeserializeOwned>(dir: &str, resources: &mut HashMap<String, T>) -> Result<(), Error> {
let dir_entries = std::fs::read_dir(dir)?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
let path_str = path.to_string_lossy().to_string();
if path.is_dir() {
read_resources(&path_str, resources)?;
} else if path.is_file() {
let id = path.file_stem().unwrap().to_string_lossy().to_string();
let t: T = read_resource(&path_str)?;
resources.insert(id, t);
}
}
Ok(())
}
This simplified example lacks any error handling and uses the filename as the ID (rather than the ID inside the resource). But, all we need to do to recursively read an entire directory of a given resource type is create a new HashMap and pass it into this recursive function.
Resource Management
With a nice map of resources available, we need to make them available to the rest of the application. We can easily create a resource set with a method to access our data by ID:
pub struct ResourceSet {
items: HashMap<String, Item>,
}
impl ResourceSet {
pub fn item(&self, id: &str) -> Option<&Item> {
self.items.get(id)
}
}
The hard part is sharing this data with the rest of the application. We want the resources to be global state, accessible from anywhere in our application. In most languages, it would be easy (albeit unsafe!) to accomplish this. Rust makes this harder. There are essentially two problems to deal with:
- By returning a reference to our Item, we are causing client code to hold a borrow on our ResourceSet for potentially long periods of time - and it will make having structs holding onto Items very difficult or impossible.
- Rust doesn't allow mutable global state.
Both of these are definitely issues - we want to be able to load and unload resources (say, if a user activates a mod or loads a new campaign) at runtime.
Using Rc
The obvious solution to (1), although it does cause a small performance hit, is to just use Rc. Instead of returning an Item reference, we return a reference counted Rc object. This makes it easy and quite transparent for our client code to pass Items around and store them as much as they want:
pub struct ResourceSet {
items: HashMap<String, Rc<Item>>,
}
impl ResourceSet {
pub fn item(&self, id: &str) -> Option<Rc<Item>> {
self.items.get(id).map(|item| Rc::clone(item))
}
}
When we first insert an item into the resource set, we simply wrap it in Rc::new(item). Then, when an item is requested, we simply do a fast Rc::clone of the item. We'll also need to slightly modify our resource reading code from before. (This is left as an exercise for the reader).
Mutable Globals
The second issue is a bit harder to tackle, and there are a few different ways to approach it. In Sulis, we make use of the thread_local macro. (In a multithreaded program, we could alternatively use lazy_static).
thread_local! {
static RESOURCE_SET: RefCell<ResourceSet> = RefCell::new(ResourceSet::default());
}
In order to allow our resources to be mutable, we wrap them in a RefCell. Then, somewhere early in our program, we read in the resources:
fn load_resources() -> Result<(), Error> {
let mut items: HashMap<String, Rc<Item>> = HashMap::new();
read_resources("items", &mut items)?;
RESOURCE_SET.with(|r| {
let mut r = r.borrow_mut();
r.items = items;
});
Ok(())
}
Most notable here is the with() syntax used by thread_local, which is somewhat clumsy. But, we can easily isolate it to just a few places, since all the resources we are pulling out are wrapped in Rcs:
pub fn get_item(id: &str) -> Option<Rc<Item>> {
RESOURCE_SET.with(|r| {
let r = r.borrow();
r.item(id)
})
}
Now, we can use get_item from anywhere in our app. In Sulis, I just put this method inside the ResourceSet impl. Then, I can
use crate::ResourceSet;
ResourceSet::get_item(id)
from anywhere.
Conclusion
Hopefully, you now have an understanding of how to setup a simple resource management system in Rust. I don't claim this is the best, most efficient, or only way to do - but it has worked well for Sulis. In future posts, I plan on expanding further on this topic.