Last week, I worked on a really fascinating problem that led me to a new appreciation of the Entity Component System framework. Specifically, that framework allowed me to dynamically change the fidelity at which I was simulating some entities by adding/removing components, but keeping the same ID. In order to explain, I need to talk a little bit about Kinematic's design.
Kinematic's two main gameplay loops are exploration of a large map made of many rooms, and factory construction. But these factories are a little different than you might be used to. Rather than organized, grid-based arrays of machines moving their products through a precise structure, Kinematic factories are more like Rube Goldberg devices. Your factory will be flinging resources back and forth across the screen in a dizzying array of arcs, bounces, forces, portals, and more.
The challenge I undertook last week was how to let you connect factory chunks in different rooms of the map, while only simulating physics in your current room. My initial thought was to create an abstracted node-graph representation of every machine in the factory. Then each node could have timings for how frequently they output resources, how long it takes to arrive at their destination, and which node represents that destination. Then I would just need to tick this node-graph every update frame, just as I tick the entities that you're able to interact with directly.
A key problem with this solution, though, is keeping the operation of the node-graph in sync with the operation of the actual machines. I don't want to be constantly implementing features in both worlds, and testing to make sure they produce the same results. The game machines use a mildly complicated state machine to keep track of when they're waiting for recipe ingredients, when they're processing them into the product, how long it takes to output the product, etc. You can see in the above .gif that they even support releasing multiple products in quick succession, as with the blue chips in the bottom left corner.
What I eventually realized is that the MachineStateMachine class, if it were told that it's "abstract", could keep chugging merrily along. It could behave exactly the same as it would if it were "real". It would just know not to try to play any animations, as the animation component no longer exists. When it outputs a resource, it would create the resource in "abstract" mode as well, which would simply count down until it was handed to its abstract recipient, or be destroyed from splatting on a wall.
This left me with two main challenges:
1. Converting to/from abstract mode
2. Determining what happens to resources, so they can arrive at the proper destination, even when abstract
Converting Between Abstract and Real
Prior to introducting the concept of "abstract mode", Machines consisted of the following components:
MachineComp - mostly a wrapper around a MachineStateMachine, with a handful of helper functions to interface with the rest of the game
ResourceInputComp - sets up collision and contains the callback to "ingest" the resource that is required for the machine's recipe
CollisionComp - the collision for the ResourceInputComp to be watching
ResourceStorageComp - simple map of resource type to count for keeping track of when the machine's recipe is fulfilled
ResourceOutputComp - creates the resources that the machine produces, and sets their position/velocity based on the machine's current trajectory settings
PosComp - gives the machine a position in the world
AnimComp - allows playing animations
RenderComp - allows rendering of said animations
Of these, most of them are still required for the machine to behave correctly. The main functions that fall away when they're not in the current room are things related to rendering and physics. Thus, in order to convert to abstract mode, I delete the Collision, Pos, Anim, and Render Comps. (I also tell most of the other Comps that they're now abstract, if it matters to them). I also create one new component, an AbstractMachineComp. This is used to keep track of which room, and which MapID within that room the machine represents. Thus the AbstractMachineComp is used to identify when/where to make the machine "real" again.
Small digression: If you read my previous blog post, I described what Kinematic engine's IDs are. They are used to group components into conceptual entities, and are generated at runtime. MapID, on the other hand, is used to persist objects on the map that will be read from and written to disk. MapID is an integer that is unique per room, typically generated by the Tiled map editor. When I write save game code, I will need to generate MapIDs on the fly, in a range that Tiled will never use, in order to let the player add new machines, but let saves be forward compatible with map changes.
So that's how I can make a machine become abstract/real. What do the resources (like that pink goo flying around) look like? They're pretty similar. The main additional challenge is how to get the resource into the right position if it's already been flying around for a few seconds when you enter the room. This was pretty simple to do; after creating the PhysicsComp that's responsible for the resource's trajectory, I just tick it repeatedly for 1/60 sec until I have ticked it as long as the abstract resource was alive. Then, the next update frame, it will continue along as if it had arrived there normally.
Determining Resource Recipients
So now we have abstract machines correctly consuming, processing, and producing resources using all the normal code the game uses when those machines are real. But the resources only arrive at the correct destination if you enter the room and let the PhysicsComp do its instantaneous journey. I needed a solution for completely abstract resources to be emitted and absorbed by completely abstract machines with no intervention from physics at all.
The first concept I need for this is a "room layout". If the player changes the trajectory of a machine, puts a mirror in the way, or swaps a recipe, whatever idea we had about the factory's operation will be wrong. Thus, every operation that might change where a resource will end up increments RoomMemo::LayoutIndex. This yields a statement of fact about a correct solution to the problem: When the player leaves a room, every ResourceOutputComp needs to know where its product will go in the current LayoutIndex.
Working backwards from there, a nearly free way to find out these results is by telling each non-abstract ResourceComp what LayoutIndex it was spawned under. Then, when the ResourceComp arrives at its destination (whether that be a ResourceInputComp, or a splatter on the wall), the ResourceComp can check its LayoutIndex against the room's. If they're the same, it tells its spawning ResourceOutputComp "After X seconds, I arrived at Y".
This just leaves one gap to plug up. If the player changes the LayoutIndex, then immediately leaves the room, we still need to know where all of the ResourceOutputComps will send their output. Thus, when leaving a room, before unloading anything, I iterate over every ResourceOutputComp that isn't abstract. I check their recorded outcome's LayoutIndex against the room's. If they don't match, I spawn a dummy resource and simulate its physics until it dies (or an upper bound of time has gone by). When the resource arrives, I don't count it towards any recipes, play any sounds, etc. I just record the result and delete the object.
As you can see here, I now have a system that lets machines and resources dynamically convert between abstract, conceptual representations and fully realized, simulating, rendering versions. All while keeping the same ID, and many of the same components.
Let me know if you have any questions. I'd love to share thoughts and ideas. Join the conversation on Reddit.