DEV Community

AZwang067260
AZwang067260

Posted on AI-assisted

Building a skill tree planner for Enshrouded 1.0 with plain JavaScript and SVG

When Enshrouded hit 1.0, the game reset every player's skill tree. Players suddenly needed to re-plan 212 skill points on a tree that costs 675 to fill, and most planners online were built for the old tree.

So I built one: a free Enshrouded skill planner that runs entirely in the browser. No framework, no build step for the JS, a few hundred lines of vanilla JavaScript drawing into an SVG. I built it with help from an AI coding assistant, and the interesting part was getting the game's rules right.

The data: a graph, not a tree

The skill tree data comes from the open-source rossicler/enshrouded-skill-tree project (GPL-3.0), which exports it from the 1.0 game files. It looks like this, simplified:

{
  "nodes": { "123": { "type": "WAND_MASTERY", "position": { "x": 410, "y": -88 } } },
  "edges": [["123", "456"], ["456", "789"]],
  "types": { "WAND_MASTERY": { "name": "Wand Mastery", "cost": 1, "maxLevel": 3 } }
}
Enter fullscreen mode Exit fullscreen mode

The first lesson: despite the name, a "skill tree" is a graph. It has 231 nodes and 291 edges, with cycles, and 21 base nodes you can start from without a neighbour. So the first thing the planner does is build an adjacency list:

var ADJ = {};
D.nodes.forEach(function (n) { ADJ[n.id] = []; });
D.edges.forEach(function (e) { ADJ[e[0]].push(e[1]); ADJ[e[1]].push(e[0]); });
Enter fullscreen mode Exit fullscreen mode

Rule 1: you can only learn what touches what you have

A node is learnable if it's a base node, or if any neighbour is already selected:

function selectable(id) {
  if (sel[id]) return false;          // already learned: use +rank instead
  if (N[id].base) return true;        // starting nodes are always open
  return ADJ[id].some(function (o) { return sel[o]; });
}
Enter fullscreen mode Exit fullscreen mode

sel is just an object of { nodeId: rank }. Adding a rank checks three things in order: is it connected, is it below max rank, and does it fit the budget:

