Screeps Tutorial – Handling Creep Roles with a State Machine

2
2106

I had an interesting Screeps Slack conversation with a new friend recently. He was working on his code, and was finding it difficult to extend the behaviour of his harvesters to support remote rooms. His code worked, but it was complicated, and already getting inefficient. This among other things was contributing to his CPU issues at GCL5. After he shared the relevant code for his harvesters I was reminded of how I used to code, and all of the challenges that I ran into to similar to what he was currently experiencing.

var util = require('utilV2');
var roleUpgrader = {
/** @param {Creep} creep **/
run: function(creep, myRooms) {
var homeRoom = Game.rooms[creep.memory.homeRoom];
if(creep.memory.fullcheck === undefined) creep.memory.fullcheck = false;
if(creep.memory.fullcheck && creep.carry.energy == 0) {
creep.memory.fullcheck = false;
}
if(!creep.memory.fullcheck && creep.carry.energy == creep.carryCapacity) {
creep.memory.fullcheck = true;
}
if(creep.memory.fullcheck) {
if(creep.upgradeController(homeRoom.controller) == ERR_NOT_IN_RANGE) {
creep.moveTo(homeRoom.controller);
}
if(creep.pos.roomName != 'W3N58' && creep.pos.getRangeTo(homeRoom.controller) > 2) {
creep.moveTo(homeRoom.controller);
}
} else {
var myRoom = _.filterWithCache('Game.myRooms.'+creep.memory.homeRoom, Game.myRooms, {filter: {name: creep.memory.homeRoom}})[0];//util.getMyRoom(homeRoom.name, myRooms);
var energy = creep.pos.findInRange(FIND_DROPPED_RESOURCES, 3);
//if(dumpTar != undefined) console.log(`dumpTar.store.energy(${dumpTar.store.energy}) > dumpTar.energyCapacity(${dumpTar.storeCapacity}) - 200`);
if (energy.length != 0 && energy[0].resourceType == RESOURCE_ENERGY && energy[0].amount > 20) {
//console.log('Picking up energy');
if (creep.pickup(energy[0]) != OK) {
creep.moveTo(energy[0]);
}
} else if(myRoom != undefined && myRoom.controllerLink != undefined && myRoom.controllerLink.energy > 0) {
if(creep.withdraw(myRoom.controllerLink, RESOURCE_ENERGY) == ERR_NOT_IN_RANGE) {
creep.moveTo(myRoom.controllerLink);
}
} else {
var storage = homeRoom.storage;
if (storage != undefined && storage.store[RESOURCE_ENERGY] > 50000) {
if (creep.withdraw(storage, RESOURCE_ENERGY) == ERR_NOT_IN_RANGE) {
creep.moveTo(storage);
} else {
}
} else {
if(creep.memory.assignedNode != undefined) {
// Find our miner if we have one.
var myMiner = util.findMyMiner(creep);
//console.log('myMiner: '+ myMiner);
//if(myMiner != undefined) {
util.collectEnergyFromMiner(creep, myMiner);
//}
} else {
energy = creep.pos.findClosestByRange(FIND_DROPPED_RESOURCES);
if (creep.pickup(energy) != OK) {
creep.moveTo(energy);
}
}
}
}
}
}
};
module.exports = roleUpgrader;

In the above sample (some of my original code!), you can see that the run function is evaluating everything that the upgrader needs to consider in it’s lifecycle, and it does it every single tick. I left the code in it’s original and embarrassing ‘glory’, I’m not even sure if it is currently in a working state. The style above is the most obvious way to code it since it leads on from the tutorial code, but it has downsides that players will quickly experience. It’s inefficient for CPU usage, it becomes difficult to maintain/evolve, you can’t easily share any of the code with other creep ‘roles’. Clearly, there must be a better way…

So what is a better option for this?

Well, let’s have a think about the lifecycle of a creep. Most creeps will follow this pattern in their life:

  • Get spawned
  • Have some initialization done
  • Move somewhere
  • Perform an action once/continually/until a condition is met
  • Move somewhere
  • Perform an action once/continually/until a condition is met

That’s a high-level template for a creep’s life cycle, and it looks like a good candidate to use a State-Machine design pattern for. If you’re not familiar with this and want details, have a quick skim of this link… or just keep reading ahead and you’ll probably figure it out 🙂

Let’s define some states for a creep’s lifecycle, and set up it’s run function with a switch statement based on it’s current state:

const STATE_SPAWNING = 0;
const STATE_MOVING = 1;
const STATE_HARVESTING = 2;
const STATE_DEPOSITING = 3;
...
var run = function(creep) {
if(!creep.memory.state) {
creep.memory.state = STATE_SPAWNING;
}
switch(creep.memory.state) {
case STATE_SPAWNING:
runSpawning(creep);
break;
case STATE_MOVING:
runMoving(creep);
break;
case STATE_HARVESTING:
runHarvesting(creep);
break;
case STATE_DEPOSITING:
runDepositing(creep);
break;
}
};

 

