Games vs Engines
Key Points:
[ ]
What is the difference between a game and a game engine?[ ]
How are general-purpose and genre-specific engines different?[ ]
What are some of the roles a game engine needs to fulfill?[ ]
How can we organize a game engine to abstract away the complexities of rendering and input handling?[ ]
How can we organize a game engine to handle an unknown number of entities of varying types?[ ]
How can we make a game engine provide featureful entities while still allowing games some flexibility?
[ ]
How do we make Rust projects out of multiple files?[ ]
How can we make a Rust project which depends on other projects (e.g., a game and its engine)?
Game Engine Architecture
So far we've been looking at individual programs and games, where the behavior is entirely determined by types and values in the code. But this puts a substantial burden on game authors: they have to write lots of code, initialize and manage their game loop, decide on how to represent game state and rules, and more. While we've begun building up a little library of game helper code, it's not yet clear it qualifies as an engine. This gets especially complicated in games where the player can move from level to level, and assets need to unloaded or reloaded on the fly.
How can we organize a game engine so that it can manage the game's dynamic state itself? One common approach popularized by the Unity game engine is to organize things into three categories:
- Game Objects, which are made up of a variety of Components (a Transform and possibly others) are arranged in a tree whose root is a Scene;
- Components, which hold instance data and implement behaviors to initialize and update the game objects they belong to. Examples include the Transform that positions an object in space relative to its parent, the Collision component that gives the object a shape and lets it participate in the collision system, or a SpriteRenderer that draws the object into a 2D scene; and
- Scriptable Objects, which are data that can be serialized (saved) and deserialized (loaded) and are used to specify things like animation data or rendering materials.
A Unity level (or Scene) is just a tree of game objects with their components, and on each frame each object's components are ticked forward.
In Rust, a GameObject
struct might have a Vec<Rc<dyn Component>>
through which it iterates every frame, and the Component
trait would have an update()
method and a render()
method (it would need to be an Rc
rather than, say, a Box
since components can store references to each other).
Unfortunately this means that every frame is full of cache misses and pointer indirection, and the engine can't effectively predict how games will update their state.
Moreover, the set of components on an object can change arbitrarily at runtime.
Certain privileged systems like rendering, collision, and physics can be made more efficient because the components responsible for them are known to the engine in advance, but game developers have no way of making their own systems as efficient as these.
Game Engine Design Choices
Unity's approach is extremely flexible and is likely connected to the fact that Unity's editor is implemented in Unity itself, so this kind of anything-goes, last-possible-second dynamism seems necessary given the engine's overall structure. It's not the only possible approach though. Later, we'll go into detail on data orientation and what is sometimes called a property-based (as opposed to component-based) architecture. For now, we'll suggest a light version based on these key concepts:
- Game loops are pipelines for transforming game state (from
now
tonext
), not cooperating networks of actors - Game state is stored in compact tables to make transformations convenient, not to reflect some imposed taxonomy (which might be true now but may not be accurate later)
- We can (for now) sacrifice some runtime dynamism in exchange for compile-time flexibility and control.
The big idea here is that instead of making one supremely flexible game engine, we can instead give the game maker a set of programmable pieces that can be combined and specialized in a variety of ways to support the specific games they need to make. What does that look like? Mainly, it's about making sure that data which are orthogonal to each other (e.g., current animation data and collision shapes and character states) are not tangled up together, and that it's easy to operate uniformly on uniformly shaped data.
In any event, optimizing for extreme extensibility is only one possible choice. Game engines like PuzzleScript and Bitsy intentionally carve out more a more restrictive "design space". This means that there are fewer possible games that can be made in these engines, but this tradeoff means that they can offer very pleasant & usable interfaces, they are often highly optimized for the types of games they're intended for, and they provide useful constraints for designers. These engines make certain commitments that allow them to be used by non-experts.
In this class, I'm absolutely open to both styles of game engines: from the uber-flexible to the hyper-specialized. For example, we can imagine a game engine for "Pong"-like games in which e.g. Pong, Breakout, Combat, Kaboom!, and other Atari-style games could be implemented straightforwardly. Such an engine might provide actor types (ball, paddles, walls) and a way to define collision event/reaction pairs, maybe by passing in anonymous functions. A game engine exploring the space of tile-based role-playing games also makes sense, where things like plot progression, maps, dialog trees, character stats, and more are given by the game developer and woven together into a complete game. From the other end of the spectrum, a totally general-purpose game engine with a focus on generality or on general-purpose performance would also be a valid choice for the class.
Organizing Game Engines
What does a game engine need to do, and how is that different from implementing a game? We can come up with arbitrarily many possibilities here, but we'll refine our attention to five main areas:
- Initializing the game program (hopefully in a cross-platform way) and communicating with the OS
- Managing the lifetimes of game objects and resources
- Providing tools to define the game world
- Simulating the game world
- Rendering the game world
Thinking of the sprite example, try to reorganize it to separate initialization and input handling (the "skeleton" of the game code), rendering (drawing e.g. sprites and issuing wgpu calls), and simulation (updating positions). A first crack at this might be to just support (1), some of (2), and (5):
struct Thing { health: f32 } struct GameState { things: Vec<Thing> } fn main() { let engine = engine::Engine::new(1024.0, 768.0); let sprite_tex = engine.load_texture("content/king.png"); engine.add_sprite(sprite_tex, [128.0, 128.0, 16.0, 16.0], [0.0, 16.0/32.0, 16.0/32.0, 16.0/32.0]); engine.run(GameState{things:vec![Thing{health:100.0}]}, simulate); } fn simulate(engine:&mut Engine<GameState>, state:&mut GameState) { // Move first sprite right and left by five units per second if engine.input.is_key_down(winit::event::VirtualKeyCode::Right) { engine.get_sprite(0).screen_region[0] += 5.0 / 60.0; } else if engine.input.is_key_down(winit::event::VirtualKeyCode::Left) { engine.get_sprite(0).screen_region[0] -= 5.0 / 60.0; } }
Here, Engine::run
will run the winit
event loop, calling out to simulate
when the current button inputs are known.
Resources are "registered" (file paths connected to resource identifiers) during initialization, and the engine manages access to those resources on its own.
Besides what images we're using, this version of Engine
doesn't know anything about our game besides that it has sprites—it just gets a big opaque blob of state which it threads through to our simulate
function.
We can make it a bit fancier by having GameState
implement some trait that the Engine
expects, but this is about as far as we can take this style of example.
A more convenient engine depends on some representation of the game state, which lets it handle (3)–(5):
fn main() { let engine = engine::Engine::new(); let player_image = engine.register_image_resource(Path::new("content/player.png")); // This engine associates a picture with each type of entity let player_ent = engine.new_entity_type() .set_image(player_image) .set_size(16.0, 16.0) .set_axis_movement(engine::Dimension::X, engine::VirtualKeyCode::Left, engine::VirtualKeyCode::Right, engine::Movement{attack_init:0.05, attack_rate:...}) // arbitrary rules in here... .on_update(|e, p| if p.x < 0.0 || p.x > e.screen_width() { p.x = e.screen.width() / 2.0; }) .register(); engine.get_level_mut(0).add_entity(player_ent, engine.screen.width()/2.0, engine.screen.height/2.0); engine.run(); } // No need for simulate or render!
In this case, the engine has a facility to register "types" of entities which are configured with horizontal or vertical movement rules, images, sizes, and custom on-update rules.
Registered entities can be added to "levels" in the world at particular locations.
Each level, then, has some Vec
or something containing the state (position and velocity) of each living entity, and some corresponding entity definition stored in the engine determines how the entity is updated every frame and rendered.
We might imagine that the game loop iterates through every type of entity in the level, updating all the instances for that type in turn before moving on to the next type.
This design puts more constraints on entities, but gives us a more convenient API.
How you structure your engine is up to you, but think about how much responsibility for objects' definitions and lifetime your engine ought to have. If you want to give an API for users to define game data (either through function calls as above or by providing some data structure), I'd be happy to talk through how to do that as flexibly and efficiently as possible. Making some commitments in the engine can make your job as a game programmer much easier, and it can also make your job as an engine programmer easier if you don't need to support absolutely any type of game.
Activity: Look up some Rust game engines and see how they are organized and where they split game code vs engine code. Some examples include Bevy, Amethyst, GGEZ, and Fyrox, but there are lots more. You shouldn't necessarily copy any of these, but at least be able to read them and figure out where they draw the line.
Follow-up: Check out specialized game-making tools like bitsy, PuzzleScript, Ren'Py, RPG Maker, … and make guesses about how they would be implemented in Rust.
Organizing Rust Code
Rust organizes code into modules, as we discussed in the Rust lecture notes (see the section "src
and entry points").
Your crate's entry point (lib.rs
for library crates, main.rs
for binary crates) is the only file that Cargo will compile automatically.
Whatever modules it defines (using mod
or pub mod
statements) will be brought into the project (as, of course, will any modules those modules define).
use
directives are issued to export symbols to other modules.
A typical lib.rs
for a game engine might look like this:
// Export all engine types at the top level //in game/src/main.rs: use engine; mod types; // define the types module as the contents of types.rs pub use types::*; //in game/src/main.rs: use engine::Vec2; use engine::Rect; mod engine; pub use engine::Engine; //in game/src/main.rs: engine::Engine::new() pub mod render; pub mod input; pub mod assets; pub use assets::Image; mod util;
If this library were called engine, it would have exports like engine::Engine
, engine::Vec2
, engine::render::RenderingMode
, or engine::Image::from_file
.
It is an error to refer to the same Rust file from two separate mod
statements---mod
just defines a module, and use
just brings a module's definitions into scope.
That handles the case of a single crate. But if we're defining both an engine and some games, we probably want to use multiple crates. We probably also want to avoid recompiling every dependency when we start a new lab, so we'll use workspaces to organize groups of projects.
Let's make a workspace called 181g
:
mkdir 181g
cd 181g
A workspace is just a folder with a special Cargo.toml
file inside, so put this inside of 181g
:
[workspace] members = [ "engine", "game-1" ]
Assuming you had two crate folders inside of 181g
(each with their own Cargo.toml
and src
directories), we'd now have a workspace with two members!
Whenever you run cargo new
inside of this folder, add that new project to the list of members.
If you want to add all new projects automatically, you can change members
to be just ["*"]
and add exclude
["target"]=.
Let's say we wanted to make a game now.
Whereas we made our engine with cargo new --lib --edition=2021 engine
, we'd make our game with cargo new --bin --edition=2021 game-1
.
Then we need to set up game-1
to depend on engine
in game-1/Cargo.toml
:
[dependencies] # Look ma, no version number! engine = { path = "../engine" }
Now you're in great shape. We can do more labs without recompiling all of WGPU and winit, and we can start to reuse code across crates.
Activity: It's a really good idea to do this right now, so that your game code is separate from your engine.