Enemy Intent Display System

Status: Design — ready for implementation Created: 2026-04-07 Implements: Visible at-a-glance intent icons above each enemy sprite Related Systems: Dice Combat UI (src/ui/dice_combat_screen.gd), Enemy Data & AI (design/gdd/enemy-data-ai.md), Status Effect System (design/gdd/status-effect-system.md)


1. Overview

The Enemy Intent Display system shows each enemy's planned next action as a persistent icon above their sprite, visible at all times during combat — no hover required. Every living enemy simultaneously displays a color-coded icon identifying their intent category (attack, defend, heal, or one of the 14 interference subtypes), a numeric value label where relevant (damage amount, shield points, dice affected), and a subtle idle animation. When the player has the Blind status applied, all intent icons are replaced by a question-mark glyph. The system integrates directly into the existing _update_enemy_display() rebuild pipeline in dice_combat_screen.gd and requires no new scene files.


2. Player Fantasy

The player should feel informed and strategically empowered before committing to a scoring decision. Seeing the full board state — three enemies each broadcasting their intent — creates the Slay the Spire moment of tactical clarity: "That goblin is going to steal my rerolls next turn, so I need to kill it now even though the slime hits harder." The icons should read as a threat dashboard, not an afterthought. Dangerous intents (attack, steal) should feel visually urgent; neutral intents (defend, heal) should feel like stolen time the player can exploit.

Primary MDA aesthetics served: Challenge (informed decision-making under pressure) and Discovery (learning what each interference type looks like and what it costs to ignore it).


3. Detailed Design

3.1 Intent Icon Container

Each enemy root control (enemy_root) receives a new child Control named intent_row positioned above the sprite group. This container holds the intent icon (TextureRect) and value label (Label) as siblings in a horizontal layout.

Positioning rules (absolute, within enemy_root):

intent_row.position.y = -28
intent_row.position.x = (root_w - INTENT_ROW_WIDTH) / 2.0
intent_row.size = Vector2(INTENT_ROW_WIDTH, 24)

INTENT_ROW_WIDTH is a tuning knob (see Section 7). Default: 64 pixels.

The intent row sits between the top of enemy_root (y=0) and the target indicator label (y=0, "v TARGET" text). Layering from top to bottom within enemy_root:

  1. Intent row (y = -28, partially above sprite_group origin)
  2. Target indicator (y = 0)
  3. Sprite group (y = 10, bobbing)
  4. Name label (y = sprite_sz + 25)
  5. HP bar row (y = name_y + 30)

The intent row must not clip into the sprite group during the bob animation. The bob range is at most 8px for a boss. The intent row at y = -28 remains clear of sprite_group at y = 10 with an 8px bob range (minimum clearance: 28 - 8 - 8 = 12px).

3.2 Icon Display

The intent icon is a TextureRect of size Vector2(16, 16) using a Kenney 1-bit sprite (see Section 4 for the full icon mapping). The icon's modulate is set to the intent color (see Section 3.4). The icon is positioned at the left of intent_row.

No texture atlas slicing is required — each icon maps to a single Kenney sprite via SpriteMap (the existing icon helper), following the same pattern used for the shield icon in the HP bar row.

3.3 Value Label

A Label sits to the right of the icon inside intent_row. Font size: 12. Outline size: 2. Outline color: Color(0, 0, 0, 1).

Display rules by action type:

Action CategoryLabel ContentExample
ATTACKDamage value"8"
DEFENDShield value"5"
HEALHP amount"12"
STEAL_REROLLCount stolen"2"
LOCK_HOLD— (no value shown)""
BURN_DICEDice count"1"
FLIP_ALL""
SNIPE""
GRAVITY""
CHAOS""
CURSE""
STEAL_DIE""
EVENODD""
FORCE_REROLL""
RIG""
LOCK_DIE""
BUFF / DEBUFF / SUMMON""

The label text color matches the intent icon color (same modulate tint).

For actions with no numeric value (empty label), the icon is centered within intent_row instead of left-aligned, so it does not appear offset.

3.4 Color Coding

Intent colors are sourced from the existing _get_intent_color() function in dice_combat_screen.gd. No new color constants are introduced. The icon modulate and value label font_color both use the return value of _get_intent_color(intent.action_type).

Danger tiers for reference (informational, not new logic):

