In our previous dev journals we discussed the hex grid generation process for Polis, how we utilized Unity’s ScriptableObject to separate the data that define the various game objects from their run-time behaviour and we dived a bit deeper into the details of our core gameplay mechanics. Now that we have a better understanding of what we are building it’s time to focus on some implementation details.
Building construction system overview
We argued that buildings are an important part of the game as they not only allow us to expand our population and storage capacity, but also transform natural resources to secondary resources we can use to support the various needs of our ever-changing civilization. The next step is to think about how we are going to construct these buildings. Let’s try to outline the construction process along with the various subsystems that might be involved:
- Given I have a tile selected, a building that can be constructed on that tile and the necessary building material in storage to cover the construction costs, when I hit the “Build” button a “construction request” is created.
- We have a limited number of builders that can be assigned on the various construction tasks which means that not all tasks can be worked at the same time. Therefore the request enters a queue and waits for builders to be assigned to it.
- Once some builders have been assigned to the task, we need to transport them and the building material to the construction site.
- As soon as the builders and the material have been transported, we can start the construction. Every “game day” the builders contribute some effort towards the construction and after a few days the construction has finished.
- Once it has finished, we’ll want to assign builders to other tasks. We might even want to notify other places as well so they can handle this event accordingly. If for example we just constructed a production building, we’ll need to let the production system know that a new building has been added so it can start the production by assigning workers to it.
This is roughly how we imagine the construction process and it’s good enough to get us started for now. There are other steps that we haven’t mentioned and surely more steps will be involved in the future as we add more features. For example as this is a procedural city building game where the city is generated dynamically as buildings are constructed, we want to be able to place these buildings in the world. We’ve created a stub class called ConstructionPlanning, which later on will be responsible for returning a position in the world for the desired construction taking into account already placed buildings, the road network, terrain, etc.
Task-based processing and Unity’s coroutines
In the construction process we described earlier we can see that various systems are involved (Construction, Transportation, Planning, Production, etc) and most of these will need to be able to process tasks independently of each other. Sometimes these tasks will have to wait for other tasks to finish before they can start (a construction task cannot be started until the transportation task(s) have finished).
The whole idea of tasks is known as task-based asynchronous programming and it’s probably better known in the context of multi-threaded applications. However asynchronous doesn’t necessarily mean that we have to use multiple threads to perform work in parallel, it merely means that we can execute a process independently of others and deal with its result once it has finished, this is known as concurrency.
Now if you decided to go multi-threaded you would probably do something like this: have a Task and a TaskScheduler. The TaskScheduler would maintain a pool of threads and a queue where tasks are added. Then the scheduler would decide which tasks to run (based on priority, available threads, some other conditions, etc), it would allocate a thread from the pool for each task and wait for them to be done. When a task has finished it could then notify others subscribed to this event, letting them handle it the way they wanted to.
We create two classes: a CoroutineTask and a CoroutineTaskQueue to handle the processing, along with a CoroutineScheduler which inherits from MonoBehaviour. This provides functions to start and stop coroutines, as you need a MonoBehaviour class to do so. The CoroutineTask provides two abstract methods, IsReady() and DoWork(). The idea is to inherit from this, implement the IsReady() to signal when the task is ready and DoWork() to provide the functionality. Then this task can be added in the CoroutineTaskQueue.
The queue executes the Update() function in a single coroutine every few frames (yield return new WaitForFixedUpdate()), checks whether tasks are ready and starts a new coroutine for each task using the IEnumerator stored in the Task variable. Using WaitForFixedUpdate means we can save a few frames, since the only thing we do is iterate through the tasks calling IsReady(). Maybe we can update less frequently in the future depending on our needs.
The coroutine we start using the Task IEnumerator stored in the CoroutineTask runs the code in Execute(), which is a coroutine wrapping around the DoWork() executing delegates to notify when our task has started and finished. We’ve profiled our use of nested coroutines and it seems that in our case there is a minor overhead we can ignore for now. We’ve launched a few thousand executions of the Execute() nested coroutine in a single frame and compared it to the same number of executions of the DoWork() coroutine only. The observed overhead is a few bytes in the garbage collector and 0.2 – 0.3ms in execution for 10,000 calls. We’ll keep an eye on this though and review it in the future.
Construction system implementation using our CoroutineTask
Now that we’ve discussed the requirements of the building construction system and implemented our task processing, we are ready to implement the construction process. We create the ConstructionSystem to handle this process, as well as a few stub classes: the TransportationSystem which is responsible for transporting goods and people, ConstructionPlanning which is a crucial system for the dynamic procedural generation of the city, deciding where to place new buildings and finally the various systems that will handle the run-time behaviour of our buildings (StorageSystem for storage buildings, ProductionSystem for production buildings and HousingSystem for housing buildings).
- A request is placed in the ConstructionSystem using the building’s commodity and the data of the selected tile. Before creating the new ConstructionTask we use the ConstructionPlanning to get a location for our new building. This currently returns a random location on the desired tile, but its purpose will be to decide where to place the buildings using the currently placed ones, the road network and the terrain. We create a new ConstructionTask and pass all this information.
- Then we place the task in the queue. When the ConstructionSystem has enough builders to work on the task it requests transportation of builders and material. At this point, the TransportationSystem will create a TransportationTask and we’ll bind functions from the ConstructionTask to handle the OnFinished event.
- As soon as the transportation task(s) have finished, we’ll have our builders and material on the construction site. Now the task can mark itself as ready and in the next Update() the construction can start. The DoWork() function of the task executes some code (adds builder effort towards construction) and waits for another “game day” to pass.
- Once required effort has been put into the construction, the DoWork() function exits executing the OnFinished() delegate, notifying other parties of the event. At this point we can decide what to do with the finished building (for now we just place it in the world and add it to the appropriate building management system: Storage, Housing or Production).
We have now built good foundations for the gameplay systems: we’ve got the commodity system to separate data from run-time behaviour, the CoroutineTask system to help us split functionality to independent tasks favoring an event driven model and the construction system as a proof of concept for these systems.
In the next couple of weeks we’ll focus on using and extending these systems to implement the behaviour of production, storage and housing buildings. We’ll discuss in more detail the various production tasks and how we’re going to implement them.