Skip to main content

Specialisation: Skill Tree Creator

Introduction
#

This solo project is a standalone skill tree creator written in C++ designed to allow users to visually create and export functional skill trees for use in their own game engines.

Over the course of 3 weeks, the goal was to create a flexible tool that separates the design user from the implementation user, allowing developers to define their own layout, connections and progression logic without having to hardcode systems in their game.

The creator exports structured .json data alongside used sprite assets, allowing developers to take the exported data and implement it whatever way works best for their engine.

Inspiration
#

I was heavily inspired by Path of Building. It demonstrates how complex and expressive skill trees can be and I wanted to achieve a similar tool while keeping it lightweight, modular and adaptable for different use cases.

The problem
#

Designing skill trees quickly becomes difficult and frustrating to manage as complexity increases. Layout, node relationships and progression rules are often entwined with engine logic, making iteration work slow.

Existing tools, such as the main inspiration of this project, are often tailored to specific games or do not provide full customization of the tree.

Because of this, my goal was to:

  • Provide a visual editing tool for rapid iteration, making as much as possible customizable
  • Output only the essential runtime data
  • Allow developers full control over implementation

Solution
#

The application is a stripped-down version of the custom engine we used for game projects at TGA, only having the means to render sprites, recieve inputs and includes the DearImGui library for rapid UI development as well as the nlohmann/json library for serialization.

The tool allows users to:

  • Create and position nodes visually
  • Define relationships between nodes
  • Assign stats and sprites
  • Export all relevant data for runtime use

One of my main focuses was balancing flexibility and usability, since giving users access to edit all data/behavior quickly leads to a cluttered interface making the tool frustrating to use. This tradeoff influenced several design decisions throughout development.

Design and Structure
#

As for the design of the tool, I chose the classic “Game Editor” layout because it provides a large view of the nodes and data being shown in a easy to read top-bottom manner. And since users will be developers, I give them a familiar look.

Viewport
#

The viewport is implemented using a dynamically sized render target that is embedded inside an ImGui window, allowing the skill tree to be rendered independently while still behaving like a native UI element.

This approach makes it possible to:

  • Handle rendering separately from UI logic
  • Support resizing without recreating the system
  • Maintains a clear separation between editor and runtime behavior
// Upon construction of the actual Skill Tree Class I set up the string references for each window
myDocumentWindowClass = std::make_unique<ImGuiWindowClass>();
        
char buffer[512];
sprintf_s(buffer, "SkillTree##ViewportPreview");
myPanelWindowNames[static_cast<size_t>(Panels::Viewport)] = buffer;
sprintf_s(buffer, "SkillTree##Settings");
myPanelWindowNames[static_cast<size_t>(Panels::Nodes)] = buffer;
// Using the string reference 
void SkillTree::RenderViewportPanel()
{
    ImGui::Begin(myPanelWindowNames[static_cast<size_t>(Panels::Viewport)].c_str());
    
    const ImVec2 viewportSize{ ImGui::GetContentRegionAvail() };
    
    if (viewportSize.x > 0 && viewportSize.y > 0 && (myViewportSize.x != static_cast<uint32_t>(viewportSize.x) || myViewportSize.y != static_cast<uint32_t>(viewportSize.y)))
    {
        myViewportSize = { static_cast<uint32_t>(viewportSize.x), static_cast<uint32_t>(viewportSize.y)};
        // Default constructing the render target in order to release GPU resources.
        myRenderTarget = {};
        myRenderTarget = RatTrap::RenderTarget::Create(myViewportSize, DXGI_FORMAT_R8G8B8A8_TYPELESS, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_R8G8B8A8_UNORM);
    }
    
    ImGui::Image(reinterpret_cast<ImTextureID>(myRenderTarget.GetShaderResourceView()), viewportSize);

    ImGui::End();
}

This lets me set the trees render target as active from the outside in my handler, making subsequent rendering appear in the given viewport.

// Render() function in SkillTreeHandler.cpp
myActiveSkillTree->GetRenderTarget()->Clear(myActiveSkillTree->GetBackgroundColor());
myActiveSkillTree->GetRenderTarget()->SetAsActiveTarget();
// Render nodes, sprites, links...

Unified item system
#

Initially, nodes and decorative elements (I call them “flavor sprites”) were implemented as separate systems. While this made the structure clearer at first, it quickly became limiting as new features were introduced.

Notable example is the implementation of Copy/Paste, which required handling both types in similar ways, leading to duplicated logic.

