Seems like you understand your system most intuitively through control flow. I tend to be similar.
If you're like me in this regard, you might exhibit a strong distaste not only for complex multithreaded code with lots of shared state but also deep call stacks and event-driven programming. Event-driven programming can be almost as difficult to reason about in terms of control flow as multithreading. A user might push a button which resizes a pane which triggers a resize event which triggers more resize events on child widgets, of which one is a viewport which triggers a GPU render event picked up by a rendering thread which then renders the scene and triggers a finished event, at which point the viewport picks it up and triggers a repaint event, and so on.
I hate working in such systems. As a result I've developed a strong preference towards what I call "flatter" code as opposed to "deeper" code, with shallow call stacks and fewer threads running at once, with meatier functions where you can look at a procedure and understand a good chunk of how your program works from it as opposed to a complex graph of teeny functions and objects interacting with each other where you have to connect the tiniest puzzle pieces together to see the big picture. Entity-component systems made a wonderful fit for me personally, since in those only the systems contain any functionality, and all of them contain meaty functionality that loops through a bunch of entities and does things with them (an entire PhysicsSystem
, e.g.). That makes it easy for me to comprehend the big picture.
Now the usual way you're supposed to do it is to not be concerned with such details. You can test each individual unit thoroughly, reason about its correctness and thread safety, and supposedly work that way from bottom-up and compose an enormous codebase and not feel lost when you look at the system top-down. That doesn't really work for me so well. That's not to say that the system didn't work. I've worked with reasonably well-written codebases that had sprawling and cascading events with lots of asynchronous code that worked quite well with each little unit doing what it's supposed to do. Yet I never felt comfortable in spite of that because I couldn't break off large chunks of the system and reason about them so well without worrying about details, and most importantly, I couldn't confidently predict what would happen if we changed something with such a busy ecosystem consisting of countless interactions between teeny things.
You can unit test microwaves and find that they do exactly what they need to do in all scenarios and heat things up. You can test plastic wrap and see that it works perfectly fine in all cases for wrapping food. But try putting plastic-wrapped food in the microwave... whoops! There are system invariants to maintain that go way beyond the correctness of a granular object or function. As an overseer type over a codebase, I always cared more about the broad invariants than the small ones, since it's easy enough to maintain granular invariants. I suppose you could try to model bigger and bigger objects that compose higher and higher-level concepts and maintain broader and broader invariants until your unit test is effectively an integration test for the entire application, but I've never seen such coarse-level testing applied thoroughly, ever, or else the testing would almost guarantee that the entire software is free of bugs.
So I find it's worth taking it easy on the cascading events and asynchronous tasks and persistent threads. You don't have to concurrently run a PhysicsSystem
at the same time as the RenderSystem
. They both have bulky enough work to do to, say, just parallelize the loops they perform while calling them in a sequential order and potentially get even better frame rates, focusing on making those systems finish faster rather than run simultaneously, and parallel loops are much easier to reason about than having entire systems running in parallel. I find that so much easier to reason about.
But anyway, for you, I'd try to find indivisible chunks of the system you can break off that are reasonably high-level, like OfficeBuilding
, not the little things like the plastic wrap and the microwave inside taken individually, where you can reason about this broad chunk of your system's correctness in spite of having its code being executed in parallel. That unit has to be thought as an independent unit and kind of like a black box so that you can then reason about your system as a whole as a small collection of huge black boxes, all of which are guaranteed to be thread safe, without understanding all the details. ECS lets you do this in a "flat" way which extends in an orthogonal fashion, OOP will tend to favor a "deeply nested" way of big behavioral objects encapsulating medium behavioral objects which encapsulate smaller behavioral objects and so on.