{"slug": "the-low-tech-ai-of-elden-ring", "title": "The Low-Tech AI of Elden Ring", "summary": "FromSoftware's Elden Ring uses a low-tech AI system based on a pushdown automaton implemented in Havok Script, where NPC behavior is driven by a stack of Goals that can push sub-goals and unwind on failure. The AI relies on weighted random selection of actions during Goal activation, offering a surprisingly simple yet effective approach for complex enemy encounters.", "body_md": "# The Low-Tech AI Of Elden Ring\n\n*FROMSOFT* has a reputation for diverse and punishing npc encounters across the\nentire Soulsborne extended series, but the implementation of the AI decision\nmaking itself is perhaps unexpectedly low-tech. Since the majority of the code\nis implemented in Havok Script (A games-oriented Lua implementation from Havok)\nit’s pretty easy to take a peek behind the fog wall to see how they’re\nimplemented.\n\nNote that none of what follows is original research, I’m just reading the code that others have done the hard work of extracting, decompiling, and reversing.\n\n# Goals\n\nThe primary tool of the *FROMSOFT* AI approach is the Goal[ 1](#fn1), which is\ntheir own terminology for a unique state that the AI can be in. Goals can be\nparametized when instanciated, and can access data stored on the Actor itself,\nbut are otherwise really just an immutable table of functions.\n\nNow the simplest option would be to organize states into a\n[Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) or maybe a Hierarchical Finite State Machine, but\n*FROMSOFT* go one step further and give the system a stack of states. This turns\nit from an [FSM](https://en.wikipedia.org/wiki/Finite-state_machine) into [Pushdown Automaton (PDA)](https://en.wikipedia.org/wiki/Pushdown_automaton).\n\nThat’s an entirely abstract definition, so after you get back from wikipedia let’s talk about it concretely from the top down.\n\nEach frame Actors will update the Goal on top of their stack of Goals. When the Goal updates, it can then push more Goals as Sub-Goals onto the stack, the topmost of which will execute next frame. The Goal’s update function returns a value indicating either Continue, Success, or Failure. Continue will leave the stack unchanged, the other two will cause the Goal to be popped from the stack. Failure will additionally cause all other unexecuted Goals to be popped from the stack up to the parent Goal (The Goal which pushed this sub-goal).\n\nFor example, we might define a Goal called `CoolBossBattle`\n\n, during the course\nof its execution it might then push a series of `Attack`\n\nSub-Goals. Those attack\nGoals can be parametized by various means, but the main one is the animation id[ 2](#fn2).\n\n```\n[ GOAL STACK ]\n\n3: Attack (R2, Combo)           <<<<-- Currently Updating\n2: Attack (R2, Repeat)\n1: Attack (R2, Finisher)\n0: CoolBossBattle\n```\n\nAfter a few seconds the first attack lands, and that Goal completes with success and is popped from the stack. However the next fails, causing the stack to unwind to its parent.\n\n```\n[ GOAL STACK ]\n\n2: Attack (R2, Repeat)          <<<<-- Failed, will be popped from the stack.\n1: Attack (R2, Finisher)        <<<<-- Will be removed as well.\n0: CoolBossBattle\n```\n\nReadying it to chose its next action now that the attempted combo of attacks has ended.\n\n```\n[ GOAL STACK ]\n\n2: Attack(L1)\n1: Attack(L1)\n0: CoolBossBattle               <<<<-- Updating, pushes 1, and 2 for the next frame.\n```\n\nNot too complex[ 3](#fn3)!\n\nIn their APIs they refer to the root of this stack as the “Top Level Goal”, which I’ve made confusing by referring to the currently executing goal as the “top” of the stack. So keep in mind those are separate things.\n\n# Activate\n\nGoals are defined by a few functions used as callbacks, and the one which contains the most AI logic is usually activate. This is called the first time that a Goal is updated, and then every subsequent time that the Goal exhausts its Sub-Goals and starts executing again.\n\nFor boss and regular npc Goals the code in Activate is responsible for choosing the next action that the Actor will take using a mix of context from the world and Actor, and randomness (which also comes from the Actor itself).\n\nThe most widely used approach uses common code to perform a weighted random selection between a number of Actions (which are just functions), calling the winner.\n\nTo return to our `CoolBossBattle`\n\n, this time in some Rusty pseudocode…\n\n```\nfn action_giga_death_ray(goals: &Goals, actor: &Actor) {\n    todo!();\n}\n\nfn action_leap_attack(goals: &Goals, actor: &Actor) {\n    todo!();\n}\n\nfn action_ground_slam(goals: &Goals, actor: &Actor) {\n    todo!();\n}\n\nfn action_light_attack_combo(goals: &Goals, actor: &Actor) {\n    let target_distance = actor.target_distance(Target::Enemy);\n    let fate = actor.next_random();\n\n    // ApproachTarget itself being a goal defined in common code!\n    if target_distance > 2.0 {\n        goals.push_sub_goal(Goal::ApproachTarget, Target::Enemy);\n    }\n\n    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Initial);\n    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);\n\n    // Unlucky buster! It's the long combo.\n    if fate < 0.2 {\n        goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);\n    }\n\n    goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Finisher);\n}\n\nfn action_heavy_attack_combo(goals: &Goals, actor: &Actor) {\n    todo!();\n}\n\nfn activate(&self, goals: &Goals, actor: &Actor) {\n    let target_distance = actor.target_distance(Target::Enemy);\n\n    let mut weights = if target_distance > 6.0 {\n        [\n            15.0,\n            65.0,\n            0.0,\n            10.0,\n            10.0,\n        ]\n    } else if target_distance > 1.5 {\n        [\n            0.0,\n            0.0,\n            5.0,\n            60.0,\n            35.0,\n        ]\n    } else {\n        [\n            0.0,\n            0.0,\n            20.0,\n            40.0,\n            40.0,\n        ]\n    };\n\n    // This doesn't exactly work this way in the Lua code, and these cooldowns\n    // don't make sense either, but hopefully it gives the rough idea.\n    //\n    // The helper function is checking last played data for the animation on the\n    // Actor itself, and then modifying the weights before they go into the\n    // common battle randomized selection.\n    weights[3] = if common::is_cooldown(goals, actor, AnimId::R1, 8.0) { 0.0 } else { weights[3]; };\n    weights[4] = if common::is_cooldown(goals, actor, AnimId::R2, 10.0) { 0.0 } else { weights[4]; };\n\n    let actions = [\n        action_giga_death_ray,\n        action_leap_attack,\n        action_ground_slam,\n        action_light_attack_combo,\n        action_heavy_attack_combo,\n    ];\n\n    // Does some common setup for the number of actions and then rolls the dice\n    // and chooses which function to call.\n    common::battle_activate(goals, actor, weights, actions);\n}\n```\n\nModifying the weights dynamically is handled in many different ways, but the most common are simple rng rolls from the actor and hp thresholding.\n\nOther, simpler, Goals than the top level battle Goal for an Actor may simply push a few sub-goals, perhaps reading some data from the Goal parameters. The nesting means that it’s possible to compose quite complex behavior from simple building blocks.\n\n# Interrupts\n\nThe other major callback defined for goals is the Interrupt. As the name suggests, this allows Goals to respond immediately to external events which are mostly configured on the Actor itself.\n\nMy understanding is that interrupts bubble up, that is, it will run the interrupt on the currently executing Goal and then its parents recursively, until it runs out of Goals or one of the interrupt callbacks returns true to indicate it has consumed the interrupt.\n\nFor example, if I wanted CoolBoss to move into a furious rage of attacks as soon as I set it on fire, then I might implement something like the following.\n\n```\nfn interrupt(&self, goals: &Goals, actor: &Actor, interrupt: Interrupt) {\n    match interrupt {\n        // If I start burning, attack!\n        SpecialEffectActivate {\n            target,\n            special_effect,\n        } => {\n            if target == Target::Self && special_effect == SpecialEffect::Fire {\n                // Since there might still be other things running when\n                // interrupt is called we need to unwind so we're on top again.\n                goals.clear_sub_goals();\n\n                goals.push_sub_goal(Goal::Attack, AnimId::R1);\n                goals.push_sub_goal(Goal::Attack, AnimId::R2);\n                goals.push_sub_goal(Goal::Attack, AnimId::R1);\n                goals.push_sub_goal(Goal::Attack, AnimId::R2);\n\n                return true;\n            }\n        }\n        // If somebody uses an item they might be in for it.\n        UseItem => {\n            let fate = actor.next_random();\n            if fate < 0.5 {\n                goals.clear_sub_goals();\n                action_light_attack_combo(goals, actor);\n            }\n        }\n        // Perform a ground slam if I get attacked from underneath.\n        Damage {\n            target,\n        } => {\n            if target == Target::Self {\n                let distance = actor.target_distance(Target::Enemy);\n                let fate = actor.next_random();\n                if distance < 1.0 && fate < 0.8 {\n                    goals.clear_sub_goals();\n                    action_ground_slam(goals, actor);\n                }\n            }\n        }\n        _ => {}\n    }\n\n    false\n}\n```\n\nThis is used to implement some truly evil features, for example the Bell Bearing Hunter will detect you spell casting or using an item and from there has an 85% chance to immediately abort its current action and launch into an attack.\n\nThey also make use of dynamic spatial watch regions configured on Actors, which trigger interrupts. For example you might add a watch for the area behind or under a boss, and use that to adapt their behavior immediately when the player tries to get clever.\n\n# Timeouts\n\nGoals, in addition to their individual state, carry a lifetime value in seconds. This is used to break out of states which become stuck for whatever reason, and lifetime seems to be used mostly as a bug containment mechanism.\n\nIt’s also possible to modify the lifetime of a parent goal during execution, to indicate continued forward progress.\n\n# Actor Data Access\n\nIn many AI decision systems you might have heard of fancy systems for data storage like “blackboards”. In the Souls games there’s an array of floats on each Actor which are set and read arbitraily from Goals by index. Good enough I suppose!\n\nA callback I didn’t mention before, Initialise, is commonly used to reset this data when an Actor is assigned a new Top Level Goal.\n\nGoals have access to a range of queries about the world through the Actor. As far as I can tell most of these are pretty “low cost” from a performance perspective. Aggro and Targeting seems to be handled outside, so it should be possible to keep the Goals very lean even considering it’s all interpreted Lua.\n\n# Actual Doing Stuff\n\nSomething I’ve entirely skipped over is how the Goals actually Do things. For\nthe most part everything in *FROMSOFT* games is animation driven.\n\nThe Goal says “play this attack animation”, and then the animation events carry hitbox information and timings, special effect triggers, projectile creation events, and whatnot. They also have a variety of “combo” features which seem to boil down to choosing a different set of events in the animations to enable faster linking of chained animation during a combo attack.\n\nAt some point they went all-in on Havok middleware. The animations are authored with Havok Animation Studio (discontinued). Previously we mentioned the AI scripts are using Havok Script (also discontinued). Physics is handled by Havok’s physics, and pathfinding is delegated to Havok AI (not discontinued, but renamed to Havok Navigation).\n\n# Misc Stuff\n\nThey seem to split AI scripting into a “logic” script, and a “battle” script, where the logic script is far more sharable, and the battle scripts are often bespoke. This seems super smart, it’s common to run into issues jamming both these things into singular hierarchies.\n\nLevel designers are able to configure the Top Level Goal for an Actor in the level itself, so you can place some enemies down with a passive Goal rather than their usual combat Goal, and they would just chill whilst otherwise functioning normally.\n\nMost of the common code is relatively compact bits of Lua, but I believe load bearing Goals like\n\n`Attack`\n\nand`MoveToSomewhere`\n\nare implemented in C++ which gives you a pretty nice balance of scriptability and performance sanity.The update function itself is sometimes used to check conditions, I expect this must have caused problems occasionally. But so long as the interface for Actors in scripting is thin I guess you can keep it under control. (Don’t add a pathfind function call…)\n\nI’ve entirely skipped over the event scripting system used to do high level encounter logic and level scripting. Unlike the AI it seems to be entirely custom, with a very restricted VM. That said, since it’s not Lua it’s hard to see how they’re actually authored. If anyone knows of primary sources for info about their tooling that would be super cool!\n\n# Conclusion\n\nThere’s a lot of enduring hype for complicated AI systems ([GOAP](https://www.gamedevs.org/uploads/three-states-plan-ai-of-fear.pdf) springs\nto mind) but I think the success of putting a lot of control in the hands of your\ndesigners and animators really speaks for itself.\n\nA [pushdown automaton](https://en.wikipedia.org/wiki/Pushdown_automaton) is also fundamentally quite fast compared to\n[Behavior Trees](https://www.gameaipro.com/GameAIPro/GameAIPro_Chapter06_The_Behavior_Tree_Starter_Kit.pdf) and planners. [Behavior Trees](https://www.gameaipro.com/GameAIPro/GameAIPro_Chapter06_The_Behavior_Tree_Starter_Kit.pdf) often require top-down re-evaluation of a complex\ntree of scripted nodes, whereas this is almost always executing a single Goal\nfrom the top of the stack[ 4](#fn4). Planners like\n\n[STRIPS](https://ai.stanford.edu/~nilsson/OnlinePubs-Nils/PublishedPapers/strips.pdf),\n\n[GOAP](https://www.gamedevs.org/uploads/three-states-plan-ai-of-fear.pdf)and\n\n[HTN](https://en.wikipedia.org/wiki/Hierarchical_task_network)add an expensive search to the middle of everything.\n\nCompared to [FSMs](https://en.wikipedia.org/wiki/Finite-state_machine) the flexibility of dynamic transitions makes it easier\nto avoid an explosion in the number of states and their transitions. This also\nmakes it far more reasonable to compose AI functionality in an imperative way.\n\nPlus of course it’s dramatically more legible than planner based solutions where individual actions are moved out of the hands of combat designers.\n\nIs it going to handle more complex scenarios than the typical Soulsborne npc or boss fight? I actually think it can go quite far.\n\n# Update\n\nSome hackernews commenters were confused by the comparison with BTs, pointing out that this scheme is quite similar to an event-based BT which keeps a stack of active nodes, and wondering about the complexity compared to “normal” game AI. Since I kind of phoned it in explaining all that stuff I’ll try to expand a little.\n\nFirstly while it’s true that event-based BTs do avoid the requirement to perform a top-down re-execution of the entire tree, not all BT implemetations actually work this way! Especially the more academic and early users look a more like the naive implementation. I find the approach here which explicitly represents the execution structure and builds it in imperative code to be significantly more straightforward than trying to retrofit a kind of execution path cache on top of a BT.\n\nSecondly, with respect to the broader question of complexity, BTs implement\ncontrol flow structures inside the BT structure as nodes (again, especially so\nin academic implementations), this is why you see BT terminology like\n“Conditions”, “Sequences”, “Selectors” and “Parallels”. This significantly\nbloats the size of trees, and the complexity of authoring them in my (limited!\nI’m not an AI programmer) experience. By comparison, the *FROMSOFT* style, even\ntoday, has an extremely low state count, and relies on imperative code inside\nthose states to implement the majority of control flow. This, with my\nperformance hat on, is tremendously important for avoiding\ndeath-by-a-thousand-cuts style issues where too much logic is held up in gnarly\ntree structures that tend to be hard to manage during authoring and execution.\n\nAnd finally, for large scale games the amount of code here is just low. *FROMSOFT*\nrely on a relatively large number of bespoke behaviors (one or more for each\nboss, practically), but those behaviors are quite small compared to basically\nanything you’d expect from other large AAA games. In production BTs elsewhere\none wouldn’t be surprised seeing trees of tens of thousands of nodes, on top of\nhundreds of individual nodes implementing control flow and actions. Similarly\naspects like the data model on Actors is just extremely barebones here compared\nto the rich data you’d perhaps expect to see in a “modern” BT implementation.\n\nTo reiterate a point from earlier as well, planners are just complex compared to this, and FSMs often end up with a similar node explosion to BTs.\n\nWhere I talk about performance it’s important to consider it in the context of\nimplementation requirements. A “weekend” long implementation of the structure\nused here (on top of a generic scripting language) is going to be basically\nsufficient to implement a game like Elden Ring.[ 5](#fn5) But if you want to\nbuild a behavior tree implementation that hits similar performance goals, you’re\ntalking about building a system that lowers the tree as authored to an optimized\nrepresentation of some kind, on top of implementing a whole host of basic\ncontrol flow and data passing primitives, plus all the tooling required to\nauthor, compose, and debug trees. The naive implementation is likely to be\ninsufficient.\n\nSo can you achieve good performance basically however you choose to design a\nsystem like this? Yes. But as I see it you’re going to need to do a **lot** more\nwork going the BT route.\n\n# References\n\nMost of the info in this post comes from [eladidu readable ds lua](https://eladidu.github.io/readable-ds-lua/)\nit’s fantastic and you can find many interesting definitions as well as a\nlittle tutorial.\n\nIf you want to get even more excited there’s a bunch of tools for extracting data from the game packages, as well as nice modding tools for patching things here and there.\n\nThis is\n\n**not** to be confused with the concept of a goal which you might know from advanced planning systems like[STRIPS](https://ai.stanford.edu/~nilsson/OnlinePubs-Nils/PublishedPapers/strips.pdf),[GOAP (Goal Oriented Action Planning)](https://www.gamedevs.org/uploads/three-states-plan-ai-of-fear.pdf)(as seen in the classic shooter, F.E.A.R) or[HTN (Hierarchical Task Networks)](https://en.wikipedia.org/wiki/Hierarchical_task_network). Those systems use a search algorithm to dynamically find a sequence of Actions which move the world into a Goal State. There’s nothing remotely so complex happening here![↩](#fnref1)Animation ids are largely based on playstation controller inputs which are then offset by a per-actor value in the npc definition. Moveset swaps can be performed by changing the offset dynamically from scripts!\n\n[↩](#fnref2)I’m glossing over a small problem… You want to be able to write your scripts so that sub-goals function as a queue, not a stack, so they are executed in the order they’re pushed. Unfortunately that slightly complicates the implementation and explanation so I’ve left it as an exercise for the reader.\n\n[↩](#fnref3)I’m not entirely sure whether they update only the current Goal from the top, or whether they recursively update currently active goals, but I suspect it might be the latter. This is still dramatically more efficient than re-evaluating decision criteria in a behavior tree.\n\n[↩](#fnref4)Obviously this is a simplification, hopefully it’s clear I’m talking about the core data structure and framework rather than the large amount of peripheral work required to draw the rest of the owl.\n\n[↩](#fnref5)", "url": "https://wpnews.pro/news/the-low-tech-ai-of-elden-ring", "canonical_source": "https://nega.tv/posts/low-tech-ai-of-elden-ring.html", "published_at": "2026-06-23 11:40:50+00:00", "updated_at": "2026-06-23 23:53:34.326064+00:00", "lang": "en", "topics": ["artificial-intelligence"], "entities": ["FromSoftware", "Elden Ring", "Havok Script", "Havok"], "alternates": {"html": "https://wpnews.pro/news/the-low-tech-ai-of-elden-ring", "markdown": "https://wpnews.pro/news/the-low-tech-ai-of-elden-ring.md", "text": "https://wpnews.pro/news/the-low-tech-ai-of-elden-ring.txt", "jsonld": "https://wpnews.pro/news/the-low-tech-ai-of-elden-ring.jsonld"}}