To adress this, both were refactored to inherit from a shared base struct where:

  • Common properties are centralized (Position, size, sprite)
  • Shared functionality can be reused
  • New features can be implemented and applied universally This simplified the overall system.
"Skill Tree Item" data
struct SkillTreeItem
{
    float x;
    float y;
    float sizeX;
    float sizeY;
    float angle;

    float color[4]{ 1.f, 1.f, 1.f, 1.f };

    std::string sprite;

    [[nodiscard]] virtual float GetXPos() const { return x; }
    [[nodiscard]] virtual float GetYPos() const { return y; }
    [[nodiscard]] virtual float GetAngle() const { return angle; }

    virtual void SetPosition(const float aX, const float aY) { x = aX; y = aY; }
};

Since flavor sprites are just that, for flavor, no additional data is needed for them aside from what the base class provides.

struct TreeFlavorSprite : SkillTreeItem {};

Tree structure
#

The skill tree class is built around a node-based structure using lightweight IDs to represent relationships. Acting as a wrapper for the nodes, it provides helper functions and handles serialization

Class header
class SkillTree
{
public:
    enum class Panels
    {
        Viewport,
        Nodes,
        Count,
    };
    SkillTree();

    void Init(const std::string& aName);

    void SpriteRefreshPopup(std::vector<std::wstring>& someSpritePaths);
    
    void Update(float aMouseX, float aMouseY, bool aMouseInView);
    bool EditorUpdate(float aMouseX, float aMouseY, bool aMouseInView);
    
    // Gives id on creation.
    SkillTreeNode* CreateRootNode(float aXPos, float aYPos);
    SkillTreeNode* CreateNode(float aXPos, float aYPos);
    SkillTreeNode* CreateNode(float aXPos, float aYPos, float aXSize, float aYSize, std::string aSprite, bool aIsRoot, bool aIsActive, int aCurrentPoints, int
        aTotalPoints);
    
    void DeleteNode(NodeId aNodeId);
    bool DeleteSprite(const SkillTreeItem* aSprite);

    // For manual linking
    bool LinkNodes(NodeId aParentId, NodeId aChildId);
    void UnlinkNodes(NodeId aParentId, NodeId aChildId);

    void CopyCurrentItem();
    void PasteCopiedItem(float aMouseX, float aMouseY);
    
    void Save();
    void Load();
    
    std::vector<SkillTreeNode>& GetNodes() { return myNodes; }
    std::vector<SkillTreeLink>& GetLinks() { return myLinks; }
    RatTrap::Vector4f GetBackgroundColor() const { return RatTrap::Vector4f(mySkillTreeData.backgroundColor[0], mySkillTreeData.backgroundColor[1], mySkillTreeData.backgroundColor[2], mySkillTreeData.backgroundColor[3]); }
    RatTrap::Vector2ui GetViewportSize() const { return myViewportSize; }
    RatTrap::Vector2f GetViewportMouse() const { return myViewportMousePos; }
    RatTrap::RenderTarget* GetRenderTarget() { return &myRenderTarget; }
    SkillTreeNode* GetNodeById(NodeId aNodeId);
    SkillTreeNode* GetNodeById(uint16_t aRawNodeId);
    SkillTreeNode* GetNodeFromMousePos(float aMouseXPos, float aMouseYPos);
    SkillTreeData* GetTreeData() { return &mySkillTreeData; }
    bool GetShouldShowPoints() const { return myShowPoints; }
    
    void WriteToJson(nlohmann::json& aJson, bool aEditorSave) const;
    void LoadFromJson(nlohmann::json& aJson, bool aEditorLoad);
    
private:
    void RenderViewportPanel();
    bool RenderEditingPanel(float aMouseX, float aMouseY, bool aMouseInView);
    
    bool RecursiveDisplay(const std::vector<NodeId>& someChildren);
    void PerformSave();

    bool myDockingInitialized{ false };
    bool mySnappingMode { true };
    bool myShowPoints{ true };
    bool myPointAllocationMode{ false };
    bool myAboutToExit{ false };
    bool myPendingSpritePopup{ false };
    
    // Saving
    bool mySaveSpritesWithRelativePath{ true };
    
    float mySnapLength{ 50.f };
    RatTrap::Vector2f myBaseSpriteSize{ 75.f, 75.f };
    
    SkillTreeItem* myLastSelectedItem{ nullptr };
    SkillTreeItem* myCopiedItem{ nullptr };
    
    SkillTreeData mySkillTreeData;
    
    std::vector<SkillTreeNode> myNodes;
    std::vector<SkillTreeLink> myLinks;

