Enemy Intent Display System
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:
- Intent row (y = -28, partially above sprite_group origin)
- Target indicator (y = 0)
- Sprite group (y = 10, bobbing)
- Name label (y = sprite_sz + 25)
- 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 Category | Label Content | Example |
|---|---|---|
| ATTACK | Damage value | "8" |
| DEFEND | Shield value | "5" |
| HEAL | HP amount | "12" |
| STEAL_REROLL | Count stolen | "2" |
| LOCK_HOLD | — (no value shown) | "" |
| BURN_DICE | Dice 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):
| Tier | Color family | Actions |
|---|---|---|
| High danger | Red family | ATTACK, LOCK_HOLD, LOCK_DIE |
| Dice threat | Orange/gold | BURN_DICE, STEAL_DIE, STEAL_REROLL |
| Disruptive | Purple family | SNIPE, CHAOS, CURSE |
| Transformative | Blue/cyan | FLIP_ALL, FORCE_REROLL, GRAVITY, EVENODD |
| Neutral | Green/blue | DEFEND, HEAL |
| Exotic | Pink | RIG |
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)andVector2(1.08, 1.08)usingTRANS_SINE. - Period: 1.6 seconds (0.8s up, 0.8s down).
- Pivot point: center of
intent_row(set viapivot_offset). - The bob tween on
sprite_groupis 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 intargeting_labelon 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 Type | Suggested Kenney Icon | Constant Name | Notes |
|---|---|---|---|
| ATTACK | sword | ICON_SWORD | Exists in Kenney set |
| DEFEND | shield | ICON_SHIELD | Already used for block display |
| HEAL | heart | ICON_HEART | Already used in HP bar |
| BUFF | arrow_up | ICON_ARROW_UP | Generic upward arrow |
| DEBUFF | arrow_down | ICON_ARROW_DOWN | Generic downward arrow |
| STEAL_REROLL | dice | ICON_DICE | Represents dice economy theft |
| LOCK_HOLD | lock | ICON_LOCK | Lock glyph |
| BURN_DICE | flame | ICON_FLAME | Fire/flame glyph |
| FLIP_ALL | arrows_swap | ICON_FLIP | Bidirectional arrows |
| SNIPE | crosshair | ICON_CROSSHAIR | Target/scope |
| GRAVITY | weight / down_arrow_heavy | ICON_GRAVITY | Heavy downward pull |
| CHAOS | star_burst / sparkle | ICON_CHAOS | Random/burst |
| CURSE | skull | ICON_SKULL | Already in sprite map |
| STEAL_DIE | hand / grab | ICON_STEAL | Grabbing hand |
| EVENODD | coin | ICON_COIN | Binary flip |
| FORCE_REROLL | refresh / cycle | ICON_REFRESH | Cycle arrows |
| RIG | wrench | ICON_WRENCH | Tampering |
| LOCK_DIE | pin / lock | ICON_PIN | Pins one die |
| SUMMON | plus | ICON_SUMMON | Add enemy |
| Blind state | question | ICON_UNKNOWN | Generic 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_SIZEstretch_mode:TextureRect.STRETCH_KEEP_ASPECT_CENTEREDfiltermode: 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
| Dependency | System | Contract |
|---|---|---|
enemy.current_intent | Enemy Data & AI | Must be set to a valid EnemyAction before _update_enemy_display() is called, or null |
_get_intent_color(action_type) | Dice Combat Screen | Existing function; returns Color for any EnemyActionType int |
_get_intent_description(intent) | Dice Combat Screen | Existing function; returns tooltip string |
_wire_combat_tip(node, text) | Dice Combat Screen | Existing function; attaches hover tooltip |
SpriteMap.make_icon(icon_id, size) | Sprite Map | Requires new icon constants added to sprite_map.gd |
is_player_blind() -> bool | Dice Combat Manager | New method required; returns whether player currently has Blind status |
_enemy_display_tweens | Dice Combat Screen | Intent pulse tween must be appended to this array |
What This System Provides
| Provided To | What |
|---|---|
| Player | Persistent visual representation of enemy next action |
| Blind System | Consumes is_player_blind() flag to override display |
| Tooltip system | Connects intent row to the existing combat tooltip panel |
Bidirectional Dependency Notes
enemy-data-ai.mdmust document thatcurrent_intentis readable by the UI layer and is set before the player's decision phase begins.status-effect-system.mdmust document that Blind is a player-affecting status that suppresses intent readability.sprite_map.gdgains new icon constants; this change must be communicated to any other system that also queriesSpriteMapto 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.
| Knob | Category | Default | Safe Range | Effect |
|---|---|---|---|---|
intent_row_y_offset | Feel | -28 | -40 to -20 | Vertical position of intent row above sprite group; lower values raise it further above the sprite |
intent_row_width | Feel | 64 | 48 to 80 | Total width of intent icon + value label container |
intent_icon_size | Feel | 16 | 12 to 24 | Pixel size of intent icon; larger is more readable but crowds multi-enemy layouts |
intent_value_font_size | Feel | 12 | 10 to 14 | Font size for numeric value label beside intent icon |
intent_pulse_scale_max | Feel | 1.08 | 1.0 to 1.15 | Peak scale during pulse animation; above 1.15 becomes visually noisy |
intent_pulse_period_normal | Feel | 1.6 | 1.0 to 3.0 | Seconds for one full pulse cycle on standard intents |
intent_pulse_period_danger | Feel | 1.0 | 0.6 to 2.0 | Seconds for one full pulse cycle on danger intents (ATTACK, LOCK_HOLD, LOCK_DIE, BURN_DICE, STEAL_DIE) |
intent_blind_color | Feel | Color(0.6, 0.6, 0.6, 0.9) | Any | Icon 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, plusBUFF/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_tweensand 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.