Image hotspots in Godot 4 with tack.
Build a clickable pixel-art train scene in Godot 4. Load polygon regions and placement points from tack. JSON, build Area2D hotspots at runtime, wire up signals, play sounds, swap sprites, and spawn polygon smoke with Tween.
Last updated:
We’re going to build a small interactive scene in Godot 4 around a pixel-art steam locomotive. Click the brass whistle and it toots. Click the cab door and a friendly engineer appears. Click the smokestack and pixel smoke drifts up out of the funnel.

The point of the exercise isn’t really the train. It’s a working pattern for defining clickable regions visually, exporting them as data, and turning that data into Godot’s native collision system at runtime. Once you can do that, you can stop hand-building dozens of CollisionPolygon2D nodes in the editor and start treating shape data like the asset it really is — version-controlled JSON that designers, tools, and code can all touch.
This tutorial assumes you’ve used Godot 4 before — you know what scenes and nodes are and you can run a project. If you’re brand new to Godot, the official “Your first 2D game” tour will get you up to speed first.
If you’ve read the p5.js version of this tutorial, the tracing-in-tack step at the top will be familiar — skim it.
What you’ll learn
- Reading a JSON region file in Godot 4 with
FileAccessandJSON.parse_string. - Programmatically building
Area2D+CollisionPolygon2Dnodes from external data. - Wiring up
Area2D.input_eventsignals withbind()so multiple regions reuse one handler. - A simple particle effect using
Polygon2Dand theTweenAPI — no extra assets, noCPUParticles2Dconfiguration ceremony.
What you’ll need
- Godot 4.x — any 4.x version works.
- The two train PNGs:
tutorial-train.png(door closed, the default state) andtutorial-train-driver.png(the door + engineer cut-out). - A short whistle sound, saved as
whistle.wav. Freesound.org has plenty of CC0 steam whistles. - A regions file from tack. — we’ll make it in the next step.
Step 1 — Trace the regions in tack.
Load tutorial-train.png into tack. with Load Image. In the right-hand panel set: Origin TL, Y-axis ↓, Units 0–1. Both 0–1 and PX work for this tutorial — 0–1 is resolution-independent, which is nice if you later swap in a higher-res version of the train.
Trace four shapes:
- A polygon around the brass whistle on top of the dome → label it
whistle - A polygon around the closed cab door → label it
door - A polygon around the funnel/smokestack → label it
smokestack - A single point at the exact pixel where the engineer cut-out’s top-left corner should land → label it
door_anchor
For the polygons, press N for a new shape, click around the outline, press C to close. For the point, press N and just click once (no drag) — that creates a one-anchor open shape, which is tack.’s representation of a point.

Open the Output panel, choose JSON, and click Download. Save it as regions.json.
The exported JSON looks roughly like this (trimmed):
{
"image": "tutorial-train.png",
"width": 1024,
"height": 576,
"origin": { "x": 0, "y": 0 },
"yAxis": "down",
"units": "norm",
"shapes": [
{
"id": "shape_xxxx1",
"label": "whistle",
"type": "polygon",
"closed": true,
"points": [
{ "x": 0.42, "y": 0.18 },
{ "x": 0.46, "y": 0.18 },
{ "x": 0.46, "y": 0.27 },
{ "x": 0.42, "y": 0.27 }
]
},
{
"id": "shape_xxxx2",
"label": "door_anchor",
"type": "point",
"closed": false,
"points": [
{ "x": 0.6914, "y": 0.2153 }
]
}
]
}
Polygons and points share the same points array — a point is just a shape with one entry and closed: false. Worth keeping in mind for the loader.
Step 2 — Project setup
Create a new Godot 4 project called TrainHotspots (or whatever you like). Drop the four files into the project root: tutorial-train.png, tutorial-train-driver.png, whistle.wav, regions.json.
In the import panel for both PNGs, set Filter to Nearest so the pixel art stays crisp at any zoom. (Project Settings → Rendering → Textures → Default Texture Filter = Nearest is the global way to do this.)
Create the scene tree like this:
Main (Node2D) ← attach main.gd
├── Train (Sprite2D) texture: tutorial-train.png
├── DoorOpenSprite (Sprite2D) texture: tutorial-train-driver.png
├── WhistleSound (AudioStreamPlayer) stream: whistle.wav
└── Hotspots (Node2D) ← empty container; we'll fill it from code
Two important details on the sprites:
- On both
TrainandDoorOpenSprite, set Centered = false in the inspector. We want theirpositionto refer to the top-left pixel, matching how tack. and PNG coordinates work. (Godot’s default of centered sprites makes the cut-out land in the wrong place by half its size.) - Set
DoorOpenSprite.Visible = false. We’ll show it only when the door is open.

