qproj eye icon qproj design docs

NPC stances: data model

design · draft · NLP and argumentation · edited 2026-11-06

Sketch. Nothing here is built. The working example is misc/labs/yarn-editor/examples/concepts-as-objects/1.yarn; see Quell integration for where this sits in Quell. Everything is hand-authored: no language model at play time or for authoring.

The player collects statements (“A is B”), links two of them into an argument (“A is B because C is D”) in the thinking editor, and presents it to an NPC. Every character holds stances on statements and on families of arguments. Responses follow from those stances by rule, so most of the dialogue is written per situation, not per argument.

The pieces

classDiagram
  direction TB
  class Registry {
    <<Asset / Resource>>
    concepts: ConceptId to name, description
    properties: PropertyId to name, description
    statements: list of Statement
    relations: property or concept pair to LeverFamily
    contraries: property pairs that can't both hold
  }
  class Statement {
    <<value>>
    concept: ConceptId
    property: PropertyId
    predicate: F or V
    subject: s, p or u
  }
  class Argument {
    <<value>>
    conclusion: Statement
    premise: Statement
    form() alpha or beta
    family() LeverFamily or none
  }
  class Stance {
    <<enumeration>>
    Affirm
    Deny
    Suspended
  }
  class Character {
    <<entity>>
  }
  class Stances {
    <<component>>
    statements: Statement to Stance
    families: LeverFamily to Stance
    ranking: value order
  }
  class Inventory {
    <<component, player only>>
    statements
    concepts
    properties
  }
  Registry --> Statement
  Argument --> Statement : conclusion, premise
  Character --> Stances
  Character --> Inventory
  Stances ..> Stance : value
  Argument ..> Registry : family looked up in
typeBevy kindwhy
RegistryRON asset, inserted as a resource once loadedThe authored world of claims: every valid statement with its tags, and every meaningful relation between predicates or subjects.
Statementplain Copy value with interned idsA concept–property pair with its PTA tags baked in. F/V is the predicate type in context, s/p/u the subject type (“this cup”, “some cups”, “all cups”), so they belong to the statement, not to the concept or property.
Argumentplain Copy valueTwo statements. Form and lever family are derived, never stored.
Stanceenum, three variantsA missing entry means never examined. Suspended means held, then lost to argument (see Aporia). The two are kept apart so that never committing to anything can’t pass for letting go.
Stancescomponent on every character entityAuthored core beliefs plus opinions on argument families. The player has one too; that’s how the game tracks the player’s own mind.
Inventorycomponent on the playerStatements the player holds. A statement is acquired whole (found in a text, heard in dialogue) or composed from a held concept and property, but only if the pair is in the registry, so every statement is authored and tagged either way.

Three places to disagree

An argument can be resisted at the conclusion, the premise, or the link between them (the PTA lever). “I agree A is B, but not because of that” splits in two:

the character saysA is BC is Dthe link
“A is B, but C is not D”AffirmDeny—
“A is B, and C is D, but that’s not why”AffirmAffirmDeny

Statement stances cover the first two columns. Link stances are held per lever family (cause, example, analogy, authority, …), not per argument: an NPC who rejects “length makes things great” rejects it for every concept.

Form and family

The tags and the registry decide what kind of argument the player made.

Form follows from the shared slot: the same concept is α (“a is X because a is Y”), the same property is β (“a is X because b is X”). A pair sharing neither is γ, and the thinking editor doesn’t accept it (see the counting below for why).

Substance comes from the baked tags: for α the predicate types of conclusion then premise (FF, FV, VF, VV), for β the subject types (su, ss, …).

Lever family: form and substance narrow the options but don’t pick one; “a is great because a is long” is αVF either way. The lever is the relation between the two predicates (α) or the two subjects (β), so it’s authored once per related pair in relations and reused across every argument that pair appears in. A pair with no authored relation has no lever: “that has nothing to do with it.”

How much to write

Symbols