Ok, so what’s the point of this?

Well, we only run the function for the state that it’s currently in. That function needs to perform any actions required (like moving, harvesting…) and it needs to check if the creep should transition to another state. We should be able to make that function pretty simple and optimize it for performance… We could even make it general enough to be shared between different creep roles!

When the creep is in the state STATE_SPAWNING, we know that it just needs to be initialized and do nothing until it pops out of the spawn.

So let’s read that sentence carefully and pull out the logic that needs to be coded…

// Call this when the creep is spawning
var runSpawning = function(creep) {
// "until it pops out of the spawn" -> when creep.spawning == false, we transition to the next state.
if(!creep.spawning) {
creep.memory.state = STATE_MOVING;	// Set the creeps new state
run(creep);	// Call the main run function so that the next state function runs straight away
return;		// We put return here because once we transition to a different state, we don't want any of the following code in this function to run...
}
// "needs to be initialized" -> we initialize the creep if that hasn't been done yet.
if(!creep.memory.init) {
// We might need to evaluate something, register the creep with some other code we have, do some work based on the memory that the creep was spawned with...
// Set up the creep's memory... Ideally you want to cache as much info as possible so that the work is only done in 1 tick of the creep's life, not all 1500 of them plus spawning time!
// For this example, we probably want to figure out which source the creep should harvest and store that in memory (the objectId, or it's position, or maybe both, or maybe not at all... it depends on your code!).
creep.memory.init = true;	// so that we know in the following ticks that it's already been initialized...
}
// "and do nothing"
}

In the above example, you can see that while the creep is spawning, it’s going to be very light on CPU usage apart from the tick where it gets initialized. Considering that creeps can take up to 150 ticks to spawn and have a lifespan of 1500 ticks, you’ve potentially saved 1649 ticks of redundant CPU usage… Awesome!

How about the STATE_MOVING function? Well we can make that very simple too!

var runMoving = function(creep) {
// Let's assume that you already set the target position in the initialization function above, and you stored it in creep.memory.targetPos
var pos = new RoomPosition(creep.memory.targetPos.x, creep.memory.targetPos.y, creep.memory.targetPos.roomName);
// Has the creep arrived?
if(creep.pos.getRangeTo(pos) <= 1) {
creep.memory.state = STATE_HARVESTING;
run(creep);
return;
}
// It hasn't arrived, so we get it to move to targetPos
creep.moveTo(pos);
};

Again, we have a very simple function defined here… And with a very small tweak we could use this state for just about any creep role! Let’s have a look at that tweak…

var runMoving = function(creep, transitionState) {
// Let's assume that you already set the target position in the initialization function above, and you stored it in creep.memory.targetPos
var pos = new RoomPosition(creep.memory.targetPos.x, creep.memory.targetPos.y, creep.memory.targetPos.roomName);
// Has the creep arrived?
if(creep.pos.getRangeTo(pos) <= 1) {
creep.memory.state = transitionState;
run(creep);
return;
}
// It hasn't arrived, so we get it to move to targetPos
creep.moveTo(pos);
};

You can see that we now pass in the next state as a function parameter. For the modified function above, we would change the relevant part of the main switch for our harvester like so…

		case STATE_MOVING:
runMoving(creep, STATE_HARVESTING);    // Harvest the source when we get there
break;

And we could reuse the same function for another role like a hauler by doing something like this in it’s main switch…

		case STATE_MOVING:
runMoving(creep, STATE_GRAB_RESOURCE);    // Pickup/withdraw/transfer resources when we get there
break;

Summary

Let’s revisit the problems that we were trying to overcome and see if we achieved the goal… Our old code was inefficient for CPU usage, it was difficult to maintain/evolve, and we couldn’t share any of the code with other creep ‘roles’. With this State-Machine design pattern we’ve been able to separate the creep’s lifecycle into simple states, and define the conditions to transition between these states:

  1. The state functions don’t require us to evaluate everything for the creep’s whole lifecycle every tick – so it’s definitely more CPU efficient
  2. The code is well layed out and it’s clear what everything is doing – so it’s more maintainable, and adding a new state to evolve the role is as simple as writing the state function and changing the transitions
  3. We can definitely share common state functions between creep roles as shown – we just need to define run functions for each role.

In the next tutorial we will build on this concept further. We’ll look at further improving the reuse by supporting custom/dynamic state transition conditions.

Any questions or feedback? Please comment on this article and I’ll do my best to answer.

2 COMMENTS