TierColor familyActions
High dangerRed familyATTACK, LOCK_HOLD, LOCK_DIE
Dice threatOrange/goldBURN_DICE, STEAL_DIE, STEAL_REROLL
DisruptivePurple familySNIPE, CHAOS, CURSE
TransformativeBlue/cyanFLIP_ALL, FORCE_REROLL, GRAVITY, EVENODD
NeutralGreen/blueDEFEND, HEAL
ExoticPinkRIG

3.5 Intent Row Animation

The intent row receives a looping scale-pulse tween, stored in _enemy_display_tweens alongside the existing idle bob tweens so it is killed and rebuilt on every _update_enemy_display() call.

Pulse spec:

  • Scale oscillates between Vector2(1.0, 1.0) and Vector2(1.08, 1.08) using TRANS_SINE.
  • Period: 1.6 seconds (0.8s up, 0.8s down).
  • Pivot point: center of intent_row (set via pivot_offset).
  • The bob tween on sprite_group is independent and continues unchanged.

Dangerous intents (ATTACK, LOCK_HOLD, LOCK_DIE, BURN_DICE, STEAL_DIE) use a faster pulse: 1.0s period (0.5s up, 0.5s down). This is the only visual distinction between danger levels beyond color; it communicates urgency without adding new UI elements.

3.6 Blind Status Interaction

When the player has the Blind status active, all intent icon textures are replaced with the question-mark icon (SpriteMap.ICON_UNKNOWN — see Section 4) and all value labels are set to "". The icon modulate is set to Color(0.6, 0.6, 0.6, 0.9) (muted grey) regardless of action type. The pulse animation continues at the standard rate.

Checking Blind status: dice_combat_manager.gd (or the player state equivalent) must expose a method is_player_blind() -> bool. The intent display reads this flag during _update_enemy_display(). This is a read-only query — the display never sets the blind flag.

The intent row tooltip (see Section 3.7) is also suppressed when blind: hovering shows "Intent: ???" instead of the action description.

3.7 Tooltip Integration

The intent_row Control is wired with _wire_combat_tip() (the existing tooltip helper). When not blind:

  • Tooltip text: the return value of _get_intent_description(enemy.current_intent) — the same string already shown in targeting_label on hover.

When blind:

  • Tooltip text: "Intent: ???"

This means intent tooltips are discoverable on hover but never required for basic play — the persistent icon communicates the essential information.

3.8 No-Intent State

If enemy.current_intent is null (can occur during enemy death frames or between phases), the intent row is not added to enemy_root. No empty container is shown.

3.9 Multiple Enemies

All living enemies rebuild their enemy_root (including intent_row) on every _update_enemy_display() call. There is no special multi-enemy logic; the existing HBoxContainer (enemy_container) arranges enemy roots side by side. Intent rows are per-enemy and independent.


4. Visual Requirements

4.1 Icon Mapping

All icons use the Kenney 1-bit pack. The icon constants below should be added to src/core/sprite_map.gd following the existing naming convention (e.g., ICON_BLOCK, ICON_SKULL).

Action TypeSuggested Kenney IconConstant NameNotes
ATTACKswordICON_SWORDExists in Kenney set
DEFENDshieldICON_SHIELDAlready used for block display
HEALheartICON_HEARTAlready used in HP bar
BUFFarrow_upICON_ARROW_UPGeneric upward arrow
DEBUFFarrow_downICON_ARROW_DOWNGeneric downward arrow
STEAL_REROLLdiceICON_DICERepresents dice economy theft
LOCK_HOLDlockICON_LOCKLock glyph
BURN_DICEflameICON_FLAMEFire/flame glyph
FLIP_ALLarrows_swapICON_FLIPBidirectional arrows
SNIPEcrosshairICON_CROSSHAIRTarget/scope
GRAVITYweight / down_arrow_heavyICON_GRAVITYHeavy downward pull
CHAOSstar_burst / sparkleICON_CHAOSRandom/burst
CURSEskullICON_SKULLAlready in sprite map
STEAL_DIEhand / grabICON_STEALGrabbing hand
EVENODDcoinICON_COINBinary flip
FORCE_REROLLrefresh / cycleICON_REFRESHCycle arrows
RIGwrenchICON_WRENCHTampering
LOCK_DIEpin / lockICON_PINPins one die
SUMMONplusICON_SUMMONAdd enemy
Blind statequestionICON_UNKNOWNGeneric question mark

