#1 The Flow System
The Flow System
An object-oriented coding schema for game logic in memory-managed systems.Monologue (n): A long, tedious speech given by one person.This is the first in a series of monologues about various game-related coding tricks and techniques that I've blundered across over the past few years. They're intended to be thought-provoking rather than authoritative, and if you find any of them novel I expect a diligent enough search will yield articles more thorough and informative. Code snippets are not written in any particular language and are intended to illustrate, not compile.
I'm starting with the Flow system, an embarrassingly simple idea that managed to elude me for over thirty years. As you already know, programming is the process of breaking a problem down until each piece can be solved with a line of code. But the fault-lines along which the problem of making a game naturally breaks down rarely correspond with the conceptual states as experienced by the user. Anyone who's ever written a game and then tried to impose a tutorial over the top will appreciate this keenly.
Consequently, the moment-to-moment representation of what is actually going on, and what is allowed to happen next, has a tendency to fragment. Flags and toggles and caveats all over the place. The Flow system was my solution to that. It condenses and localises decision-making, and leverages features common to most popular languages to improve readability and accessibility to newcomers to the code base.
Introduction
The Flow system began as an experiment in object-oriented state management. The project was a popular mobile title with a number of mini-games connected by an overarching system of progression, resource management and randomised events (think 'Warioware the RPG'). Our job was to create a different selection of mini-games and re-theme the title for a new market. The overriding priority of preserving the delicate, organically-grown balance that had made it so successful obliged us to adapt the existing code-base rather than start afresh.However, since the mini-games were naturally modular, we had freer reign, so I decided to try an OO approach. The base class began life similar to this:
Class Flow
{
Flow Pump();
{
Flow Pump();
Flow Message(FlowPacket pack);
}
Each frame or logical update, the code managing the mini-game would do this:
current_flow = current_flow.Pump();
While internally, the various stages of the mini-game would behave thus:
Flow Pump()
{
if (ready_for_next_state) return new NextFlow();
Each frame or logical update, the code managing the mini-game would do this:
current_flow = current_flow.Pump();
While internally, the various stages of the mini-game would behave thus:
Flow Pump()
{
if (ready_for_next_state) return new NextFlow();
return this;
}
Messages and events were treated similarly: the current flow was passed the event as a FlowPacket, and had the opportunity to return either itself or a new flow.
Finally, for state information that needed to persist across flows, it seemed natural to bundle it up into a class of its own and hand an instance of that class to the constructor of each new flow:
if (ready_for_next_state) return new NextFlow(persistent_state);
Messages and events were treated similarly: the current flow was passed the event as a FlowPacket, and had the opportunity to return either itself or a new flow.
Finally, for state information that needed to persist across flows, it seemed natural to bundle it up into a class of its own and hand an instance of that class to the constructor of each new flow:
if (ready_for_next_state) return new NextFlow(persistent_state);
First Impressions
Obviously the Flow class did its job - as any simple state machine would have. But there were some quality-of-life consequences that caught my attention. Iterating the minigame designs was quick and simple, and did not obfuscate the final code. Other team members found the new structure easier to read, modify and extend. Of particular note was the ease with which it was possible to later tutorialise the mini-games, introducing additional steps, pauses, dialogues and other custom behaviours. On that basis, I decided to explore the possibility of re-basing the entire game on the Flow system.Digging Deeper
The existing code-base for the project was the product of 10 years' organic growth and modification. It was difficult to work with or modify safely, and team morale was low at the prospect of pushing on and potentially re-using the same code-base again in future projects.The game code had originally been broken down and organised - entirely reasonably - according to which page of UI or 'screen' the player was looking at - except that in reality many screens could be accessed from different places in the overall game-loop. Because the current screen was considered to be in charge, moving on or getting back on track had been accomplished by ad-hoc checks of data values that, upon closer inspection, sometimes correlated with the current state more by coincidence than design. Traversing the game-loop also ticked forward the player's career, in a spread-out, better-not-accidentally-miss-a-step kind of way.
To top it off, lurking behind everything was a centralised message-handling hub responsible for calling functions in every single screen, and which would simply act, unquestioningly, upon any message sent its way from the UI or anywhere else. Here, too, messages originally intended for one purpose had been co-opted elsewhere as handy shortcuts to make sure things happened or get the player where he needed to be.
As you can imagine, all this made it practically impossible to anticipate the consequences of changes to code or data, even with a deep grasp of its place within the whole, and seemingly innocuous tweaks - even bug fixes - would routinely spiral into player-save-destroying catastrophe.
Over the next fortnight I teased out the elements of logical progression from each 'screen' class (a process not unlike filleting a fish), capturing them instead inside a skeleton of Flow classes. All the meat of the code remained where it was. Only the top-level message-routing and decision-making was extracted, so that quite often two or three Flows could be viewed on a single page of text.
The transformation was quite remarkable, both as a process and an outcome. Considering how anxious and intimidated the team had become by the project, its seemingly endless nuances and detail and temperamental nature - it really wasn't very difficult to refactor. It pretty much just worked, and when it didn't work, or something had been missed, it was easy to fix. While it might only be a single data-point for the efficacy of porting old code to the Flow system, it's a far from insignificant one in my opinion.
As you can imagine, all this made it practically impossible to anticipate the consequences of changes to code or data, even with a deep grasp of its place within the whole, and seemingly innocuous tweaks - even bug fixes - would routinely spiral into player-save-destroying catastrophe.
Over the next fortnight I teased out the elements of logical progression from each 'screen' class (a process not unlike filleting a fish), capturing them instead inside a skeleton of Flow classes. All the meat of the code remained where it was. Only the top-level message-routing and decision-making was extracted, so that quite often two or three Flows could be viewed on a single page of text.
The transformation was quite remarkable, both as a process and an outcome. Considering how anxious and intimidated the team had become by the project, its seemingly endless nuances and detail and temperamental nature - it really wasn't very difficult to refactor. It pretty much just worked, and when it didn't work, or something had been missed, it was easy to fix. While it might only be a single data-point for the efficacy of porting old code to the Flow system, it's a far from insignificant one in my opinion.
During this filleting process, particular qualities of the flow-based code I was writing impressed themselves upon me time and again:
Command and Control, Readability and Comprehension
The current flow is uniquely responsible for what happens next.That might seem like a trivial observation, but in my experience the natural tendency is for logical control to become diffused and entangled. Over time, understanding what can happen next at any given point tends to require a broader and broader grasp of the whole.
In the Flow system, the only way of transferring control is for the current Flow to return a different flow, either from its regular update (Pump) or in response to a message. That means that anyone seeking to understand the logical progression of the game from a given point knows, absolutely, that they need only inspect the code for the active flow, the functions it calls, and the values it checks.
Handing over control is final and irrevocable.
Consider the implications of Return New NextFlow() as the sole mechanism for transferring control. Return is guaranteed to be the last statement executed in any function. If you are reading the code and have understood everything up to that point, you can be confident that nothing beyond plays any part in determining later behaviour. No sneaky Columbo-style 'one last thing' to catch you out.
The other side of the hand-over is the New function. In most languages, unless you really go out of your way to make life difficult for yourself, New is one of the few commands guaranteed to do exactly what you ask of it. New cannot change its mind.
This combination of Return and New enforces a certain discipline in your code's decision-making process. Tell me if this sounds familiar: you have a state A, which proceeds to B in some fashion. Later on, you discover that sometimes A needs to hand over to C instead. But rather than clutter A up with that decision-making code, you put a caveat into the start of B that redirects to C. Maybe B and C share some set-up activities - it seems a reasonable choice to make. Except:
- What if some other state, X, also hands over to B? Now it, too, could potentially hand over to C instead. Is that OK? Do you even know about state X? Maybe you should ask the rest of your team if they've written X while you weren't looking.
- You hire a new programmer. They look at A and see that it proceeds to B. What if they don't notice the potential deviation to C? If they do, should they also check C to make sure it doesn't change its mind again? How deep does the rabbit-hole go?
Weaknesses can be Strengths in Disguise
Initially, I was concerned that Return New would lead to code duplication. After all, if X does also hand over to B, and therefore does need to know about C - what then?The answer proved quite straightforward: wrap up each of these commonly-encountered deviation checks in a static function.
Return GoToStateBOrC(parameters);
This preserves the desirable qualities of the 'Return' part of the hand-over, and also conveys a whole raft of meta-data to the reader:
- There must be further decision-making to do, because otherwise the contents of the function would be in the 'New' of the next flow.
- This decision-making is done on the way from other states, too, otherwise the contents of the function would be in this flow.
Of course, it's possible to work around the system, to set a flag in the constructor of B that prompts it to return a New C at the earliest opportunity - but you're clearly going against the grain by doing so, which is often enough to enforce good practice.
Something similar happened on at least three other occasions that I can recall: I would encounter a problem that made me think "Oh, that's a shame, this system won't let me do something I need to do". Then I would scratch my head for a bit, and realise that the thing the Flow system was making difficult was actually super dangerous. Working with flows was forcing me to confront and fix the stuff that was making the project so hard to work with in the first place.
Subroutines and Resume
After working with the Flow system for a short time, the need for a subroutine mechanism made itself felt. Return New is great for going forward, not so much for going back.NB: 'Subroutine' here refers to a temporary persistent state, like a confirmation dialogue.
After some experimentation, I decided the flow invoking the subroutine should do this:
Return New F_Subroutine(this);
Flow Resume()
{
return this;
}
And the subroutine flow would do this:
Flow returnTo;
F_Subroutine New(Flow from)
{
returnTo = from;
And the subroutine flow would do this:
Flow returnTo;
F_Subroutine New(Flow from)
{
returnTo = from;
}
...
Return returnTo.Resume();
...
Return returnTo.Resume();
Again there's rich meta-data embedded in the code: the invoking flow is explicitly passing itself as a parameter, the only reason for which is the expectation of regaining control later. Similarly, the fact that the invoked flow is a subroutine is clear from its first line. At debug-time, the state of the invoking flow is directly available via the 'returnTo' member. Finally, the convention of Return returnTo.Resume() allows the invoking flow both to respond to its reawakening, and to seamlessly hand over to other flows based on the outcome without awkward flag-setting or waiting for the next update.
After a little more usage, I elaborated upon the 'Resume' function to accept a FlowPacket - the same base type used for messages - which streamlined the handing back of results from subroutine flows.
A few hours later, I amended the convention again, making it the responsibility of the invoking flow to create and pass in the flow packet that will be filled in and returned as a result:
Return New F_Subroutine(this,New ResultPacket())
or
Return New F_Subroutine(New ResultPacket(this))
The theme here, as you may have noticed, is to front-load as much context as possible for the reader's benefit: not only is this a subroutine state we're handing over to, but we expect it to fill in some information and return it to us. And if we want to know what we're going to do with it, we can look in the Resume function.
You may also have noticed that although the invoking flow clearly expects an answer from the subroutine and to regain control later, it has absolutely no say over whether that happens. The subroutine is free to throw returnTo away and head for pastures new. Is that a problem?
In practice, my experience was: no. This is no worse than in many other schemata, and at least here the intention to resume control is explicit both in the invoking code and the subroutine.
Nested Flows and Walled State Gardens
While it's perfectly possible to represent an entire game using a flat Flow hierarchy, it can be useful to nest flow systems. We did this with the minigames: in the parent flow you simply maintain, pump and relay (filtered) messages to a child flow via the same mechanisms:
Flow Pump()
{
childFlow = childFlow.Pump();
}
The most significant upside of doing this is that childFlow can be derived from a separate base class unrelated to the parent's base Flow class. By deriving childFlow from a functionally equivalent MinigameFlow instead, you create a walled garden of states. In our project, for example, it is flat-out impossible for a minigame to accidentally wander off into RPG states because the parent flow will not accept a Flow in return from pumping or messaging a MinigameFlow. It won't even compile.
Inheritance versus Parameterisation
Obviously, as in any OO system you enjoy all the standard benefits of inheritance. But the flow system, in which adding new classes is the bread-and-butter of development, can also benefit in terms of readability. By way of example, we created a base dialogue flow and decided to use inheritance, rather than parameterisation, to specify all the various dialogues found in the game. Which of these do you find more immediately understandable:
SpawnDialogue(180,300,200,"Quick, make a choice!",true,true)
or
F_SpecificDialogue New()
{
x = 180;
y = 300;
timeout = 200;
message = "Quick, make a choice!";
showOK = true;
showCancel = true;
}
Final Thoughts
In all honesty, managing logical control with such a limited palette can feel strange: at once confining and liberating. I found it unnerving at first, to be constantly and rapidly capping off completed states - to have so little context to worry about at any given time! Then there were the times, as I mentioned before, when the system pushed back hard against something I subsequently realised was dangerous - I was unused to my code stopping me hurting myself. And it can take a while to become sensitive to the meta-data implicit in Return New and the other conventions. But in my opinion it's definitely something worth trying out.
Comments
Post a Comment