# 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