Processing Machines and Overstep
Hi! I’m back with another blog post about what I’ve been working on for my factory game. Last time I left you all off with a sneak peek of what was coming up next, and so now I’m here to talk about it :) And what “it” is, is the processing of items into new items, and the machines that do that item transformation. I like to call these machines “Processors”.
Processing Machines (a.k.a. Processors)
In factory games, one of the main things you do to reach your goals is to take some set of items, and process them into a different, usually more valuable set of items. In fact, I’d say that the vast majority of machines that you end up placing down into the world in any factory game can be considered a processor. A smelter that takes some fuel and turns copper ore into copper ingots? That’s a processor. The machine that grinds stone into sand? That’s a processor. The chemical reactor that takes three different chemicals and outputs another with a waste product to boot? That’s a processor.
With so many machines in factory games being a processing machine, it seems a bit silly for each of them to have their own bespoke code for doing that processing. So let’s make a generic module that all processing machines can use! But that requires us to actually look at the differences and commonalities between all processing machines.
Differentiators & Commonalities
I’d argue that there are far more commonalities than there are differentiators for processors. In fact, I think there are only two real differentiators between all of these different transformations of items:
- The machine itself, meaning things like:
- How much space does this machine take up?
- How big is its input and output inventory?
- What items turn into what other items, and how long does it take to do so?
For the first differentiator, we can just give each machine its own item type (each item in my game is an enum variant of the enum items::Type), and for the second, we can define a set of recipes for that machine. So that begs the question, what exactly is a recipe?
Recipes
Before we can get into the details of what a recipe looks like, we need to define what the different types of processing machines there are. Sometimes, a processor just takes a singular input item type (maybe needing multiple of that item) and converts it into some set out output (e.g. a grinder that takes stone and grinds that into sand). Other times, a processor will take multiple different types of items, and some of those items could be used for other recipes (e.g. a fabricator that turns 5 stone into a stone smelter, or turns 1 stone and 1 copper ingot into a basic circuit). The differentiator here is whether there is any overlap in input item types across multiple recipes. There can even be a machine that takes multiple input items but there’s no overlap across other recipes!
All of that is to say that there are in effect two main types of processing machines:
- Automatic processors, where there is no overlap in input item types across recipes, and
- Manual processors, where there is overlap in input item types across recipes, meaning the player must manually set the recipe in order for us to know what recipe to choose for processing
So, with that in mind, we can define what our recipe structures look like:
pub enum Recipes {
Manual(Vec<ComplexRecipe>),
Complex(Vec<ComplexRecipe>),
Item(Vec<ItemRecipe>),
Fluid(Vec<FluidRecipe>),
}
pub struct ComplexRecipe {
pub id: recipe_ids::RecipeId,
pub processing_time_secs: f32,
pub input_items: Option<Vec<(items::Type, usize)>>,
pub input_fluids: Option<Vec<(fluids::Type, usize)>>,
pub output: RecipeOutput,
}
pub struct ItemRecipe {
pub id: recipe_ids::RecipeId,
pub processing_time_secs: f32,
pub input_item: (items::Type, usize),
pub output: RecipeOutput,
}
pub struct FluidRecipe {
pub id: recipe_ids::RecipeId,
pub processing_time_secs: f32,
pub input_fluid: (fluids::Type, usize),
pub output: RecipeOutput,
}
pub struct RecipeOutput {
pub output_items: Option<Vec<(items::Type, usize)>>,
pub output_fluids: Option<Vec<(fluids::Type, usize)>>,
}
As you can see, I may have gone a bit overboard in splitting up to different types of processing recipes. ItemRecipes and FluidRecipes are effectively just special cases of Recipes::Complex where there’s only one input item or one input fluid. Was it necessary? Probably not. But did I do it anyway? Yes, yes I did. For the purposes of this blog post, all you really need to know about is the distinction between automatic and manually set recipes.
Now that we know what the structure of a recipe looks like, we need to figure out how to actually use that in a meaningful way for processing machines.
Processors
So what does the actual structure of a processing machine look like? Well, like this!
#[derive(Component)]
pub struct Processor {
state: ProcessorState,
recipes: Recipes,
active_recipe_idx: Option<usize>,
power_consumption: Option<usize>,
idle_ticks: usize,
/// If the final step of processing a recipe processes less than the recipe's processing speed,
/// this will be set to Some(lost_progress)
overstep: Option<f32>,
}
/// State of a processor machine
pub enum ProcessorState {
Idle,
Processing(f32),
}
The only bits of this structure that we need to care about right now are the state (whether the processor is processing a recipe or not), and the list of recipes (that we just talked about above). I’ll talk a little bit about some of the other parts of the structure later in this post (notably overstep), but I’ll leave the rest to your imagination. Maybe some parts even hint at what’s to come in the next blog post!
Anyway, this allows us to define a common structure for all processing machines to use. But, how do we use it? Well you may have noticed the #[derive(Component)] above the Processor struct. Last time I mentioned Resources in Bevy, the game engine I’m using to make this game. I mentioned how Resources are global pieces of data with only one instantiation in the whole game world. Components on the other hand, are pieces of data that can be attached to any Entity within the game world. So, whenever we create a new machine entity in the world that’s a processor, we also give it an instance of the Processor struct as a component. This both stores all of the associated data to each processor, it also marks that entity as a processor so that we can perform the proper operations on it.
A quick side note here, the way this Processor structure exists right now means that every instance of a processing machine that’s created in the world will store its own separate list of recipes instead of referencing a global list for that type of machine. This is definitely not optimal, but sometimes game dev is about doing the easy thing that Just Works(TM), and optimizing later if need be. I thought I’d call this out because it’s good to remember that the things we create don’t need to be perfect all of the time :)
So, great! Now we have a way to define the different types of recipes, and a Processor structure to attach to every processor entity in the world, but how does processing actually happen?
Processing Items
This is slightly different depending on whether the processor has automatic or manually set recipes, but for the most part it’s the same. The main difference is that automatic recipe processors will accept any item in the whole list of input items until some item exists in its input inventory. If there is an item, it will automatically set its active recipe based on that item since we know that only one recipe has that item as an input. Manually set recipe processors will accept no items until a recipe is manually set, and then only accept the inputs of that recipe.
So with that out of the way, what do processors actually do? I’ll spare most of the details here, but the gist of what happens every factory simulation tick is:
- Check the input inventory of the processor. If this is an automatic processor, set the active recipe based on what’s in the inventory. If what’s in the input inventory is enough to start processing the active recipe, consume the input and change the processor’s state to processing.
- For every processor that’s in the processing state, progress forwards based on the speed of the current recipe. If the current progress reaches 100%, output the recipe’s output into the machine’s output inventory if there’s room. If there isn’t room, just wait at 100% until there is room.
- For automatic processors, check to see if the inventory is empty, and if so, clear the active recipe.
- Re-set the list of items that the processor will accept from inserters.
And that’s about it! There are a few extra details and considerations in there, but that’s pretty much all that we need to do.
This means that each unique processor needs to define very little to actually be put into the game. For example, this is all the code for the Grinder processor:
const GRINDER_INVENTORY_SIZE: usize = 1;
const GRINDER_POWER_CONSUMPTION_KW: usize = 20;
#[derive(Component)]
#[require(
factory::processors::Processor::new(Grinder::recipes(), Some(GRINDER_POWER_CONSUMPTION_KW)),
factory::processors::InputItemInventory(ItemInventory::new(GRINDER_INVENTORY_SIZE, None)),
factory::processors::OutputItemInventory(ItemInventory::new(GRINDER_INVENTORY_SIZE, None)),
factory::power::PowerEntityType::RequiresPower
)]
pub struct Grinder;
impl Grinder {
pub fn new() -> Self {
Grinder
}
pub fn recipes() -> factory::processors::Recipes {
factory::processors::Recipes::Item(vec![
factory::processors::ItemRecipe {
id: factory::processors::recipe_ids::RecipeId::Sand,
processing_time_secs: 1.,
input_item: (items::Type::Stone, 1),
output: factory::processors::RecipeOutput {
output_items: Some(vec![(items::Type::Sand, 2)]),
output_fluids: None,
},
},
factory::processors::ItemRecipe {
id: factory::processors::recipe_ids::RecipeId::CopperSand,
processing_time_secs: 1.,
input_item: (items::Type::CopperOre, 1),
output: factory::processors::RecipeOutput {
output_items: Some(vec![(items::Type::CopperSand, 2)]),
output_fluids: None,
},
},
factory::processors::ItemRecipe {
id: factory::processors::recipe_ids::RecipeId::CarbonDust,
processing_time_secs: 2.5,
input_item: (items::Type::CarbonBrick, 1),
output: factory::processors::RecipeOutput {
output_items: Some(vec![(items::Type::CarbonDust, 2)]),
output_fluids: None,
},
},
])
}
}
Seriously, that’s it. Just a marker Component so that we know this entity is a Grinder, and a list of recipes for it.
You’ll also notice that I’m using #[requires(...)] above the Grinder struct. This is a Bevy specific feature called “Required Components”. It allows you to define other components that must also be inserted onto an Entity whenever the defined component is inserted. This means that, whenever a Grinder is placed into the world, it will automatically get all of the other components listed in the #[requires(...)] list. The main important one we can see is the Processor::new(...) component that defines this machine as a processor. Once again, there are some other things that are a hint as to what’s coming up next.
Every processing machine requires this little code to add to the game, and when new items and recipes for those processors arise in the design, it’s very easy to add/change them.
Now I did say that I’d go back and talk about an interesting piece of the Processor structure, overstep. So let’s do that now!
Overstep
Overstep is a concept that I’ve had to take into consideration for multiple machines so far in the factory, not just processing machines. As you may have noticed, the progress of machines is being stored as an f32, or a 32-bit floating point number. This is interpreted as a percentage, from 0 to 100. Every factory simulation tick, this progress percentage increments based on the speed of the machine, and whether it’s properly fueled/powered. So what happens if we’re at 99.5% progress, but the next tick we would normally be moving forwards 2.75%? Well, we’d lose out on a whole 2.25% progress that’d we’d normally expect. Over time this can cause the actual speed of recipes or other machines to be slower than what’s listed. For example, inserters currently operate at 1 item per second, but with this lost progress, it would probably be closer to 0.9 items per second. That’s no good, and so that’s where overstep comes in.
Every time a machine “loses” progress due to reaching the end of its processing for a certain recipe (or whatever else), we store that lost progress as overstep. Then, on the next factory simulation tick, if the machine is still operating, we add that overstep progress onto whatever progress the machine would normally be making. This means that we’ll never actually “lose” forward progress and machines will actually operate at their listed speeds. Awesome!
Do note that we only store overstep for one factory simulation tick though. If a machine sits idle at the end of its processing, there would actually be no lost progress since it’d be sitting idle after that previous tick no matter whether we moved the full amount forwards or not.
Endings
That’s about all I’ve got for you today! Processing machines are far from finished. I still need to add in a system for fuel consumption (early stone smelters are going to actually be burning materials to operate), and there will always be tweaks to make here and there. But I think this covers most of the interesting bits about processors and some of the details and considerations about them and other machines in my game.
I know this post was very word and code heavy rather than the other recent posts that have had more fun gifs, so thank you for bearing with me and reading this far. Once again, if you’re curious about anything, or want to be kept in the loop when I have more to share about this game, feel free to send me an email at minhaul.creations@gmail.com, or find me on Discord by my username @minhaul.
I’ll still leave you with something though! So have a look at this chain of copper ore being ground into copper sand, and then being smelted into copper ingots, all with the proper ratios to create fully saturated belts (how satisfying!). You may even notice at the end that something has gone awry with the factory. I wonder what that could be…?
Thanks for reading!
