≡ · EQUI

Godot 4.x Project — Step-by-Step Build Tutorial
This guide builds EQUI from scratch in Godot 4. Each step tells you exactly which nodes to create, what properties to set, and what code to write. No TSCN dumps — just clear editor instructions.

1. Autoload Singletons

These are script-only files (no scene). Create them in res://Autoload/ then register them in Project Settings → Autoload.

1 Register the 3 autoloads

Open Project → Project Settings → Autoload and add these three:

PathName
res://Autoload/SaveManager.gdSaveManager
res://Autoload/AudioManager.gdAudioManager
res://Autoload/PuzzleGenerator.gdPuzzleGenerator

2 Autoload/SaveManager.gd

extends Node

const SAVE_FILE := "user://equi_progress.cfg"
const SECTION := "progress"

var tier: int = 0
var puzzle: int = 0
var has_save: bool = false

func _ready() -> void: load_game()

func has_progress() -> bool: return has_save

func save_game() -> void:
	var cfg := ConfigFile.new()
	cfg.set_value(SECTION, "tier", tier)
	cfg.set_value(SECTION, "puzzle", puzzle)
	cfg.save(SAVE_FILE)
	has_save = tier > 0 or puzzle > 0

func load_game() -> void:
	var cfg := ConfigFile.new()
	if cfg.load(SAVE_FILE) != OK: tier=0; puzzle=0; has_save=false; return
	tier = cfg.get_value(SECTION,"tier",0)
	puzzle = cfg.get_value(SECTION,"puzzle",0)
	has_save = tier > 0 or puzzle > 0

func reset_progress() -> void:
	tier=0; puzzle=0; has_save=false
	var d := DirAccess.open("user://")
	if d: d.remove("equi_progress.cfg")

3 Autoload/AudioManager.gd

extends Node

func play_click() -> void: _tone(800, 0.05, 0.08)
func play_violate() -> void: _tone(150, 0.2, 0.04, "sawtooth")
func play_hint() -> void: _tone(880, 0.1, 0.06, "triangle")
func play_menu_select() -> void: _tone(660, 0.08, 0.06)

func play_complete() -> void:
	_tone(523, 0.4, 0.1); await get_tree().create_timer(0.15).timeout
	_tone(659, 0.4, 0.1); await get_tree().create_timer(0.15).timeout
	_tone(784, 0.4, 0.1); await get_tree().create_timer(0.15).timeout
	_tone(1047, 0.4, 0.1)

func _tone(f: float, dur: float, vol: float, wave: String = "sine") -> void:
	var p := AudioStreamPlayer2D.new(); add_child(p)
	var g := AudioStreamGenerator.new(); g.mix_rate=22050; g.buffer_length=max(dur+0.05,0.1)
	p.stream = g; var pb := p.get_stream_playback()
	if not pb: p.queue_free(); return
	p.play(); var n: int = int(g.mix_rate*dur)
	var b: PackedVector2Array = []; b.resize(n)
	for i in n:
		var t: float = float(i)/g.mix_rate
		var e: float = clamp((1.0-float(i)/n)*(1.0-float(i)/n)*3,0,1)
		var s: float
		match wave:
			"sine": s = sin(t*f*TAU)
			"sawtooth": s = 2*(t*f-floor(t*f+0.5))
			"triangle": s = 2*abs(2*(t*f-floor(t*f+0.5)))-1
		b[i] = Vector2(s*e*0.3, s*e*0.3)
	var _ = pb.push_buffer(b)
	await get_tree().create_timer(dur+0.05).timeout; p.queue_free()

4 Autoload/PuzzleGenerator.gd

Procedural graph generation: random point placement + kNN connectivity + MST for tree tiers + spring relaxation. Poisson disc removed — spring relaxation handles layout naturally.

extends Node

class PPoint:
	var x: float; var y: float
	func _init(x_:float,y_:float): x=x_; y=y_

