It’s been a while since the last installment of this series (see part 1 and part 2). We got a little busy with actually shipping the game. 🙂 But now that that’s done, I have some time to conclude the simulation overview as well.
In previous parts, we talked about how the production rules system is set up, and what kinds of rules and resource conversions it operates on. But how well does it perform?
Very well, it turns out. Our production system is very efficient – in a town with many hundreds of entities, the rule engine’s CPU consumption is barely noticeable in the profiler, even when running on rather underpowered tablets.
Most of the processing power is spent, predictably, on checking conditions. One of the design goals for this system was to make sure conditions can be checked in constant or near-constant time, to help with performance. We have three optimizations in place to help with this: flexible frequency of rule execution, a drastically simplified language for conditions and actions, and an efficient world model that makes querying really computationally cheap.
Condition Checking
Production systems vary in how often the rules should be run. For example, we may want to run rules all the time on each frame, or maybe on a set frequency like 1Hz, or for a turn-based game, on every turn.
Our game’s economy is specified in terms of game clock days, eg. a farm produces wheat every 7 days. So it was sufficient to run most rules once per day, or even less frequently – and we made frequency tunable as needed on a per-rule basis.
Finally, there’s a very simple scheduler that keeps track of which rules are supposed to run when, so that they don’t get accessed until their prescribed time. This is a very simple optimization, but saves a lot of unnecessary work.
Resource Manipulation Language
Resource conditions and updates in our system look like this:
"doWork": "inputs": [ "map 5 gold" ], "outputs": [ "unit 5 gold" ]
This means each unit only has access to three types of resource bins: “map”, “unit”, or “player”, and it can’t make generic queries like in some other production systems. Here “map” and “unit” are like contextually-bound variables: “unit” refers to the entity that’s currently executing this rule, “map” refers to all tiles underneath the unit, and “player” refers to the entity representing player’s city inventory.
This is a form of deictic representation. Just like in English, the words “this” and “that” have different references depending on context, so in our query system, there are variables like “map”, “unit”, and “player” that are already bound based on context. Game units typically only care about themselves and their immediate surroundings, so that fits very well.
This representation is constrained, but also eliminates a huge and expensive search problem.
(Short introduction to deictic representation is in the Pengi paper by Agre and Chapman – although in that paper it’s cumbersomely called an “indexical-functional” representation. The term “deictic” replaces it later in Agre’s book “Computation and Human Experience”.)
Data Model
Most conditions are resource queries, and most actions are resource modifications. For example: check if there’s gold underground, and if so, consume it and produce gold in my inventory; check if there are workers working here, and if there is gold in my inventory, and if so, move gold over to player’s inventory, and so on.
As we described before, we store resources in resource bins, and those bins are attached to units, map tiles, and the player’s data object. Since there are currently 64 resource types, each resource bin is then implemented as an array of 64 double-precision floats, indexed by resource. In other words: querying and modifying resources in a bin is a constant-time operation.
So a resource query such as “unit gold > 5” works as follows: first we get a reference to the unit’s own resource bin (via a simple switch statement), then look up resource value (an array lookup), and finally do the appropriate comparison against the right hand side value (another simple switch statement). All this adds up to a constant-time operation.
A query such as “map gold > 5” is marginally more expensive, because it means “add up gold stored in all tiles under the unit, and check if > 5”. Fortunately, units are not arbitrarily large – the largest one is 2×2 map tiles, which means we execute at most 4 tile lookups, making it still a constant-time operation.
In conclusion
This sums up the performance side of our production system. Adding some specific limitations, and especially by restricting our query language, gave us really good efficiency – which we needed in order to run on underpowered devices such as tablets.