function add(id) {
  var t = type(id);
  if (!sel[id] && !selectable(id)) return notice('Locked: connect ' + t.n + ' first.');
  if ((sel[id] || 0) >= t.m) return notice(t.n + ' is already at max rank.');
  if (spent() + t.c > budget()) return notice('Not enough points for ' + t.n + '.');
  sel[id] = (sel[id] || 0) + 1;
  render();
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: refunds must cascade

This is the bug every quick planner has. If you refund a node in the middle of a path, everything beyond it is now floating, disconnected from any base node. The game won't allow that, so the planner can't either.

The fix is a flood fill from the selected base nodes that pretends the removed node doesn't exist. Anything it can't reach gets refunded too:

function reachable(without) {
  var seen = {}, stack = [];
  for (var id in sel) if (id !== without && N[id].base) { seen[id] = 1; stack.push(id); }
  while (stack.length) {
    var u = stack.pop();
    ADJ[u].forEach(function (v) {
      if (sel[v] && v !== without && !seen[v]) { seen[v] = 1; stack.push(v); }
    });
  }
  return seen;
}

function remove(id) {
  if (sel[id] > 1) { sel[id]--; return render(); }   // lower a rank first
  var keep = reachable(id);
  var lost = Object.keys(sel).filter(function (x) { return x !== id && !keep[x]; });
  if (lost.length && !confirm('This also refunds ' + lost.length + ' connected skills. Continue?')) return;
  delete sel[id];
  lost.forEach(function (x) { delete sel[x]; });
  render();
}
Enter fullscreen mode Exit fullscreen mode

Because the graph has cycles, this is strictly better than "remove the subtree": a node with a second path back to a base survives.

Rule 3: the budget isn't just your level

In Enshrouded you get 2 points per level after level 1, plus 1 per Shroud Root and 3 per Elixir Well you find while exploring. Exploration is actually the bigger half: 114 of the 212 points at level 50. So the budget comes from the level input and a checkbox per biome:

function budget() {
  var level = Math.max(1, Math.min(50, +lv.value || 1));
  var b = 2 * (level - 1);
  document.querySelectorAll('[data-biome]').forEach(function (c) {
    if (c.checked) b += +c.dataset.points;
  });
  return b;
}
Enter fullscreen mode Exit fullscreen mode

That model had a bug I only found when a Reddit user posted three builds that all had "extra points". The math was right; my assumption that players find every Root and Well was not. Now an optional "my total skill points" field overrides the computed budget, because the in-game number always wins.

Shareable builds in a URL hash

Builds had to be shareable on Reddit and Discord without accounts or a backend, so the whole build lives in the URL fragment. The first version wrote one base-36 character per node, which worked but produced ~280-character links.

The current version treats the build as one big mixed-radix number: one bit per node for "taken", plus the rank only for taken multi-rank nodes, then the level, Flame level and a 7-bit explored-biome mask. The number is written in base62, so links are at most ~98 characters and contain only letters and digits:

var n = 0n, mul = 1n;
function put(d, r) { n += BigInt(d) * mul; mul *= BigInt(r); }
ORDER.forEach(function (id) {
  var r = sel[id] || 0, m = type(id).m;
  put(r ? 1 : 0, 2);
  if (r && m > 1) put(r - 1, m);
});
Enter fullscreen mode Exit fullscreen mode

Old long links are still decoded and rewritten in place. On load, the decoder runs reachable() once more and drops anything disconnected, so a hand-edited or outdated link can never produce an impossible build.

Pan, zoom and the click that didn't register

The tree is one SVG <g> with a transform. Wheel zooms around the cursor, one pointer pans, two pointers pinch-zoom. My first version called setPointerCapture on pointerdown, and node clicks randomly stopped working. With capture on, pointerup targets the SVG, not the node you pressed.

The fix: remember which node was under the pointer at pointerdown, and only capture once the pointer has moved more than 5 px, when it's clearly a drag:

svg.addEventListener('pointerdown', function (ev) {
  var g = ev.target.closest('[data-node]');
  downNode = g ? g.getAttribute('data-node') : null;
  drag = { x: ev.clientX, y: ev.clientY, vx: vt.x, vy: vt.y };
  moved = false;
});
svg.addEventListener('pointermove', function (ev) {
  if (!drag) return;
  if (!moved && Math.hypot(ev.clientX - drag.x, ev.clientY - drag.y) > 5) {
    moved = true;
    svg.setPointerCapture(ev.pointerId);
  }
  // ...pan
});
Enter fullscreen mode Exit fullscreen mode

On pointerup, a click is "no drag happened and we have a downNode". On touch, the first tap only selects the node so you can read its tooltip, and a second tap learns it.

Presets from a shortest path

The site also has build guides, and each one opens a preset in the planner. Hand-placing ~100 nodes per build would be miserable, so presets are generated at build time in Python. Each guide lists its core skills, and a small Dijkstra finds the cheapest connected path to each one from what's already selected, where an edge's weight is the point cost of the node it enters. The leftover points then go greedily to the cheapest reachable skills in the build's own area. All nine level 50 presets land at 211 or 212 of 212 points.

What I'd do differently

  • Generate the client data, don't hand-write it. All costs, ranks and in-game text come from the export, so a game patch means re-running one script.
  • Validate links on load. Treat the URL as untrusted input and re-check connectivity, as above.
  • Keep it framework-free if the state is tiny. The whole state is one object and two inputs. Re-rendering everything on each change is fast enough at 231 nodes.

The planner is live at enshroudedbuilds.org/skill-planner, and the code is GPL-3.0 like the data it's built on. Feedback welcome, especially if you've solved cascade refunds or pinch-zoom more elegantly.

Source: https://github.com/AZwang067260/enshroudedbuilds

Top comments (0)