So the question is - how should I implement the interaction between
the various systems?
Ideally they don't interact, not in any direct sense. The systems in an ECS all have access to the central ECS database where they can fetch entities and components attached to them. They don't talk to each other directly. They talk to the database and all run independently of each other.
Dependencies Flow Towards Raw Data, Not Abstractions
The dependencies in an ECS do not flow towards functions, not even abstract functionality. They all flow towards raw data which might sound like an epic violation of many accepted software engineering principles, and in my opinion it is, but yields something easier to maintain for some cases. Maybe some software engineering principles are wrong or at least not applicable for all scenarios. There are many situations where it's easier to achieve data stability than interface/design stability. As a basic example, it's much easier to reason about what data fields a raw matrix component should have once and for all and keep that stable (unchanging) for years to come. It's much harder to figure out all the functions an abstract
IMatrix interface should provide once and for all and keep that perfectly stable (unchanging) for years to come without facing temptations, if not outright needs, to add and remove and change functions.
So in appropriate cases, when your dependencies flow towards data instead of abstract functionality, your codebase will find fewer and fewer and fewer reasons to have to face central design changes with cascading effects and potentially big parts having to be rewritten. To direct dependencies towards data in that case is directing them towards stability. It's worth asking yourself as a developer whether the tendency in your system is for developers to add, change, and remove functions or to add, change, and remove data from components. If it's the former case, you might benefit greatly from an ECS engine.
If systems start to depend on each other a lot, that's directing dependencies away from data and towards functionality, and many of the maintenance benefits and the ability to reason about the correctness of your engine and easily keep it stable at the design level will be lost. Of course a pragmatic solution might sometimes call for a system calling a function in another every once in a while, but you should generally seek to keep that to a bare minimum. Instead of talking directly to each other, you can have systems modify and attach components to entities in a way such that other systems can then pick up those changes and react accordingly.
[...] the rendering system must know the data from the positional
component of an entity in order to draw it in a correct position. And
That it can grab from the ECS database, looping through entities with renderable and position components, just as the physics system before it might loop through entities with position components and modify their position. Generally each system fits into a basic loop model:
for each entity with the components I'm interested in:
do something with the components
... and you have to start thinking about doing things in passes, often multiple passes even if the intuitive solution is to do everything in one pass. For example, it might come more intuitively to loop through all your game entities and apply physics and respond to input and process AI and render them all in one go. That can minimize the amount of loops you have and also require less state. However, the ECS tackles this typically with multiple simpler passes and sometimes slightly more intermediary state to use from one pass to the next, but as a trade-off, it leads to a much easier system to maintain and one which is easier to change and potentially parallelize* and vectorize.
- As yuri mentioned, it could also make things harder to parallelize, at least across systems in an inter-system way, but could make things easier to parallelize in an intra-system way because it's easier to reason about the correctness of a parallel loop without locking if it's, say, making less state changes on the way and the code involved in the pass is much simpler. In my blunt opinion, it's often not worth multithreading the systems themselves so much as the loops they are performing inside for the most performance-critical systems.
Multiple, Simpler Passes
It's somewhat similar to GPU programming since GPUs aren't so good at doing complex things with each iteration, so they often excel instead at doing simple things per iteration that add up to a complex task after repeatedly running through the same data with multiple, simpler passes.
Unlike GPU programming, you can still potentially do much more complex things in a single pass, but each pass will represent like one logical thought: "for each of these components, apply physics", not both physics and rendering. The physics system performs its own pass just as the rendering system, living in its own isolated world, performs its own completely separate and detachable rendering pass. Each system lives in its own little world, seeing only the ECS database and being able to grab components and entities inside. They shouldn't have to bother with what other systems are doing.
In fact, in a well-designed ECS, you can remove any system from the engine and not have the codebase collapse horribly on itself because systems don't depend on each other to function. All they care about is the central database and the components (which are raw data) that they are interested in processing. They all live in their own isolated world. As a result you should be able to remove the physics system from your game, at which point motion components will cease to have physics applied, but everything else should keep on working just as before. It's extremely orthogonal in that respect.
Event-driven programming can be a bit awkward with ECS, but one straightforward way to solve that is to have event queue components. A system can push events to these queue components for another system to pop and process in a deferred fashion without the first system directly calling functions in the second. Again the bulk of your interactions should not be system->system, but system->ECS, and system->component.