The Low-Tech AI of Elden Ring

g0xA52A2A1 pts0 comments

The Low-Tech AI Of Elden Ring - NEGA.TV

MODE SELECT

The Low-Tech AI Of Elden Ring

FROMSOFT has a reputation for diverse and punishing npc encounters across the<br>entire Soulsborne extended series, but the implementation of the AI decision<br>making itself is perhaps unexpectedly low-tech. Since the majority of the code<br>is implemented in Havok Script (A games-oriented Lua implementation from Havok)<br>it’s pretty easy to take a peek behind the fog wall to see how they’re<br>implemented.<br>Note that none of what follows is original research, I’m just reading the code<br>that others have done the hard work of extracting, decompiling, and reversing.<br>Goals<br>The primary tool of the FROMSOFT AI approach is the Goal1, which is<br>their own terminology for a unique state that the AI can be in. Goals can be<br>parametized when instanciated, and can access data stored on the Actor itself,<br>but are otherwise really just an immutable table of functions.<br>Now the simplest option would be to organize states into a<br>Finite State Machine or maybe a Hierarchical Finite State Machine, but<br>FROMSOFT go one step further and give the system a stack of states. This turns<br>it from an FSM into Pushdown Automaton (PDA).<br>That’s an entirely abstract definition, so after you get back from wikipedia<br>let’s talk about it concretely from the top down.<br>Each frame Actors will update the Goal on top of their stack of Goals. When the<br>Goal updates, it can then push more Goals as Sub-Goals onto the stack, the<br>topmost of which will execute next frame. The Goal’s update function returns a<br>value indicating either Continue, Success, or Failure. Continue will leave the<br>stack unchanged, the other two will cause the Goal to be popped from the stack.<br>Failure will additionally cause all other unexecuted Goals to be popped from the<br>stack up to the parent Goal (The Goal which pushed this sub-goal).<br>For example, we might define a Goal called CoolBossBattle, during the course<br>of its execution it might then push a series of Attack Sub-Goals. Those attack<br>Goals can be parametized by various means, but the main one is the animation id2.<br>[ GOAL STACK ]

3: Attack (R2, Combo) After a few seconds the first attack lands, and that Goal completes with<br>success and is popped from the stack. However the next fails, causing the stack<br>to unwind to its parent.<br>[ GOAL STACK ]

2: Attack (R2, Repeat) Readying it to chose its next action now that the attempted combo of attacks has<br>ended.<br>[ GOAL STACK ]

2: Attack(L1)<br>1: Attack(L1)<br>0: CoolBossBattle Not too complex3!<br>In their APIs they refer to the root of this stack as the “Top Level Goal”, which<br>I’ve made confusing by referring to the currently executing goal as the “top” of<br>the stack. So keep in mind those are separate things.

Activate<br>Goals are defined by a few functions used as callbacks, and the one which<br>contains the most AI logic is usually activate. This is called the first time<br>that a Goal is updated, and then every subsequent time that the Goal exhausts<br>its Sub-Goals and starts executing again.<br>For boss and regular npc Goals the code in Activate is responsible for choosing<br>the next action that the Actor will take using a mix of context from the world<br>and Actor, and randomness (which also comes from the Actor itself).<br>The most widely used approach uses common code to perform a weighted random<br>selection between a number of Actions (which are just functions), calling the<br>winner.<br>To return to our CoolBossBattle, this time in some Rusty pseudocode…<br>fn action_giga_death_ray(goals: &Goals, actor: &Actor) {<br>todo!();

fn action_leap_attack(goals: &Goals, actor: &Actor) {<br>todo!();

fn action_ground_slam(goals: &Goals, actor: &Actor) {<br>todo!();

fn action_light_attack_combo(goals: &Goals, actor: &Actor) {<br>let target_distance = actor.target_distance(Target::Enemy);<br>let fate = actor.next_random();

// ApproachTarget itself being a goal defined in common code!<br>if target_distance > 2.0 {<br>goals.push_sub_goal(Goal::ApproachTarget, Target::Enemy);

goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Initial);<br>goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);

// Unlucky buster! It's the long combo.<br>if fate 0.2 {<br>goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);

goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Finisher);

fn action_heavy_attack_combo(goals: &Goals, actor: &Actor) {<br>todo!();

fn activate(&self, goals: &Goals, actor: &Actor) {<br>let target_distance = actor.target_distance(Target::Enemy);

let mut weights = if target_distance > 6.0 {<br>15.0,<br>65.0,<br>0.0,<br>10.0,<br>10.0,<br>} else if target_distance > 1.5 {<br>0.0,<br>0.0,<br>5.0,<br>60.0,<br>35.0,<br>} else {<br>0.0,<br>0.0,<br>20.0,<br>40.0,<br>40.0,<br>};

// This doesn't exactly work this way in the Lua code, and these cooldowns<br>// don't make sense either, but hopefully it gives the rough idea.<br>//<br>// The helper function is checking last played data for the animation on the<br>// Actor itself, and then modifying the weights before they go into the<br>// common battle randomized selection.<br>weights[3] = if...

goals goal actor stack attack from

Related Articles