    std::vector<std::string> myNodeSpriteNames;
    std::vector<std::string> myFlavorSpriteNames;
    std::vector<std::wstring> mySpritesToAdd;

    std::string myBaseNodeName { "baseNode.png" };
    std::string myBaseRootNodeName { "baseRootNode.png" };
    
    std::string myPanelWindowNames[static_cast<size_t>(Panels::Count)];
    std::shared_ptr<ImGuiWindowClass> myDocumentWindowClass;

    RatTrap::Vector2f myViewportMousePos;
    RatTrap::Vector2ui myViewportSize;
    RatTrap::RenderTarget myRenderTarget;
};

Nodes and Links #

Each node maintains references to parent and child nodes aswell as associated links for visual and logical connections. This approach avoids pointer ownership issues while making serialization straightforward.

Node Data
struct NodeId
{
    uint16_t id;

    bool IsValid() { return id != std::numeric_limits<uint16_t>::max(); }

    constexpr bool operator==(const NodeId& aOther) const noexcept
    {
        return id == aOther.id;
    }
    constexpr bool operator!=(const NodeId& aOther) const noexcept
    {
        return id != aOther.id;
    }
};

struct SkillTreeNode : SkillTreeItem
{
    SkillTreeNode() = default;

    SkillTreeNode(SkillTreeNode&&) = default;
    SkillTreeNode& operator=(SkillTreeNode&&) = default;

    SkillTreeNode(const SkillTreeNode&) = delete;
    SkillTreeNode& operator=(const SkillTreeNode&) = delete;

    void DisplayImGui();
    // Dump everything
    void DisplayNodeInfo(bool aEditMode);

    std::vector<std::string> GetEvaluatedStats() const;
    
    NodeId GetNodeId() const { return id; }
    
    NodeId id;

    bool root{ false };
    bool active{ false };
    int totalPoints{ 0 };
    int currentPoints{ 0 };

    std::string name;
    std::string sprite;

    std::vector<std::unique_ptr<StatToParse>> stats;
    
    std::vector<NodeId> parents;
    std::vector<NodeId> children;
};

Connection visualisation is done through sprites that rotate and scale based on the child->parent positions. I chose to use sprites in order to allow the user insert their own lines if they so wished. Initially, I wanted to allow any line sprites, but since the scaling/rotation math would be different for each, I had to limit the application to only a handful.

Data and Serialization
#

One of my key goals for the tool was to output only the data necessary for runtime usage.

The system serializes, into three different files:

  • Node positions and properties Name.json
  • Relationships between nodes Name.json
  • Link Data NameEditor.json
  • Gameplay-related values NameTreeData.json

The reason for this is to separate neccessary data from application data. For external use, the only needed file is Name.json, which includes all node data. The NameEditor.json contains the link struct, including size, angle, position and sprite. However, since the nodes save ID’s for their hierarchy it is not needed to actually create the skill tree, mainly being used by the application itself to render lines. Likewise, the NameTreeData.json includes id tracker, flavor sprites and background data used by the application.

Both NameEditor.json and NameTreeData.json is data that the user will most likely handle themselves in their own engine. By separating them, I allow an example of how to handle that data, save it for the application that actually uses it and allow the user to implement it if they so wish.

By exporting this with json, the tool remains separate from any specific engine. Developers are free to interpret and use the data however they choose.

Write To Json
 void SkillTree::WriteToJson(nlohmann::json& aJson, const bool aEditorSave) const
{
    using namespace nlohmann;

    if (!aEditorSave)
    {
            aJson["nodes"] = json::array();

        for (const auto& node : myNodes)
        {
            json nodeJson;
            nodeJson["id"] = node.id.id;
            nodeJson["root"] = node.root;
            nodeJson["active"] = node.active;
            nodeJson["totalPoints"] = node.totalPoints;
            nodeJson["currentPoints"] = node.currentPoints;
            nodeJson["x"] = node.x;
            nodeJson["y"] = node.y;
            nodeJson["sizeX"] = node.sizeX;
            nodeJson["sizeY"] = node.sizeY;
            nodeJson["name"] = node.name;

            std::string spritePath = node.sprite;

            if (mySaveSpritesWithRelativePath)
            {
                if (!spritePath.starts_with("Sprites/")) // does NOT start with "Sprites/"
                {
                    spritePath = "Sprites/" + spritePath;
                }
            }
            nodeJson["spritePath"] = spritePath;

            json statArray = json::array();
            for (const auto& stat : node.stats)
            {
                json statJson;
                statJson["stat"] = stat->GetOriginalString();
                statArray.push_back(statJson);
            }
            nodeJson["stats"] = statArray;

            json parentArray = json::array();
            for (const auto& [id] : node.parents)
            {
                json parentJson;
                parentJson["parentId"] = id;
                parentArray.push_back(parentJson);
            }
            nodeJson["parents"] = parentArray;

            json childArray = json::array();
            for (const auto& [id] : node.children)
            {
                json childJson;
                childJson["childId"] = id;
                childArray.push_back(childJson);
            }
            nodeJson["children"] = childArray;

            aJson["nodes"].push_back(nodeJson);
        }
    }
    else
    {
        aJson["links"] = json::array();
        
        for (const auto& link : myLinks)
        {
            json linkJson;
            linkJson["flipped"] = link.flipped;
            linkJson["style"] = static_cast<uint8_t>(link.style);
            linkJson["startNode"] = link.startNodeId;
            linkJson["endNode"] = link.endNodeId;
            linkJson["x"] = link.x;
            linkJson["y"] = link.y;
            linkJson["sizeX"] = link.sizeX;
            linkJson["sizeY"] = link.sizeY;
            linkJson["angle"] = link.angle;
            linkJson["path"] = link.spritePath;
            aJson["links"].push_back(linkJson);
        }
    }
}

