← Back to blog

Godot 4: Put Signal Rules in Your Prompt So MCP Wires the Health Bar

Before asking Godot MCP in Cursor, define a health_changed signal contract so the HUD connects without deep get_node paths—prompt templates, GDScript samples, anti-patterns, and Play acceptance.

Published
  • Godot MCP
  • Godot 4
  • signals
  • HUD
  • GDScript
  • Cursor
  • AI game development
  • tutorial

The most common “AI broke my scene” failure in Godot is not a syntax error—it is a health bar script that hard-codes:

$"/root/Game/Player/HitPoints".hp  # or get_node("../../Player/...")

Reparent the tree or instance Player as a child scene, and that path dies. Godot MCP follows your prompt faithfully—if you do not forbid hard paths, the model will keep inventing them.

This post does one job: in Cursor (or Claude Code), state a signal contract first, then let MCP wire player health to the HUD bar. No install walkthrough, no full platformer build.

StepWhat you do
1Name the signal, args, emitter / receiver
2Prompt MCP with constraints to edit scripts and connections
3Accept in Play; fix the prompt using anti-patterns

Related:

Background: Scene organization — child scenes should expose signals / exports, not deep paths.

Why signal rules belong in the prompt

ApproachShort termAfter reorg
get_node("../Player/Health")FastOften breaks
@onready var bar = %HealthBar (unique name)Fine in one sceneStill brittle across scenes
signal health_changed(current, max) + connectTwo extra linesHUD and Player trees can change independently

MCP has no lasting “architecture memory” unless you repeat constraints. A pasteable contract block is cheaper than cleaning hard paths out of every diff.

Minimal node skeleton

You do not need a full UI. v1 only needs:

Game (Node2D / Node)
├── Player (CharacterBody2D)  ← player_health.gd
│   └── ...
└── HUD (CanvasLayer)
    └── HealthBar (ProgressBar or TextureProgressBar)  ← hud_health.gd

Naming:

NodeSuggested nameNotes
Player rootPlayerOwns health script
HUD layerHUDCanvasLayer
BarHealthBarProgressBar; absolute HP or max_value = 1 ratio

If the HUD came from AI Studio, rename to stable English IDs before wiring.

Signal contract (paste at the top of every prompt)

Treat this as project law:

[Signal contract — health]
1. Only the health script on Player emits:
   signal health_changed(current: float, max_health: float)
   Past-tense / completed naming; args always (current, max_health).
2. HUD bar script only connects that signal and updates ProgressBar; no get_node to Player from HUD.
3. Forbidden: deep cross-scene NodePaths, Autoload as a health bus (not needed here), polling for Player in _process.
4. Prefer editor signal connections, or @export Node / NodePath on the HUD then .connect — never hard-code "/root/...".
5. Damage and heal only change values on Player and emit; UI never mutates HP.

That block is the point of this article: contract before implementation.

Step 1: Read-only smoke test

List the child names under the root of the currently edited scene. Do not modify any files or nodes.

Only write once you see Player and HUD (or your equivalents).

Step 2: Player emits only

Append after the contract:

[Signal contract — health] (paste full block)

In res://scripts/player_health.gd (create if missing) and attach to Player:
- @export var max_health: float = 100.0
- var current: float
- signal health_changed(current: float, max_health: float)
- In _ready: current = max_health; emit health_changed once
- take_damage(amount), heal(amount); clamp 0..max_health; emit after every change
- Do not reference HUD; do not get_node into UI
Save and report signal argument types.

Reference implementation:

extends Node
class_name PlayerHealth

signal health_changed(current: float, max_health: float)

@export var max_health: float = 100.0
var current: float

func _ready() -> void:
	current = max_health
	health_changed.emit(current, max_health)

func take_damage(amount: float) -> void:
	if amount <= 0.0 or current <= 0.0:
		return
	current = maxf(0.0, current - amount)
	health_changed.emit(current, max_health)

