Building a Godot 4 skill tree data structure

Model a Godot 4 skill tree as a Resource: nodes with prerequisites, gating allocation, refunding points, and why the stats snapshot won't persist which nodes you took.

The player clicks a node three rings deep in the tree and expects the point to go in. Your job is to say no. The node above it isn't taken yet, so the click should do nothing and no stat should move, while the cursor makes the refusal obvious. Then they take the parent, click again, and now it works. That gate is the whole problem. Everything else in a skill tree is drawing lines between circles.

I built the Stats/Skills addon around a stat aggregator that already knows how to apply and remove modifiers by a string id. A Godot 4 skill tree, once you strip the art off it, is a set of nodes where each node grants a couple of those modifiers when allocated and pulls them back out when refunded, plus a rule about which nodes you're allowed to allocate at all. This post builds that rule and that data structure on top of the free Lite core. I'll be plain about where the Lite tier stops and the paid runtime picks up, because the boundary happens to fall right on the interesting part.

Here's the piece of the core you need to know first. StatsComponentLite is a Node that holds a flat list of modifiers and re-derives each stat on demand. You add a modifier with a dictionary and a source id, and you remove every modifier sharing that id in one call.

@onready var stats: StatsComponentLite = $StatsComponentLite

# op is one of: "flat", "percent_add", "percent_mult"
stats.add_modifier({"stat": "damage", "op": "flat", "amount": 5.0, "source_id": "skill:sharp_edge"})

var removed := stats.remove_modifier("skill:sharp_edge")  # returns how many it pulled
stats.get_stat("damage")  # final value, clamped to the StatDefinitionLite range

That source_id is the hinge the whole design hangs from. The addon namespaces ids with a colon (its own examples use item:sword and buff:rage), so a skill:<node_id> id fits right in. Once every node owns a namespaced id, allocating a node is 'add its modifiers under skill:its_id' and refunding it is 'remove_modifier(skill:its_id)'. You never track the individual modifiers a node granted. The id is the handle.

Modeling the skill tree node as a Resource

Make the node a Resource so you can author a tree in the inspector, save it as a .tres, and hand it to a designer who never opens the script. A node needs an id, a display cost, the modifiers it grants, and the ids of the nodes that must already be allocated before this one is reachable. That last field is the prerequisite list, and it's what turns a bag of nodes into a tree.

class_name SkillNodeData
extends Resource

@export var id: String = ""
@export var display_name: String = ""
@export var cost: int = 1

# Ids of nodes that must be allocated before this one unlocks.
# Empty means it's a root. The player can take it with no prereqs.
@export var requires: Array[String] = []

# Each entry is a modifier dict the StatsComponentLite pipeline understands:
#   {"stat": "damage", "op": "flat", "amount": 5.0}
# No source_id here on purpose. The tree stamps that in when it allocates.
@export var grants: Array[Dictionary] = []

Note what's not in grants: the source_id. If you bake it into the resource you've written the node's own id in two places, and the day they drift is the day a refund silently leaves a modifier behind. The tree stamps the source id in at allocation time from the node's own id, so there's exactly one source of truth for it.

The tree itself is another Resource, and it's barely more than a list. I keep an id lookup alongside so prerequisite checks don't scan the array every time.

class_name SkillTreeData
extends Resource

@export var nodes: Array[SkillNodeData] = []

var _by_id: Dictionary = {}  # id -> SkillNodeData, built lazily

func node(id: String) -> SkillNodeData:
    if _by_id.is_empty() and not nodes.is_empty():
        for n in nodes:
            if n != null:
                _by_id[n.id] = n
    return _by_id.get(id, null)

The gate: can this node be allocated

Allocation state lives in the runtime, not the resource, because the resource is shared and read-only at play time. The runtime holds a set of allocated ids and a point budget. A node is allocatable when four things hold: it exists, it isn't already taken, the player can afford it, and every id in its requires list is already allocated. That's it. No graph traversal, no distance math, nothing that walks the tree. A node one requirement away from a taken node is reachable. A node whose parent you haven't touched is not.

class_name SkillTreeRuntime
extends Node

@export var tree: SkillTreeData
@export var stats: StatsComponentLite

var points: int = 0
var _allocated: Dictionary = {}  # id -> true

func is_allocated(id: String) -> bool:
    return _allocated.has(id)

func can_allocate(id: String) -> bool:
    var n := tree.node(id)
    if n == null or is_allocated(id):
        return false
    if points < n.cost:
        return false
    for req in n.requires:
        if not is_allocated(req):
            return false
    return true

This is the method your UI calls every frame on the node under the cursor, the same way a drag callback gates a drop. If it returns false the button stays dead and the click never allocates. Wire the button's disabled state straight to it and the illegal click becomes impossible instead of merely rejected.

Allocating and refunding through the modifier pipeline

Allocation does two things and no more. Spend the points, and push every modifier in grants into the stats component under this node's stamped source id. Because the aggregator re-derives on each add_modifier, the stat change is live before the function returns.

func allocate(id: String) -> bool:
    if not can_allocate(id):
        return false
    var n := tree.node(id)
    var source := "skill:%s" % n.id
    for mod in n.grants:
        var m := mod.duplicate()      # don't mutate the shared Resource's dict
        m["source_id"] = source       # stamp identity in from the node id
        stats.add_modifier(m)
    points -= n.cost
    _allocated[id] = true
    return true

Refunding is where the source-id convention earns back all the ceremony. You don't remember what a node granted. You ask the stats component to drop everything under that node's id, hand the points back, and clear the allocated flag. One remove_modifier call unwinds a node no matter how many stats it touched, and it returns the count it pulled if you want to assert the node actually had modifiers.

func refund(id: String) -> bool:
    if not is_allocated(id):
        return false
    # Guard: don't strand a child whose only path here runs through this node.
    for other in tree.nodes:
        if is_allocated(other.id) and id in other.requires:
            return false
    var n := tree.node(id)
    stats.remove_modifier("skill:%s" % n.id)  # pulls every modifier this node added
    points += n.cost
    _allocated.erase(id)
    return true

That refund guard is doing real work. Without it a player refunds a root, keeps the three nodes downstream of it, and now holds skills whose prerequisites are gone. The tree contradicts itself. The check walks the allocated set and refuses the refund if any taken node still lists this one as a requirement, which forces refunds to unwind leaf-first, the same order a sane respec would take anyway. If you'd rather cascade the refund instead of blocking it, recurse into the dependents here before removing this node. That's a design call, and blocking is the one that surprises players less.

Where the Lite core stops

Everything above builds on the free StatsComponentLite, and its modifier pipeline is the whole engine underneath the tree. What Lite doesn't ship is the tree layer itself. The SkillNode and SkillTree resources, plus the SkillTreeComponent runtime with its SkillTreeUI connection lines and click-to-unlock, are the paid tier's headline feature and are absent from Lite by design. The classes I wrote here (SkillNodeData, SkillTreeData, SkillTreeRuntime) are my own names for a rough version of that, sitting on the Lite aggregator, so nothing above depends on the paid runtime.

What you have is a tree you can author as a .tres, a prerequisite gate that makes illegal allocations impossible rather than merely refused, and allocate/refund that route entirely through one modifier source id per node. Give each node an x/y and draw a Line2D from each node to the ones it requires, and you have the picture too. The data structure is done. The rest is presentation over a model that already holds.

Or use mine

Stats Lite, free on GitHub · Stats / Skill Trees, $4.99

All Godot posts · Home