One approach is to put your important subsystems behind interface classes, and have one struct that contains a pointer to each. This lets you just pass around “the environment” (or “the world”) to the other systems, without having to change 32 different prototypes everytime you add another subsystem.
This is generally better than using singletons everywhere, because you can enforce ordering, and there is no “global” state to leak between unit tests – you can create a new, fresh environment for each unit test. You can also run systems in parallel in multiple threads without them stepping on each other.
Finally, because you wrap the various subsystems into a virtual interface, you can swap out fake/temporary systems for files, networking, rendering, databases, and anything else that would normally interact with physical I/O. This makes it easier to write robust unit tests for things that “need to read a file” without having to wait for disk.
Example (sketch):
class IService {
public:
virtual void startEnv(Environment *env) = 0;
};
class IFilesystem : public IService {
public:
virtual future<shared_ptr<IBlob>> readFile(string const &path) = 0;
virtual future<Status> writeFile(string const &path, sharer_ptr<IBlob> const &data) = 0;
virtual future<vector<string>> listDirectory(string const &path) = 0;
};
class IClock {
public:
virtual Timestamp now() = 0;
};
...
struct Environment {
IFilesystem *filesystem = nullptr;
IClock *clock = nullptr;
...
void startAll() {
filesystem->startEnv(this);
clock->startEnv(this);
...
}
};
You'll then have concrete implementations for the interfaces, that you set up in the beginning.
class CRealClock : public IClock {
public:
Timestamp now() override { return read_the_physical_clock(); }
void startEnv(Environment *env) override {}
};
class CFakeClock : public IClock {
public:
Timestamp now() override { testTime.increment(1); return testTime; }
void startEnv(Environment *env) override {}
Timestamp testTime; // poke this in unit test runners to advance time
};
...
Environment *env = new Environment();
env->filesystem = new CMemoryFilesystem();
env->clock = new CFakeClock();
env->startAll();
useThisEnvironmentInATest(env);
When you make an Actor or ParticleSystem or whatever, you simply make sure to pass the Environment to it, and it can get what it needs from there. The specific interfaces you choose to expose in the Environment varies by your engine – do you support “files” or “assets" or “blobs” for reading things? Do you have a “renderer” or a “scene graph” or an “object registry?" And on and on.
If you're going with an object oriented design, this is a good way of dealing with “basic subsystems” in a way that avoids the pitfalls of singletons, and avoids having to pass 18 separate pointers to the constructor of each other new subsystem.