Where exact Kenney names are uncertain, the programmer should use the closest 1-bit icon in ~/Downloads/Game Dev Assets/1-bit_icons/Sprites/. Priority is immediate readability at 16x16px. If a best-fit icon is ambiguous, ATTACK (sword) and DEFEND (shield) take highest priority as they are the most frequent intents.

4.2 Icon Size and Rendering

  • Display size: Vector2(16, 16)
  • expand_mode: TextureRect.EXPAND_IGNORE_SIZE
  • stretch_mode: TextureRect.STRETCH_KEEP_ASPECT_CENTERED
  • filter mode: nearest-neighbor (pixel art — do not enable linear filtering)

4.3 Color Palette Reference

No new colors are introduced. All colors come from the existing _get_intent_color() return values. The 4-tone greyscale art style is preserved; colors are applied as modulate tints over the 1-bit white-on-transparent sprites.


5. Edge Cases

5.1 Enemy Dies Mid-Turn

When an enemy is killed during the player's scoring phase, _update_enemy_display() is called which skips dead enemies. The intent row is not displayed for dead enemies. The death animation (dissolve shader) runs concurrently on the preserved node; the intent row is part of enemy_root and fades with it.

5.2 Enemy with Null Intent at Turn Start

Some enemies may have current_intent == null during the very first combat frame before AI runs. The intent row is skipped entirely (see Section 3.8). This is not an error state; the row appears on the next _update_enemy_display() call after intent is set.

5.3 Boss with Boss Passive Label

Bosses show a passive description label at name_y + 22, which shifts hp_y down by 22px. The intent row is positioned relative to the top of enemy_root (y = -28), not relative to name_y, so it is unaffected by the passive label's presence.

5.4 Stunned Enemy

A stunned enemy cannot act but may still have a current_intent set from the previous turn (the AI does not clear intent on stun). The intent icon should still be shown to communicate what the enemy will do once it recovers. The stun label ("STUNNED") at y = -20 within sprite_group and the intent row at y = -28 within enemy_root are in different coordinate spaces; they do not overlap.

If the enemy's intent becomes irrelevant while stunned (i.e., its pattern advances past a stun turn), it is the AI system's responsibility to set the correct post-stun intent. The display shows whatever current_intent is set to.

5.5 Three-Enemy Layout Overflow

At maximum enemy count (3 bosses, each root_w = 320px), the total HBoxContainer width is 3 * 320 = 960px. At 1280px minimum viewport width, this leaves 320px of margin. Intent rows do not add horizontal width (they are children of enemy_root, not siblings), so overflow behavior is unchanged from the existing layout.

5.6 Zero Damage Attack

If intent.value == 0 for ATTACK (theoretically possible via relic/status modification), the label shows "0". This is valid — it communicates the attack is blocked or nullified. Do not suppress the 0 label for attacks.

5.7 Value Overflow (Large Numbers)

Boss attacks can reach up to ~40 damage. At font size 12, a 2-digit number fits within the 48px label width of intent_row (64px total minus 16px icon). A 3-digit number (100+) would overflow. Maximum attack damage is capped at 40 in the current design (design/gdd/enemy-data-ai.md). If this cap is ever raised above 99, reduce label font size to 10 or truncate with "99+".

5.8 Blind Status Removed Mid-Combat

If Blind is removed during combat (via consumable or relic), _update_enemy_display() is called, which re-queries is_player_blind(). The icons rebuild with correct textures and colors immediately. No special transition animation is required.

5.9 Intent Changes Between Player Turns

If a relic or consumable causes an enemy's intent to change outside the normal turn cycle, _update_enemy_display() must be called by the triggering code to refresh the icon. The display is stateless and always reflects enemy.current_intent at rebuild time.


6. Dependencies

What This System Requires

DependencySystemContract
enemy.current_intentEnemy Data & AIMust be set to a valid EnemyAction before _update_enemy_display() is called, or null
_get_intent_color(action_type)Dice Combat ScreenExisting function; returns Color for any EnemyActionType int
_get_intent_description(intent)Dice Combat ScreenExisting function; returns tooltip string
_wire_combat_tip(node, text)Dice Combat ScreenExisting function; attaches hover tooltip
SpriteMap.make_icon(icon_id, size)Sprite MapRequires new icon constants added to sprite_map.gd
is_player_blind() -> boolDice Combat ManagerNew method required; returns whether player currently has Blind status
_enemy_display_tweensDice Combat ScreenIntent pulse tween must be appended to this array