static func knn_graph(pts: Array[PPoint], k: int = 3) -> Array[Array]:
	var edges: Array[Array] = []
	for i in pts.size():
		var ds: Array = []
		for j in pts.size():
			if i==j: continue
			ds.append({"d": hypot(pts[i].x-pts[j].x,pts[i].y-pts[j].y), "j": j})
		ds.sort_custom(func(a,b): return a.d=k: break
			var j: int = ds[n].j
			if j>i:
				var ex: bool = false
				for ei in edges:
					if (ei[0]==i and ei[1]==j) or (ei[0]==j and ei[1]==i): ex=true; break
				if not ex: edges.append([i,j]); added+=1
	return edges

static func _uf(parent: Array[int], x: int) -> int:
	while parent[x]!=x: parent[x]=parent[parent[x]]; x=parent[x]
	return x

static func _un(parent: Array[int], a:int, b:int) -> void:
	parent[_uf(parent,a)]=_uf(parent,b)

static func ensure_connected(pts: Array[PPoint], edges: Array[Array]) -> Array[Array]:
	var n:=pts.size(); if n<2: return edges
	var p: Array[int]=[]; p.resize(n)
	for i in n: p[i]=i
	for e in edges: _un(p,e[0],e[1])
	var comps: Dictionary = {}
	for i in n:
		var r:=_uf(p,i)
		if not comps.has(r): comps[r]=[]
		comps[r].append(i)
	if comps.size()<=1: return edges
	var cl: Array = comps.values()
	for ci in cl.size()-1:
		var bd:=INF; var be: Array=[]
		for a in cl[ci]: for b in cl[ci+1]:
			var d:=hypot(pts[a].x-pts[b].x,pts[a].y-pts[b].y)
			if d0: edges.append(be); _un(p,be[0],be[1])
	var c2: Dictionary={}
	for i in n:
		var r:=_uf(p,i)
		if not c2.has(r): c2[r]=[]
		c2[r].append(i)
	var cl2: Array=c2.values()
	for ci in cl2.size()-1:
		var ra:=_uf(p,cl2[ci][0]); var rb:=_uf(p,cl2[ci+1][0])
		if ra!=rb:
			var bd:=INF; var be: Array=[]
			for a in cl2[ci]: for b in cl2[ci+1]:
				var d:=hypot(pts[a].x-pts[b].x,pts[a].y-pts[b].y)
				if d0: edges.append(be); _un(p,be[0],be[1])
	return edges

static func minimum_spanning_tree(pts: Array[PPoint], edges: Array[Array]) -> Array[Array]:
	var n:=pts.size(); var p: Array[int]=[]; p.resize(n)
	for i in n: p[i]=i
	var s: Array=[]; var mst: Array[Array]=[]
	for e in edges:
		s.append({"a":e[0],"b":e[1],"d":hypot(pts[e[0]].x-pts[e[1]].x,pts[e[0]].y-pts[e[1]].y)})
	s.sort_custom(func(a,b): return a.d void:
	var pad:=80.0; var ideal:=min(w,h)*0.18
	var rep:=2500.0; var stf:=0.08; var damp:=0.6; var n:=nodes.size()
	var vx: Array[float]=[]; var vy: Array[float]=[]; vx.resize(n); vy.resize(n)
	var adj: Array[Array]=[]; adj.resize(n)
	for i in n: adj[i]=[]
	if n>0 and nodes[0].has("_edges"):
		for e in nodes[0]._edges: adj[e.from].append(e.to); adj[e.to].append(e.from)
	for it in iters:
		var tmp:=1.0-float(it)/iters; var cx:=w/2; var cy:=h/2
		for i in n:
			var fx:=0.0; var fy:=0.0; var nx:=nodes[i].x; var ny:=nodes[i].y
			for j in n:
				if i==j: continue
				var dx:=nodes[j].x-nx; var dy:=nodes[j].y-ny
				var d:=max(hypot(dx,dy),1.0); var f:=rep/(d*d)
				fx-=dx/d*f; fy-=dy/d*f
			fx+=(cx-nx)*0.001; fy+=(cy-ny)*0.001
			for nb in adj[i]:
				var dx:=nodes[nb].x-nx; var dy:=nodes[nb].y-ny
				var d:=max(hypot(dx,dy),1.0); var f:=(d-ideal)*stf
				fx+=dx/d*f; fy+=dy/d*f
			vx[i]=(vx[i]+fx)*damp; vy[i]=(vy[i]+fy)*damp
			nodes[i].x+=vx[i]*tmp; nodes[i].y+=vy[i]*tmp
			nodes[i].x=clamp(nodes[i].x,pad,w-pad); nodes[i].y=clamp(nodes[i].y,pad,h-pad)

static func generate_puzzle(width:float,height:float,opts:Dictionary={})->Dictionary:
	var nc:=opts.get("node_count",randi_range(5,8)); var vmax:=opts.get("value_max",6)
	var tree:=opts.get("tree_only",true); var lock:=opts.get("lock_ratio",0.5)
	var pad:=80.0; var pw:=width-pad*2; var ph:=height-pad*2
	# Random points — spring relaxation will spread them
	var pts: Array[PPoint] = []
	for i in nc*3: pts.append(PPoint.new(randf_range(0,pw),randf_range(0,ph)))
	var sel: Array[PPoint] = []
	for i in min(nc,pts.size()): sel.append(PPoint.new(pts[i].x+pad,pts[i].y+pad))
	while sel.size()nj: ni=e[1]; nj=e[0]
		var tgt:=absi(nodes[ni].value-nodes[nj].value)
		var edge: Dictionary={"from":ni,"to":nj,"type":"Delta","target":tgt,"state":"neutral","anim_phase":randf_range(0,TAU)}
		edges.append(edge)
		if not nodes[ni].has("_edges") or nodes[ni]._edges==null: nodes[ni]._edges=[]
		nodes[ni]._edges.append(edge); nodes[nj]._edges.append(edge)
	var deg: Array[int]=[]; deg.resize(nodes.size())
	for e in edges: deg[e.from]+=1; deg[e.to]+=1
	var nn:=nodes.size(); var tgt_l:=max(1,min(int(nn*lock),nn-1))
	var sel2: Array[int]=[]; var mx:=0; var fi:=0
	for i in nn: if deg[i]>mx: mx=deg[i]; fi=i
	sel2.append(fi)
	var cand: Array[int]=[]
	for i in nn: if not sel2.has(i): cand.append(i)
	cand.sort_custom(func(a,b): return deg[b]-deg[a])
	var st:=max(1,int(cand.size()/max(1,tgt_l-sel2.size()+1)))
	for i in range(0,cand.size(),st):
		if sel2.size()>=tgt_l: break
		if not sel2.has(cand[i]): sel2.append(cand[i])
	for e in edges:
		if sel2.has(e.from) and sel2.has(e.to):
			if randf()>0.5: sel2.erase(e.from)
			else: sel2.erase(e.to)
	for idx in sel2: nodes[idx].locked=true; nodes[idx].guess=nodes[idx].value
	return {"nodes":nodes,"edges":edges,"value_max":vmax}

static func hypot(a:float,b:float)->float: return sqrt(a*a+b*b)
static func clamp(v:float,mn:float,mx:float)->float: return max(mn,min(mx,v))

2. Leaf Scene: Planet

5 Create res://Scenes/Planet.tscn

Root node: Area2D → name "Planet" → attach script res://Scripts/Planet.gd

Children to add:

#Node typeNameKey properties
1CollisionShape2DCollisionShape2DShape: CircleShape2D, radius 30
2LabelNodeLabeltext "#0", offset (-15,24)-(15,38), h_align center, font_size 10
3LabelQMarktext "?", offset (-10,-10)-(10,10), h+v_align center, font_size 18

No LockLabel — locked planets are now distinguished by an orbital ring drawn in code. QMark starts visible and is hidden when the player sets a moon count.

6 res://Scripts/Planet.gd

# Planet — a single node in the constellation
# Tap to cycle moons. Locked planets show an orbital ring.
class_name Planet extends Area2D

signal tapped(planet: Planet)

var idx: int = -1
var value: int = 0
var locked: bool = false
var guess: int = -1          # -1 = unset
var is_hint_target: bool = false

var _anim_phase: float = 0.0
var _planet_radius: float = 22.0

@onready var node_label: Label = %NodeLabel
@onready var qmark_label: Label = %QMark

func _ready() -> void:
	_anim_phase = randf_range(0, TAU)
	input_event.connect(_on_input)

func _on_input(_viewport: Node, event: InputEvent, _shape_idx: int) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		tapped.emit(self)
		get_viewport().set_input_as_handled()

func setup(index: int, val: int, is_locked: bool) -> void:
	idx = index; value = val; locked = is_locked
	name = "Planet_%d" % idx
	node_label.text = "#%d" % (idx + 1)
	refresh(-1)

func refresh(g: int = -1, hint: bool = false) -> void:
	guess = g; is_hint_target = hint
	qmark_label.visible = (guess < 0 and not locked)
	queue_redraw()

func _draw() -> void:
	var r: float = _planet_radius + (4.0 if locked else 0.0)
	var pos := Vector2.ZERO
	var time: float = Time.get_ticks_usec() * 0.000001
	# Glow
	var gr: float = r + 12.0 + 4.0 * sin(time * 1.5 + _anim_phase)
	var glow: Color
	if is_hint_target: glow = Color(0.976,0.792,0.141,0.15)
	elif locked: glow = Color(0.424,0.388,1.0,0.12)
	elif guess >= 0: glow = Color(0.0,0.902,0.463,0.08)
	else: glow = Color(1,1,1,0.04)
	draw_circle(pos, gr + 10.0, glow)
	# Body
	var fill: Color; var stroke: Color; var sw: float
	if locked: fill = Color(0.165,0.102,0.29); stroke = Color(0.424,0.388,1.0,0.6)
	elif is_hint_target: fill = Color(0.165,0.165,0.102); stroke = Color(0.976,0.792,0.141); sw = 2.5
	elif guess >= 0: fill = Color(0.102,0.102,0.227); stroke = Color(0.784,0.745,1.0,0.6); sw = 1.5
	else: fill = Color(0.055,0.055,0.133); stroke = Color(0.784,0.745,1.0,0.3); sw = 1.5
	draw_circle(pos, r, fill)
	draw_arc(pos, r, 0, TAU, 64, stroke, sw, false)
	# Orbital ring for locked planets (Saturn-style)
	if locked:
		var rc := Color(0.424, 0.388, 1.0, 0.35)
		draw_arc(pos, r*1.9, PI*0.1, PI*0.9, 32, rc, 2.5, false)
		draw_arc(pos, r*1.9, -PI*0.9, -PI*0.1, 32, rc, 2.5, false)
		draw_arc(pos, r*2.2, PI*0.15, PI*0.85, 32, Color(0.424,0.388,1.0,0.2), 1.5, false)
		draw_arc(pos, r*2.2, -PI*0.85, -PI*0.15, 32, Color(0.424,0.388,1.0,0.2), 1.5, false)
	# Hint ring
	if is_hint_target:
		var da := 0.3 + 0.3 * sin(time * 4.0)
		draw_arc(pos, r + 8.0, 0, TAU, 64, Color(0.976,0.792,0.141,da), 3.0, true)
	# Moons
	var mc: int = value if locked else (guess if guess >= 0 else 0)
	if mc > 0:
		var mt: float = time * 0.8; var ms: float = 3.5 if mc <= 4 else 2.5
		for m in mc:
			var a: float = float(m)/mc * TAU + mt + idx * 1.5
			var mr: float = r + 10.0 + 4.0 * sin(time*0.3 + idx + m)
			var mx := cos(a)*mr; var my := sin(a)*mr
			var mc2 := Color(0.941,0.902,0.549,0.3) if locked else Color(0.941,0.902,0.549,0.8)
			draw_circle(Vector2(mx,my), ms+2.0, Color(0.941,0.902,0.549,0.15))
			draw_circle(Vector2(mx,my), ms, mc2)

3. Leaf Scene: Starlane

7 Create res://Scenes/Starlane.tscn

Root node: Line2D → name "Starlane" → attach script res://Scripts/Starlane.gd
Set default_color rgba(100,100,160,0.3), width 1.5

Children:

#Node typeNameKey properties
1LabelConstraintLabeltext "D0", offset (-25,-20)-(25,0), h_align center, font_size 14
2LabelSubLabeltext "abs diff", offset (-30,0)-(30,16), h_align center, font_size 10

8 res://Scripts/Starlane.gd

# Starlane — a constraint edge between two planets
class_name Starlane extends Line2D

var from_idx: int = -1
var to_idx: int = -1
var target_value: int = 0
var state: String = "neutral"  # neutral, waiting, satisfied, violated
var _anim_phase: float = 0.0

@onready var constraint_label: Label = %ConstraintLabel
@onready var sub_label: Label = %SubLabel

func _ready() -> void: _anim_phase = randf_range(0, TAU)

func setup(frm:int, to:int, target:int, type_str:String="Delta") -> void:
	from_idx=frm; to_idx=to; target_value=target
	name="Starlane_%d_%d"%[frm,to]
	constraint_label.text=type_str+str(target)
	set_state("neutral")

func set_state(s: String) -> void:
	state=s
	match s:
		"satisfied": default_color=Color(0,0.902,0.463); width=3; constraint_label.theme_override_colors/font_color=Color(0,0.902,0.463)
		"violated": default_color=Color(1,0.09,0.267); width=2.5; constraint_label.theme_override_colors/font_color=Color(1,0.09,0.267)
		"waiting": default_color=Color(0.392,0.392,0.627,0.5); width=1.5; constraint_label.theme_override_colors/font_color=Color(0.588,0.588,0.784,0.5)
		_: default_color=Color(0.392,0.392,0.627,0.3); width=1.5; constraint_label.theme_override_colors/font_color=Color(0.588,0.588,0.784,0.5)

func update_endpoints(from:Vector2, to:Vector2) -> void:
	clear_points(); add_point(from); add_point(to)
	var mid:=(from+to)*0.5
	constraint_label.position=Vector2(mid.x-25,mid.y-20)
	sub_label.position=Vector2(mid.x-25,mid.y+2)

4. PuzzleBoard Gameplay Scene

9 Create res://Scenes/PuzzleBoard.tscn

Root node: Node2D → name "PuzzleBoard" → attach script res://Scripts/PuzzleBoard.gd

No children needed. The script creates Planet and Starlane instances at runtime.

10 res://Scripts/PuzzleBoard.gd

# PuzzleBoard — orchestrates the puzzle
class_name PuzzleBoard extends Node2D

signal puzzle_solved; signal toast_requested(msg: String)

var nodes_data: Array[Dictionary] = []
var edges_data: Array[Dictionary] = []
var value_max: int = 6
var _planets: Array[Planet] = []; var _starlanes: Array[Starlane] = []
var _undo_stack: Array[Dictionary] = []
var _hints_used:=0; var _max_hints:=3; var _hint_target_idx:=-1
var _solved:=false; var _animating:=false; var _dialog_pending:=false
var _completion_time:=0.0; var _center:=Vector2.ZERO
var _particles: Array[Dictionary] = []; var _view_size:=Vector2(1280,720)
const PC:=80

func load_puzzle(data:Dictionary,view_w:float,view_h:float)->void:
	clear_all()
	nodes_data=data.nodes; edges_data=data.edges; value_max=data.value_max
	_view_size=Vector2(view_w,view_h); _solved=false; _animating=false; _dialog_pending=false
	_undo_stack.clear(); _hints_used=0; _hint_target_idx=-1; _particles.clear()
	_center=Vector2.ZERO
	for n in nodes_data: _center+=Vector2(n.x,n.y)
	_center/=max(nodes_data.size(),1)
	PuzzleGenerator.relax_layout(nodes_data,view_w,view_h,100)
	for i in nodes_data.size():
		var n:=nodes_data[i]; var p:Planet=preload("res://Scenes/Planet.tscn").instantiate()
		p.position=Vector2(n.x,n.y); p.setup(i,n.value,n.locked)
		if not n.locked: p.guess=-1; p.refresh(-1)
		p.tapped.connect(_on_planet_tapped); add_child(p); _planets.append(p)
	for e in edges_data:
		var l:Starlane=preload("res://Scenes/Starlane.tscn").instantiate()
		l.setup(e.from,e.to,e.target,e.type)
		l.update_endpoints(Vector2(nodes_data[e.from].x,nodes_data[e.from].y),Vector2(nodes_data[e.to].x,nodes_data[e.to].y))
		add_child(l); _starlanes.append(l)
	validate_all()

func clear_all()->void:
	for p in _planets: if is_instance_valid(p): p.queue_free()
	for s in _starlanes: if is_instance_valid(s): s.queue_free()
	_planets.clear(); _starlanes.clear()

func _on_planet_tapped(p:Planet)->void:
	if _solved or _animating or p.locked: return
	AudioManager.play_click()
	_undo_stack.append({"planet":p,"prev":p.guess})
	if p.guess<0: p.guess=0
	elif p.guess>=value_max: p.guess=-1
	else: p.guess+=1
	p.refresh(p.guess); validate_all()

func validate_all()->void:
	var filled:=true
	for p in _planets: if p.guess<0 and not p.locked: filled=false; break
	if not filled:
		for i in edges_data.size():
			var e:=edges_data[i]; var l:=_starlanes[i]
			var as:=(_planets[e.from].guess>=0)or _planets[e.from].locked
			var bs:=(_planets[e.to].guess>=0)or _planets[e.to].locked
			var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
			var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
			if as and bs: l.set_state("satisfied" if abs(av-bv)==e.target else "violated")
			elif as or bs: l.set_state("waiting"); else: l.set_state("neutral")
		return
	var ok:=true
	for i in edges_data.size():
		var e:=edges_data[i]; var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
		var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
		if abs(av-bv)!=e.target: ok=false; break
	if ok and not _solved:
		for l in _starlanes: l.set_state("satisfied"); _start_completion()
	elif not ok:
		for i in edges_data.size():
			var e:=edges_data[i]; var l:=_starlanes[i]
			var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
			var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
			l.set_state("satisfied" if abs(av-bv)==e.target else "violated")
		AudioManager.play_violate()
		await get_tree().create_timer(1.5).timeout
		if not _solved and _undo_stack.size()>0:
			var last:=_undo_stack.pop_back()
			last.planet.guess=-1; last.planet.refresh(-1); validate_all()

func _start_completion()->void:
	_solved=true; _animating=true; _completion_time=Time.get_ticks_msec()/1000.0
	AudioManager.play_complete()
	for i in PC:
		_particles.append({"x":randf_range(0,_view_size.x),"y":randf_range(0,_view_size.y),
			"vx":randf_range(-2,2),"vy":randf_range(-2,2),"size":randf_range(1,3),
			"phase":randf_range(0,TAU),
			"color":[Color(0.424,0.388,1),Color(0,0.902,0.463),Color(0.976,0.792,0.141),Color(1,0.42,0.616)][randi()%4]})

func _process(delta:float)->void:
	var t:=Time.get_ticks_msec()/1000.0
	for p in _planets:
		if p.guess<0 and not p.locked:
			p.qmark_label.theme_override_colors/font_color=Color(1,1,1,0.3+0.2*sin(t*2+p.idx*1.7))
	for i in _starlanes.size():
		if i0.5:
			var ease:=clamp((el-0.5)/2,0,1); ease=ease*ease*(3-2*ease)
			if ease<1:
				for i in _planets.size(): _planets[i].position=_planets[i].position.lerp(_center,ease*delta*3)
		if el>=3 and not _dialog_pending: _dialog_pending=true; _animating=false; puzzle_solved.emit()

func undo()->void:
	if _undo_stack.size()==0 or _solved: return
	var last:=_undo_stack.pop_back()
	last.planet.guess=-1; last.planet.refresh(-1); validate_all()

func reset_puzzle()->void:
	_undo_stack.clear(); _solved=false; _animating=false; _dialog_pending=false
	_hints_used=0; _hint_target_idx=-1; _particles.clear()
	for p in _planets: if not p.locked: p.guess=-1; p.refresh(-1)
	for s in _starlanes: s.set_state("neutral")
	AudioManager.play_menu_select()

func use_hint()->void:
	if _solved or _animating: return
	if _hints_used>=_max_hints: toast_requested.emit("No hints remaining"); return
	AudioManager.play_hint(); _hints_used+=1
	var unset: Array[int]=[]
	for i in _planets.size(): if _planets[i].guess<0 and not _planets[i].locked: unset.append(i)
	if unset.size()==0: toast_requested.emit("All planets filled"); return
	var best:=unset[0]; var score:=-1
	for idx in unset:
		var s:=0
		for e in edges_data:
			var o:=e.to if e.from==idx else e.from
			if _planets[o].guess>=0 or _planets[o].locked: s+=1
		if s>score: score=s; best=idx
	_hint_target_idx=best; _planets[best].is_hint_target=true; _planets[best].queue_redraw()
	toast_requested.emit("Check constraints around planet #%d"%(best+1))
	await get_tree().create_timer(2.5).timeout
	if is_instance_valid(self) and best<_planets.size():
		_hint_target_idx=-1; _planets[best].is_hint_target=false; _planets[best].queue_redraw()

5. UI Scenes

11 Create res://Scenes/HUD.tscn (in-game overlay)

Root: CanvasLayer → name "HUD", layer 2, attach script res://Scripts/HUD.gd

ChildTypeProperties
BgColorRectanchor fill screen, color rgba(0,0,0,0.35), ignore mouse
MenuBtnButtontext "x", flat, pos (12,6)-(52,50), font_size 18, color rgba(1,1,1,0.7)
TitleLabeltext "Tier 1 . Puzzle 1", pos (64,4)-(300,30), font_size 11
SubtitleLabelpos (64,28)-(300,52), font_size 9
UndoBtnButtontext "Undo", flat, anchor L:0.75 R:0.82, T:6 B:50, font_size 14
ResetBtnButtontext "Reset", flat, anchor L:0.84 R:0.91, T:6 B:50, font_size 14
HintBtnButtontext "?", flat, anchor L:0.93 R:1.0, T:6 B:50, font_size 18
ProgressLabeltext "1 / 8", h_align center, anchor T:0.92 B:0.98, font_size 10, ignore mouse

12 res://Scripts/HUD.gd

class_name HUD extends CanvasLayer
signal menu_pressed; signal undo_pressed; signal reset_pressed; signal hint_pressed
@onready var title_label:Label=%Title; @onready var subtitle_label:Label=%Subtitle
@onready var progress_label:Label=%Progress; @onready var hint_btn:Button=%HintBtn
func _ready()->void:
	%MenuBtn.pressed.connect(func():menu_pressed.emit())
	%UndoBtn.pressed.connect(func():undo_pressed.emit())
	%ResetBtn.pressed.connect(func():reset_pressed.emit())
	%HintBtn.pressed.connect(func():hint_pressed.emit())
func set_puzzle_info(tn:int,pn:int,name:String,total:int)->void:
	title_label.text="Tier %d . Puzzle %d"%[tn+1,pn+1]; subtitle_label.text=name
	progress_label.text="%d / %d"%[pn+1,total]
func set_hint_button_state(used:int,maxh:int)->void:
	hint_btn.theme_override_colors/font_color=Color(1,1,1,0.4 if maxh-used<=0 else 0.7)

13 Create res://Scenes/MenuScreen.tscn (splash)

Root: CanvasLayer → name "MenuScreen", layer 3, script res://Scripts/MenuScreen.gd

ChildTypeProperties
BgColorRectanchor fill screen, color rgba(0.027,0.027,0.059,0.8), ignore mouse
LogoSymbolLabeltext "=", anchor L:0 R:1 T:0.18 B:0.45, h+v_align center, font_size 80, color #6c63ff
NameLabelLabeltext "EQUI", anchor L:0 R:1 T:0.4 B:0.5, h+v_align center, font_size 24
TaglineLabeltext "Deductive Graph Puzzle", anchor L:0 R:1 T:0.46 B:0.55, font_size 12
NewGameBtnButtontext "NEW GAME", flat, anchor L:0.35 R:0.65 T:0.58 B:0.65, font_size 14
ContinueBtnButtontext "CONTINUE", flat, anchor L:0.35 R:0.65 T:0.67 B:0.73, font_size 12, starts hidden
HintLabeltext "Tap planets - Place moons - Satisfy all Delta constraints", anchor L:0 R:1 T:0.82 B:0.88, font_size 10, ignore mouse

14 res://Scripts/MenuScreen.gd

class_name MenuScreen extends CanvasLayer
signal new_game_pressed; signal continue_pressed
@onready var continue_btn:Button=%ContinueBtn
func _ready()->void:
	%NewGameBtn.pressed.connect(func():new_game_pressed.emit())
	%ContinueBtn.pressed.connect(func():continue_pressed.emit())
func refresh_continue(has:bool)->void: continue_btn.visible=has

15 Create res://Scenes/TierIntro.tscn

Root: CanvasLayer → name "TierIntro", layer 4, script res://Scripts/TierIntro.gd

ChildTypeProperties
BgColorRectanchor fill, color rgba(0,0,0,0.85), block mouse
CardColorRectanchor L:0.25 R:0.75 T:0.28 B:0.72, color #101022
TierNumLabeltext "TIER I", anchor L:0.25 R:0.75 T:0.3 B:0.36, h+v_align center, font_size 16, color #6c63ff
TierNameLabelanchor L:0.25 R:0.75 T:0.36 B:0.44, font_size 26, color white
DescLabelanchor L:0.3 R:0.7 T:0.46 B:0.56, font_size 11, word wrap
DifficultyLabelanchor L:0.25 R:0.75 T:0.58 B:0.63, h_align center, font_size 12
BeginBtnButtontext "BEGIN", flat, anchor L:0.38 R:0.62 T:0.64 B:0.7, font_size 14

16 res://Scripts/TierIntro.gd

class_name TierIntro extends CanvasLayer
signal begin_pressed
@onready var tn:Label=%TierNum; @onready var nm:Label=%TierName
@onready var desc:Label=%Desc; @onready var diff:Label=%Difficulty
const ROM:=["I","II","III","IV","V","VI"]
const DC:Dictionary={"Easy":Color(0,0.902,0.463),"Medium":Color(0.976,0.792,0.141),
	"Hard":Color(1,0.42,0.616),"Expert":Color(1,0.09,0.267)}
func _ready()->void: %BeginBtn.pressed.connect(func():begin_pressed.emit())
func show_for(ti:int,name:String,d:String,diff_s:String)->void:
	tn.text="TIER "+(ROM[ti] if ti

17 Create res://Scenes/DialogOverlay.tscn

Root: CanvasLayer → name "DialogOverlay", layer 5, script res://Scripts/DialogOverlay.gd

ChildTypeProperties
BgColorRectanchor fill, color rgba(0,0,0,0.6), block mouse
CardColorRectanchor L:0.28 R:0.72 T:0.32 B:0.68, color #101022
IconLabelanchor same as Card, T:0.33 B:0.4, h+v_align center, font_size 36, color gold
TitleLabelanchor L:0.28 R:0.72 T:0.4 B:0.48, font_size 18
TextLabelanchor L:0.3 R:0.7 T:0.48 B:0.56, font_size 11, word wrap
BtnContainerHBoxContaineranchor L:0.3 R:0.7 T:0.58 B:0.66, center alignment

18 res://Scripts/DialogOverlay.gd

class_name DialogOverlay extends CanvasLayer
signal dismissed
@onready var icon:Label=%Icon; @onready var title:Label=%Title
@onready var text:Label=%Text; @onready var btns:HBoxContainer=%BtnContainer
func _ready()->void: visible=false
func show_dialog(ic:String,ti:String,tx:String,buttons:Array[Dictionary])->void:
	icon.text=ic; title.text=ti; text.text=tx
	for c in btns.get_children(): c.queue_free()
	for bd in buttons:
		var b:=Button.new(); b.text=bd.get("text","OK"); b.flat=true
		b.size_flags_horizontal=Control.SIZE_SHRINK_CENTER
		b.theme_override_font_sizes/font_size=12
		b.theme_override_colors/font_color=bd.get("color",Color(0.753,0.749,1))
		b.pressed.connect(bd.get("action",func():dismiss())); btns.add_child(b)
	visible=true
func dismiss()->void: visible=false; dismissed.emit()

6. Root Scene

19 Create res://Scenes/Main.tscn

Root: Node → name "Main" → attach script res://Scripts/Main.gd

Instance the following scenes as children (right-click Root → Instance Child Scene):

Instanced sceneNode nameInitially visible?
res://Scenes/MenuScreen.tscnMenuScreenYes
res://Scenes/HUD.tscnHUDNo
res://Scenes/PuzzleBoard.tscnPuzzleBoardNo
res://Scenes/TierIntro.tscnTierIntroNo
res://Scenes/DialogOverlay.tscnDialogOverlayNo

Then add a CanvasLayer child named "ToastLayer", layer 6. Inside it, add a Label named "Toast" with:

20 res://Scripts/Main.gd — State machine

class_name Main extends Node

enum State{SPLASH,TIER_INTRO,PLAYING,DIALOG}
var state:=State.SPLASH; var tier:=0; var puzzle:=0
const PPT:=8; var _td:Dictionary={}; var _tw:Tween
@onready var ms:MenuScreen=%MenuScreen; @onready var hud:HUD=%HUD
@onready var pb:PuzzleBoard=%PuzzleBoard; @onready var ti:TierIntro=%TierIntro
@onready var dlg:DialogOverlay=%DialogOverlay; @onready var toast:Label=%Toast

const TIERS:=[
	{"name":"Void Garden","desc":"Delta on tree graphs.","tree":true,"vmax":3,"lock":0.45,"diff":"Easy","base":4},
	{"name":"Echo Orbits","desc":"Delta with cycles.","tree":false,"vmax":4,"lock":0.40,"diff":"Medium","base":6},
	{"name":"Binary Flares","desc":"Mixed Delta and larger graphs.","tree":false,"vmax":5,"lock":0.38,"diff":"Medium","base":7},
	{"name":"Wrap Nebula","desc":"Tight Delta at higher values.","tree":false,"vmax":6,"lock":0.35,"diff":"Hard","base":9},
	{"name":"Dual Spectra","desc":"Complex graphs with high values.","tree":false,"vmax":6,"lock":0.32,"diff":"Hard","base":11},
	{"name":"Singularity","desc":"Expert puzzles. Minimal locked planets.","tree":false,"vmax":6,"lock":0.28,"diff":"Expert","base":13},
]

func _ready()->void:
	ms.new_game_pressed.connect(_on_new_game)
	ms.continue_pressed.connect(_on_continue)
	hud.menu_pressed.connect(_on_menu); hud.undo_pressed.connect(_on_undo)
	hud.reset_pressed.connect(_on_reset); hud.hint_pressed.connect(_on_hint)
	ti.begin_pressed.connect(_on_begin_tier)
	pb.puzzle_solved.connect(_on_puzzle_solved)
	pb.toast_requested.connect(_show_toast)
	enter_splash()

func enter_splash()->void:
	state=State.SPLASH; pb.visible=false; hud.visible=false
	ti.visible=false; dlg.visible=false; ms.visible=true
	ms.refresh_continue(SaveManager.has_progress())

func _on_new_game()->void:
	SaveManager.reset_progress(); tier=0; puzzle=0; _gen(); show_tier_intro()
func _on_continue()->void:
	tier=SaveManager.tier; puzzle=SaveManager.puzzle; _gen(); show_tier_intro()

func _gen()->void: _td={"puzzles":_gen_puzzles()}; _td.merge(TIERS[tier],true)

func _gen_puzzles()->Array:
	var td:=TIERS[tier]; var vp:=get_viewport_rect(); var r:Array=[]
	for i in PPT:
		var nc:=min(td.base+i,16 if tier<=3 else 20)
		var d:=PuzzleGenerator.generate_puzzle(vp.size.x,vp.size.y,{"node_count":nc,"value_max":td.vmax,"tree_only":td.tree,"lock_ratio":td.lock+i*0.01})
		if d.nodes.size()>=3: r.append(d)
	return r

func show_tier_intro()->void:
	state=State.TIER_INTRO; ms.visible=false; hud.visible=false
	pb.visible=false; dlg.visible=false
	ti.show_for(tier,TIERS[tier].name,TIERS[tier].desc,TIERS[tier].diff)

func _on_begin_tier()->void: ti.visible=false; puzzle=0; hud.visible=true; _load(0)

func _load(idx:int)->void:
	if idx>=_td.puzzles.size(): _tier_complete(); return
	state=State.PLAYING; puzzle=idx; pb.visible=true
	pb.load_puzzle(_td.puzzles[idx],get_viewport_rect().size.x,get_viewport_rect().size.y)
	hud.set_puzzle_info(tier,idx,TIERS[tier].name,PPT); hud.set_hint_button_state(0,3)

func _on_puzzle_solved()->void:
	state=State.DIALOG; SaveManager.save_game()
	var last:=puzzle>=_td.puzzles.size()-1
	if last:
		dlg.show_dialog("*",TIERS[tier].name+" Complete!","All constellations in this chapter stabilized.",[{"text":"Next Chapter","action":_on_next_tier}])
	else:
		dlg.show_dialog("*","Constellation Stable","Puzzle %d solved."%(puzzle+1),[{"text":"Next Puzzle","action":_on_next_puzzle},{"text":"Menu","color":Color(1,1,1,0.5),"action":enter_splash}])

func _tier_complete()->void:
	dlg.show_dialog("*",TIERS[tier].name+" Complete!","All puzzles in this tier complete.",[{"text":"Next Chapter","action":_on_next_tier}])

func _game_complete()->void:
	dlg.show_dialog("*","The Cosmos is Yours","You mastered every constellation.",[{"text":"Title Screen","action":enter_splash}])

func _on_next_puzzle()->void: dlg.visible=false; _load(puzzle+1)
func _on_next_tier()->void:
	dlg.visible=false; tier+=1
	if tier>=TIERS.size(): _game_complete()
	else: _gen(); show_tier_intro()
func _on_menu()->void: dlg.visible=false; pb.clear_all(); pb.visible=false; hud.visible=false; enter_splash()
func _on_undo()->void: pb.undo()
func _on_reset()->void: pb.reset_puzzle()
func _on_hint()->void: pb.use_hint()
func _show_toast(msg:String)->void:
	toast.text=msg
	if _tw and _tw.is_valid(): _tw.kill()
	_tw=create_tween(); _tw.tween_property(toast,"modulate",Color(1,1,1,0.85),0.2)
	_tw.tween_interval(1.5); _tw.tween_property(toast,"modulate",Color(1,1,1,0),0.3)

7. Build Order & Key Notes

Quick reference

#FileType
1-4Autoload/*.gdScript-only singletons (register in Project Settings)
5-6Scenes/Planet.tscn + Scripts/Planet.gdArea2D with CollisionShape2D + Labels
7-8Scenes/Starlane.tscn + Scripts/Starlane.gdLine2D with Label children
9-10Scenes/PuzzleBoard.tscn + Scripts/PuzzleBoard.gdNode2D — creates Planet/Starlane instances
11-12Scenes/HUD.tscn + Scripts/HUD.gdCanvasLayer overlay
13-14Scenes/MenuScreen.tscn + Scripts/MenuScreen.gdCanvasLayer splash
15-16Scenes/TierIntro.tscn + Scripts/TierIntro.gdCanvasLayer intro card
17-18Scenes/DialogOverlay.tscn + Scripts/DialogOverlay.gdCanvasLayer dialog
19-20Scenes/Main.tscn + Scripts/Main.gdRoot — instances all scenes

Key architecture notes