Continued from part 1.
The game project is split into the toplevel application
crate and a world
library crate in a subdirectory. The world crate
handles the game state and logic, while the main crate does user
interface. It's something like a client/server architecture, though
there isn't anything like a wire protocol between the two.
The toplevel client crate has full access to the public interface of the world crate, but the world crate isn't aware of the toplevel. Most of the time this works fine. The client translates UI events into game actions, updates world state with the action and reads the current worldstate to know what to draw on screen. The worldstate only stores persistent objects like trees and chests though, transient things like sound effects and particle clouds that don't matter to the game logic beyond the fact that they got spawned don't live in the game state. Transient effects get passed to the toplevel as values in the message queue in the world crate.
If I ever want to have NetHack style "Are you sure you want to stick your head in the Sphere of Annihilation Y/N" style confirmation dialogs that get tripped from within the world logic, I'm going to have new problems with figuring out the UI - world interaction. Though maybe I can just push functionality like that entirely into the UI side.
The screen state machine
The different screens in the game are implemented as state machine states that consume events from the backend and return state transition instructions. The title screen state is a very simple example that currently just renders the title screen. In the future, there will probably be some menu logic here to handle settings and save games. Most of the gameplay happens in the gameplay state. When the gameplay state is created, it initializes the global game world object and then starts running the game, displaying the game world and reading the user input. This object is a bit of a mess of responsibilities, and should probably split into multiple modules at some point.
The command loop
The inputs from the player to the game go through function
action::input
. The player controls a single avatar
character, which has its own speed of action. It won't act on every
world update frame, so the client uses
action::control_state
to find out whether it
should try to come up with some player input or just update the
world. When left to its own devices without a player expecting
input, the world runs at 30 frames per second.
Theoretically the game should run exactly the same every time it gets the same random number generator seed and player input sequence, to the point where these could be used as save game data, but I don't have tests in place to see if this is actually the case.
The action
module in the world crate is a general grab-bag of free
functions that operate on the global world state.
The global world structure
With some hesitation about losing all the lovely referential transparency, I decided to just go ahead and put the mutable world state in a thread-local store accessible from anywhere (as long as I stick to a single thread). This lets me stop worrying about just how the world reference should be carried over to every point that wants to know things about the global game state. Roguelikes are basically some simple display code you throw up in a weekend and then years and years of accrued rat's nest of world logic code that needs to know obscure things about dark corners of the game world deep in some logic path or another.
It's also starting to become obvious at this point that the Rust
borrow checker hates video games. If you implement the world
structure naively, the first time you take a write reference to the
world you stop being able to read anything else from the world. For
example, you might want to do something like
world.do_mutable_stuff(world.do_immutable_query())
. Nope. Can't
have mutable call's parameter be an immutable call to the same
object. I don't know if there's a good reason here why the compiler
doesn't just translate this into something like the let x = world.do_immutable_query(); world.do_mutable_stuff(x);
form that I
always end up typing by hand.
It gets more fun. Worlds contain game entities. You'll want to go through them, run game logic for them and update them. Accessing the entities of a naively put together world structure gets the world locked. If you got a read lock, congrats, now you can't change the world state in any way while holding on to the entity. Maintaining a single write lock on the other hand becomes an ergonomical nightmare if you want to, say, call a method on an entity that wants to query the world state, another method that wants to mutate the world state, maybe call one method with the other's return value as its parameter and so on.
The world access pattern
After wondering whether the decision to try to write video game
logic in Rust was one of those not very clever ideas to begin with,
I came up with a pattern to manage things. The Cell
that I use to
implement the thread-local worldstate value does a runtime
equivalent of borrow checking. As long as I make sure never attempt
to borrow an already borrowed world, everything will work fine, and
I can write high-level game code that doesn't get gimped by constant
borrow checker issues.
The way I ended up doing things is to push reading or changing
things in the physical worldstate data structures as deep down into
the call chain as I can, and only acquiring the Cell borrow when I
absolutely must access the physical worldstate memory. At this point
I push a closure past the borrow airlock using the world::with
or
world::with_mut
functions. Once within the borrow, calling any
other function that uses the airlock will probably cause a runtime
panic, so the code inside the closure needs to be short and simple.
Most of the time this works quite well, since the with
code
pattern is pretty distinct. The one thing I can't do is give the
concrete datatypes that go in the world any sort of complex methods
that interact with the high-level world logic, since those would end
up calling other airlock functions and panic on me.
Now I can have stuff like game entity handles, game world location values and the like, which can be passed around with no borrow locks and have interfaces that use the high level game logic that bottom out to pushing the actual physical gamestate data around. Though I also need to write some getter and setter type boilerplate code because I can't expose references to any of the internal data through the API.