Managing state in a game or simulation can be complex. For a simulation, there are several different kinds of state we might need to consider:
- State which is created from user input, and will affect the main simulation (e.g.
m_willShoot = true
) - State which represents the core of the simulation (e.g.
std::vector<Bullets>
) - State which is used to visualise the results of the simulation (e.g.
std::vector<BulletFragments>
)
When considering the overall structure of a simulation, there are various points where real-time input can be supplied. This input must be “normalized” in some way, before it can affect the main game state. The goal of this step is to make sure that the main simulation is deterministic when given the same sets of input.
Finally, when rendering a simulation, there may be effects or additional state (such as particle systems) which are inconsequential to the main simulation state.
Basic Example of State Management
typedef double TimeT;
#define abstract = 0
// fixed timestamp to ensure determinism
// - if determinism is not important, this can be changed easily.
const TimeT TIME_STEP = 1.0/120.0;
struct Bullet
{
TimeT startTime;
// ... velocity, direction, power, etc
};
class IWorldSimulation
{
public:
virtual ~IWorldSimulation (); // implementation skipped
virtual const std::vector<Bullet> & bullets () const abstract;
};
class IWorldEvents
{
public:
virtual ~IWorldEvents (); // implementation skipped
virtual bool willShoot () const abstract;
}
class IWorldRenderer
{
public:
virtual ~IWorldEvents (); // implementation skipped
virtual void render (const IWorldSimulation &) abstract;
};
struct WorldEvents : virtual public IWorldEvents
{
bool m_willShoot;
virtual bool willShoot () const
{
return m_willShoot;
}
};
class WorldSimulation : virtual public IWorldSimulation
{
private:
TimeT m_currentTime;
std::vector<Bullet> m_bullets;
public:
// .. appropriate constructures, destructors, etc left to the imagination
void update (const IWorldEvents & events)
{
TimeT oldTime = m_currentTime;
m_currentTime += TIME_STEP;
if (events.willShoot()) {
Bullet b;
b.startTime = m_currentTime;
m_bullets.push_back(b)
}
foreach(Bullet & bullet, m_bullets) {
// update bullet velocity / position
}
}
const std::vector<Bullet> & bullets () const
{
return m_bullets;
}
};
class WorldRenderer
{
protected:
// Implementation left up to the imagination
BulletParticleEffect m_particles;
public:
virtual void render (const IWorldSimulation &) const = 0;
{
foreach(Bullet & bullet, worldSimulation.bullets()) {
// render bullet, etc
m_particles.addEffect(bullet);
}
}
};
class Application
{
// Assume the constructor sets up everything
// Allocating all required objects, such as worldSimulation and worldRenderer.
// Setting up some sort of runloop for firing callbacks at appropriate times
private:
IWorldSimulation * worldSimulation;
// We can drop in any type of renderer - opengl renderer, directx renderer, software renderer, etc.
IWorldRenderer * worldRenderer;
// called at 120 hz
void updateSimulation ()
{
IWorldEvents * events = processUserInput();
// or...
// IWorldEvents * events = processNetworkInput();
worldState->update(worldEvents);
}
// called at 60 hz, 30 hz, etc or as required
void renderSimulation ()
{
worldRenderer->render(worldSimulation);
}
};
The benefits of this kind of structure should be apparent, however I'll spell them out:
- Strict interfaces (
IWorldSimulation
,IWorldEvents
,IWorldRenderer
) are very helpful:- Helps to maintain encapsulation of unrelated state. This is a problem because it can reduce the reliability and reproducibility of the simulation. Simulation can be repeated deterministically given the same set of input.
- Allows for different types of renders (i.e. different platforms), state input (i.e. from a network). Not all simulations need visual output or visual output may only be desirable when testing. For a complex physical simulation, it might be run across many nodes, and therefore visual simulation needs to be done differently when the computation is distributed vs when it is run on a local machine.
- Clear boundaries of functionality, and separation of different pieces of the code. Improved clarity of code, because separate functional units can be produced and maintained separately. Better support for SCM, and easier for people to work on individual portions of code/functionality. Easier to test code with unit tests, etc.
- Separation of updating the simulation and rendering the simulation.
- Can run simulation and rendering at different speeds, depending on requirements.
- Often useful for visual updates to be synced with screen refreshes, which might be 60hz, 30hz, PAL/NTSC, etc depending on platform.
- Can have a clear separation between server and client state when dealing with networked simulations.