What This System Provides

Provided ToWhat
PlayerPersistent visual representation of enemy next action
Blind SystemConsumes is_player_blind() flag to override display
Tooltip systemConnects intent row to the existing combat tooltip panel

Bidirectional Dependency Notes

  • enemy-data-ai.md must document that current_intent is readable by the UI layer and is set before the player's decision phase begins.
  • status-effect-system.md must document that Blind is a player-affecting status that suppresses intent readability.
  • sprite_map.gd gains new icon constants; this change must be communicated to any other system that also queries SpriteMap to avoid constant name collisions.

7. Tuning Knobs

All values below must live in an external config resource (e.g., assets/data/combat_ui_config.tres or equivalent), never hardcoded.

KnobCategoryDefaultSafe RangeEffect
intent_row_y_offsetFeel-28-40 to -20Vertical position of intent row above sprite group; lower values raise it further above the sprite
intent_row_widthFeel6448 to 80Total width of intent icon + value label container
intent_icon_sizeFeel1612 to 24Pixel size of intent icon; larger is more readable but crowds multi-enemy layouts
intent_value_font_sizeFeel1210 to 14Font size for numeric value label beside intent icon
intent_pulse_scale_maxFeel1.081.0 to 1.15Peak scale during pulse animation; above 1.15 becomes visually noisy
intent_pulse_period_normalFeel1.61.0 to 3.0Seconds for one full pulse cycle on standard intents
intent_pulse_period_dangerFeel1.00.6 to 2.0Seconds for one full pulse cycle on danger intents (ATTACK, LOCK_HOLD, LOCK_DIE, BURN_DICE, STEAL_DIE)
intent_blind_colorFeelColor(0.6, 0.6, 0.6, 0.9)AnyIcon modulate color when player is Blind

Danger intent list (determines which actions use the fast pulse) should also be data-driven as an exported array: danger_intent_types: Array[int].


8. Acceptance Criteria

Functional Criteria

  • All 15+ living enemies in a combat encounter display an intent icon simultaneously without requiring hover.
  • Each of the 14 interference types (STEAL_REROLL, LOCK_HOLD, BURN_DICE, FLIP_ALL, SNIPE, GRAVITY, CHAOS, CURSE, STEAL_DIE, EVENODD, FORCE_REROLL, RIG, LOCK_DIE, plus BUFF/DEBUFF/SUMMON) has a distinct icon that differs from the ATTACK and DEFEND icons.
  • ATTACK intents display the correct numeric damage value next to the icon.
  • DEFEND intents display the correct numeric shield value next to the icon.
  • HEAL intents display the correct numeric HP amount next to the icon.
  • STEAL_REROLL intents display the count being stolen.
  • Intent icons with no numeric value (e.g., CHAOS, FLIP_ALL) show no label text and center the icon within the row.
  • When player has Blind status, all intent icons show the question-mark glyph with muted grey color.
  • When Blind is removed, icons immediately restore to correct icons on the next _update_enemy_display() call.
  • Intent row is absent for dead enemies and enemies with null intent.
  • Hovering any intent row shows a tooltip matching the existing _get_intent_description() output (or "Intent: ???" when Blind).
  • All intent tweens are stored in _enemy_display_tweens and killed on display rebuild (no tween leaks).
  • No hardcoded values — all tuning knobs resolve from external config.

Experiential Criteria (Playtesting)

  • A new player, without reading any tooltip, can correctly identify which enemy is about to deal direct damage and which is about to interfere with dice after one full combat encounter.
  • Players report feeling "informed" rather than "surprised" by enemy actions in a post-session survey (target: >80% agreement after 3 runs).
  • In a combat with 3 enemies, players can read all three intents within 2 seconds of the enemy phase ending (measured via think-aloud playtest session).
  • Playtesters do not describe the intent icons as "cluttered" or "too busy" when 3 enemies are present simultaneously.
  • Dangerous intents (attack, lock, steal) are perceived as more urgent than neutral intents (defend, heal) without players being told there is a visual distinction.
  • The Blind status creates genuine information anxiety — playtesters report feeling uncertain when icons are hidden, without feeling cheated.
Built with LogoFlowershow