Making a game testable
If you want to be able to test a single frame of a game, a whole frame, then you need one thing: Referential transparency.
An expression is called referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. ~ John C. Mitchell (2002). Concepts in Programming Languages, via the wikipedia page on referential transparency.
Referential transparency allows you to ask for the next frame of a game, and compare it to the expected frame definition, confident that they will always always be equivalent, provided your expected value is correct. Which means that if you have referentially transparent frames, then you can test them! Example in nonsense pseudo code:
val gameTime = GameTime.is(Seconds(123)) val actual: (Model, View) = MyGame.calculateNextFrame(gameTime) val expected: (Model, View) = (expectedModel, expectedView) assert(actual == expected)
The above would only hold true if there are no side effects. The problem is that games are random, time sensitive, and usually use mutable state for better performance - all of which are normally side effecting issues.
Some of the key things that Indigo gives you:
- Known time - each frame's logic gets one time value regardless of how long it takes to process the frame.
- Pseudo randomness - seeded from the game's running time, but you can always find out what "random" values were used provided you use a propagated
- Immutability - the state and all inputs to a frame are immutable, leading to consistent results.
- Side effect free, declarative APIs - since your state is immutable, you must describe what you'd like to happen next, rather than being able to directly action it now, all but eliminating race conditions.
- Predictable scene composition -
SceneUpdateFragments are combined very simply allowing you to test the view description in an ordinary unit test.
"Your whole game, as a single, pure, stateless function."
The default interfaces you're presented with as part of Indigo's framework offer a range of functions and values that you need to decide how to implement, but that's all just to improve the user experience.
Beneath the API is a single function that looks something like this:
def run( model: Model, viewModel: ViewModel, gameTime: GameTime, globalEvents: List[GlobalEvent], inputState: InputState, dice: Dice, boundaryLocator: BoundaryLocator ): Outcome[(Model, ViewModel, SceneUpdateFragment)]
The point of this function is purity: What you get out, should be a result of what you put in and nothing else.
Scala is an impure functional programming language, so you are not restricted to writing games that obey these notions of purity and referential transparency, but you should start there.
Inputs are immutable and predictable
It will come as no surprise to Scala functional programmers, but all of the inputs to the run function above are immutable. You can access them and read from them but you can't change them. This eliminates a whole class of errors around race conditions during frame evaluation.
Walking through them one at a time:
model: Model- This is the read-only state of the model, as calculated by the previous frame (or the initial state).
viewModel: ViewModel- This is the read-only state of the view-model, as calculated by the previous frame (or the initial state).
gameTime: GameTime- Time information such as frame deltas, all based on the current running time of your game.
GlobalEventsonly live for one frame, and can only be accessed in the next frame. This list then, is all* of the events generated by the previous frame.
inputState: InputState- Represents the state of the keyboard, mouse and game pad.
dice: Dice- Pseudo random elements can be added using the provided
Diceinstance, which is seeded from the game's running time. (Warning: Dice start at 1, not 0 ...don't they...)
boundaryLocator: BoundaryLocator- A service that can be used to find the boundaries of scene elements.
*With the exception that some events, intended to instruct Indigo itself to do something, will not be available to the next frame. For example an emitted
PlaySoundevent will be delivered to the Audio system, but not to the next frame.
Key outputs are Monoids
Some clarification, the return type of the function above is:
Outcome[(Model, ViewModel, SceneUpdateFragment)]
But it's easier to think of this as:
(Model, ViewModel, SceneUpdateFragment, List[GlobalEvent])
- An updated model
- An updated view model
- A new scene to draw (
- A list of events to be processed and passed to the relevant systems or the next frame. (
The first two, the model and view model, are not really used by the indigo engine at all, they are simply recorded and passed as input to the next frame.
List[GlobalEvent] however are critical as they are the instructions that tell Indigo what side effects to produce. Rendering graphics, playing sounds, storing and fetching data, communicating over a network, etc.
Since the scene and events can be produced as outputs from a range of processes, it's essential that they can be combined reliably and predictably so that they can be tested (hence, Monoidal).
This has the incredibly useful property that you don't need to describe your frame's results (graph and events) all in one go. You can define them in little modules (such as Indigo's
SubSystems) and be sure that Indigo will combine them in a predictable and test friendly way when the game is run.