Hello everyone
After playing around with AIs (https://www.reddit.com/r/bindingofisaac/comments/sfh4wa/i_put_the_haunts_ai_on_monstro_and_how_to_fix/) a few weeks ago, I decided to take a deeper look and understand how they work. Swapping AIs is funny, but altering existing ones and making new ones is even better. And speaking of altering AIs, there is still someone that needs a little fix.
*Still staring at Delirium*
So uh... This is quite long, again. A TL;DWTR might be useful I guess ?
TL;DWTR: every enemy in the game has access to exactly six variables that take different values to make the AI work. Basically, the game checks the values of these variables and takes decisions based on that. Setting these variables to values the game wasn't programmed to deal with softlocks the AI. Delirium doesn't use these values because it would lead to an absurd amount of softlocks, so they are always set to 0, in order to have the AI of the boss Delirium transforms into reset and work "properly". The AI system is overall badly designed, and when Nicalis realized it during the development of Delirium they didn't care because it would have been too complicated to change it.
And now, for those who want to take a deep dive into the unknown depths below, let's go.
Mom ?
u/jsgnextortex pointed out in the comments of my previous post that most NPCs use "States" in their AIs. The modding wiki doesn't provide a lot of information about them so I had to check that by myself. Here's what I found.
Every enemy in the game (what the modding API calls an EntityNPC) has a few variables that are used by its AI. They are called State, StateFrame, I1, I2, V1 and V2 (I guess ProjectileDelay and ProjectileCooldown are also used, but I didn't study them because it was complicated enough with six variables).
Now, as someone who has to program basically every single day, seeing variables with such helpful names is always a pleasure. Even more so when you realize how they are actually used.
These six (possibly eight, but let's keep it to six) variables exist in every single enemy in the game. To be more precise, every enemy in the game, has a set of them in memory. By "every enemy" in the game, I mean that if you enter a room with seven enemies in it, you'll have seven sets of six variables in memory, one for each enemy in the room. It doesn't matter if it is seven times the same enemy, like seven Lil' Haunt, each one will have its own set of variables independent from the other.
I'll talk about how these variables are used through examples on two simple bosses (Monstro and Isaac) before moving on to Delirium, and why Delirium was doomed from the start. I modded my game to be able to see, on screen, the value of each variable, as well as the animation currently played, for all instances of an entity. On screens, you'll see these values at the top.
How AIs work: Monstro, my beloved
Let's start with the most indicative variable: State. The State variable can have multiple values documented here: https://wofsauge.github.io/IsaacDocs/rep/enums/NpcState.html. If an enemy is idle, State is set to 3 (Idle State). If it is moving without actively chasing the player, the State is 4. If it is attacking, its usually 8, 9, 10, 11 or 12 (there are five of them). Now, does setting this variable to a given value always do something ?
Well... Let's look at our beloved Monstro. Here is a quick description of what happens when Monstro's State variable takes some values :
- State 3 : Monstro is completely idle. This usually lasts for a single frame, before the game decides whether Monstro hops (state 4), spits tears (state 8), or jumps (state 6).
- State 4 : Monstro hops towards the player. Once the animation ends, State becomes 3 (Idle).
Entity is the full ID of Monstro, A is the current animation, S is the value of State, SF the value of StateFrame. I1, I2, V1 and V2 are the other variables.
- State 6 : Monstro leaps in the air. This quickly transitions into State 7 (Stomp).
Monstro spends so little time in this animation that it was hard to take a screenshot
- State 7 : Monstro slams down on the ground, spawning a cluster of tears when it lands. Once the animation ends, State becomes 3 (Idle).
https://preview.redd.it/iq2sdak60nj81.png?width=1200&format=png&auto=webp&s=211e9df580d1f627388c375eb022590ba9dcea04
- State 8 : Monstro spits a cluster of tears at the player. Once the animation ends, State becomes 3 (Idle).
https://preview.redd.it/c8xhqkt20nj81.png?width=1200&format=png&auto=webp&s=90d75fa512853f960d19e9cb02bc67e237d6fc87
What happens if we force Monstro into any of these states while it is in a different State ?
- State 3 : Monstro immediately transitions into hop, jump or spit. As a result, this cancels any animation that was in progress.
- State 4 : Monstro moves as if it had actually performed a hop. Monstro then softlocks.
- State 6 : Monstro finishes its current animation if it is playing "Taunt" or "Jump Down" and then softlocks. If Monstro is playing "Walk", it disappears from the screen and transitions into State 7.
- State 7 : Monstro moves at high speed towards the player while continuing its current animation. Once it has stopped moving, it transitions into State 3.
- State 8 : if Monstro is playing "Walk" or "JumpUp", it interrupts the animation, stands still and then transition into state 3. If Monstro is playing "JumpDown", the animation completes and Monstro spits tears as if he was playing "Taunt" along with the throw up sound instead of the body slam sound. Monstro then properly transition into State 3.
Ah... Interesting right? That's because, for Monstro at least, State is not enough. Basically, the AI of Monstro is a really simple loop: check the current state, check some condition based on the current state, and proceed. What are these transition conditions ? (I simplified them a bit)
- State 3 has no special leave or remain condition. Monstro remains in state 3 for a single update frame and then transitions into either 4, 6 or 8.
- State 4: if Monstro is not playing its hop animation, the game will wait until Monstro completes a "Walk" animation.
- State 6: if Monstro is not playing its jump animation, the game waits until Monstro completes "JumpUp" if the current animation is "Taunt" or "JumpDown". If the animation is "Walk", the game transitions brutally into State 7.
- State 7: if Monstro is not playing its landing animation, the game will transition into State 3.
- State 8: if Monstro is not playing its attack animation, the game will transition into State 3.
We can actually represent that as a state machine ! (Foreshadowing for later). This is basically a graph. You start from the top bubble and then you follow the arrows, with at most one arrow in a single logic frame. Your perform the instructions to the right of the arrow you are following; instructions are to be read from top to bottom. AnimationFinished(s) allows the arrow to be followed only if the animation "s" is finished. If you can follow multiple arrows, randomly pick one. And that's a visual representation of how Monstro's AI works (with a few simplifications, of course).
One day I'll learn to use graphviz and dot in order to have something not horrible
So it is all a matter of animations once again ? Well... Not really. In my previous post I made a mistake in my assumptions: that animations are everything. Not really. While it is true that animations and triggers in animations are important, they are not self-sufficient.
Playing an animation doesn't automatically does everything. If I order Monstro to play its spit animation, the game will not necessarily have him spit tears. The animation will play regardless of everything else, but the tears will only spawn if Monstro's state is 8. Similarly, asking Monstro to play its landing animation will not cause tears to spawn when he lands, it will only do it if Monstro's state is 7.
So animations, triggers and states are linked ? Eeeeh... Not always.
How AIs work: Isaac
Let's take a look at Isaac (Boss). He doesn't have a lot of animations right ? However, he does have a lot of attacks. Does that mean there are like, fifty possible states ?
Not really. If we look at the states used by Isaac (only in the first phase to keep it simple), Isaac alternates between state 3 (Idle) and state 8 (Attack). Is there a way to find which attack Isaac is performing ?
Indeed, such an identifier exists. It is the I1 value. When Isaac's State is 8, the I1 variable holds the identifier of the attack Isaac is performing. If Isaac's State is 3, the value of I1 is ignored. Now... How does Isaac knows how to perform the attack ? Because unlike many other bosses, there are not multiple animations available. It's not as if the animation of Isaac crying is looped over a duration of five seconds with a trigger every two frames in order to spawn a tear. (Despite how I said that, it is not the case, Isaac doesn't really use triggers in its first phase).
Well, this is where the I2 variable comes in ! When Isaac initiates an attack, the I2 variable is set to 0 and increases by 65536 every frame (I honestly don't have any idea why the 65536 is there). Then, the AI of Isaac can use the value of I1 alongside the value of I2 to be informed of which attack is being performed and which "frame" of the attack is active. By constantly resetting I2 to 0, you can cause an attack to loop indefinitely. Once the I2 value reaches a threshold (which depends on I1), Isaac switches to State = 3.
When Isaac's state is 3, the I2 value holds the amount of frame * 65536 until Isaac switches to an attack. The value of I2 is decreased by 65536 every frame.
A few captures to illustrate. Look at the value of I1 mainly. You can also see that both the State and the animation do not change.
If you are curious, V1 is an angle in radian between the vector (1, 0) and the first bullet spawned by Isaac while firing its circle of bullet
I'm not sure what V2 means, seems like coordinates, but the room isn't big enough to have a 1996 I think
I2 is the time spent in the attack. Note that all attacks use the animation \"1Attack\", so I1 and I2 are used to ,respectively, distinguish between attacks and track how long the attack has been going on
Automata theory (just a little bit)
Now, you may be wondering "What happens if I put the AI in a configuration where there is no possible transition ? What if I put, I dunno, state 600 on Monstro ?".
Oh, then the AI softlocks.
How does Delirium wo... Wait WHAT ?
Basically, every AI in the game continuously checks the State variable, and from that it decides what the NPC should do. What an NPC should do is translated by a modification of State, of I1, of I2, of whatever variable the game needs to change to reach a state that is configured in such a way that the NPC does something.
This is why I titled the previous section "Automata Theory". In automata theory, we have a concept called "transition system". A transition system is basically a machine, that has a state, and a set of rules that say "If I'm in a given state and some conditions are met I can switch to another state". Switching to another state is called performing a transition, and rules are called "transition rules". If a machine reaches a state from which no transitions are available, then the machine is blocked.
An AI in Isaac (and in basically every game, because there is a fun little theorem that states every program is equivalent to a transition system) is a transition system. If you put it in a state from which no transitions are available, then it won't do anything.
Remember that transition system I showed for Monstro? Here is what it would look like if I represent an unused state, for instance 5 :
There is no escape.
Now, you can't reach the lone bubble, not normally. But if someone were to accidentally configure state to 5 on Monstro, there would be no way out. No arrow goes out from this bubble. So you cant' leave.
And with that, we are getting closer to why Delirium couldn't work.
How does Delirium work internally ?
Quick reminder from the previous post: there is a C++ function called "UpdateAI", called by every enemy. This function checks the ID of the enemy and then calls another function, the AI function, depending on the ID.
The AI function of Delirium is called every frame. Inside the AI function, there is a check "Is Delirium transformed ?". If this check passes, then Delirium changes its ID to the ID of the boss it is transformed has, and calls "UpdateAI" himself in order to update the AI of the boss it is transformed as.
In other words, if Delirium (ID 412) is transformed into Monstro (ID 20), then at the beginning of the current logic frame, Delirium's ID is 412. UpdateAI updates the AI of Delirium. As part of this update, Delirium notices it is transformed into Monstro, and replaces its ID with 20. It then calls UpdateAI again, which calls Monstro's AI. Once this call to UpdateAI finishes, Delirium resets its ID to 412.
Now, you may be wondering "Wait, how can Delirium transform its ID ?". The ID is just a value in memory. The AI of Delirium changes it before calling UpdateAI, and resets it to Delirium's own ID after the call. However, if you've been paying attention up until now, there is something you may be realizing.
"Hold on a second. If Delirium calls the AI function of other bosses... Then shouldn't Delirium's AI variables, State, I1 and so forth take the values required for other bosses ?".
You're perfectly correct. If we take a look at Delirium's AI variables during the fight, they are updated according to the AI of the boss Delirium is transformed into. So... Which values do they hold when Delirium is not transformed ? Maybe we can predict when Delirium will transform ? When it will turn red ? Prevent telefrag ? Actually make it... *gasp*... A good boss ?
Delirium performing the bullet spit of Monstro. Notice how the Entity has ID 412, and is performing the \"Taunt\" animation while having State = 8. The ID discrepancy is due to the fact that the MC_POST_UPDATE callback is called once Delirium has reset its ID to 412. It is technically difficult to witness Delirium changing its ID.
Delirium performing Monstro's short hop. Notice the animation \"Walk\" and the state = 4.
No. They're all 0.
This boss physically hurts me
"What do you mean they're all 0 ? How does the boss work then ?".
Bold move assuming Delirium works.
Well, I1, I2, State, StateFrame etc. Are all 0. All the time. When Delirium is not transformed at least.
Also can we remove the fucking rocks from that room ? They are like the most bullshit thing EVER. Fucking SPIDERS AND SKULLS.
What did we do to deserve this ?
How does the boss work then? My best guess would be that Delirium's AI uses other variables that are not accessible in the modding API to update its independent AI. The thing that makes me say that is the fact that if we watch the memory location of I1, I2 etc. in the memory space of Delirium, they never EVER change while it is not transformed. This is a much deeper inspection than dumping the variable in a callback with the modding API, this is basically suspending the game immediately as a CPU instruction changes the value in memory, even if it just temporary. The values are NEVER changed when Delirium is not transformed.
"Why is that?" I hear you ask.
Remember what happens if you attempt to update an AI when the AI variables are in an invalid configuration ? Let's say for a moment that Delirium actually uses the AI variables.
Let's say Delirium uses State 9 for one of its attacks, and Delirium is transformed into Monstro. State 9 is meaningless for Monstro's AI, so Monstro's AI softlocks. Okay. Let's set State to 8 instead before transforming into Monstro. However, don't forget to save that non transformed state 9 somewhere in memory because you need to reset it after updating Monstro's AI, so Delirium can continue its own updates.
But, oh look, Delirium has transformed into Isaac. Mmmh, maybe Monstro has changed its state, and we can only use 3 or 8 on Isaac, and also we need to properly configure I1 and I2 according to the State, oh and don't forget that some values of I1 are forbidden depending on Isaac's health, and we may also need to set values in V1 because it is actually phase 3 !
Also don't forget that you need to save these variables somewhere after updating Isaac's AI because Isaac's AI will need to continue the attacks on the next update cycle. And we need to reset the variables to what they were before updating Isaac's AI so Delirium's AI updates properly on the next frame.
And then Delirium has transformed into the Haunt. Oh no, I1 and I2 are meaningless now. And State 8 is completely unfair, the player will never have the time to react to the immediate bullet spit ! What can we do ?????
Oh and did I make it clear that you'd have to do that for EVERY SINGLE BOSS IN THE ENTIRE GAME ?
Yes. For every single one of them you'd have to review its AI and determine "Ah, yes, when Delirium transforms into this boss, I can actually transition into THIS precise configuration of AI values to have the boss be fair". And as we've seen there is no constant meaning to these variables.
StateFrame is either ignored, a timer or a counter, depending on the boss and depending on State. Sometimes it progresses by step of 1, sometimes by step of 65536 because why the fuck not. State 8 requires I1. Or animations. Or both. Or something else. V1.X is an angle. Or an attack identifier. If it is an angle, it may expressed in radians. Or in degrees. It depends on the boss. Hush uses states 600 and 300 which are not documented anywhere, but who cares now ?
So the solution proposed to this problem by Nicalis is incredibly simple. Just put all variables to 0. State 0 causes any AI to initialize as if the entity was just spawned. This prevents all softlocks.
It's also stupid, because initializing AIs over and over and over again as Delirium transforms creates situations that are... *sigh*
Initialize the Haunt ? You'll spawn Lil' Haunts and The Haunt will be invincible for a while. Initialize Mega Satan ? Its phase counter resets, so it thinks it did not summon the Harbringers, and now you have Harbringers in the middle of the whole mess, plus Mega Satan is invisible because its AI was never properly initialized (yes, that's why Mega Satan sprite bugs out, there is a special trigger to make him visible). Initialize Death ? You know have another Death's Horse because it has < 50% HP. Initialize War ? Immediate explosion if < 50% HP. Initialize The Lamb ? Spawn a new body if < 50% HP. Transform into Blue Larry Jr. / Grey Hollow ? Slash the health of Delirium into the number of segments because the AI redistributes the health across the segments. Initialize Mom's Heart ? Now there's eyes somewhere in the room that can shoot from offscreen at you.
It's bad.
Why it couldn't work
I've been struggling to write this section for several hours now. This is the third complete rewrite because it's either a roast of Nicalis, or an explanation that requires knowledge of programming, or both.
The problem is twofold: the AI system in Isaac is not really well designed (I honestly think it is horrendous and the kind of stuff I, as a teacher, would expected from first year CS students, not from seasoned programmers like the people at Nicalis), and because of that, because of the amount of extra work it would have required just to make Delirium, Nicalis wasn't really motivated to actually make the necessary changes. As such, they kept using a badly designed system, which results in this mess.
You may be thinking "Wait, why do you say the system is badly designed ? Every AI in the game, save for Delirium, works, doesn't that mean the system is good ? Why change it ?".
The problem is that I1, I2 etc. are meaningless. State is the only variable that makes some sense, and even then... For instance, did you know that Mausoleum Mom uses STATE_STOMP when stomping once, and STATE_JUMP when stomping multiple times (with I2 indicating the number of stomps) ? Did you know that Pin / Wormwood / Scolex use STATE_IDLE while moving underground, instead of the more reasonable STATE_MOVE ? Did you know that Dark Esau uses STATE_SUICIDE while held in Anima Sola, instead of, I don't know, STATE_SPECIAL ?
And then there is the other variables. Isaac uses I1 for the identifier of its attacks in state 8. Mom's Heart uses V1.X for the identifier of its attacks in state 8. Hush uses I1 for its orientation while in state 600 (its attack state) and I2 is a bitmask indicating the patterns to use in the current attack while StateFrame is a timer indicating when the game needs to transition back to IDLE. The Frail sets I2 to 1 during its second phase, except when it needs to fire its Brimstones, in this case I2 is the duration of the attach in frames * 65536 and decreases every frame because why not.
And YES. It WORKS. But it cannot be maintained. I've spent days watching the values of these variables change on different bosses just to get an idea of how said bosses work, just to try and understand how Delirium works, and even if I had access to the complete source code, it would have been a chore. Which variable stores Isaac's attack ID ? Read the entirety of the AI to find out. There is no "int _current_attack_id" variable, there's just "int I1, I2, State, StateFrame; Vector V1, V2;". And you cannot change anything without first understanding everything, which is bad software design. Programmers always say "Variables and functions must have meaningful names, and do one and only one specific thing" for a reason. Because then you don't need to understand everything to do something. You just need to understand the idea behind it.
The big problem is simply the fact that Nicalis did not want to separate the AI from the EntityNPC (for no reason; any person with a passing knowledge of design patterns would say "Use a Strategy pattern and split the AI from the NPC") . So they decided to use a few variables that would have a completely different meaning depending on the context, and, when they realized that making Delirium work would mean collecting every possible meaning of each of these variables, documenting them, and then assembling all these meanings in order to make the boss work... They gave up. They didn't want to do it properly so they said "Put State to 0 and let the AIs reset".
And even then, splitting the AIs from the entity, that's just the beginning. Having properly encapsulated AIs, isolated in well defined structures, with their own variables, would not inherently make Delirium better. It would make coding the boss easier, but there is still a tremendous amount of work required. Sure, you'd have more informative names than "I1" and "I2", and you'd probably have true identifiers for attacks, but you'd still have to code functions like "Can Delirium transform into this boss from its current state ?", "Can Delirium spawn its own bullets while transformed into this boss with this configuration ?". Splitting the AIs from the entity would allow these checks to be neatly isolated in each AI, instead of having a function of ten thousand lines where you check every boss for every condition, and with meaningful variables rather than I1 and others. But still...
That's a lot of work.
How does one fix Delirium ? Is it possible ?
Yes it is possible. You need to rewrite the boss from scratch.
I wasn't sure in my previous post, but I've come to the conclusion that it is impossible to do otherwise: unless we patch the executable to get access to the variables internally used by Delirium to control its AI, and expose them in Lua, there is no other choice.
Then, the people wanting to fix Delirium would have to understand every AI. Find the information that allows to identify every attack. For every attack, think "Can Delirium transform now ? Can Delirium spawn tears now ?". It's long and complicated. And that's what should have been done five years ago by Nicalis.
Closing thoughts
I guess I should recognize the good points of this design, so here are the only two I can think of.
It's easy to test, and you can easily put it in a modding API.
No, really, it's easy to test. Just configure the values from the console, launch the animation if needed, and there. You can test that a state works. And exposing it in the modding API is just a matter of adding a few properties to a userdata, which Nicalis already does with other objects, so... There, done.
But that's all there is to it. Easy to test. Easy to expose in an API. Impossible to maintain on the long term. Impossible to interact with from elsewhere. Impossible to think about how to create a boss that has to interact with other bosses AIs. Nicalis did not want to review every boss AI to make Delirium because that's tedious and time consuming and just... Really hard considering how the game is coded.
That's why Delirium fails. Because making it good is time-consuming. Because the underlying system is badly designed. And, overall, because it's hard.
(Post-post edit: just realized I didn't talk about how AIs work for modders that create new entities. Basically, they can do whatever they want. Since State and the others don't have any natural meaning, modders can completely ignore them and write their own AI system. Which I strongly recommend).
submitted by
No comments:
Post a Comment