Save the scene as main.tscn. Attach a new script main.gd to the root. Now we write code.
Step 3 — Loading the JSON
Top of main.gd:
extends Node2D
const REGIONS_PATH := "res://regions.json"
@onready var door_open_sprite: Sprite2D = $DoorOpenSprite
@onready var whistle_sound: AudioStreamPlayer = $WhistleSound
@onready var hotspots: Node2D = $Hotspots
var anchors: Dictionary = {} # label -> Vector2
var door_open: bool = false
func load_regions(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("Could not open " + path)
return null
return JSON.parse_string(file.get_as_text())
FileAccess.open returns a file handle (or null on error). JSON.parse_string turns the text into a Godot Dictionary / Array tree, or null if the JSON is malformed. The Variant return type is honest about both possibilities.
Step 4 — The loader (the centerpiece)
This is where the real work happens. We iterate over data.shapes, and for each polygon we build a node tree at runtime: an Area2D with a CollisionPolygon2D child, with the polygon’s points loaded from the JSON. For each point shape we just stash its position in a dictionary.
func build_hotspots(data: Dictionary) -> void:
var w: float = data["width"]
var h: float = data["height"]
var normalised: bool = data["units"] == "norm"
for shape in data["shapes"]:
match shape["type"]:
"polygon":
add_polygon_hotspot(shape, w, h, normalised)
"point":
store_anchor(shape, w, h, normalised)
func add_polygon_hotspot(shape: Dictionary, w: float, h: float, norm: bool) -> void:
var area := Area2D.new()
area.name = shape["label"]
var collision := CollisionPolygon2D.new()
var points := PackedVector2Array()
for p in shape["points"]:
var x: float = (p["x"] * w) if norm else float(p["x"])
var y: float = (p["y"] * h) if norm else float(p["y"])
points.append(Vector2(x, y))
collision.polygon = points
area.add_child(collision)
area.input_event.connect(_on_hotspot_input.bind(shape["label"]))
hotspots.add_child(area)
func store_anchor(shape: Dictionary, w: float, h: float, norm: bool) -> void:
var p = shape["points"][0]
var x: float = (p["x"] * w) if norm else float(p["x"])
var y: float = (p["y"] * h) if norm else float(p["y"])
anchors[shape["label"]] = Vector2(x, y)
Three things worth pointing out:
Area2D is Godot’s “trigger zone”. It listens for mouse and physics input over its collision shape and fires signals when something happens. It’s exactly what we want for a clickable region.
PackedVector2Array is Godot’s optimised vector list. CollisionPolygon2D.polygon requires this specific type — a regular Array won’t work — but converting tack.’s {x, y} objects to Vector2s in a packed array is straightforward.
bind() is how one signal handler serves many regions. area.input_event normally fires with (viewport, event, shape_idx). By calling .bind(shape["label"]) we add the label as a fourth argument, so one handler function can tell which hotspot was clicked from a single parameter.
Step 5 — The signal handler
func _on_hotspot_input(_viewport: Node, event: InputEvent, _shape_idx: int, label: String) -> void:
if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
return
match label:
"whistle": _on_whistle_clicked()
"door": _on_door_clicked()
"smokestack": _on_smokestack_clicked()
The guard at the top filters out everything except left-mouse-button-down events. Area2D.input_event fires for hover, motion, button releases, and keyboard input that arrives via the viewport — we only care about clicks here.
Step 6 — The whistle (audio)
func _on_whistle_clicked() -> void:
whistle_sound.play()
That’s it. AudioStreamPlayer was set up in the scene tree with the WAV already assigned. One line.
If you want to prevent overlapping plays, guard with if not whistle_sound.playing:.
Step 7 — The door (sprite swap with anchor)
This is where the point shines. The cut-out DoorOpenSprite is positioned at the door_anchor point’s coordinates. Because that point was placed exactly where the cut-out’s top-left corner used to be in the original train image, the cut-out lands pixel-perfectly back where it came from.
Add to _ready() (which we’ll write in a moment):
if anchors.has("door_anchor"):
door_open_sprite.position = anchors["door_anchor"]
And the click handler:
func _on_door_clicked() -> void:
door_open = not door_open
door_open_sprite.visible = door_open
Toggle a boolean, toggle visibility. The position never needs to change because the anchor never changes.
This is the pattern you’ll come back to constantly once you start using points. Where does the speech bubble appear above this NPC? Where does the muzzle flash spawn from this turret? Where is the pivot for that rotating weather vane? Each of those is a tack. point waiting to happen.
Step 8 — The smokestack (Polygon2D puffs with Tween)
Godot has CPUParticles2D and GPUParticles2D for proper particle systems, but for a small click-driven puff effect they’re overkill — a lot of inspector configuration to set up just to spawn twelve squares. We’ll do it with Polygon2D instead, which has two nice properties: it parallels the polygons we traced in tack. (continuity!) and it works with Godot’s Tween system out of the box.
const SMOKE_ORIGIN := Vector2(184, 70) # top of the funnel; eyeballed for now
func _on_smokestack_clicked() -> void:
for i in 12:
spawn_smoke_puff(SMOKE_ORIGIN)
func spawn_smoke_puff(origin: Vector2) -> void:
var puff := Polygon2D.new()
var size := randf_range(8.0, 14.0)
puff.polygon = PackedVector2Array([
Vector2.ZERO,
Vector2(size, 0),
Vector2(size, size),
Vector2(0, size),
])
puff.color = Color(0.86, 0.86, 0.86)
puff.position = origin + Vector2(randf_range(-4, 4), 0)
add_child(puff)
var target := puff.position + Vector2(randf_range(-12, 12), randf_range(-75, -45))
var tween := create_tween().set_parallel(true)
tween.tween_property(puff, "position", target, 1.4)
tween.tween_property(puff, "modulate:a", 0.0, 1.4)
tween.chain().tween_callback(puff.queue_free)
Each puff is a 4-vertex Polygon2D square at a random size. We tween its position and its alpha in parallel over 1.4 seconds, then queue it for deletion. set_parallel(true) makes the two tween calls run simultaneously instead of sequentially; chain() then switches back to sequential mode for the cleanup callback.
The smoke origin is hardcoded to a position roughly above the funnel. Promoting it to a tack. point is one of the “what to try next” exercises.
Step 9 — _ready ties it together
func _ready() -> void:
door_open_sprite.visible = false
var data = load_regions(REGIONS_PATH)
if data == null:
return
build_hotspots(data)
if anchors.has("door_anchor"):
door_open_sprite.position = anchors["door_anchor"]
Run the scene (F5, set main.tscn as the main scene if Godot prompts). Click around. The whistle toots, the door opens and closes, the funnel puffs.
If a polygon doesn’t fire when you click, it’s almost always one of two things: the Area2D is below something else in the tree that’s eating input (try moving Hotspots to be the last child), or the polygon coordinates didn’t get scaled correctly (debug-print the points after build_hotspots to check).
To see the regions overlaid on the train while testing, turn on Debug → Visible Collision Shapes in the editor menu.

What to try next
You now have a small but real pattern: visual data in tack., parsed at runtime, turned into Godot’s native collision system. Some directions:
- Move the smoke origin into tack. Add a point called
smoke_originat the top of the funnel and replaceSMOKE_ORIGINwithanchors["smoke_origin"]. Now you can re-aim the smoke without touching code. - Animate the wave. Replace
DoorOpenSpritewith anAnimatedSprite2Dand flip between two engineer frames every half-second while the door is open. - Click each wheel. Trace each red wheel as its own polygon in tack. and rotate them on click, or play a chuff sound for each. The same code handles them — you just add cases to the
matchstatement. - Make a HotspotLayer custom node. Wrap the loading and building logic into a reusable
@tool-friendly Node2D that takes a JSON path as an exported property and builds itself. Now any scene can drop in clickable regions just by pointing at a tack. file. - Trade scenes by clicking regions. With multiple JSON files and matching images, clicking the door could change scenes entirely — a point-and-click adventure interaction in about ten extra lines.
The interesting thing is that none of these need new techniques. They’re all combinations of trace a region and place something at a point — the two ideas you already know.
If you build something with this, send a link — we’d love to feature reader projects. Bug reports and feature requests for tack. itself go to the same place.
Happy tracing. 🚂