In previous parts we established nomenclature; entites and entity management; and the components that supply entities their data. Now we turn our attention to “systems.” Systems are where we store all logic and algorithms. We have many systems, each responsible for a particular task, and these systems collect data from one or more components in order to act on a given entity.
Here’s a very simple System base class from which all systems will derive:
class System def process_one_game_tick raise RuntimeError, "systems must override process_one_game_tick()" end end
Let’s agree that our game runs at a certain number of frames-per-second, and for simplicity’s sake we’ll also agree to update our logic every frame. We don’t know at compile-time what the FPS will be; could be 20 FPS, could be 60 FPS. Regardless, we’ll say that every frame-to-frame transition is a “game tick”. That’s what we’re referring to with “process_one_game_tick”, which is the method every system must implement.
As with components, systems challenge us with a philosophical question: what are they, how are they defined?
A system should encapsulate some “mechanism” that is necessary to cause some change within your game; my personal favorite mental image is a complex clockwork of gears and wheels, turning and producing results. And although systems rely on one or more components to do their work, there is no automatic one-to-one ratio between components and systems, nor must they have any shared names.
Ask yourself, what things need to happen in my game? Every frame, what’ll take place?
- The lunar lander will accelerate downward a little bit due to gravity
- The lander will accept and respond to user input
- The graphics will be rendered
- The engine might burn and alter the lander’s velocity
- Fuel might be expended
- Something might collide with something else
Not an exhaustive list, but a fine starting point to describe the things that will take place during game play. I remind you of the flexibility that Entity-Component systems give us. You do not need to overarchitect this in the beginning.
Your First System
Let’s examine the Engine system for the lunar lander.
class EngineSystem < System def process_one_game_tick(delta, entity_mgr) engine_entities = entity_mgr.get_all_entities_with_component_of_type(Engine) engine_entities.each do |entity| engine_component = entity_mgr.get_component_of_type(entity, Engine) fuel_component = entity_mgr.get_component_of_type(entity, Fuel) if engine_component.on && fuel_component.remaining > 0 location_component = entity_mgr.get_component_of_type(entity, SpatialState) renderable_component = entity_mgr.get_component_of_type(entity, Renderable) amount = engine_component.thrust*delta fuel_component.burn(amount) current_rotation = renderable_component.rotation x_vector = -amount * Math.sin(current_rotation * Math::PI / 180.0); y_vector = -amount * Math.cos(current_rotation * Math::PI / 180.0); location_component.dy += y_vector location_component.dx += x_vector engine_component.on=false end end end end
First, you’ll notice that two things are always passed to the system in its process_one_game_tick method: a delta, and an entity_manager. The former tells the system how much time elapsed between the last frame and this one so that the system can scale its responses accordingly; this is how we get our game to behave consistently across different-performing platforms.
The latter enables the system to request the entities and components that it needs to do its job.
Walking through this system:
- First, this system only cares about all entities with an Engine component; it doesn’t care about any other kinds of entities.
- Looping, we ask the entity manager for the entity’s Fuel and Engine components, because we’re going to need data from those.
- Is there fuel, and is the engine “on”? Then we need to retrieve some additional components to do our job: SpatialState (for position) and Renderable (where we store rotation). Incidentally, the Engine system doesn’t know or care how an engine component got activated — keyboard, mouse, AI, whatever. It only cares if the engine is on.
- We use the aforementioned delta amount to scale our response to thrust.
- We reduce the Fuel component’s fuel load appropriately.
- We figure out which way the lander is pointed and make the thrust vector “aim” appropriately.
- We turn the engine off now. The engine may be reactivated next frame.
This is a particularly good example of how a system works. You can see it consulting all the components that it needs to do its job, and reading / writing data as appropriate. Systems don’t just passively read the components, they alter them and directly influence the entities.
You might ask, what about gravity, collision, or the other things that might influence the lander? How and when do they get a say?
The answer lies in your game loop. As mentioned, every “tick” the systems are instructed to do their work. And one elegant aspect of this is that you determine the order in which systems are called. In our lunar lander game the render() call, which is called once per frame, might look like this:
def render(gdx_delta) delta = gdx_delta * 1000 # GDX uses seconds, so convert to milliseconds @input.process_one_game_tick(delta, @entity_manager) @engine.process_one_game_tick(delta, @entity_manager) @physics.process_one_game_tick(delta, @entity_manager) @landed = @landing.process_one_game_tick(delta, @entity_manager) @game_over = @collision.process_one_game_tick(delta, @entity_manager) # Snip…
This makes intuitive sense; first we look for input (which might, for example, turn the lander engine on for the tick). Then we let the engine do its work, followed by our physics / motion system. Last we check to see if the lander either made it successfully to the landing-pad, or alternatively if the lander hit something.
The Gravity System
Our physics / motion system is interesting enough to warrant a quick examination.
class Physics < System # This constant could conceivably live in the gravity component... ACCELERATION = 0.005 # m/s^2 DOWN = Math.cos(Math::PI) def process_one_game_tick(delta, entity_mgr) gravity_entities = entity_mgr.get_all_entities_with_component_of_type(GravitySensitive) gravity_entities.each do |e| spatial_component = entity_mgr.get_component_of_type(e, SpatialState) # vertical speed will feel gravity's effect spatial_component.dy += ACCELERATION * delta end moving_entities = entity_mgr.get_all_entities_with_component_of_type(Motion) moving_entities.each do |e| spatial_component = entity_mgr.get_component_of_type(e, SpatialState) # move horizontally according to dx amount = 0.01 * delta * spatial_component.dx spatial_component.x += (amount) # now fall according to dy amount = 0.01 * delta * spatial_component.dy spatial_component.y += (amount * DOWN) end end end
First just some constants defining the gravity constant “G” for our game, plus the downward direction that will be used for calculating gravity’s effect.
Then we query the EntityManager for all components with a GravitySensitive component. Remember that one? It was the completely empty component with no data in it. Here it’s being used as architected: to identify anything that “feels” gravity so that we can act on it. As before, the “delta” time-between-ticks is used to scale gravity’s downward acceleration: dy (Y velocity) is altered accordingly. The result is a lunar lander that gracefully arcs across the screen with increasing downward velocity — until you fire the engine!
The next piece of the system just wants to know, “which entities have a position and are moveable?” It takes these components — supplied, as always, by the EntityManager — and adjusts their screen positions (scaled to the delta).
Could we have separated Gravity and Motion into two separate systems? Sure. It was easy enough to keep them in one place but if our needs got complex enough we could have done so.
Also, note the “pluggability” here. One day you might come across a third-party physics library that is much more sophisticated than our own. Compared to OOP, Entity-Component architecture makes it a lot easier to substitute this third-party library for our rudimentary one by altering a single system. In fact, you could “A-B test” different physics libraries at once just by authoring separate systems for them and calling them as appropriate.
In the next installment we’ll learn about basic libGDX concepts…