← Back to news

The Low-Tech AI of Elden Ring

nega.tv|45 points|28 comments|by g0xA52A2A|Jun 23, 2026

The Low-Tech AI of Elden Ring

FromSoftware is legendary for creating NPC encounters that are both diverse and mercilessly punishing across the Soulsborne series. However, if you look under the hood, the actual decision-making logic is surprisingly primitive.

Because much of the logic is written in Havok Script (a specialized Lua implementation by Havok), it is relatively simple to peek behind the "fog wall" and analyze the implementation.

Disclaimer: This is not original research. I am interpreting and summarizing code that has already been extracted, decompiled, and reverse-engineered by the community.

The Concept of "Goals"

The cornerstone of FromSoftware's AI is the Goal. In their terminology, a Goal is a specific state an AI entity can occupy.

  • Nature: Goals are essentially immutable tables of functions.
  • Flexibility: They can be parameterized upon instantiation and can read data directly from the Actor (the NPC).

While one might expect a complex Hierarchical Finite State Machine, FromSoftware utilizes a stack of states.

How the Goal Stack Works

Every frame, the Actor updates the Goal currently at the top of its stack. This Goal can then push "Sub-Goals" onto the stack, which will be executed in the subsequent frames.

The update function returns one of three states:

  1. Continue: The stack remains as is.
  2. Success: The current Goal is popped off the stack.
  3. Failure: The current Goal is popped, and all subsequent Sub-Goals are cleared until the system returns to the Parent Goal (the one that originally pushed the sub-goal).

Visualizing the Stack Flow

Example Scenario: Imagine a CoolBossBattle Goal. It pushes a sequence of attacks:

  • Stack Level 0: CoolBossBattle
  • Stack Level 1: Attack (R2, Finisher)
  • Stack Level 2: Attack (R2, Repeat)
  • Stack Level 3: Attack (R2, Combo) \leftarrow Currently Updating

If the "Combo" attack lands, it returns Success and is popped. If the "Repeat" attack then fails, the stack unwinds entirely back to CoolBossBattle, allowing the boss to decide a new course of action.

AI Logic Flow

The activate Function: The Brain of the AI

In the API, the root of the stack is the "Top Level Goal." The most critical logic resides in the activate callback. This function runs:

  • The first time a Goal is updated.
  • Whenever a Goal resumes execution after its Sub-Goals have been exhausted.

The activate function determines the next move using a combination of world context, Actor data, and randomness.

Weighted Random Selection

The AI typically uses a weighted random system to pick an "Action" (a function). The probability of an action can be expressed as:

Probability(Actioni)=Weightij=1nWeightj\text{Probability}(Action_i) = \frac{Weight_i}{\sum_{j=1}^{n} Weight_j}

Decision Matrix Example

Based on the distance to the player, the AI adjusts weights:

DistanceGiga Death RayLeap AttackGround SlamLight ComboHeavy Combo
Far (>6.0> 6.0)15.065.00.010.010.0
Mid (1.56.01.5 - 6.0)0.00.05.060.035.0
Close (<1.5< 1.5)0.00.020.040.040.0

Pseudocode Implementation

Here is a simplified representation of how this looks in a Rust-like syntax:

fn action_light_attack_combo(goals: Goals, actor: Actor) {
    // ApproachTarget is a common goal
    goals.push_sub_goal(Goal::ApproachTarget, Target::Enemy);
    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Initial);
    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat); 
    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Finisher);
}

fn activate(self, goals: Goals, actor: Actor) {
    let dist = actor.target_distance(Target::Enemy);
    
    // Determine weights based on distance
    let mut weights = if dist > 6.0 {
        [15.0, 65.0, 0.0, 10.0, 10.0]
    } else if dist > 1.5 {
        [0.0, 0.0, 5.0, 60.0, 35.0]
    } else {
        [0.0, 0.0, 20.0, 40.0, 40.0]
    };

    // Apply cooldowns to modify weights
    if common::is_cooldown(goals, actor, AnimId::R1, 8.0) {
        weights[3] = 0.0;
    }
    if common::is_cooldown(goals, actor, AnimId::R2, 10.0) {
        weights[4] = 0.0;
    }

    let actions = [
        action_giga_death_ray, 
        action_leap_attack, 
        action_ground_slam, 
        action_light_attack_combo, 
        action_heavy_attack_combo
    ];

    // Roll the dice and execute
    common::battle_activate(goals, actor, weights, actions);
}

Final Considerations

The AI's behavior is further tuned by:

  • RNG Rolls: Simple random number generation from the Actor.
  • HP Thresholds: Changing weights when health drops.
  • Parameter Reading: Simple Goals that just push sub-goals based on their own settings.

By nesting these simple, weighted decisions within a stack, FromSoftware creates the illusion of complex, adaptive combat behavior using very straightforward tools.