symbolmeaning
𝑆,𝑛=|𝑆|authored statements
𝑘,𝑗number of concepts, number of properties
𝑛𝑐statements whose concept is 𝑐
𝑚𝑝statements whose property is 𝑝
𝑁number of NPCs
Λlever families

Arguments the player can make

An argument is an ordered pair (conclusion, premise) of distinct statements. α pairs share a concept, β pairs share a property. Two distinct statements can’t share both.

𝐴𝛼=∑𝑐𝑛𝑐(𝑛𝑐−1)𝐴𝛽=∑𝑝𝑚𝑝(𝑚𝑝−1)𝐴=𝐴𝛼+𝐴𝛽out of𝑛(𝑛−1)ordered pairs

With statements spread evenly (𝑛𝑐=𝑛𝑘, 𝑚𝑝=𝑛𝑗):

𝐴=𝑛(𝑛𝑘−1)+𝑛(𝑛𝑗−1)

The growth is quadratic in statements per concept and only linear in the number of concepts. Adding new concepts is cheap; piling claims onto one concept is what explodes. Allowing γ would bring back all 𝑛(𝑛−1) pairs, which is the reason to leave it out.

Relations (levers)

One relation per related pair of properties sharing a concept, and per related pair of concepts sharing a property:

𝑅≤∑𝑐(𝑛𝑐2)+∑𝑝(𝑚𝑝2)

This is an upper bound. Most pairs have no meaningful relation and aren’t written. The quadratic term is still here, but it’s paid once for the whole game, not once per NPC.

One NPC

An NPC is a partial map of core beliefs 𝜎𝑁:𝑆→{A,D}, a partial map of family opinions 𝜌𝑁:Λ→{A,D}, and a value ranking. Presenting the argument (conclusion 𝑥, premise 𝑦, family 𝜆) looks up three values, each Affirm, Deny or missing: 33=27 situations. Checked in order, they fall into seven response classes:

𝜎(𝑦) premise𝜌(𝜆) link𝜎(𝑥) conclusionresponse class
Danyanycounter the premise: “C is not D”
missinganyanyask for support: “why is C D?”
ADanyreject the link: “that’s not a reason”
Amissinganyprobe the link: “how does that follow?”
AAAagree: “yes, and…”
AAmissingconvinced: adopts A is B
AADconflict: the value ranking decides

“Ask for support” is how chains happen: the player answers with an argument whose conclusion is the premise C is D.

Suspended counts as missing in this table. Whether a single argument can re-establish a suspended stance is open (see Aporia).

Per NPC, then:

𝑊𝑁=|𝜎𝑁|+|𝜌𝑁|+7𝑣+𝑏

where 𝑣 is the number of variants written per response class (slots fill in the statements) and 𝑏 the bespoke lines for arguments that matter to the story.

Total

𝑊=𝑘+𝑗⏟definitions+𝑛⏟statements+𝑅+∑𝑁𝑊𝑁

Compare writing a response per argument per NPC: 𝑁⋅𝐴, quadratic per concept and multiplied by every NPC.

Worked example: one small debate

20 statements over 5 concepts and 10 properties, evenly spread (𝑛𝑐=4, 𝑚𝑝=2), one NPC. That’s about one in-world text and one conversation.

itemcounthow
arguments the player can make80 of 38020⋅3+20⋅1
definitions155+10
statements20
relations, at most405(42)+10(22)
NPC core beliefs 15guess
NPC family opinions4one per family
response variants147⋅2
bespoke lines 10guess
total 120 short items

Per-argument writing for the same slice is 80 responses, but every new NPC adds another 80; under the rule, a second NPC adds about 45.

Minds that change

Responses are recomputed from the current stances every time, so changing a stance costs no extra writing for the arguments themselves. What it costs:

The machinery is symmetric. The player has Stances too, so an NPC presenting an argument to the player uses the same model with the roles swapped, and the player’s stances become the record of their change of mind. Changing NPCs’ minds and changing the player’s mind can both be tried on the same data.

