Advertisement

Single responsibility principle when handling game logic

Started by March 21, 2018 06:33 AM
6 comments, last by frob 6 years, 5 months ago

I have a question about the "single responsibity principle".

I'm a hobby-gamedev. I try to organize code into different systems, but I often end up with a few classes that know a lot about other classes, and I'd like to know if there is a way to avoid this.

As an example: Let's say, I work on a citybuilder (like Cities:Skylines, but really crappy) and I want the player to be able to build roads in the game. To build a road a lot of different systems have to be used, for example:

  • InputHandling: handling of mouse input (clicks, movements) to start and end roads and determine the position and shape of the road
  • MeshBuilder: generate and constantly update a mesh for the road (or a mesh for a colored "proxy road" to visualize whether the road can be build)
  • Geometry/CollisionHandler: to find closest roads/junctions for "snapping" and to check whether the new road collides with existing geometry
  • RoadNetwork (a directed graph of all roads and junctions, used for pathfinding and more): to determine whether the road can be build (there may be some "logical" restrictions, e.g. only a limited number of roads can connect to a junction)
  • Scene: to add/update the new Mesh/Model
  • UI: to give feedback to the user in case of errors (in the sense of "You can't build here.")
  • and more


I have no problems to separate all that functionality into the mentioned systems or helpers. But then there is a level "above" all that where I usually end up with classes (here a "RoadBuilderTool") that use all these systems to implement the "stuff that actually needs to happen" = the logic of building a road, and those classes depend on all the lower-level classes.

"Separation of concerns", "Single responsibity principle" and similar guidelines tell me that I should avoid that, but I don't see how to do it here.

Perhaps Events/Messages could be used to avoid coupling here, but I think Events also hide the flow of the execution and that's not great.

This is just one example of many. I manage to organize and separate code into different systems, until I don't, and I'm not sure what would be a better way to do this (or even if there is a better way).

Thanks for advice!

 

You're right, there is pressure between them.  Communications between systems can require knowledge of other systems, but relying on other systems creates dependencies and unwanted coupling.

Dependency inversion can help with parts of that.  When you've got multiple types of things provide a base abstraction and implement all the details separately. This can also help with composing objects since you can maintain a collection of abstract objects and never need to know (nor care) what the concrete objects are. Your systems will have knowledge of each other since that is required for interoperation, but they'll still be open for creating new features in your game without breaking old ones.

Input handling is nearly always handled by a somewhat complex state machine, coupled with the Chain of Responsibility pattern. The input may test first against special high-priority commands, then against input modifiers, then passed to UI commands, then passed to game simulation commands, etc.  Different game states can cause modules to be processed in different orders, or to be excluded from the processing queue.

Events or a message bus are quite common in games and they allow a good way to broadcast general events.  Many systems can attach to event listeners. You may have a tutorial that respond to events, achievements that respond, and loggers that can record all events matching the criteria. They're great for "fire and forget" systems where you're announcing "this happened" rather than doing specific processing.  They need a bit of maintenance: messages that are never broadcast need to have listeners and message removed; messages that are broadcast but never handled need to have the broadcast and message removed.

 

No matter how you do it, the various systems will always need to interact.  Factory methods, composition and collections of abstract interfaces, visitor objects, command objects, and many other patterns are often used to reduce the coupling between systems while still allowing interaction.  Each has a cost, and that cost also needs to be balanced. Sometimes when all the costs are considered there are times and places where the tight coupling is the best option.

On top of all that you've got concerns about how you know about each system.  Dependency injection works if you know both the dependency and the system to inject.  A common compromise is a small number of well-known instances, such as a global structure instance (or pointer to a global structure instance) that has pointers to the various major components, with your own rules and policies about when those well-known objects are valid and when they can be modified.  Exactly how you handle it also varies based on specific needs.

Advertisement

frob, thanks for your comment!

I get what you said about Input Handling, Events and the "global structure instance" (I call these things "ContextObjects"), and I kinda use these patterns or techniques.

But I have problems with splitting the functionality of the mentioned "RoadTool" into smaller objects with less coupling.

I try to explain:

When the player starts to build a road, the mentioned RoadTool has to use or query the Input to register mouse movements, the CollisionManager to check for existing geometry, the RoadNetwork to check for other logical constraints, the Scene and UI to give feedback. All these operations build basically one "logical block" to determine whether the road can be build.

So far I thought the same as you say: "No matter how you do it, the various systems will always need to interact." and I coded it the way I described (depending on all these systems) and that works. I'm basically ok with that: the class uses and depends on a lot of systems, but the logic for the task of "building a road" is bundled in one place, and that's helpful for me when I think about the code.

But then I read about the SRP and SoC and SOLID and ask myself: how would a good OO-Programmer solve this situation without deteriorating the readability and comprehensibility of the code? What is a good practical solution to this according to the OO principles?

I have no idea how to do that, and since it's usually rather hard to explain this, I have also a hard time to find resources about problems like these. Maybe my brain works better with procedural code. :)

Thanks again!

4 hours ago, hn17 said:

But then there is a level "above" all that where I usually end up with classes (here a "RoadBuilderTool") that use all these systems to implement the "stuff that actually needs to happen" = the logic of building a road, and those classes depend on all the lower-level classes

This is fairly common. It's fine for the "single responsibility" of a class to be gluing together two other classes to create a more useful composite. As you go up the layers of a software architecture, more and more of your classes become these "glue" types.

