1
\$\begingroup\$

I'm working my way through a lot of game development resources, coming from a completely different field of software development, I'm interested in learning what patterns are frequently used in game development, and which aren't.

During my research, I wanted to know if/how event queues are handled in games, and came across Game Programming Patterns' post about Event Queues. (as an aside, what an awesome resource that book is!)

In it, Bob Nystrom gives an example of a "play audio" queue, where one part of the system can produce/enqueue the playing of an audio fragment, and the audio handler/consumer can act on that request whenever it can.

I like this pattern a lot, but the end-result still feels like it results in domains being crossed, because the sender (in this example the part of the system that deals with a unit dying) has to ask the audio system to play the "deathSoundID" sound using playSound(deathSoundID) (which, in this case, then enqueues that request for the audio engine to pick up as soon as it can).

What I expected to be proposed is that the sender in this case tells the audio engine that an event occurred (by having the audio engine listen for events from this specific game system), at which point the audio engine can then determine if/how it wants to act on that specific event.

Now, admittedly, there's still crossing of domains here, as in my example, the audio engine now has to have some kind of (huge) case statement that couples any game event to an audio-related action, but you no longer have to litter unrelated functions with calls to playSound, and it also forces you to be a good citizen, and allow other systems to act on whatever action you just took, be it the audio engine in this case, or the "gamepad rumbler" engine that wants to rumble your gamepad because you've just slain a large enemy.

Another downside to this is that you need many more queues for all your systems (if you want to avoid some kind of global event queue, which I think you should), which might not be practical/performant enough to do, so perhaps that's the reason why it wasn't chosen here.

Either way, as a newcomer to game development, I'm interested to hear why one would be preferable over the other, and in what situations that would be the case.

\$\endgroup\$
  • 1
    \$\begingroup\$ "Making good decisions come from experience. Experience comes from making bad decisions." Don't be afraid to try, knowing you'll refactor when you realise your mistakes. But remember a game is worthless if it never comes out so at some point you have to bite the bullet, stop refactoring, and just plough through. \$\endgroup\$ – Stephane Hockenhull Aug 21 '18 at 18:41
3
\$\begingroup\$

Telling the audio system that an event occurred (versus demanding that the audio system play this sound right now) is generally preferable because it lets the audio system make the final determination about whether or not/how to play the sound. Whether that looks like "posting a request" or "posting an event" is largely a semantic issue. The important part, as you note, is that the audio system gets to decide the specifics.

Audio engineers typically have limitations to work with that are both technical and physical. Technically, the engine may only support playing N sounds simultaneously, and physically a player may only want to hear M sounds at the same time (otherwise it sounds like noise). Similar restrictions exist for background music tracks.

Gameplay code that triggers a character death probably isn't aware of what else is going on in the world, audio-wise, and so by telling the audio system "a death sound should play," the audio system can look up the priority assigned to death sounds and decide if that sound gets to override any other sounds currently playing, or if it needs to reduce the volume on the background music track, or if it needs to mix that sound in with some other enqueued sounds to reduce the total number of audio buffers being streamed to fit some technical constraint.

All of those priorities and mixes and overrides are usually defined in data controlled by the audio engineers who oversee that domain; it's better to have that data (and it is data, in a production-scale system, not "one big case statement," although the concept is basically the same) localized in one spot there, for them (via tools like Wwise) than to scatter it around a bunch of areas in the gameplay code.

For similar reasons, game code will often want to say "play a death sound" rather than "play screaming_burning_on_fire_omg.wav because it will let audio engineers control sound variations, randomness, pitch shifting based on other audio events and queues, from within their data rather than having to poke around in a bunch of different areas in code or data.

For games with smaller, perhaps one-person teams, this kind of organization is maybe less useful. But for any larger-scale project where there is a dedicated team working on the audio, that team will likely prefer the approach where they get to actually make the decisions about how the audio works.

\$\endgroup\$
  • \$\begingroup\$ Thank you for the detailed explanation, truly appreciated! One interesting aspect of your answer is that you advocate that gameplay code tells the audio system a death sound should play, which would still tie some audio logic to that gameplay system. My thinking was it should tell the audio system (or any system that is interested, for that matter) a death just occurred (with any relevant data attached to that event), after which the audio system can then determine what it wants to do. Could you elaborate a bit more on those two choices, and which one is preferable, when? \$\endgroup\$ – JeanMertz Aug 21 '18 at 17:29
  • 1
    \$\begingroup\$ At some point, whether the event you post is "somebody died" or "a death sound should play," something must tell your audio API to play a sound. If you're using tools like Wwise, you still do that by saying "a death sound should play," so there's little point in abstracting away that extra layer unless you have other systems that want to do complex logic on events. For example, that's one way to build an achievements system. But fundamentally that just moves the abstraction over the audio layer around, it doesn't really eliminate it, so it becomes something of a matter of taste. \$\endgroup\$ – Josh Aug 21 '18 at 17:39
  • 1
    \$\begingroup\$ Further, there is an argument that driving too much logic via "events" is too much action-at-a-distance engineering; it makes it less obvious what other behavior ever actually occurs at the point where any event is raised, which can make it harder to reason about the state of the game and thus easier to write buggy new behavior. There is a balance you need to strike between that kind of flexibility and the dangers that come with it, and balance depends a lot on team dynamic and style. \$\endgroup\$ – Josh Aug 21 '18 at 17:42
  • 1
    \$\begingroup\$ (I'd generally argue that gameplay logic is effectively the highest level of abstraction in a game and so it's okay for that level of logic to "know about" all the other stuff. It is the outermost level of the onion, as it were.) \$\endgroup\$ – Josh Aug 21 '18 at 17:46
  • \$\begingroup\$ I see your point, and indeed the lack of visibility can lead to a difficult to reason about system, making debugging (or refactoring for that matter) a lot more difficult. Your point of gameplay logic being the outermost level is an interesting one. I can see where that's the actual business logic, and other systems, such as the physics simulation, audio engine, etc is the "infrastructure" part that shouldn't talk to each other, and have clear interfaces that the gameplay logic can then hook into (such as playAudio). Again, thank you for the insights, very valuable, and much appreciated. \$\endgroup\$ – JeanMertz Aug 21 '18 at 17:49