The Skill Tree will be saved within the Bin folder, with all sprites used in that skill tree.

Parsing
#

With the application I have integrated a parser used for stats, inspired by the RPG skill tree generator. The purpose of it is to show an example of how one could parse the stats, and is not included upon exporting. The parser is split into two sections:

  • ParseFormula - Handles numeric/calculated stats.
  • ParseUnlock - Handles skills/stats that only unlock something

Upon adding a stat/skill, this specific parser will search for ‘CurrentPoints’, any of the ‘+ - / *’ symbols or plain numbers and if found categorise it as a formula, which allow for incrementation of stats based on number of points. Furthermore, there are three output methods, Evaluate, GetDesc and GetOriginalString.

Method Formula Example Unlock Example
Evaluate “Defense 0” “Ability MoonWalk” / “Ability (Locked)”
GetDesc “Defense (CurrentPoints*5)” “Ability unlocks: MoonWalk”
GetOriginalString “Defense CurrentPoints*5” “Ability MoonWalk”

Where the GetOriginalString is what gets saved in the node by the application.

Parser Header

enum class StatType : uint8_t
{
    Formula,
    Unlock
};

class StatToParse
{
public:
    virtual ~StatToParse() = default;
    virtual std::string Evaluate(int aCurrentPoints) const = 0;
    virtual std::string GetDesc() const = 0;
    virtual std::string GetOriginalString() const = 0;

protected:
    std::string myName{};
    StatType myType{};
};

class ParseFormula : public StatToParse
{
public:
    ParseFormula(const std::string& aStatName, const std::string& aStatFormula);
    std::string Evaluate(int aCurrentPoints) const override;
    std::string GetDesc() const override;
    std::string GetOriginalString() const override;

private:
    double EvaluateFormula(int aCurrentPoints) const;
    
    static double EvaluateExpression(std::string aExpression);
    static double ParseAdditionSubtraction(const std::string& aExpression, size_t& aPos);
    static double ParseMultiplicationDivision(const std::string& aExpression, size_t& aPos);
    static double ParseNumber(const std::string& aExpression, size_t& aPos);
    
    std::string myFormula;
};

class ParseUnlock : public StatToParse
{
public:
    ParseUnlock(const std::string& aStatName, const std::string& aStatUnlock);
    std::string Evaluate(int aCurrentPoints) const override;
    std::string GetDesc() const override;
    std::string GetOriginalString() const override;

private:
    std::string mySkillName;
};

class SkillTreeParser
{
public:
    // Parse
    static std::unique_ptr<StatToParse> ParseSkill(const std::string& aStatString);
        
private:
    static bool IsFormula(const std::string& aStatString);
};

Implementation
#

To integrate the generated data into an engine, a lightweight wrapper would be used to:

  • Load the json data
  • Reconstruct the node graph
  • Handle rendering and interaction

With this, the runtime implementation can remain minimal and focused only on gameplay behavior.