A violation of the SRP would be when a "glue" object also has many other responsibilities / a large amount of custom logic of it's own.

The idea of having "layers" in software architecture is fairly common and usually very good practice. Usually your lower layers will contain many decoupled types -- e.g. mouse input handling and mesh generation have no knowledge of each other. And then your higher layers will take multiple types from the lower layers and compose them into useful wholes -- e.g. mesh generation that reacts to mouse input :)

Gameplay code tends to be largely just these kinds of "glue", and ends up being dependent on absolutely everything on the layers below it though... This can be quite demoralizing, as even with all the very careful decoupling and obeying SRP/etc, you just end up pushing all your problems up to the next layer, where eventually you get a massive tangled mess of dependencies at the top.

However, SOLID also has some tools to go a step further and move more of these 'vertical' features to be 'horizontal' instead, via DIP.

e.g. in a layered architecture, you might have:


//layer 1
class Mouse
{
  float GetX();
  float GetY();
}
//layer 1
class Mesh
{
  void AddQuad(float x1, float y1, float x2, float y2);
}
//layer 2
class MouseQuads
{
  Mouse mouse;
  Mesh  mesh;
  void OnClick()
  {
    const static float s_quadSize = 10;
    mesh.AddQuad( mouse.GetX(), mouse.GetY(), mouse.GetX() + s_quadSize, mouse.GetY() + s_quadSize );
  }
}

With DIP, you could move the MouseQuads logic down to layer 1:


//layer 0
class Vec2 { float x, y; }
class Quad { Vec2 pos1; Vec2 pos2; }

//layer 1
class Mouse
{
  Vec2 GetPosition();
}
//layer 1
class Mesh
{
  void AddQuad(Quad);
}
//layer 1
class MouseQuadGenerator
{
  const static float s_quadSize = 10;
  Quad ClickToQuad(Vec2 pos1)
  {
    Vec2 pos2 = { pos1.x + s_quadSize, pos1.y + s_quadSize };
    return Quad{ pos1, pos2 };
  }
}

//layer 2
class MouseQuads
{
  MouseQuadGenerator gen;
  Mouse mouse;
  Mesh  mesh;
  void OnClick()
  {
    mesh.AddQuad( gen.ClickToQuad(mouse.GetPosition()) );
  }
}

Sorry this isn't the best example, because the logic being relocated is so simple (adding a size to an x/y pair to produce a second x/y pair)... but I hope you get the idea -- a part of the logic from layer 2 has successfully moved down to layer 1, and a new set of interfaces have been added at layer 0.

Note that "interfaces" could be abstract base classes and virtual functions/etc, or as in this example, just simple plain old data structures. One of the reasons that many people love plain old C, is that building architecture interfaces around plain old data structures can have very positive outcomes ;) 

The logic in the first version is operating on the Mouse/Mesh types directly, which means the file containing this logic has a dependency on those types.

In the second version, the input/output interfaces of the Mouse/Mesh types have been moved to Layer0, allowing the logic to depend only on those interfaces and not on the Mouse/Mesh types themselves. This reduces the dependencies/coupling of the file containing the logic.

In such a simple example though, it just looks like we've made the code more verbose and complex for no benefit though, so as always, use common sense when applying these principles.

For C++ specific advice on large program structure, the bible is: https://www.amazon.com/Large-Scale-Software-Design-John-Lakos/dp/0201633620

Thanks Hodgman!

19 minutes ago, Hodgman said:

Gameplay code tends to be largely just these kinds of "glue", and ends up being dependent on absolutely everything on the layers below it though... This can be quite demoralizing, as even with all the very careful decoupling and obeying SRP/etc, you just end up pushing all your problems up to the next layer, where eventually you get a massive tangled mess of dependencies at the top.

It's reassuring to read that this is somewhat common. :) 

Thanks for the DIP example! I think I understand now how introducing a new layer that way could be used to decouple a class.

I guess in practice SoC and SRP are not so much rules, but rather guidelines, right? I think that confused me quite a bit. Yours and frobs comments are helpful to put that in perspective.

 

 

 

I read "single responsibility more as :it should deal with one thing and one thing only. A renderer draws stuff, the input system collects and distributes user input, and the road builder ensures the user can build roads.

It's fine to use other parts for dealing with sub-problems, and sub-tasks to do it imho. You like the number of sub-components to be small, but if you need 11 people to make a team for soccer, you need 11 people. This is however not a violation of single responsibility, but of "not have many dependencies". Things to consider is whether you can split the task in several smaller parts, ie checking buildability can be done separately from actual placement, so you could split it in 3 parts (one checker, one real builder, and one "if check_ok then build" glue-ish component).

Advertisement

None of the patterns are hard-and-fast requirements. People noticed there were frequent patterns in software.  One system implemented it one way, another implemented it a second way, but ultimately they follow a similar style.  They were given names to make them easier to talk about, and easier to reason about.

Many patterns are positive: this solution applies to many problems. In short order people had long lists of ways to apply the patterns. Discussion finds ways they can be expanded or specialized.

Some patterns are negative: this solution works in the short term but causes headaches down the road. Often they come with some caveats of when they are less troublesome, and cases where they may be appropriate despite the headaches.

 

There are enormous books on patterns, on my bookshelf I've got five, plus there are many websites for various patterns on topics like automated test patterns and such. If you need one, here is a good starting point.

This topic is closed to new replies.

Advertisement