1
\$\begingroup\$

There's a lot to balance in practical game development.

Generic event system vs domain-specific:

One doesn't preclude the other.

You can use a generic queue system:

audioQueue.SendEvent("PlaySound", deathSoundID);

And wrap it in a convenient function:

void playSound(ID_TYPE soundID) {
    audioQueue.SendEvent("PlaySound", soundID);
}

//...

playSound(deathSoundID);

By wrapping a generic message system into domain-specific API calls we get the advantage of a tested and debugged generic message system while keeping the code very readable at-a-glance and easily refactor-able.

Decision making

It can be up to the sender, the receiver, or both. We can even add a 3rd layer in between the two ends to add for example a context-aware filter.

eg: Turn playSound() from global function to a local member function of actor object that can make more decisions, and then call the global playSound() function.

The receiver (audio system) can decide to ignore the message or alter its interpretation.

The message parameter soundID can be either a specific sound or a class of sound, it's really up to the receiver.

The message can be an order to play a sound or a request to play a sound, again it's up to the receiver.

Redirection

Nothing prevents the audio system from sending rumble commands as part of the sound effect processing.

In fact, the rumble motor is just a very low-frequency sound generator and some advanced haptic-feedback use low-frequency weighted solenoids (essentially a "speaker" without a diaphragm).

So we're not even breaking out the audio concept nor functionality.

If you feel things would be more manageable with one more abstraction you can create an "effect" system that then sends events to other sub-systems.

But too many abstraction layers can make it very difficult to know what's happening and why.

Domain-Crossing

"Perfect is the enemy of good."

You'll always have domain-crossing: Things have to talk to one another.

The best practice is to keep it to a minimum, not to eliminate it completely which is not possible.

Separation of Concerns

The important part here is to keep game-specific logic out of the lower generic/reusable engine code.

You can add callback-functions and/or data-driven masks, remapping tables, and settings if game-specific context-sensitive decisions/tweaks are needed so the engine-level code remains game-agnostic.

ie: If one level needs a particular sound remapped, a remapping table feature can be added to the audio engine. The audio engine doesn't "know" it's on the "Fire Level" it just knows it has to remap the sound. It's up to something inside that level on the game-side to enable/disable the remapping.

\$\endgroup\$
  • \$\begingroup\$ Thank you for the detailed answer. One thing that stood out: then call the global playSound() function. I wonder how IoC and DI are applied in game development. I certainly like the fact that code becomes much more testable if it can be free of any global state (or references), and purely relies on its own logic, or any logic explicitly given to it by an outside actor. This then combats the use of globals. Are these patterns you see used in game development? \$\endgroup\$ – JeanMertz Aug 22 '18 at 8:20
  • \$\begingroup\$ In short, I'm worried about the long-term maintainability by using too much global state. Is there a balance to struck here, or should I prevent this at all cost (my preference), but is this even possible with popular game development libraries? Are they designed to support this style of programming? I like your "Perfect is the enemy of good" quote, and I agree, but I also prefer to at least be aware of potential pitfalls in the long-run, and avoid them if at all possible and doable without too much overhead. \$\endgroup\$ – JeanMertz Aug 22 '18 at 8:21
  • 1
    \$\begingroup\$ There's always a global state to some degree: The game itself as a whole, the game world, the 3D rendering engine, the TV, the master volume, the list of players, etc. Even the "audioQueue" object is a global object (a Singleton is a global object under a different name). There is a difference between a global module (the audio engine) and shared global states. A global module can keep track of private states in a global list: "This sound emitter (GUID) owned by (actor) is at x,y,z". It is not the same as everyone accessing each other's states willy-nilly. \$\endgroup\$ – Stephane Hockenhull Aug 22 '18 at 12:33
  • 1
    \$\begingroup\$ At some point it comes down to discipline: Yes, we could walk around with full body armour and have airport-security type checks on every street corner but that's not practical. If someone goes out of their way to intentionally trash sound emitters they shouldn't be, you have a talk with them. The idea is to make it difficult to accidentally trash things or create a mess, not make development a pain over both engine and game code. And no matter how hard you try, some people can (for whatever reason) get real creative when it comes to creating a mess. You can't fix that with APIs. \$\endgroup\$ – Stephane Hockenhull Aug 22 '18 at 12:46
  • 1
    \$\begingroup\$ Where to draw the line is a matter of opinion and gamedev.stackexchange has a rule against opinion-based questions/answers (because we could debate where the line is forever and nobody would be wrong) so I'm sticking to the drawbacks of the extremes. \$\endgroup\$ – Stephane Hockenhull Aug 22 '18 at 13:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.