Intro
I'm writing an FPS game in c++. There is a timed game mode, players run around a map shoot from a variety of weapons which are either hitscan or projectile based, when a shot connects, based on information about the game state players are assigned points, when the time is up the player with the most points wins.
I am currently working on the client side, where we use opengl for graphics, glfw for input and window, and other libraries for physics and sound. In each of the designs I've thought about there are always systems: Graphics
, Input
, Physics
, Sound
, Gameplay
which store their own state and can be interacted with through methods they expose.
Design 1
In this approach in int main() { ... }
we initialize the systems and store them in variables which reference the respective systems, whenever a function is called which needs a certain system we pass it in as an argument.
For example, we have character.update_velocity(delta_time, Input, Sound)
and then inside we have the input object is needed to be passed into this call because we check Input.forward_pressed
etc... in order to change their velocity depending on what keys are pressed. if (Input.jump_pressed) { Sound.play_sound("jump") ... }
.
Apparent Benefit: You're focusing on telling the computer on what to do to make the game happen rather than other things.
Apparent Downside: Your function signatures get cluttered with systems. You need to work around callbacks with fixed signatures because you can't pass systems directly as parameters into them.
Design 2
We use the same approach, but instead of passing the systems that are needed to specific function calls, we instead make each of the systems global which means that the functions don't need to take them in as arguments, but the code inside these functions stays the same as design 1.
Apparent Benefit: The signature of functions is not cluttered by a bunch of systems. Fixes the callback thing mentioned in Design 1.
Apparent Downside: It's not immediately obvious what systems a function call will use by looking at its signature
Design 3
In this design we use an event system to separate concerns. Now instead of passing in the systems into the respective function calls that need them, the function calls emit signals which then call the code. There is a class called SignalMediator
which contains all of the systems and then sends signals to respective systems whenever certain events get triggered.
For example, we have character.update_velocity(delta_time, Input)
when a jump occurs we call SignalMediator.emit_signal("jump_occurred")
now the Sound
object subscribes to the SignalMediator
's jump_occurred signal and defines what should occur when this happens.
Apparent Benefits: Our code becomes easier to read because separate functions only interact with a single system at once.
Apparent Downside: The code that makes the game happen is now spread out over more files so it's harder to see all of the functionality at once
Question
I've started writing the game following design 1, but I'm heavily considering design 3 and I've frozen myself up because I'm not sure how design 1 will scale over time as opposed to design 3. I'm also curious if any of the designs I've listed have performance overheads that I'm not considering.
If anyone has insight into these designs or other designs that keep performance, code complexity and maintainability in mind could you share your experiences?
Sound
for example. Usually it's a separate worker thread whose job is to dequeue representation of what sound/music it should start sending to the audio device. So the only abstraction you need isplay_sound("jump")
that can be called from anywhere because that actually makes it trivial to read your code unlike anything else. Or you know,sound_system.enqueue("jump");
If that makes you feel better.update_velocity(...)
we make a callplay_sound(...)
so we can't run this function without the sound system up and running. So we comment that line out and make some tests for it which we can run to verify the height of the jump, but now the production version is missing the sound of the jump, how would you have testable and production ready code at the same time with your proposed approach?#define play_sound(x) do {} while(0)
or even a different function call so you can test your game as it is including fake audio thread with simulated lag for example. Testing environment will always be hacky and buggy mess and I think it's not beneficial to worry about it that much. Ease of writing code that works, and final program doing its job is the primary goal.