func heal(amount: float) -> void:
	if amount <= 0.0 or current <= 0.0:
		return
	current = minf(max_health, current + amount)
	health_changed.emit(current, max_health)

Temporary damage key (delete later):

Add temporary debug_damage.gd on Player: pressing H calls PlayerHealth.take_damage(10) on the same node (or parent). Wiring acceptance only. Do not touch HUD.

If the script sits on the Player root, change extends Node to match (e.g. CharacterBody2D), or make PlayerHealth a child Health—pick one in the prompt so MCP does not guess the mount point.

Step 3: HUD subscribes via exported reference

[Signal contract — health] (paste full block)

Create res://scripts/hud_health.gd on HUD or HealthBar:
- @export var player_health: PlayerHealth
- @onready var bar: ProgressBar = $HealthBar  # only if script is on HUD; path stays inside HUD subtree
- _ready: assert player_health != null; player_health.health_changed.connect(_on_health_changed)
- _on_health_changed(current, max_health): bar.max_value = max_health; bar.value = current
  (or ratio: max_value = 1; value = current / max_health)
- Forbidden: get_node to Player; forbidden: /root absolute paths
Then assign the exported player_health to Player's PlayerHealth (or Health child) in the editor. Save the scene.

Reference:

extends CanvasLayer

@export var player_health: PlayerHealth
@onready var bar: ProgressBar = $HealthBar

func _ready() -> void:
	assert(player_health != null, "Assign PlayerHealth in the inspector")
	player_health.health_changed.connect(_on_health_changed)

func _on_health_changed(current: float, max_health: float) -> void:
	bar.max_value = max_health
	bar.value = current

$HealthBar inside the HUD is fine—same subtree. Forbidden is a deep path across to Player.

Editor “Node → Connect a Signal” via MCP is also fine:

Connect via the editor: Player (PlayerHealth) health_changed → HUD script _on_health_changed. Do not hand-write NodePath strings.

Step 4: Play acceptance checklist

  1. On enter: bar full (or correct ratio)
  2. Press H: bar.value drops
  3. To 0: bar empty; UI must not write current
  4. After heal: bar rises
  5. Reparent Player under e.g. Entities and Play again: bar still updates if the export ref remains—that is the win vs hard paths

If it breaks after reparenting, fix the empty export—do not ask MCP for “another get_node”.

Acceptance prompt:

Diagnose from Play errors: if the signal never fires, check connect; if player_health is null, only fix the export. Do not “fix” with absolute get_node paths. Diagnose first; wait for my OK before editing.

Anti-patterns: change the prompt, do not merge

Anti-patternWhy it hurtsAdd to the prompt
get_node("/root/Game/Player")Root path churn“No /root or absolute NodePath”
get_parent().get_node("Player")Sibling coupling“HUD must not assume Player sibling layout”
Autoload GlobalHealth.hpGlobal junk drawer“No new Autoload for this task”
HUD _process hunting PlayerSlow and brittle“No polling; signals only”
Signal named update / set_hpVague, collision-prone“Signal must be health_changed”

Hand-writing still wins for hot-path movement and complex combat math; health-bar wiring is boilerplate where a contracted MCP prompt is usually cheaper. See Godot MCP vs manual scripting.

Reusable “contract + task” skeleton

Swap names for mana / boss bars:

[Signal contract]
- Emitter: <node/script>
- signal <name>(...)
- Receiver: <HUD method>
- Forbidden: absolute NodePath, Autoload bus, _process polling

[Task]
1. …
2. …
3. Save the scene; list new signals and how they connect (editor / .connect)

Summary

  1. Hard paths are a prompt hole, not “MCP cannot Godot”
  2. Paste health_changed(current, max_health) before asking for edits
  3. Player only emits; HUD only connects and updates ProgressBar
  4. Cross-object links use @export or editor signal connect—not /root/...
  5. Accept by reparenting Player and watching the bar—not by “script generated OK”

You now have a pasteable Godot MCP prompt rule set; inventory, quest log, and kill counters can reuse the same pattern.

More guides you might like