GOAP vs Rule-Based Reasoning: Planning to Act vs Proving It's True
Polyglot Programming & Paradigm Shifting draws the line between logic programming and goal-oriented programming precisely: one answers "is this true?", the other answers "what sequence of actions gets me from here to a declared goal state?" That distinction is easy to state and easy to underestimate, so this page takes one small domain, solves it both ways, and shows exactly what each answer does and doesn't give you — then points at the real problems each approach is actually good for.
Same Domain, Two Genuinely Different Answers
A small world: a door that's locked, a key, some oil, and a lamp that needs lighting before the door can be opened in the dark. Expressed as GOAP — actions with preconditions, effects, and a cost, searched for the cheapest path to a goal1 — this is action_add/goal/pursue in PatLang, run directly rather than described:
# ---- GOAP: an ordered, costed plan to reach a goal state ----
action_add("get_key", [], [["has_key", []]], [], 1)
action_add("get_oil", [], [["has_oil", []]], [], 1)
action_add("light_lamp", [["has_oil", []]], [["lit", []]], [], 1)
action_add("open_door", [["has_key", []], ["lit", []]], [["open", []]], [], 1)
# A pricier shortcut that still needs light -- the planner should prefer
# the cheaper ordinary route over this even though it's also valid.
action_add("kick_door", [["has_key", []], ["lit", []]], [["open", []]], [], 50)
goal escape {
open()
}
let the_plan = pursue escape
print("plan length: " + list_len(the_plan))
print(the_plan)
Output:
plan length: 4 [get_key, get_oil, light_lamp, open_door]
The planner didn't just confirm the door can open — it produced an ordered, executable sequence of four concrete steps, and it correctly preferred the ordinary four-step route over the pricier kick_door shortcut even though both satisfy the same preconditions. That's a genuinely different kind of answer from what the same domain gives back expressed as logic instead — facts and rules, queried by backward-chaining resolution2, with no actions, no cost, and nothing executed:
# ---- The same domain as pure logic: can "open" ever be derived at all? ----
rule_add("has_key", [], [])
rule_add("has_oil", [], [])
rule_add("lit", [], [["has_oil", []]])
rule_add("open", [], [["has_key", []], ["lit", []]])
let sols = solve("open", [])
print("open derivable: " + list_len(sols) + " way(s)")
Output:
open derivable: 1 way(s)
That's the entire, honest difference. The logic version can tell you the door is openable in principle, and how many distinct ways the derivation succeeds — genuinely useful information — but it will never tell you what to actually do, in what order, or what that costs. Asking a rule engine "what's my plan" is a category error; it was never built to answer that question, and bolting an ordering onto its output afterwards is exactly the amount of extra engineering that just building the GOAP version directly would have saved.
Both Fail Cleanly When Nothing Works — and That's the Useful Part
Success isn't the interesting case. Take the same domain and remove get_oil entirely — nothing anywhere produces has_oil, so the lamp can never be lit and the door can never open, no matter what order anything happens in:
action_add("get_key", [], [["has_key", []]], [], 1)
action_add("light_lamp", [["has_oil", []]], [["lit", []]], [], 1)
action_add("open_door", [["has_key", []], ["lit", []]], [["open", []]], [], 1)
goal escape {
open()
}
let the_plan = pursue escape
print("plan length: " + list_len(the_plan))
print(the_plan)
Output:
plan length: 0 []
The planner doesn't crash, doesn't return a partial or best-effort plan, and doesn't guess — it comes back with an empty plan, a clean, checkable signal that the goal is genuinely unreachable from the current world state before anything is ever executed. That's worth having as an explicit, first-class outcome rather than an edge case to catch after the fact: a real system built on this (a deployment pipeline, an IT remediation runbook, an NPC's decision loop) can check plan length == 0 and refuse to start, rather than discovering the failure three steps into actually doing something. The equivalent logic-only version answers the same underlying question, in its own vocabulary:
rule_add("has_key", [], [])
rule_add("lit", [], [["has_oil", []]])
rule_add("open", [], [["has_key", []], ["lit", []]])
let sols = solve("open", [])
print("open derivable: " + list_len(sols) + " way(s)")
Output:
open derivable: 0 way(s)
Both approaches detect the same underlying impossibility, and both do it without running a single action — but notice what's missing from the logic side that the planner gave you for free: which fact is actually the blocker. solve reports that open isn't derivable and stops there; working out that has_oil specifically is the unreachable fact means walking the rule graph yourself, by hand, outside the language.
GOAP doesn't hand you that answer automatically either — pursue only ever tells you a plan's length, not why it's zero — but because each action's preconditions and effects are already sitting there as ordinary data, walking backward from the goal to find the first fact nothing produces is a program you can write once and reuse, rather than a proof you re-derive by eye every time a plan comes back empty. Keep a small registry alongside action_add, and a short recursive walk answers the "why" question directly:
let mut actions = []
action_add("get_key", [], [["has_key", []]], [], 1)
actions = list_push(actions, ["get_key", [], [["has_key", []]]])
action_add("light_lamp", [["has_oil", []]], [["lit", []]], [], 1)
actions = list_push(actions, ["light_lamp", [["has_oil", []]], [["lit", []]]])
action_add("open_door", [["has_key", []], ["lit", []]], [["open", []]], [], 1)
actions = list_push(actions, ["open_door", [["has_key", []], ["lit", []]], [["open", []]]])
make a function called action_that_produces takes registry, wanted_fact returns result
let mut i = 0
let mut result = false
while i < list_len(registry) do
let rec = list_get(registry, i)
let effects = list_get(rec, 2)
let mut j = 0
while j < list_len(effects) do
let eff_name = list_get(list_get(effects, j), 0)
if eff_name == wanted_fact then
result = rec
end
j = j + 1
end
i = i + 1
end
return result
end
make a function called is_reachable takes registry, fact_name returns ok
let producer = action_that_produces(registry, fact_name)
if producer == false then
return false
end
let preconds = list_get(producer, 1)
let mut i = 0
let mut all_ok = true
while i < list_len(preconds) do
let pre_name = list_get(list_get(preconds, i), 0)
if is_reachable(registry, pre_name) then
i = i + 1
else
all_ok = false
i = i + 1
end
end
return all_ok
end
make a function called explain_missing takes registry, fact_name returns reason
let producer = action_that_produces(registry, fact_name)
if producer == false then
return fact_name
end
let preconds = list_get(producer, 1)
let mut i = 0
let mut blocker = false
while i < list_len(preconds) do
let pre_name = list_get(list_get(preconds, i), 0)
if is_reachable(registry, pre_name) then
i = i + 1
else
if blocker == false then
blocker = explain_missing(registry, pre_name)
end
i = i + 1
end
end
return blocker
end
goal escape {
open()
}
let the_plan = pursue escape
print("plan length: " + list_len(the_plan))
if list_len(the_plan) == 0 then
let cause = explain_missing(actions, "open")
print("no valid plan -- blocked on: '" + cause + "'")
end
Output:
plan length: 0 no valid plan -- blocked on: 'has_oil'
explain_missing doesn't just report that open is unreachable — it walks open's precondition lit, finds lit unreachable too, walks its precondition has_oil, and stops there because nothing in the registry produces it: the actual root cause, three hops down from the goal, found by the program itself rather than read off the rule graph by a person. That's the concrete version of the claim above — not just that GOAP's data could support this kind of diagnosis, but a working example of it doing so.
Where GOAP Is Genuinely the Right Tool
- Game AI is GOAP's original home and still its best-known use: an NPC choosing a sequence of concrete actions — reload, take cover, flank — to reach a combat goal, replanning cheaply when the world state changes mid-fight. See Goal-Oriented Action Planning for the full treatment, including the STRIPS lineage this page's citation traces back to.
- Build systems are GOAP wearing a very different hat: a
Makefiletarget is a goal, its dependencies are preconditions, andmakeworks backward from the goal to find which rules actually need to run, in what order — see the Make example in Paradigms & Paradigm Shifting's "Six Accents" section for the same total-the-prices problem solved this way. - Automated planning more broadly — robotics, logistics, workflow orchestration, IT remediation runbooks that need to get a system from its current, broken state to a known-healthy one — is a genuine, established field built on exactly this action/precondition/effect/cost shape, well beyond games13. Anywhere the real question is "what do I actually do next, and in what order" rather than "is this possible," that's the signal to reach for planning over proving.
Where Rule-Based Logic Is Genuinely the Right Tool
- Diagnosis and root-cause queries — "given these symptoms, what could explain them?" — are naturally backward-chaining questions with no action or ordering involved at all; PatLang's own
solveengine, and Prolog before it, exist for exactly this shape of question. - Validity and compliance checking — "does this configuration satisfy every constraint?" — wants a definite yes/no (and ideally which constraint failed), not a sequence of steps; there's nothing to execute, only something to verify.
- Type systems and static analysis are, underneath, rule-based derivation engines: "is this expression well-typed" is a provability question through and through, answered once, with no notion of cost or order to a proof.
The Real Test: Does the Answer Need an Order?
Before reaching for either, ask one question of the actual problem: does a correct answer need to say what to do, in what sequence, or does it only need to say whether something holds? A monitoring system deciding whether an alert condition is currently true is a rules problem. The same system deciding what remediation steps to actually run, and in what order, to clear that alert is a planning problem — and treating it as a rules problem instead, hoping an ordering falls out of the proof somehow, is the mismatch this whole page has been demonstrating one small domain at a time.
See Also
For the paradigm-level framing this page expands on, see Polyglot Programming & Paradigm Shifting. For GOAP developed fully in its original game-AI context, see Goal-Oriented Action Planning. For a full worked PatLang example combining backward-chaining resolution and GOAP planning in one program, including the newer goal/pursue/activate surface syntax, see PatLang's goal-oriented programming demo.
References
Fikes, R. E., & Nilsson, N. J. (1971). STRIPS: A new approach to the application of theorem proving to problem solving. Artificial Intelligence, 2(3–4), 189–208. https://doi.org/10.1016/0004-3702(71)90010-5 ↩↩
Kowalski, R. (1974). Predicate logic as a programming language. Proceedings of IFIP Congress 1974, Stockholm. North-Holland, 569–574. ↩
Ghallab, M., Nau, D., & Traverso, P. (2004). Automated Planning: Theory and Practice. Morgan Kaufmann. ↩