Load From Json
void SkillTree::LoadFromJson(nlohmann::json& aJson, const bool aEditorLoad)
{
    using namespace nlohmann;

    if (!aEditorLoad)
    {
        myNodes.clear();
        
        if (!aJson.contains("nodes"))
        {
            return;
        }

        for (auto& nodeJson : aJson["nodes"])
        {
            SkillTreeNode& node = myNodes.emplace_back();

            if (nodeJson.contains("id"))
            {
                node.id.id = nodeJson["id"].get<uint16_t>();
            }
            if (nodeJson.contains("root"))
            {
                node.root = nodeJson["root"].get<bool>();
            }
            if (nodeJson.contains("active"))
            {
                node.active = nodeJson["active"].get<bool>();
            }
            if (nodeJson.contains("totalPoints"))
            {
                node.totalPoints = nodeJson["totalPoints"].get<int>();
            }
            if (nodeJson.contains("currentPoints"))
            {
                node.currentPoints = nodeJson["currentPoints"].get<int>();
            }
            if (nodeJson.contains("x"))
            {
                node.x = nodeJson["x"].get<float>();
            }
            if (nodeJson.contains("y"))
            {
                node.y = nodeJson["y"].get<float>();
            }
            if (nodeJson.contains("sizeX"))
            {
                node.sizeX = nodeJson["sizeX"].get<float>();
            }
            if (nodeJson.contains("sizeY"))
            {
                node.sizeY = nodeJson["sizeY"].get<float>();
            }
            if (nodeJson.contains("name"))
            {
                node.name = nodeJson["name"].get<std::string>();
            }
            if (nodeJson.contains("spritePath"))
            {
                node.sprite = nodeJson["spritePath"].get<std::string>();
            }

            if (nodeJson.contains("stats"))
            {
                for (const auto& stat : nodeJson["stats"])
                {
                    std::string statOriginalString = stat["stat"].get<std::string>();
                    node.stats.push_back(SkillTreeParser::ParseSkill(statOriginalString));
                }
            }

            if (nodeJson.contains("parents"))
            {
                for (const auto& parentId : nodeJson["parents"])
                {
                    NodeId parent{};
                    parent.id = parentId["parentId"].get<uint16_t>();
                    node.parents.push_back(parent);
                }
            }
            if (nodeJson.contains("children"))
            {
                for (const auto& childId : nodeJson["children"])
                {
                    NodeId child{};
                    child.id = childId["childId"].get<uint16_t>();
                    node.children.push_back(child);
                }
            }
        }
    }
    else
    {
        myLinks.clear();
        
        if (!aJson.contains("links"))
        {
            return;
        }
        for (const auto& jsonLink : aJson["links"])
        {
            SkillTreeLink link{};
            link.flipped = jsonLink["flipped"].get<bool>();
            link.style = static_cast<LinkStyle>(jsonLink["style"].get<uint8_t>());
            link.startNodeId = jsonLink["startNode"].get<uint16_t>();
            link.endNodeId = jsonLink["endNode"].get<uint16_t>();
            link.x = jsonLink["x"].get<float>();
            link.y = jsonLink["y"].get<float>();
            link.sizeX = jsonLink["sizeX"].get<float>();
            link.sizeY = jsonLink["sizeY"].get<float>();
            link.angle = jsonLink["angle"].get<float>();
            link.spritePath = jsonLink["path"].get<std::string>();
            myLinks.push_back(link);
        }
    }
}

Using the tool
#

The creator provides instructions for quick-commands as well as guides on how to navigate it.

A typical workflow when using the tool would look like:

  1. Create/Load a tree.
  2. Create/Edit nodes.
  3. Link nodes to define progression path.
  4. Assign stats and properties.
  5. Save and export.
  6. Load data into the engine and render the tree when needed.

This separation allows designers and programmers to iterate independently, significantly speeding up development.

Challenges
#

Challenges I faced did not neccessarily include the logic itself, but the structure and tool development. More than anything, I realized that if the tool is not versatile enough or easy to navigate, then the existence of the tool becomes redundant. The target audience is developers, if the tool is not to their liking, they can simply create their own skill tree.

Due to this, I often battled with:

  • Balancing the flexibility and usability. Mentioned earlier, I wanted to give users access to edit whatever they liked, which led to a blob-looking interface. I often struggled with what properties were “fine” to limit and where I could apply support for custom integration.
  • The evolving structure of the system. Early design decisions had to be revisited and refactored as new features or requirements emerged. This is mostly due to not thinking of these said features during planning.

Reflection
#

What worked well:

  • The editor workflow feels efficient and familiar
  • Quality of life features are effective, such as drag & drop .png/.jpg, making the tool feel less cheap

Areas for improvement:

  • Line sprite handling
  • User experience

Given more time, the next step would be to further refine the user experience, making editable data more user friendly and less frustrating to use. I also wish to add more features, providing the tool with more support for navigation, creation, overall settings for saving, and more.

References
#

Related