Skip to content

A new phase for bonzAI: update()

bonzaiferroni edited this page Jun 30, 2017 · 7 revisions

One thing that bonzAI has always done rather inefficiently is finding/creating its Operations. Every tick it has to scan through your flags and create new objects. These get used for one tick only. The cost for this process will increase as the number of flags and operation types increase.

There is another drawback to this method. Since your operations/missions only live for one tick, the only way to give them a "history" is to save data to memory.

So what can be done?

Let's make a new phase

I was curious about how things might be different if I started declaring my operations/missions at the global scope so they could live longer than a single tick. I decided to accomplish this by adding a new phase. Really, I just broke up the current init phase into two functions:

  • init() - Runs once per the lifetime of an object
  • update() - Runs every tick

Most of my missions/operations could be converted to this scheme by simply renaming the current init function as update and then adding a new init function (yes, this probably seems silly, lets not go into that). I also had to do some tinkering in the main loop to support this behavior, and I added a lot of new constructs like OperationFactory. In fact, I got rid of loopHelper.ts. There is enough different there to deserve its own article, which I'll write before I actually merge this code to the master branch.

I ran into some snags, for sure, see the next section.

Playing with fire: creating objects in global

Any object that gets instantiated outside your main loop can live for multiple ticks. Here's the tricky part, since your object environment is supported by multiple nodes, most of the time you will not get the same object for multiple ticks.

My naive understanding of global when I first started using it was this: An object gets created in global scope on tick 0. That object is now "live" on ticks 0, 1, 2, 3, 4, etc. Hooray! Simple caching and intuitive behavior.

Unfortunately my parade was quickly rained upon. That isn't how it will work, when multiple nodes support your object environment. Here is a more typical scenario:

Node 1: 0 2    7
Node 2:  1 3     9
Node 3:     4 6
Node 4:      5  8

So operation foo on node 1 will get created on tick 0 and be live for ticks 0, 2, 7. The same operation will get created on node 3 on tick 4 and be live for ticks 4, 6.

So if you work this way, it is important to have an accurate understanding of the "lifetime" of each of your objects. Any data that you cache on an object needs to be something that is fixed or very unlikely to change. For example, you'll get into some funny behavior if the four versions of your foo operation disagree on where a creep should travel.

You still have operation.memory and mission.memory which work the way they always have, and are still useful for providing a reliable "history" for any operation/mission.

Dealing with short-lived objects

All gameobjects, Creep, Room, etc. really are only intended to be used on the tick that they are created. You can cache a creep on tick 5, and that reference will remain valid, but it will always refer to the creep that existed on tick 5. A new creep object with the same soul was created on all subsequent ticks and those are the objects that you ought to be using.

Other problems can come up from caching short-lived objects. We could assign operation.sources each tick, and that would work as expected 99% of the time. But what if you lose vision in that room? Unless you now explicitly assign undefined to operation.sources, it will continue to hold a reference to the sources that existed on a previous tick.

To solve this problem, I started using an operation.state and mission.state object. A new state object is guaranteed each tick. So if you save a value to mission.state.foo on tick 5 and not on tick 6, you can count on mission.state.foo to be undefined on tick 6.

A bit of housekeeping

While I was making such a huge overhaul, I decided to change something that had been bugging me. I decided to make all the phase functions have the same identifiers, on the operation and mission level. So instead of operation.init() and mission.initMission(), it is now just mission.init(). This means there will be some arbitrary renaming ahead.

How I updated all my missions/operations

For the most part, I started out with the process I described above. I renamed the init phase to update and created a new init phase. Letting it stay this way will have behavior similar to what it was before, the code will run each tick.

There may be some functions that you want to move to the init function, so that they only run once per the life of the object. Stuff like igorPosition, upgraderPositions are great to put here. I would advise a bit of caution though, this is not a good place to put CPU-heavy functions with the rationale that it is good to only run them once. Most of the time, this will run during global refresh phases which already have to do a lot of heavy lifting by parsing your code. Adding a lot of extra overhead to these ticks will cause spikes and possibly timeouts.

The vast majority of the updates to each mission were just assigning the short-lived objects to mission.state. I started defining an interface for mission.memory and mission.state in the abstract class. This is nice because any doesn't give you any type help, and it is easy to run into bugs. The downside is it adds a bit of extra work for setting up each mission. I now declare something like this in each mission header:

interface MiningState extends MissionState {
    container: StructureContainer;
    source: Source;
    storage: StructureStorage;
}

interface MiningMemory extends MissionMemory {
    potencyPerMiner: number;
    positionsAvailable: number;
    transportAnalysis: TransportAnalysis;
    distanceToStorage: number;
    roadRepairIds: string[];
    prespawn: number;
    positionCount: number;
    spawnDistance: number;
}

Within MiningMission I now have these two fields:

export class MiningMission extends Mission {
    public state: MiningState;
    public memory: MiningMemory;
    // ...
}

Converting a mission is fairly simple if you do something like this, and remove the fields in the class itself for the object in question. For example, if I remove miningMission.container and add it to MiningState, my IDE will now complain about each miningMission.container reference. I just have to iterate through these (F2 on webstorm) and change the reference to miningMission.state.container. This is a pretty brainless task, I recommend doing it with a light beer-buzz and some metallica playing.

So what are the payoffs?

First, the CPU drop was pretty dramatic, at least by my standards. I immediately saw a 10% - 15% drop. Most of this was in the init() phase, which went from about 50-60 cpu per tick to 30-40. This is just because it doesn't have to create thousands of missions/operations each tick anymore.

Second, the fact that your objects can now live longer than one tick opens the door for a lot of creativity. For example, in my BaseRepairMission I have a function that figures out which towers should participate in the repair based on the coordinates of the object being repaired. Since this return value is not going to change very often, I can memoize the function fairly painlessly. I put this in my init() function:

this.towerSearch = _.memoize(this.towerSearch);

And works exactly as you'd expect! On each global refresh I see this function has a cpu cost of around .2, and on all other ticks I see it has a cpu cost of around .002. Pretty neat!

You now also have an easy way to cache much larger objects, like costmatrices. It was prohibitive to cache these on the mission/operation level before just due to the memory footprint. Now, go ahead and save them to a field in your operation/mission, no problemo!

What are the drawbacks?

The major issue, bonzAI just got more complicated. An extra phase, and a pretty decent chance for unintuitive behavior in each mission due to how the nodes support objects. It is up to you to decide if that is worth it. When I'm done testing this code, I plan to immortalize the current code in the master branch as version 2. This new code will become version 3.