# Unified Character Architecture (Player + Enemy)
> **Design goal**
> One Brain. One State system. One execution pipeline.
> Player input and Enemy AI differ only in **how Facts are produced**, not in how decisions are made.
---
## 1. Core Architectural Decisions
- **States decide actions**
- **Brain mediates decisions**
- **Character executes**
- **Policy validates actions**
- **Transitions are control-flow, not actions**
- **Animation is presentation, not gameplay**
- **Enemy AI replaces input, not states**
---
## 2. High-Level Data Flow
```mermaid
flowchart LR
InputOrAI[Input / AI Sensors]
InputOrAI --> FactsProvider
FactsProvider --> Facts
Facts --> Brain
%% State execution
Brain --> CurrentState[IState.update]
CurrentState --> StateResult
%% Transition path
StateResult -->|target_state| TransitionCheck[State Graph]
TransitionCheck -->|allowed| BrainChange[Brain.change_state]
BrainChange --> CurrentState
%% Action path
StateResult -->|intent| ActionPolicy
ActionPolicy -->|approved| Character
%% Presentation path
StateResult -->|animation| AnimationController
```
## 3. Core Data Objects
### 3.1 Facts (World Snapshot)
```gdscript=
class_name Facts
var input: InputSnapshot = InputSnapshot.new()
# Character physical state
var velocity: Vector2
var is_on_floor: bool
# Combat / control state
var is_locked: bool = false
var stamina: float = 0.0
# Time
var frame: int = 0
# Inventory
var inventory_items: Dictionary = {}
func inventory_has_item(item_id: StringName, quantity := 1) -> bool:
return inventory_items.get(item_id, 0) >= quantity
```
**Invariant:**
Facts must always be complete and non-null.
>For enemy, the AI and stack FSM logic, will resolve the actions of enemy into simple behavioral states as same in enemy, i.e. idle, walk, attack etc.
### 3.2 InputSnapshot (Control Abstraction)
```gdscript=
class_name InputSnapshot
var just_pressed: Dictionary = {}
var just_released: Dictionary = {}
var is_pressed: Dictionary = {}
var horizontal: float = 0.0
```
**Used by:**
- Player (keyboard / controller)
- Enemy (AI-generated virtual input)
**For player, autoload of InputManager will handle filling the InputSnapshot instance**
InputManager:
```gdscript=
extends Node
signal input_snapshot_ready(snapshot)
const ACTIONS := [
"move_left", "move_right",
"jump", "attack", "interact",
"dash", "inventory"
]
func _physics_process(_delta: float) -> void:
var snapshot := InputSnapshot.new()
for a in ACTIONS:
snapshot.just_pressed[a] = Input.is_action_just_pressed(a)
snapshot.just_released[a] = Input.is_action_just_released(a)
snapshot.is_pressed[a] = Input.is_action_pressed(a)
snapshot.horizontal = (
Input.get_action_strength("move_right")
- Input.get_action_strength("move_left")
)
input_snapshot_ready.emit(snapshot)
```
**For enemy, AI + stack FSM will be responsible for filling this snapshot**
### 3.3 Intent (Gameplay Action Only)
```gdscript=
class_name Intent
enum Type {
NONE,
MOVE,
JUMP,
ATTACK
}
var type: Type = Type.NONE
var move_axis: float = 0.0
```
Intent contains **ONLY** things that affect the world.
It is only responsible for handling **behavioural** work, not animation and transitions.
## 4. State Output Contract
### 4.1 StateResult (State → Brain)
```gdscript=
class_name StateResult
var intent: Intent = null # gameplay action
var next_state: StringName = &"" # requested transition
var animation: StringName = &"" # presentation
```
**Why this exists**
| Field | Why it is NOT in Intent |
| ---------- | ----------------------------- |
| intent | (behavioural) validated + executed |
| next_state | control-flow, validated by stateGraph or TransitionGraphPolicy |
| animation | presentation |
## 5. State System (Decision Layer)
### 5.1 IState Interface
- IState is **behavioural**, not **perceptual**.
- States should not “reason about the world”
- States should reason about intent-ready signals
```gdscript=
class_name IState
func get_id() -> StringName
func enter(facts: Facts) -> void
func update(facts: Facts) -> StateResult
func exit() -> void
```
**States:**
- read Facts
- return StateResult
- never execute logic directly
There would be two types of states:
- States which do not consume any sort of resource on `enter()`, would just extend from `IState`.
- But `IActionState`, extended from `IState`, would cost resouces, and would be validated by policy instance of its correspondance.
#### 5.1.1 Example: IdleState
```gdscript=
extends IState
func get_id() -> StringName:
return &"idle"
func update(facts: Facts) -> StateResult:
var r := StateResult.new()
r.animation = &"idle"
if facts.input.horizontal != 0:
r.next_state = &"walk"
return r
```
#### 5.1.2 Example: WalkState
```gdscript=
extends IState
func get_id() -> StringName:
return &"walk"
func update(facts: Facts) -> StateResult:
var r := StateResult.new()
if facts.input.horizontal == 0:
r.next_state = &"idle"
return r
var i := Intent.new()
i.type = Intent.Type.MOVE
i.move_axis = facts.input.horizontal
r.intent = i
r.animation = &"walk"
return r
```
**Invariant:**
Idle never moves. Walk owns movement.
Non-resource consuming states, do not require any policy validation.
### 5.2 IActionState
```gdscript=
# core/states/action_state_interface.gd
class_name IActionState
extends IState
## Return the resource / commitment this state requires on entry
func get_entry_cost(facts: Facts) -> Dictionary:
# Example return:
# { "stamina": 20 }
return {}
```
#### 5.2.1 Example Action State
```gdscript=
# core/states/combat/light_attack_state.gd
extends IActionState
class_name LightAttackState
const STAMINA_COST := 15
const HIT_FRAME := 6
const RECOVERY_FRAME := 18
var frame := 0
func get_id() -> StringName:
return &"light_attack"
func get_entry_cost(_facts: Facts) -> Dictionary:
return { "stamina": STAMINA_COST }
func enter(_facts: Facts) -> void:
frame = 0
# stamina is assumed to be RESERVED already by StateGraph
func update(facts: Facts) -> StateResult:
frame += 1
var r := StateResult.new()
# Animation always reflects current state
r.animation = &"light_attack"
# Hit frame → emit gameplay effect
if frame == HIT_FRAME:
var i := Intent.new()
i.type = Intent.Type.ATTACK
r.intent = i
# Recovery done → leave state
if frame >= RECOVERY_FRAME:
r.next_state = &"idle"
return r
func exit() -> void:
pass
```
## 6. Brain (Decision Mediator)
**Brain Flow**
```mermaid
%%{init: {
"theme": "forest"
}}%%
flowchart TD
Facts["Facts"]
ForceCheck["Forced Transition Check<br/>(Die, Stun, Override)"]
Update["IState.update(facts)"]
Result["StateResult"]
HasNext{next_state != empty?}
GraphCheck["StateGraph.can_transition"]
ChangeState["Brain.change_state"]
NoTransition["No Transition"]
EmitAnim["Emit animation_requested"]
HasIntent{intent != null?}
PolicyExec["ActionPolicy.validate(intent)"]
EmitIntent["intent_approved.emit"]
EndTick["End Tick"]
%% Entry
Facts --> ForceCheck
ForceCheck -->|triggered| ChangeState
ChangeState --> EndTick
ForceCheck -->|not triggered| Update
%% State evaluation
Update --> Result
Result --> HasNext
%% Transition path (ONLY StateGraph involved)
HasNext -->|yes| GraphCheck
GraphCheck -->|allowed| ChangeState
GraphCheck -->|denied| NoTransition
%% No-transition path
HasNext -->|no| NoTransition
NoTransition --> EmitAnim
EmitAnim --> HasIntent
%% Execution path (ONLY Policy involved)
HasIntent -->|yes| PolicyExec
PolicyExec -->|approved| EmitIntent
PolicyExec -->|denied| EndTick
HasIntent -->|no| EndTick
EmitIntent --> EndTick
```
## Brain Tick Invariants
**1. StateResult is evaluated exactly once**
- `IState.update(facts)` runs once per tick
- Brain never re-enters state logic in the same frame
- All decisions are derived from a single `StateResult`
---
**2. Transitions strictly short-circuit execution**
- If `next_state` exists **and StateGraph allows it**:
- Brain changes state
- **No animation is emitted**
- **No intent is validated or executed**
- This guarantees:
- no “Idle but moving”
- no “Attack animation without attack”
- no partial-frame side effects
---
**3. StateGraph owns *all* transition legality**
- StateGraph decides whether a state change is allowed
- This includes:
- lockouts
- stamina / cooldown checks *for entering a state*
- forced states (Die, Stun)
- Policy is **not involved** in state transitions
---
**4. Animation and Intent are parallel but ordered outputs**
- Animation is emitted **only if the state remains active**
- Animation is **never validated**, as it is attached to current state. Current states are validate before entering, hence are always plausible
- Intent is validated **only after animation emission**
- Animation reflects *current state*, not successful execution
---
**5. Policy validates execution, nothing else**
- Policy answers only:
> “May this intent produce gameplay effects right now?”
- Policy does **not**:
- decide states
- block animations
- affect transitions
---
**6. Intent.NONE is meaningful**
- States may emit:
- animation only
- neither animation nor intent
- Brain does not early-return on `Intent.NONE`
- Presentation continues independently of gameplay actions
---
**Brain Responsibilities**
- Hold the current state
- Invoke `IState.update(facts)`
- Apply forced and requested transitions (via StateGraph)
- Emit animation requests
- Validate and emit intents (via Policy)
**Brain Signals**
```gdscript=
signal intent_approved(intent: Intent)
signal animation_requested(anim: StringName)
```
## 7. Transition Validation (Updated)
**All state transitions are validated exclusively by the StateGraph.**
>The **difference** between **policyValidation** and **stateGraph** validation is that, **later** will check if the **transition is plausible**, while the **former** will handle if the execution of **current state is plausible**.
Intent is *never* involved in transition decisions.
### 7.1 Responsibility Split
- **StateGraph**
- Decides whether a state change is allowed
- Validates *commitment-level* rules:
- resource availability (stamina, cooldowns)
- global locks (stun, death)
- forced overrides (Die, Stun, Scripted)
- Guarantees that any active state is always *plausible*
- **Brain**
- Queries the StateGraph
- Applies transitions
- Never decides legality itself
- **Policy**
- Never validates transitions
- Validates only *execution-time effects* (Intents)
---
### 7.2 Transition Validation API
```gdscript
class_name StateGraph
func can_transition(
from: StringName,
to: StringName,
facts: Facts
) -> bool:
return true
```
### StateGraph Example
```gdscript=
func can_transition(from: StringName, to: StringName, facts: Facts) -> bool:
var next_state := state_map[to]
# Handle action states
if next_state is IActionState:
var cost := next_state.get_entry_cost(facts)
if cost.has("stamina"):
if facts.stamina < cost["stamina"]:
return false
# structural rules
return true
```
## 8. Character (Execution Layer)
**Responsibilities**
- calls brain ticks
- Apply velocity
- Run physics
- Execute approved intents
Character:
- does NOT read input
- does NOT change states
- does NOT validate actions
- does NOT manage animations
## 9. Interface Action Policy (Gameplay Rules)
This is the interface for Action policies, which is used to maintain coherent rules for characters.
Player Policy and Enemy Policy will inherit from these.
```gdscript=
class_name IActionPolicy
func validate(
state_id: StringName,
intent: Intent,
facts: Facts
) -> bool
```
**Used for:**
- stamina
- hitstun
- lockouts
- combat rules
## 10. Animation System (Presentation Layer)
**Why Separate**
- Animation must never be blocked by policy
- Animation must work even when no actions/state happens.
- 1 state = 1 animation doesn't really scale well.
- Animation ≠ gameplay authority
**AnimationSystem**
```gdscript=
class_name AnimationController
func request(anim: StringName) -> void
```
Connected to:
```gdscript=
brain.animation_requested.connect(animation_controller)
```
## 11. Input & Facts Providers
Player
```
InputService → InputSnapshot → Facts → Brain
```
Enemy
```
AI Sensors → AI FSM → InputSnapshot → Facts → Brain
```
## 12. Enemy AI Architecture
**Key Rule**
> AI replaces input, not states
Enemy uses:
- same Brain
- same Behavioural States
- different Policies
- same execution
Inside the player/enemy/boss/etc. inherited from base character class, following policies can be attached.
```gdscript=
player.brain.policy = PlayerPolicy.new()
grunt.brain.policy = EnemyPolicy.new()
boss.brain.policy = BossPolicy.new()
```
### 12.1 Stack-Based AI FSM (Directive Layer)
Purpose:
- choose which behavioral state runs
- override via push / pop
- react to events
Examples:
- PatrolMode
- ChaseMode
- CombatMode
FSM does NOT:
- emit intents
- move character
- bypass Brain
### 12.2 Enemy Flow
```mermaid
flowchart TB
Sensors --> DirectiveFSM
DirectiveFSM --> AISnapshot
AISnapshot --> Facts
Facts --> Brain
Brain --> State
State --> Intent
Intent --> Character
```
## 13. Authority Summary
```mermaid
flowchart LR
FSM[AI FSM] -->|chooses| State
State -->|decides| Intent
Brain -->|validates| Intent
Character -->|executes| Intent
```
## 14. Player vs Enemy Comparison
| Layer | Player | Enemy |
| -------------- | ------------------- | ------------------- |
| Input Source | Keyboard | AI FSM |
| Facts Provider | InputService | AIService |
| Brain | Shared | Shared |
| States | Shared | Shared + Enemy-only |
| Policy | Player Policy | Enemy Policy |
| Animation | Shared | Shared + Enemy only |
## 15. Non-Negotiable Invariants
- Intent contains ONLY gameplay actions
- Transitions are control-flow, not actions
- Animation is never validated
- AI never emits intents, just directive states
- Brain never touches engine APIs
- State ownership is absolute
## 16. Final Mental Model
> AI decides inputs
States decide actions
Brain mediates
Policy validates
StateGraph validates transitions
Character executes
Animation presents
---
## Some decisions to change
- [ ] `validate()` is a boolean. Returning string, will help to pass it through the UI, showing the message on screen to user. Also, help with debugging
- [ ] Guarantee Immutability of Facts and SnapShots