Procedural programming is always enough.
The other day, I saw this video, "Procedural Programming Is Not Enough."
Honestly, I agree with the contents of the video, but I disagree with the takeaway, that the problem was procedural programming. To briefly summarize the video, consider how you might represent a simplified model of a "move" from a game like pokemon. The initial procedural solution shown is along the lines of this:
class Attack {
public string name;
public float accuracy;
public int damage;
public int roundsPoisoned;
public bool makesContact;
public Attack(
string name,
float accuracy,
int damage,
int roundsPoisoned,
bool makesContact
) {
// ...
}
}
The main argument is that procedural programming fails here, because it forces every attack to consider data that it would never deal with. If I wanted to add a new field, like roundsBurned or roundsParalyzed, every single move is forced to consider those fields, even if they never touch them. This could be especially problematic the more types of effects there are, such as causing rain, putting enemies to sleep, or modifying the stats of the attacker, or of the enemy.
The approach that's being taken here is usually called a "megastruct" or a "fat struct", where you use one struct to represent everything. Empirically, it does work in a lot of cases, but this isn't one of them in my opinion. There are enough moves that do enough different things, that it would be wrong to force them into one struct. In the video, he solves the problem with using interfaces, which is what I'd do in an object-oriented language as well.
You'll notice that I defined Attack as a class, and that's because the video focuses on "using a procedural style in an object-oriented language". I won't be doing this, because my goal is to showcase what procedural programming really looks like, which requires features not present in most OOP languages (mainly tagged unions and function pointers). So as a first step, let's rewrite it in Odin, which I consider to be the epitome of procedural programming amongst modern languages:
Attack :: struct {
name: string,
accuracy: f32,
damage: i32,
rounds_poisoned: i32,
makes_contact: bool
}
So for starters, the first thing I'd do is consider if this really is enough. Odin default initializes everything to 0, so even if there were more fields I didn't care about leaving them as 0 is fine. No matter how many other rounds_* fields I add, if they all stay zeroed, nothing bad happens.
scratch := Attack {
name = "Scratch",
damage = 40,
accuracy = 1,
makes_contact = true
}
Since scratch doesn't inflict any effects, we just omit them from our initializer. Still though, it would get annoying to manage having several different types of effects. Our attacking procedure may look something like this:
do_attack :: proc(attack: Attack, target: ^Pokemon) {
if !hit(attack) do return
target.health -= attack.damage
target.rounds_poisoned += attack.rounds_poisoned
target.rounds_paralyzed += attack.rounds_paralyzed
target.rounds_burned += attack.rounds_burned
target.rounds_sleeping += attack.rounds_sleeping
target.rounds_frozen += attack.rounds_frozen
target.rounds_confused += attack.rounds_confused
}
And again, this code is "fine", it would work and is readable, but there's definetly room to improve. Ideally, there'd be an enum to represent status effects, that way new ones can be added easily, and represnted without modifying attacks or pokemon, so let's do just that:
Status_Effect_Type :: enum {
Poison,
Paralysis,
Burn,
Sleep,
Freeze,
Confusion
}
Then we modify our attack struct to store a slice of effects
Status_Effect :: struct {
type: Status_Effect_Type,
duration: i32
}
Attack :: struct {
name: string,
accuracy: f32,
damage: i32,
status_effects: []Status_Effect,
makes_contact: bool
}
We aren't looking at the pokemon struct here, but it could use an enumerated array to efficiently and automatically store all of its effects. Again, we could probably stop here, but let's go further and see if we can get that object-oriented extensibility that people love so much. Let's say you have a new type of move, one that affects the user of the move, rather than the enemy.
We could take the "dumb" approach, throwing even more new fields into the Attack struct, but that's wasting space, and could perhaps be error prone, depending on the handling code. Instead, let's use a union. Unions serve a similar purpose to interfaces, except they directly state all of their variants at their declaration. Since you always know the exact types that a union may hold, you can put whatever data you want in the variants, rather than having to rely on shared method implementations. It's a bit like sealed interface Foo permits A, B, C, and then using switch pattern matching to check if it's an A, B, or C in Java. Here's how I'd refactor the code to properly support multiple "move types"
Any_Move :: union #no_nil {
Attack,
Stat_Change,
}
Move :: struct {
derived: Any_Move,
name: string,
accuracy: f32,
}
Attack :: struct {
damage: i32,
status_effects: []Status_Effect,
makes_contact: bool
}
Stat_Change :: struct {
health_change: f32,
defense_change: f32,
accuracy_change: f32,
confusion_change: i32,
// ...
}
I'm a big fan of this structure. You could argue that we should also use an enum for stat changes, and that may be true. It just depends on how these stats are actually stored on pokemon. The #no_nil is necessary because by default, Odin unions have a "nil state", where they hold no values at all. This is super useful for some situations, but doesn't make sense here.
Using our new structure is super simple too, and arguably even more readable than before:
scratch := Move {"Scratch", 1, Attack {
damage = 40,
makes_contact = true
}}
acid_armor := Move{"Acid Armor", 1, Stat_Change {
defense_change = +2
}}
If we wanted to add a new type of stat change, something for the enemy, we could do that too, super easily!
Enemy_Stat_Change :: distinct Stat_Change // New type, same structure
Any_Move :: union #no_nil {
Attack,
Stat_Change,
Enemy_Stat_Change
}
We make a new type, add it to the union, and now we can use it anywhere
confuse_ray := Move{"Confuse Ray", 1, Enemy_Stat_Change {
confusion_change = rand.int_range(1,5)
}}
We can take this further, too. Some moves may do a bunch of different things at once, like damage the enemy, and heal the user. One more move type, and we're good
Compound_Move :: struct {
moves: []Any_Move
}
Any_Move :: union #no_nil {
Attack,
Stat_Change,
Enemy_Stat_Change,
Compound_Move
}
Now, we can make a ton of different moves
absorb := Move {"Absorb", 1, Compound_Move{{
Attack {
damage = 20,
},
Stat_Change {
health_change = 10
}
}}}
Final thing, what if a move had to do something truly special? What if we wanted one move to change the game's time, or transform some pokemon, or add an item to the user's inventory or something? Well we could add more types, but this sounds like a good use case for a function pointer. We might define something like this
Custom_Move_Callback :: #type proc(self: ^Move, user: ^Pokemon, game_state: ^Game_State)
Custom_Move :: struct {
data: rawptr,
callback: Custom_Move_Callback
}
Any_Move :: union #no_nil {
Attack,
Stat_Change,
Enemy_Stat_Change,
Compound_Move,
Custom_Move
}
Now that we have this, we can really write whatever we want
// pokemondb didnt show me any real moves that do this, so i made it up
scavenge := Move {"Scavenge", 1, Custom_Move{
callback = proc(self: ^Move, user: ^Pokemon, game_state: ^Game_State) {
add_item(&game_state.player.inventory, random_item(game_state))
}
}}
I'll end it here, because this was just a demonstation of how advanced this stuff can get. You might have noticed that passing callbacks feels a bit "functional", and our self combined with data: rawptr feels a bit like emulating subtype polymorphism. This is totally true, and it's because procedural programming is a very foundational paradigm, that other paradigms build on.
If I did have to make this much more complicated, I'd probably go for a components approach, similar to our Compound_Move but more generalized. Obviously pokemon has a bunch of random niche moves that do wild things, so you'd need to be storing a lot of state regardless. Some moves have accuracy calculated dynamically, some moves take more than one turn, and so on. Still, the idea generalizes.
At the end of the day, you can basically do anything with well-structured procedural code. It's explicit, it's direct, and it neatly represents what you're trying to say without leaving room for invalid states.