Simplifying state: Synchronous hardware ideas applied to software
State madness
Lots of code, contains a ton of state. My current project is especially prone due to it being very rule-driven.
The problem that arises, is as the codebase grows, you naturally end up having multiple systems interacting with each-other.
Now in my special case, everything is completely compiletime known. Meaning there isn’t really any dynamic modularity to handle, but that makes this example even easier to show.
We’re going to look at one particular approach to solving complex communication between different “entities”, inspired by my experiences from writing hardware systems.
Just use if statements
#include "room1.h"
void update() {
if (room1.lever1) {
door.state = OPEN;
} else {
door.state = CLOSED;
}
}
Some might think the above code is too simple, or silly. One might say “It’s natural for most systems to grow to some complexity level where you need handlers, objects, etc. globals are bad for extensibility”.
However, I think this is blatantly false. If you want to get work done, you better try to write things in a way which is this simple. Over-complicating things is a super easy trap to fall into.
if (((room1.lever1 || room1.lever2) &&
(room2.lever1 && room2.lever4)) ||
secret_room.master_lever) {
door.state = OPEN;
} else {
door.state = CLOSED;
}
As things get more complex, don’t add some weird system, just continue to do simple things. Imagine writing the snippet above using said weird system where you push changes, or communicate via functions. If possible you want door.state
to only be changed at one place. This has the added benefit of being extremely readable. However, you will loose context other places, but if you’re good at grepping that shouldn’t be a problem.
Even in situations where less information is known at compiletime, this can be applied in some form. For example, if you don’t know which switches are bound to which doors it’s generally solved by iterating through some datastructures. It’s worth noting that in dynamic cases, you don’t usually have lots of weird cases, they tend to be more predictable.
Wait, all logic inside “update()”?
Yes, that’s right. Just run all your logic each frame. Computers are blazingly fast, checking some if statements doesn’t take that long. You’re probably going to be limited by other things before a bunch of branches limit you.
Compared to signals, event systems, and other methods of solving this problem, just having an update functions allows a lot more flexibility. Using event systems leads to exponentially more code as you add states and signals. A lot of them also use string identifiers, and dynamic access. Which is extremely bothersome for extensibility, and is prone to typos.
However, one thing should be noted, handling state with just globals + update()
does have one limitation when it comes to being able to write concise code. This is when you want code to vary with state, and not just data. You can fix this by having a switch statement, using function pointers, or even more if statements.
But there is a better way
Corutines
You can create a corutine instance for each of your update() functions.
This lets you store code state without having to write lots of code managing it. Personally I use minicoro to do my corutine states.
void update(coro_t* co) {
while (true) {
if (room1.lever1) break;
yield(co);
}
while (true) {
if (room1.lever2 || room2.lever1) break;
u32 res = do_update_1();
if (res > 3) do_update_2();
yield(co);
}
while (true) {
if (room4.lever3 && room4.push_button) break;
do_update_2();
yield(co);
}
open_final_door();
}
I’m hoping this simple example sort of shows how extensible and concise the code gets by doing this. Even very stateful programs can be implemented this way, and the huge benefit is that you can follow the code linearly downwards. It’s also extremely easy to debug, as you can just step through each corutines update()
function.
When it gets bothersome
What happens if we want to only act on changes to some of these variables?
void update(coro_t* co) {
bool has_pushed_lever1 = false;
while (true) {
if (!hash_pushed_lever1 && room1.lever1) {
play_audio(&audio_sources[AUDIO_LEVER_OPEN]);
has_pushed_lever1 = true;
}
if (hash_pushed_lever1 && !room1.lever1) {
play_audio(&audio_sources[AUDIO_LEVER_CLOSE]);
has_pushed_lever1 = false;
}
yield(co);
}
}
You start having to do stuff like this.
You might get the idea of using some event system just for this. Now it generally gets the job done, but as explained it gets hard to manage. Event systems play very poorly if you want situations like explained further above, where you have lots of things in your if statements. Having to set up event listeners for each one of those and somehow collecting them together is how you get horrible spaghetti code. Callbacks also don’t allow you to change a corutine’s state in any meaningful way, which is even more of a problem. This means you will have to go back to switch statements again. How horrid!
You probably already know the solution to this. It is to just store the previous state of each such variable.
typedef struct {
bool state;
bool last_state;
} lever_t;
If you have ever used or made a platform library that isn’t event based, you have probably encountered this for button presses or the like: if (platform_key_pressed(KEYCODE_SPACE) == true)
It’s essentially the same concept. Each frame, only one place edits the lever’s data, meaning that as long as every single piece of code is ran each frame, you can just check if the state has changed.
How this is related to synchronous hardware?
When making synchronous hardware, you often have control signals which only exist for a single “clock-cycle”.
Take this VHDL code for blinking an LED at 1Hz as an example
clock_enable_1hz_gen : enable_gen port map (
clk_50hz => CLOCK, resetn => NRESET,
clock_divisor => "00000001011111010111100001000000",
enable => clock_enable_1hz
);
alive_gen : process (CLOCK, NRESET) is
begin
if rising_edge(CLOCK) then
if NRESET = '0' then
LEDR(0) <= '0';
elsif clock_enable_1hz = '1' then
LEDR(0) <= not LEDR(0);
end if;
end if;
end process;
This process is synchronous because it only does something during the rising edge of the clock signal. clock_enable_1hz is only on during one clock cycle, and is enabled every nth rising edge, such that we can toggle the LED at 1Hz. This technique is riddled across hardware design. When designing hardware, everything is ran each clock-cycle. Everything is an update() function.
This is essentially the same as our software, where we run each update function each “frame” instead of each “clock-cycle”
This constraint of hardware design, gives life to this unique way of sharing stateful information. And gives us a feature we can exploit when writing software as well.
Using this knowledge
bool secret_hold_button_click;
void update(coro_t* co) {
float hold_time = 0;
while (true) {
secret_hold_button_click = false; // always reset
if (room.secret_hold_button[0].state == true)
hold_time += delta_time();
else
hold_time = 0;
if (hold_time > 1.0) {
secret_hold_button_click = true;
hold_time = 0;
}
yield(co);
}
}
// somewhere else...
void update_special_room() {
if (secret_hold_button_click && special_mode_active)
play_audio(&audio[AUDIO_SPECIAL_ROOM]);
}
We can propagate control signals through our “synchronous” codebase just by having variables that have a per-frame lifetime.
I’ve found this extremely powerful. It’s simpler and more concise than an event system. It allows you to still use corutines, and is thereby easy to debug. It also creates manageable codepaths, and you can inspect the memory to see which states are active at any given moment.
This is not limited to booleans either. We can for example use counters. Or for more advanced info, you might want to to create an array instead.
struct room_t {
s32 changes_this_frame; // signed on purpose
u32 people_in_room;
}
void update_room(coro_t* co, room_t* room) {
s32 in_room_prev = 0;
while (true) {
room.people_in_room = count_people_in_room(room1);
room.changes_this_frame = (s32)room.people_in_room - in_room_prev;
in_room_prev = people_in_room;
yield(co);
}
}
Comparison to callback driven system
Simple condition
void init() {
event_add_listner(room.levers[0].on_click, try_open_room_secret);
}
ev_func(try_open_room_secret) {
if (room.has_secret) {
room.secret_doors[0] = OPEN;
event_del_listner(room.levers[0].on_click, try_open_room_secret);
}
}
vs
void update(coro_t* co) {
while (true) {
if (room.has_secret && lever_pressed(room.levers[0])) {
room.secret_doors[0] = OPEN;
return;
}
yield(co);
}
}
As you can see, they’re fairly comparable currently. You could even say the event system has some benefits.
More complex conditions
You need to press multiple buttons within a time frame.
timer_t* room_timer;
bool lever_states[2];
void init() {
for (u32 i = 0; i < count_of(lever_states); i++)
event_add_listner(room.levers[i].on_click, try_open_room_secret);
room_timer.init();
event_add_listner(room_timer.on_timeout, reset_levers);
}
ev_func(try_open_room_secret) {
// lets hope the event poster gives information about which lever was pressed...
lever_t* lever = (lever_t*)ev_data;
if (!room_timer.timer_started) {
room_timer.start_timer(10.0);
}
lever_states[lever.index] = true;
bool all_pressed = true;
for (u32 i = 0; i < count_of(lever_states); i++) {
if (!lever_states[i]) {
all_pressed = false;
break;
}
}
if (all_pressed) {
room.secret_doors[0] = OPEN;
for (u32 i = 0; i < count_of(lever_states); i++)
event_del_listner(room.levers[i].on_click, try_open_room_secret);
event_del_listner(room_timer.on_timeout, reset_last_lever);
room_timer.destroy();
}
}
ev_func(reset_levers) {
lever_states = {0};
}
vs
void update(coro_t* co) {
while (true) {
bool lever_states[2] = {0};
for (u32 i = 0; i < count_of(lever_states); i++)
if (lever_pressed(room.levers[i]) goto lever_pressed;
yield(co);
continue;
lever_pressed:
for (yield_for_seconds(co, 10.0)) {
bool all_pressed = true;
for (u32 i = 0; i < count_of(lever_states); i++) {
if (lever_pressed(room.levers[i]) lever_states[i] = true;
if (!lever_states[i]) all_pressed = false;
}
if (all_pressed) {
room.secret_doors[0] = OPEN;
return;
}
}
}
}
The event solution even requires us to create a new “timer” module. Meanwhile, corutines can naturally yield for a set amount of time just by checking what the current time is.
The event solution also has more management around it, requiring us to initialize the timer and event systems, as well as cleaning them up once we’re done with them.
This only gets worse for the event driven solution once you add multiple types of events. You will have to pull your code into even more functions, and do checks from data accumulated from different event handlers. The corutine solution doesn’t have to change—It is 100% extensible, we have yet to introduce any abstractions on it.
Doing it the other way around
We can also read at the reset point, and allow other code to set the data.
struct entity_t {
vec2 pos;
int health;
bool on_fire;
bool immune_to_fire_this_frame;
};
void fire_immunity_circle(coro_t* co) {
vec2 circle_centre;
float radius;
while (true) {
for (e in entities) {
if (vec2_dist(e.pos, circle_centre) <= radius) {
e.immune_to_fire_this_frame = true;
}
}
yield(co);
}
}
void update_entity(entity_t* e) {
if (e.on_fire) {
if (!e.immune_to_fire_this_frame)
e.health -= 1;
}
e.immune_to_fire_this_frame = false;
}
The above code makes it so you don’t have to track which entities are inside your circle, to disable immunity on their eventual exit. You also don’t have problems if the immunity fields are intersecting.
The other way to achieve the same would be to loop over all immunity fields inside update_entity()
. This would indeed work. However, it means you have to store all your immunity fields in some array(s). Toggling, enableing and doing weird things would get increasingly constrained.
The above code gives you full control over how the immunity fields are allocated and applied. You can have weird logic like only applying to entities on a team, or who are in the same building, or who are named in some certain way. All without changing the interface to towards the entities. It’s really flexible.
Closing thoughts
I believe that using checking logic each frame—combined with corutines and synchronous control signals—is an extremely powerful and simple way to implement heavy and intertwined logic.