Ripples and contradictions

A change of mind shouldn’t recompute everything (minds become volatile) or nothing (one belief never touches another). How far it ripples depends on how much the person knows about the relations between their ideas. Two ways to model that:

Either way, some beliefs are left behind, and the player can point that out. That needs a second kind of relation: contrary properties, which can’t both hold of the same subject (“is a virtue” / “is a weakness”). An NPC is inconsistent when it affirms (𝑐,𝑝) and (𝑐,𝑞) with 𝑝 and 𝑞 contrary. That’s mechanical to find, and the count is small: one check per contrary pair per concept.

Pointing out an inconsistency isn’t δ. δ supports a claim by who says it (“Einstein said so”). “You believe X is Y and X is Z, which can’t both be true” is a challenge to the NPC’s commitments: a dialogue move, not a PTA argument type. Dialogue-game theory calls the set of claims a party is committed to its commitment store (Walton and Krabbe, Commitment in Dialogue, 1995). Recast as an argument, it’s α with a negative conclusion (“X is not Y, because X is Z”, contrariety as the lever), which needs the negation question below settled first.

Aporia

A belief can’t be dropped at will. It’s dropped when argumentation breaks its hold: the character can no longer affirm or deny it. That’s Suspended, distinct from a missing entry (never examined). “I’m not sure” early in the game is a missing entry; aporia comes late, and only through argument. It’s a release of dukkha, not passivity.

Two routes lead there, both built on contrary relations:

Both need accumulated commitments to collide, so aporia can’t happen early, and it can’t happen to a character who never committed to anything. Nothing has to script it as late. The held stances are Wittgenstein’s ladder: climbed, then thrown away (see the ladder).

For the player, suspended stances are what the ending checks: see Quell story progression: design.

γ (no shared slot) and δ (arguments from authority, “Einstein said so”) are out: γ for the counting above, δ because a subject would have to be a statement. Both add complexity the game doesn’t need.

P (policy) is out too: statements are F or V. Debates settle what is the case and how good or bad it is, never what to do. Decisions are authored choices offered outside the debate, built from the conversation so far; the player picks one and doesn’t make one up. Letting players argue for policies would mean an open-ended space of behaviour on top of the space of beliefs. “You shouldn’t lie” can still be voiced, typed as the V statement “lying is wrong”.

Presenting an argument

flowchart LR
  INV["player Inventory"] -->|pick conclusion, premise| BUILD["build Argument"]
  REG[("Registry")] -->|form, family| BUILD
  BUILD --> NPC["NPC's Yarn node"]
  NPC -->|"response_class(…)"| Q["query system:<br/>reads Stances"]
  Q -->|class| NPC
  NPC -->|"hold_stance, learn_*"| CMD["command systems:<br/>write Stances, Inventory"]

Both directions work in bevy_yarnspinner 0.9 (vendored in bin/vn-jun-26/third_party/YarnSpinner-Rust). Commands are registered systems taking In<T>, and so are functions: examples/bevy_yarnspinner/src/bin/custom_function.rs registers get_counter(counter: Res<Counter>) -> u32 with add_function. So a response_class function can read Stances directly and the Yarn node branches on the class. Quell doesn’t depend on it yet.

Yarn passes strings, so ids are interned at that boundary and nowhere else.

Background

The F/V/P split comes from debate theory, where it classifies propositions by what they ask of the audience. ATIP v6 cites Freeley and Steinberg, Argumentation and Debate (12th ed., 2009), pp. 55–58; Hinton and Wagemans also cite Broda-Bahm et al. (2004) and Skorupski (2010). ATIP applies it to the predicate of an alpha statement, and the s/p/u split (from Aristotle’s De interpretatione) to the subject of a beta statement. Quell keeps only F and V.

Which properties fit which concepts is a separate problem, category mistakes; the closest systematic treatment is Sommers’ predicability trees (Types and Ontology, 1963). The registry’s statement list is the hand-authored version of that tree.

See also