Coroutines in Colonist States - Programming

While working on saving, we had to deal with saving the states and actions of colonists in an optimal way.

Probably the biggest issue was my reliance on nested coroutines with all sorts of local variables everywhere. 

 

AI States

Colonists have a basic AI system, where states are queued with a priority. When their current state is finished, the colonists perform the next highest priority state in the queue. 

States are things like Use Bathroom and Find Food.

States are composed of actions. Action can either succeed or fail. States themselves can be actions too, so you could create a state out of several other states.

This is about all you need to know as it relates to saving, but we wrote an entire blog post about the AI system during the Kickstarter.

 

Coroutines

Unity allows you to somewhat easily use coroutines. A coroutine, at least for our purposes, is just some function that can be paused and resumed later. You can also pause coroutines and resume them when other coroutines are finished.

So if you looked at how colonists walk, they do something like:

 
 
IEnumerator WalkSomewhere()
{
    yield return RotateTowards();
    while(NotAtVertex() == true)
    {
        colonist.MoveTowards(targetVertex);
        yield return WaitForFrame();
    }
} 
 



So the colonist would rotate towards a vertex, and only once that was finished would it start to walk towards the vertex.

 

Saving Coroutine Progress

There's no immediate problems with that, until it comes time to save and load.

How do you save in some external file that a colonist has finished the first part of a coroutine and is now somewhere in the middle? And then how do you load that from a file and resume it appropriately?

I played around with a few ideas, but all of them were overly complicated. I could, for example, create coroutines with some sort of lookup and then save them by their unique key and identify them later when loading.

This also makes no mention of the trouble of saving and loading variables that were declared within the coroutine method.

 

Limiting Coroutines

I didn't have any good ideas for saving coroutines, so I decided that I would never use coroutines in any colonist AI actions. After all, actions are supposed to be the most basic unit of colonist action.

(Nested coroutines are allegedly bad for performance too)

I still needed a way to wait for a frame, though, so I created a specific action that would wait for single frame, called Action_WaitForFrame. This is the only action that currently has a coroutine.

As an interesting result, the current action of all colonists when saving is always WaitForFrame, because all other actions are performed immediately.

 

Restructuring Everything

I then had the fun task of breaking up all of the various coroutines in different actions and creating a bunch of new actions out of them.

The RotateTowards coroutine became its own action called RotateTowardsVertex, where a TargetVertex is passed.

In general, these coroutines became states that resembled the following pattern:

  1. Do something
  2. Wait for frame
  3. Success finish

In case you were worrying, we can optionally pass in a max wait time, so that actions will fail if they take too long. The state GoToObject, for example, will fail if the colonist has to wait at a vertex for too long (this could happen if there's a traffic jam or if a door is broken).

 

Coroutines Aren't Evil

We could technically still use coroutines in actions. It's only the nested coroutines and local variables that are actually an issue. Actually, it's really only the local variables that are an issue.

For example, the RotateTowards could easily look something like:

 

void DoAction()
{
    StartCoroutine(RotateTowards());
} 
 
IEnumerator RotateTowards()
{
    while(NotRotated() == true)
    {
        colonist.RotateTowards(targetVertex);
        yield return WaitForFrame();
    }
    FinishSuccess();
} 
 
bool NotRotated()
{
    float currentAngle = colonist.GetCurrentAngle();
    float targetAngle = GetPassedData(PassedDataType.TargetRotation);
 
    return currentAngle != targetAngle;
}

Where DoAction would be called when a colonist is loaded. In this specific example, no variables are actually stored in the action's class.

We could have followed this pattern as well. We would just have to pinky promise that we would never ever use a nested coroutine.

And if we did use a nested coroutine, it would have to be created in a way that it would be skipped if its condition was already satisfied.

In the above example, RotateTowards only waits for a frame if the colonist isn't already  rotated correctly. I could call that coroutine 1000 times and it wouldn't affect anything (except performance).

 

Satisfying Conclusion

I didn't intend for this to get so long. Originally, I had intended on talking about both the colonist states and the binary serializer that I worked on, but I like going into the specifics and providing real details.

So I'll just talk about the binary serializer later.

By the way, the example code was just a generalization, so don't judge me.