Data-Driven Design: Raw Files
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.
If you've ever played Dwarf Fortress, one of its defining characteristics (under the hood) is the raw file system. Huge amounts of the game are detailed in the raws
, and you can completely "mod" the game into something else. Other games, such as Tome 4 take this to the extent of defining scripting engine files for everything - you can customize the game to your heart's content. Once implemented, raws
turn your game into more of an engine - displaying/managing interactions with content written in the raw files. That isn't to say the engine is simple: it has to support everything that one specifies in the raw files!
This is called data-driven design: your game is defined by the data describing it, more than the actual engine mechanics. It has a few advantages:
- It makes it very easy to make changes; you don't have to dig through
spawner.rs
every time you want to change a goblin, or make a new variant such as acowardly goblin
. Instead, you edit theraws
to include your new monster, add him/her/it to spawn, loot and faction tables, and the monster is now in your game! (Unless of course being cowardly requires new support code - in which case you write that, too). - Data-driven design meshes beautifully with Entity Component Systems (ECS). The
raws
serve as a template, from which you build your entities by composing components until it matches yourraw
description. - Data-driven design makes it easy for people to change the game you've created. For a tutorial such as this, this is pretty essential: I'd much rather you come out of this tutorial able to go forth and make your own game, rather than just re-hashing this one!
A downside of web assembly
Web assembly doesn't make it easy to read files from your computer. That's why we started using the embedding system for assets; otherwise you have to make a bunch of hooks to read game data with JavaScript calls to download resources, obtain them as arrays of data, and pass the arrays into the Web Assembly module. There are probably better ways to do it than embedding everything, but until I find a good one (that also works in native code), we'll stick to embedding.
That gets rid of one advantage of data-driven design: you still have to recompile the game. So we'll make the embedding optional; if we can read a file from disk, we'll do so. In practice, this will mean that when you ship your game, you have to include the executable and the raw files - or embed them in the final build.
Deciding upon a format for our Raw files
In some projects, I've used the scripting language Lua
for this sort of thing. It's a great language, and having executable configuration is surprisingly useful (the configuration can include functions and helpers to build itself). That's overkill for this project. We already support JSON in our saving/loading of the game, so we'll use it for Raws
also.
Taking a look at spawner.rs
in the current game should give us some clues as to what to put into these files. Thanks to our use of components, there's already a lot of shared functionality we can build upon. For example, the definition for a health potion looks like this:
In JSON, we might go for a representation like this (just an example):
{
"name" : "Healing Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000"
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
}
Making a raw files
Your package should be laid out like this:
| Root folder
\ - src (your source files)
At the root level, we'll make a new directory/folder called raws
. So your tree should look like this:
| Root folder
\ - src (your source files)
\ - raws
In this directory, create a new file: spawns.json
. We'll temporarily put all of our definitions into one file; this will change later, but we want to get support for our data-driven ambitions bootstrapped. In this file, we'll put definitions for some of the entities we currently support in spawner.rs
. We'll start with just a couple of items:
{
{
"items" : [
{
"name" : "Health Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
},
{
"name" : "Magic Missile Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20"
}
}
}
]
}
If you aren't familiar with the JSON format, it's basically a JavaScript dump of data:
- We wrap the file in
{
and}
to denote the object we are loading. This will be ourRaws
object, eventually. - Then we have an array called
Items
- which will hold our items. - Each
Item
has aname
- this maps directly to theName
component. - Items may have a
renderable
structure, listing glyph, foreground and background colors. - These items are
consumable
, and we list their effects in a "key/value map" - basically aHashMap
like we've used before, aDictionary
in other languages.
We'll be adding a lot more to the spawns list eventually, but lets start by making these work.
Embedding the Raw Files
In your project src
directory, make a new directory: src/raws
. We can reasonably expect this module to become quite large, so we'll support breaking it into smaller pieces from the beginning. To comply with Rust's requirements for building modules, make a new file called mod.rs
in the new folder:
And at the top of main.rs
add it to the list of modules we use:
In our initialization, add a call to load_raws
after component initialization and before you start adding to World
:
The spawns.json
file will now be embedded into your executable, courtesy of RLTK's embedding system.
Parsing the raw files
This is the hard part: we need a way to read the JSON file we've created, and to turn it into a format we can use within Rust. Going back to mod.rs
, we can expand the function to load our embedded data as a string:
This will panic (crash) if it isn't able to find the resource, or if it is unable to parse it as a regular string (Rust likes UTF-8 Unicode encoding, so we'll go with it. It lets us include extended glyphs, which we can parse via RLTK's to_cp437
function - so it works out nicely!).
Now we need to actually parse the JSON into something usable. Just like our saveload.rs
system, we can do this with Serde. For now, we'll just dump the results to the console so we can see that it did something:
(See the cryptic {:?}
? That's a way to print debug information about a structure). This will fail to compile, because we haven't actually implemented Raws
- the type it is looking for.
For clarity, we'll put the classes that actually handle the data in their own file, raws/item_structs.rs
. Here's the file:
At the top of the file, make sure to include use serde::{Deserialize};
and use std::collections::HashMap;
to include the types we need. Also notice that we have included Debug
in the derived types list. This allows Rust to print a debug copy of the struct, so we can see what the code did. Notice also that a lot of things are an Option
. This way, the parsing will work if an item doesn't have that entry. It will make reading them a little more complicated later on, but we can live with that!
If you cargo run
the project now, ignore the game window - watch the console. You'll see the following:
Raws { items: [Item { name: "Healing Potion", renderable: Some(Renderable { glyph: "!", fg: "#FF00FF", bg: "#000000" }), consumable: Some(Consumable { effects: {"provides_healing": "8"} }) }, Item { name: "Magic Missile Scroll", renderable: Some(Renderable { glyph: ")", fg: "#00FFFF", bg: "#000000"
}), consumable: Some(Consumable { effects: {"damage": "20", "ranged": "6"} }) }] }
That's super ugly and horribly formatted, but you can see that it contains the data we entered!
Storing and indexing our raw item data
Having this (largely text) data is great, but it doesn't really help us until it can directly relate to spawning entities. We're also discarding the data as soon as we've loaded it!
We want to create a structure to hold all of our raw data, and provide useful services such as spawning an object entirely from the data in the raws
. We'll make a new file, raws/rawmaster.rs
:
That's very straightforward, and well within what we've learned of Rust so far: we make a structure called RawMaster
, it gets a private copy of the Raws
data and a HashMap
storing item names and their index inside Raws.items
. The empty
constructor does just that: it makes a completely empty version of the RawMaster
structure. load
takes the de-serialized Raws
structure, stores it, and indexes the items by name and location in the items
array.
Accessing Raw Data From Anywhere
This is one of those times that it would be nice if Rust didn't make global variables difficult to use; we want exactly one copy of the RawMaster
data, and we'd like to be able to read it from anywhere. You can accomplish that with a bunch of unsafe
code, but we'll be good "Rustaceans" and use a popular method: the lazy_static
. This functionality isn't part of the language itself, so we need to add a crate to cargo.toml
. Add the following line to your [dependencies]
in the file:
lazy_static = "1.4.0"
Now we do a bit of a dance to make the global safely available from everywhere. At the end of main.rs
's import section, add:
This is similar to what we've done for other macros: it tells Rust that we'd like to import the macros from the crate lazy_static
. In mod.rs
, declare the following:
Also:
The lazy_static!
macro does a bunch of hard work for us to make this safe. The interesting part is that we still have to use a Mutex
. Mutexes are a construct that ensure that no more than one thread at a time can write to a structure. You access a Mutex by calling lock
- it is now yours until the lock goes out of scope. So in our load_raws
function, we need to populate it:
You'll notice that RLTK's embedding
system is quietly using a lazy_static
itself - that's what the lock
and unwrap
code is for: it manages the Mutex. So for our RAWS
global, we lock
it (retrieving a scoped lock), unwrap
that lock (to allow us to access the contents), and call the load
function we wrote earlier. Quite a mouthful, but now we can safely share the RAWS
data without having to worry about threading problems. Once loaded, we'll probably never write to it again - and Mutex locks for reading are pretty much instantaneous when you don't have lots of threads running.
Spawning items from the RAWS
In rawmaster.rs
, we'll make a new function:
It's a long function, but it's actually very straightforward - and uses patterns we've encountered plenty of times before. It does the following:
- It looks to see if the
key
we've passed exists in theitem_index
. If it doesn't, it returnsNone
- it didn't do anything. - If the
key
does exist, then it adds aName
component to the entity - with the name from the raw file. - If
Renderable
exists in the item definition, it creates a component of typeRenderable
. - If
Consumable
exists in the item definition, it makes a new consumable. It iterates through all of the keys/values inside theeffect
dictionary, adding effect components as needed.
Now you can open spawner.rs
and modify spawn_entity
:
Note that we've deleted the items we've added into spawns.json
. We can also delete the associated functions. spawner.rs
will be really small when we're done! So the magic here is that it calls spawn_named_item
, using a rather ugly &RAWS.lock().unwrap()
to obtain safe access to our RAWS
global variable. If it matched a key, it will return Some(Entity)
- otherwise, we get None
. So we check if item_result.is_some()
and return if we succeeded in spawning something from the data. Otherwise, we use the new code.
You'll also want to add a raws::*
to the list of items imported from super
.
If you cargo run
now, the game runs as before - including health potions and magic missile scrolls.
Adding the rest of the consumables
We'll go ahead and get the rest of the consumables into spawns.json
:
...
{
"name" : "Fireball Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"area_of_effect" : "3"
}
}
},
{
"name" : "Confusion Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"confusion" : "4"
}
}
},
{
"name" : "Magic Mapping Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#AAAAFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"magic_mapping" : ""
}
}
},
{
"name" : "Rations",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
}
}
]
}
We'll put their effects into rawmaster.rs
's spawn_named_item
function:
You can now delete the fireball, magic mapping and confusion scrolls from spawner.rs
! Run the game, and you have access to these items. Hopefully, this is starting to illustrate the power of linking a data file to your component creation.
Adding the remaining items
We'll make a few more JSON entries in spawns.json
to cover the various other items we have remaining:
{
"name" : "Dagger",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 2
}
},
{
"name" : "Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 4
}
},
{
"name" : "Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00AAFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 1
}
},
{
"name" : "Tower Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 3
}
}
There are two new fields here! shield
and weapon
. We need to expand our item_structs.rs
to handle them:
We'll also have to teach our spawn_named_item
function (in rawmaster.rs
) to use this data:
You can now delete these items from spawner.rs
as well, and they still spawn in game - as before.
Now for the monsters!
We'll add a new array to spawns.json
to handle monsters. We're calling it "mobs" - this is slang from many games for "movable object", but it has come to mean things that move around and fight you in common parlance:
"mobs" : [
{
"name" : "Orc",
"renderable": {
"glyph" : "o",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 8
},
{
"name" : "Goblin",
"renderable": {
"glyph" : "g",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 8,
"hp" : 8,
"defense" : 1,
"power" : 3
},
"vision_range" : 8
}
]
You'll notice that we're fixing a minor issue from before: orcs and goblins are no longer identical in stats! Otherwise, this should make sense: the stats we set in spawner.rs
are instead set in the JSON file. We need to create a new file, raws/mob_structs.rs
:
We'll also modify Raws
(currently in item_structs.rs
). We'll move it to mod.rs
, since it is shared with other modules and edit it:
We also need to modify rawmaster.rs
to add an empty mobs
list to the constructor:
We'll also modify RawMaster
to index our mobs:
We're going to want to build a spawn_named_mob
function, but first lets create some helpers so we're sharing functionality with spawn_named_item
- avoid repeating ourselves. The first is pretty straightforward:
When we add more SpawnType
entries, this function will necessarily expand to include them - so it's great that it's a function. We can replace the same code in spawn_named_item
with a single call to this function:
Let's also break out handling of Renderable
data. This was more difficult; I had a terrible time getting Rust's lifetime checker to work with a system that actually added it to the EntityBuilder
. I finally settled on a function that returns the component for the caller to add:
That still cleans up the call in spawn_named_item
:
Alright - so with that in hand, we can go ahead and make spawn_named_mob
:
There's really nothing we haven't already covered in this function: we simply apply a renderable, position, name using the same code as before - and then check blocks_tile
to see if we should add a BlocksTile
component, and copy the stats into a CombatStats
component. We also setup a Viewshed
component with vision_range
range.
Before we update spawner.rs
again, lets introduce a master spawning method - spawn_named_entity
. The reasoning behind this is that the spawn system doesn't actually know (or care) if an entity is an item, mob, or anything else. Rather than push a lot of if
checks into it, we'll provide a single interface:
So over in spawner.rs
we can use the generic spawner now:
We can also go ahead and delete the references to Orcs, Goblins and Monsters! We're nearly there - you can get your data-driven monsters now.
Doors and Traps
There are two remaining hard-coded entities. These have been left separate because they aren't really the same as the other types: they are what I call "props" - level features. You can't pick them up, but they are an integral part of the level. So in spawns.json
, we'll go ahead and define some props:
"props" : [
{
"name" : "Bear Trap",
"renderable": {
"glyph" : "^",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : true,
"entry_trigger" : {
"effects" : {
"damage" : "6",
"single_activation" : "1"
}
}
},
{
"name" : "Door",
"renderable": {
"glyph" : "+",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false,
"blocks_tile" : true,
"blocks_visibility" : true,
"door_open" : true
}
]
The problem with props is that they can be really quite varied, so we end up with a lot of optional stuff in the definition. I'd rather have a complex definition on the Rust side than on the JSON side, to reduce the sheer volume of typing when we have a lot of props. So we wind up making something reasonably expressive in JSON, and do a lot of work to make it function in Rust! We'll make a new file, prop_structs.rs
and put our serialization classes into it:
We have to tell raws/mod.rs
to use it:
We also need to extend Raws
to hold them:
That takes us into rawmaster.rs
, where we need to extend the constructor and reader to include the new types:
We also make a new function, spawn_named_prop
:
We'll gloss over the contents because this is basically the same as what we've done before. We need to extend spawn_named_entity
to include props:
Finally, we can go into spawner.rs
and remove the door and bear trap functions. We can finish cleaning up the spawn_entity
function. We're also going to add a warning in case you try to spawn something we don't know about:
If you cargo run
now, you'll see doors and traps working as before.
Wrap-Up
This chapter has given us the ability to easily change the items, mobs and props that adorn our levels. We haven't touched adding more yet (or adjusting the spawn tables) - that'll be the next chapter. You can quickly change the character of the game now; want Goblins to be weaker? Lower their stats! Want them to have better eyesight than Orcs? Adjust their vision range! That's the primary benefit of a data-driven approach: you can quickly make changes without having to dive into source code. The engine becomes responsible for simulating the world - and the data becomes responsible for describing the world.
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.