Game — Skill / Tech Tree (nodes + links)
An interactive in-game skill and tech tree for a fictional action title, Ashen Vanguard. CSS-drawn hex nodes spread across four tiers, wired together with animated SVG link lines that light up as prerequisites resolve. A live skill-points budget tracks every choice, while locked, available, and unlocked states gate progression. Spend points to unlock branches, hover for effect tooltips, confirm a build, or hit respec to refund everything and start the loadout over.
MCP
Codice
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.10);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(1200px 600px at 18% -10%, rgba(124, 77, 255, 0.16), transparent 60%),
radial-gradient(1000px 520px at 100% 0%, rgba(0, 229, 255, 0.12), transparent 55%),
var(--bg);
color: var(--text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 24px 16px 40px;
}
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.014) 0,
rgba(255, 255, 255, 0.014) 1px,
transparent 2px,
transparent 4px
);
mix-blend-mode: overlay;
}
.shell {
position: relative;
z-index: 2;
max-width: 1080px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
padding: 16px 18px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: var(--r-lg);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 18px 40px rgba(0, 0, 0, 0.45);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 38px;
height: 38px;
flex: none;
background:
conic-gradient(from 220deg, var(--accent), var(--accent-2), var(--accent-3), var(--accent));
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
box-shadow: var(--glow);
animation: spinGlow 6s linear infinite;
}
@keyframes spinGlow {
to {
filter: hue-rotate(360deg);
}
}
.brand-studio {
font-family: "Orbitron", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.32em;
color: var(--accent);
text-shadow: 0 0 12px rgba(0, 229, 255, 0.5);
}
.brand-title {
margin: 2px 0 0;
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: clamp(18px, 3vw, 26px);
letter-spacing: 0.04em;
}
.hud {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 14px;
background: rgba(10, 11, 16, 0.55);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
}
.hud-stat {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.hud-label {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 3px;
}
.hud-value {
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 20px;
color: var(--accent);
text-shadow: 0 0 14px rgba(0, 229, 255, 0.45);
}
.hud-value.muted {
color: var(--text);
text-shadow: none;
font-size: 16px;
}
.hud-divider {
width: 1px;
align-self: stretch;
background: var(--line-2);
}
/* ---------- Buttons ---------- */
.btn {
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
color: var(--text);
background: var(--panel-2);
padding: 9px 16px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: transform 0.12s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
border-color: var(--accent);
box-shadow: var(--glow);
}
.btn:active {
transform: translateY(0);
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn-ghost {
background: transparent;
color: var(--muted);
}
.btn-ghost:hover {
color: var(--text);
border-color: var(--accent-3);
box-shadow: 0 0 18px rgba(255, 61, 113, 0.4);
}
.btn-glyph {
font-size: 14px;
}
.btn-primary {
background: linear-gradient(120deg, var(--accent-2), var(--accent));
border-color: transparent;
color: #05060a;
padding: 12px 26px;
font-size: 13px;
box-shadow: 0 8px 26px rgba(0, 229, 255, 0.3);
}
.btn-primary:hover {
box-shadow: 0 10px 34px rgba(124, 77, 255, 0.5);
}
/* ---------- Board ---------- */
.board-wrap {
position: relative;
background: linear-gradient(180deg, var(--bg-2), var(--panel));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 22px 50px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.legend {
display: flex;
gap: 18px;
margin-bottom: 10px;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
display: inline-block;
}
.dot-unlocked {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.dot-available {
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.dot-locked {
background: #3a3e55;
}
.board {
position: relative;
width: 100%;
height: 520px;
background-image:
linear-gradient(rgba(231, 233, 243, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(231, 233, 243, 0.035) 1px, transparent 1px);
background-size: 38px 38px;
border-radius: var(--r-md);
overflow: hidden;
}
.links {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.nodes {
position: absolute;
inset: 0;
}
/* ---------- Nodes ---------- */
.node {
position: absolute;
width: 92px;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
}
.node:focus-visible {
outline: none;
}
.node-hex {
position: relative;
width: 64px;
height: 64px;
display: grid;
place-items: center;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
background: linear-gradient(160deg, var(--panel-2), var(--panel));
border: 0;
transition: transform 0.15s ease, filter 0.2s ease;
}
/* draw a neon border with a pseudo wrapper */
.node-hex::before {
content: "";
position: absolute;
inset: -2px;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
background: var(--line-2);
z-index: -1;
}
.node-icon {
font-size: 24px;
line-height: 1;
filter: grayscale(0.7) opacity(0.6);
transition: filter 0.2s ease, transform 0.2s ease;
}
.node-rank {
position: absolute;
bottom: -4px;
font-family: "Orbitron", sans-serif;
font-size: 9px;
font-weight: 700;
padding: 1px 6px;
border-radius: 20px;
background: rgba(10, 11, 16, 0.85);
border: 1px solid var(--line-2);
color: var(--muted);
}
.node-name {
font-size: 11px;
font-weight: 600;
text-align: center;
color: var(--muted);
max-width: 96px;
transition: color 0.2s ease;
}
/* States */
.node[data-state="locked"] .node-hex {
filter: saturate(0.4) brightness(0.7);
}
.node[data-state="available"] .node-hex::before {
background: linear-gradient(160deg, var(--accent), var(--accent-2));
}
.node[data-state="available"] .node-hex {
animation: pulseAvail 2.2s ease-in-out infinite;
}
.node[data-state="available"] .node-icon {
filter: grayscale(0) opacity(1);
}
.node[data-state="available"] .node-name {
color: var(--text);
}
@keyframes pulseAvail {
0%, 100% {
box-shadow: 0 0 0 rgba(0, 229, 255, 0);
}
50% {
box-shadow: 0 0 22px rgba(0, 229, 255, 0.55);
}
}
.node[data-state="unlocked"] .node-hex::before {
background: linear-gradient(160deg, var(--success), #18b85e);
}
.node[data-state="unlocked"] .node-hex {
background: linear-gradient(160deg, rgba(54, 226, 122, 0.22), var(--panel));
box-shadow: 0 0 20px rgba(54, 226, 122, 0.4);
}
.node[data-state="unlocked"] .node-icon {
filter: grayscale(0) opacity(1);
}
.node[data-state="unlocked"] .node-name {
color: var(--text);
}
.node[data-state="unlocked"] .node-rank {
color: var(--success);
border-color: rgba(54, 226, 122, 0.5);
}
.node:hover .node-hex,
.node:focus-visible .node-hex {
transform: scale(1.08);
}
.node:focus-visible .node-hex::before {
background: var(--accent);
}
.node:hover .node-icon {
transform: scale(1.1);
}
/* burst animation on unlock */
.node.just-unlocked .node-hex {
animation: burst 0.55s ease;
}
@keyframes burst {
0% {
transform: scale(1);
}
40% {
transform: scale(1.28);
box-shadow: 0 0 36px rgba(54, 226, 122, 0.8);
}
100% {
transform: scale(1);
}
}
/* ---------- Links (SVG) ---------- */
.link-line {
fill: none;
stroke: rgba(231, 233, 243, 0.14);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke 0.4s ease, stroke-width 0.4s ease;
}
.link-line.is-available {
stroke: rgba(0, 229, 255, 0.45);
stroke-dasharray: 6 7;
animation: flow 1.4s linear infinite;
}
.link-line.is-active {
stroke: var(--success);
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(54, 226, 122, 0.6));
}
@keyframes flow {
to {
stroke-dashoffset: -26;
}
}
/* ---------- Tier rail ---------- */
.tier-rail {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin-top: 12px;
font-family: "Orbitron", sans-serif;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--muted);
text-align: center;
}
/* ---------- Footer ---------- */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.hint {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.hint strong {
color: var(--accent);
}
/* ---------- Tooltip ---------- */
.tooltip {
position: fixed;
z-index: 50;
max-width: 250px;
padding: 12px 14px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--accent);
border-radius: var(--r-md);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.6), var(--glow);
pointer-events: none;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.16s ease, transform 0.16s ease;
}
.tooltip.show {
opacity: 1;
transform: translateY(0);
}
.tt-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.tt-name {
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 13px;
color: var(--text);
}
.tt-cost {
font-family: "Orbitron", sans-serif;
font-size: 11px;
color: var(--warn);
white-space: nowrap;
}
.tt-effect {
font-size: 12px;
color: var(--muted);
margin: 0 0 6px;
}
.tt-status {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tt-status.locked {
color: var(--danger);
}
.tt-status.available {
color: var(--accent);
}
.tt-status.unlocked {
color: var(--success);
}
.tt-prereq {
margin: 6px 0 0;
font-size: 11px;
color: var(--muted);
}
.tt-prereq b {
color: var(--accent-3);
}
/* ---------- Toasts ---------- */
.toast-stack {
position: fixed;
z-index: 60;
bottom: 22px;
right: 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
font-size: 13px;
font-weight: 500;
padding: 11px 16px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
border-radius: var(--r-sm);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
animation: toastIn 0.25s ease;
max-width: 320px;
}
.toast.success {
border-left-color: var(--success);
}
.toast.warn {
border-left-color: var(--warn);
}
.toast.danger {
border-left-color: var(--danger);
}
.toast.out {
animation: toastOut 0.3s ease forwards;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(18px);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateX(18px);
}
}
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.topbar {
flex-direction: column;
align-items: stretch;
}
.hud {
justify-content: space-between;
}
}
@media (max-width: 520px) {
body {
padding: 14px 10px 28px;
}
.board {
height: 460px;
}
.hud {
gap: 10px;
padding: 8px 10px;
flex-wrap: wrap;
}
.hud-divider {
display: none;
}
.hud-value {
font-size: 17px;
}
.node {
width: 70px;
}
.node-hex {
width: 52px;
height: 52px;
}
.node-icon {
font-size: 19px;
}
.node-name {
font-size: 10px;
}
.footer {
justify-content: stretch;
}
.btn-primary {
width: 100%;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}(function () {
"use strict";
// ---- Tree data: Ashen Vanguard tech tree -----------------------------
// x/y are percentages of the board (0-100). tier drives left-to-right flow.
var NODES = [
// Tier I (roots, no prereqs)
{ id: "core", name: "Vanguard Core", icon: "⚡", tier: 0, x: 9, y: 50, cost: 1, prereq: [], effect: "Base discipline. +5% stamina regen on all combat actions." },
// Tier II
{ id: "blade", name: "Ashen Edge", icon: "⚔️", tier: 1, x: 33, y: 22, cost: 2, prereq: ["core"], effect: "Melee strikes deal +18% damage and ignore 10% armor." },
{ id: "guard", name: "Bulwark", icon: "⛨", tier: 1, x: 33, y: 50, cost: 1, prereq: ["core"], effect: "Block window widened by 0.2s; perfect-block reflects 30%." },
{ id: "spark", name: "Static Coil", icon: "✨", tier: 1, x: 33, y: 78, cost: 2, prereq: ["core"], effect: "Abilities apply Shock, chaining to 2 nearby enemies." },
// Tier III
{ id: "rend", name: "Cleaving Rend", icon: "\u{1F300}", tier: 2, x: 60, y: 14, cost: 3, prereq: ["blade"], effect: "Heavy attacks become a wide arc hitting all front targets." },
{ id: "fury", name: "Embered Fury", icon: "\u{1F525}", tier: 2, x: 60, y: 36, cost: 3, prereq: ["blade", "guard"], effect: "On kill, gain +25% attack speed for 6s. Stacks twice." },
{ id: "ward", name: "Aegis Ward", icon: "\u{1F6E1}️", tier: 2, x: 60, y: 64, cost: 2, prereq: ["guard"], effect: "Deploy a 4s barrier absorbing 400 damage. 18s cooldown." },
{ id: "overld", name: "Overload", icon: "\u{1F50B}", tier: 2, x: 60, y: 86, cost: 3, prereq: ["spark"], effect: "Shocked enemies detonate, dealing 120 arc damage in radius." },
// Tier IV (capstones)
{ id: "exec", name: "Executioner", icon: "\u{1F480}", tier: 3, x: 88, y: 25, cost: 4, prereq: ["rend", "fury"], effect: "ULTIMATE: enemies below 22% HP are instantly executed." },
{ id: "titan", name: "Titanfall", icon: "\u{1F985}", tier: 3, x: 88, y: 52, cost: 4, prereq: ["fury", "ward"], effect: "ULTIMATE: slam the field, stunning all foes for 3s." },
{ id: "storm", name: "Tempest Crown", icon: "⛈️", tier: 3, x: 88, y: 79, cost: 4, prereq: ["ward", "overld"], effect: "ULTIMATE: summon a roaming storm dealing 80 dps for 10s." }
];
var TOTAL_POINTS = 12;
var byId = {};
NODES.forEach(function (n) {
n.unlocked = false;
byId[n.id] = n;
});
// ---- DOM refs --------------------------------------------------------
var board = document.getElementById("board");
var nodesLayer = document.getElementById("nodes");
var svg = document.getElementById("links");
var tooltip = document.getElementById("tooltip");
var toastStack = document.getElementById("toast-stack");
var elPointsAvail = document.getElementById("points-available");
var elPointsSpent = document.getElementById("points-spent");
var elUnlocked = document.getElementById("nodes-unlocked");
var nodeEls = {}; // id -> button element
var linkEls = []; // { from, to, path }
// ---- State helpers ---------------------------------------------------
function prereqsMet(node) {
return node.prereq.every(function (pid) {
return byId[pid].unlocked;
});
}
function stateOf(node) {
if (node.unlocked) return "unlocked";
if (prereqsMet(node)) return "available";
return "locked";
}
function spentPoints() {
return NODES.reduce(function (sum, n) {
return sum + (n.unlocked ? n.cost : 0);
}, 0);
}
function availablePoints() {
return TOTAL_POINTS - spentPoints();
}
// ---- Build nodes -----------------------------------------------------
NODES.forEach(function (node) {
var btn = document.createElement("button");
btn.className = "node";
btn.type = "button";
btn.setAttribute("role", "treeitem");
btn.style.left = node.x + "%";
btn.style.top = node.y + "%";
btn.dataset.id = node.id;
btn.innerHTML =
'<div class="node-hex">' +
'<span class="node-icon">' + node.icon + "</span>" +
'<span class="node-rank">' + node.cost + " SP</span>" +
"</div>" +
'<span class="node-name">' + node.name + "</span>";
btn.addEventListener("click", function () {
onNodeClick(node);
});
btn.addEventListener("mouseenter", function (e) {
showTooltip(node, e);
});
btn.addEventListener("mousemove", positionTooltip);
btn.addEventListener("mouseleave", hideTooltip);
btn.addEventListener("focus", function () {
showTooltip(node, null, btn);
});
btn.addEventListener("blur", hideTooltip);
nodesLayer.appendChild(btn);
nodeEls[node.id] = btn;
});
// ---- Build links (SVG paths) ----------------------------------------
function buildLinks() {
svg.innerHTML = "";
linkEls = [];
var rect = board.getBoundingClientRect();
svg.setAttribute("viewBox", "0 0 " + rect.width + " " + rect.height);
NODES.forEach(function (node) {
node.prereq.forEach(function (pid) {
var from = byId[pid];
var x1 = (from.x / 100) * rect.width;
var y1 = (from.y / 100) * rect.height;
var x2 = (node.x / 100) * rect.width;
var y2 = (node.y / 100) * rect.height;
var midX = (x1 + x2) / 2;
var d =
"M " + x1 + " " + y1 +
" C " + midX + " " + y1 + ", " + midX + " " + y2 + ", " + x2 + " " + y2;
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
path.setAttribute("class", "link-line");
svg.appendChild(path);
linkEls.push({ from: pid, to: node.id, path: path });
});
});
}
// ---- Render ----------------------------------------------------------
function render() {
NODES.forEach(function (node) {
var st = stateOf(node);
var el = nodeEls[node.id];
el.dataset.state = st;
el.setAttribute("aria-pressed", node.unlocked ? "true" : "false");
el.setAttribute(
"aria-label",
node.name + ", " + node.cost + " skill points, " + st
);
});
linkEls.forEach(function (link) {
var from = byId[link.from];
var to = byId[link.to];
link.path.classList.remove("is-available", "is-active");
if (from.unlocked && to.unlocked) {
link.path.classList.add("is-active");
} else if (from.unlocked && prereqsMet(to)) {
link.path.classList.add("is-available");
}
});
var avail = availablePoints();
elPointsAvail.textContent = avail;
elPointsSpent.textContent = spentPoints();
var unlockedCount = NODES.filter(function (n) {
return n.unlocked;
}).length;
elUnlocked.textContent = unlockedCount + " / " + NODES.length;
elPointsAvail.style.color = avail === 0 ? "var(--warn)" : "var(--accent)";
}
// ---- Interactions ----------------------------------------------------
function onNodeClick(node) {
if (node.unlocked) {
toast(node.name + " is already unlocked.", "warn");
return;
}
if (!prereqsMet(node)) {
var missing = node.prereq
.filter(function (pid) {
return !byId[pid].unlocked;
})
.map(function (pid) {
return byId[pid].name;
});
toast("Locked — requires " + missing.join(" + "), "danger");
return;
}
if (node.cost > availablePoints()) {
toast("Not enough skill points (" + node.cost + " needed).", "danger");
return;
}
node.unlocked = true;
var el = nodeEls[node.id];
el.classList.add("just-unlocked");
setTimeout(function () {
el.classList.remove("just-unlocked");
}, 600);
render();
toast(node.name + " unlocked — " + node.cost + " SP spent.", "success");
// Highlight newly available children briefly via tooltip-less pulse.
NODES.forEach(function (child) {
if (child.prereq.indexOf(node.id) !== -1 && stateOf(child) === "available") {
flashAvailable(child.id);
}
});
}
function flashAvailable(id) {
var el = nodeEls[id];
if (!el) return;
el.animate(
[
{ filter: "brightness(1)" },
{ filter: "brightness(1.6)" },
{ filter: "brightness(1)" }
],
{ duration: 700, iterations: 1 }
);
}
function respec() {
var spent = spentPoints();
if (spent === 0) {
toast("Nothing to respec yet.", "warn");
return;
}
NODES.forEach(function (n) {
n.unlocked = false;
});
render();
toast(spent + " skill points refunded. Build reset.", "success");
}
document.getElementById("respec-btn").addEventListener("click", respec);
document.getElementById("commit-btn").addEventListener("click", function () {
var picks = NODES.filter(function (n) {
return n.unlocked;
});
if (!picks.length) {
toast("Pick at least one skill before confirming.", "warn");
return;
}
toast(
"Build confirmed — " + picks.length + " skills, " + spentPoints() + " SP committed.",
"success"
);
});
// ---- Tooltip ---------------------------------------------------------
function showTooltip(node, evt, anchorEl) {
var st = stateOf(node);
var statusText = st === "unlocked" ? "Unlocked" : st === "available" ? "Available" : "Locked";
var prereqHtml = "";
if (node.prereq.length) {
var names = node.prereq.map(function (pid) {
var p = byId[pid];
return p.unlocked ? p.name : "<b>" + p.name + "</b>";
});
prereqHtml = '<p class="tt-prereq">Requires: ' + names.join(" + ") + "</p>";
}
tooltip.innerHTML =
'<div class="tt-head">' +
'<span class="tt-name">' + node.name + "</span>" +
'<span class="tt-cost">' + node.cost + " SP</span>" +
"</div>" +
'<p class="tt-effect">' + node.effect + "</p>" +
'<span class="tt-status ' + st + '">' + statusText + "</span>" +
prereqHtml;
tooltip.classList.add("show");
tooltip.setAttribute("aria-hidden", "false");
if (evt) {
positionTooltip(evt);
} else if (anchorEl) {
var r = anchorEl.getBoundingClientRect();
placeTooltip(r.left + r.width / 2, r.top);
}
}
function positionTooltip(evt) {
placeTooltip(evt.clientX, evt.clientY);
}
function placeTooltip(x, y) {
var tw = tooltip.offsetWidth || 240;
var th = tooltip.offsetHeight || 120;
var left = x + 16;
var top = y + 16;
if (left + tw > window.innerWidth - 12) left = x - tw - 16;
if (top + th > window.innerHeight - 12) top = y - th - 16;
tooltip.style.left = Math.max(8, left) + "px";
tooltip.style.top = Math.max(8, top) + "px";
}
function hideTooltip() {
tooltip.classList.remove("show");
tooltip.setAttribute("aria-hidden", "true");
}
// ---- Toast helper ----------------------------------------------------
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastStack.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () {
el.remove();
}, 320);
}, 2600);
}
// ---- Init & resize ---------------------------------------------------
function init() {
buildLinks();
render();
}
var resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(init, 150);
});
init();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ashen Vanguard — Skill / Tech Tree</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<main class="shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div class="brand-text">
<span class="brand-studio">NULLFORGE</span>
<h1 class="brand-title">Ashen Vanguard</h1>
</div>
</div>
<div class="hud" role="group" aria-label="Skill points budget">
<div class="hud-stat">
<span class="hud-label">Skill Points</span>
<span class="hud-value" id="points-available" aria-live="polite">12</span>
</div>
<div class="hud-divider" aria-hidden="true"></div>
<div class="hud-stat">
<span class="hud-label">Spent</span>
<span class="hud-value muted" id="points-spent">0</span>
</div>
<div class="hud-divider" aria-hidden="true"></div>
<div class="hud-stat">
<span class="hud-label">Unlocked</span>
<span class="hud-value muted" id="nodes-unlocked">0 / 12</span>
</div>
<button class="btn btn-ghost" id="respec-btn" type="button">
<span class="btn-glyph" aria-hidden="true">⟲</span> Respec
</button>
</div>
</header>
<section class="board-wrap" aria-label="Skill tree">
<div class="legend" aria-hidden="true">
<span class="legend-item"><i class="dot dot-unlocked"></i> Unlocked</span>
<span class="legend-item"><i class="dot dot-available"></i> Available</span>
<span class="legend-item"><i class="dot dot-locked"></i> Locked</span>
</div>
<div class="board" id="board">
<svg class="links" id="links" aria-hidden="true" preserveAspectRatio="none"></svg>
<div class="nodes" id="nodes" role="tree" aria-label="Tech tree nodes"></div>
</div>
<div class="tier-rail" aria-hidden="true">
<span>TIER I</span><span>TIER II</span><span>TIER III</span><span>TIER IV</span>
</div>
</section>
<footer class="footer">
<p class="hint">Click an <strong>available</strong> node to spend a point. Hover or focus any node for its effect.</p>
<button class="btn btn-primary" id="commit-btn" type="button">Confirm Build</button>
</footer>
</main>
<div class="tooltip" id="tooltip" role="tooltip" aria-hidden="true"></div>
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Skill / Tech Tree (nodes + links)
A neon-lit progression panel for the fictional studio Nullforge and its action game Ashen Vanguard. Skills are rendered as CSS-clipped hexagon nodes laid out across four tiers, from the Vanguard Core root to ultimate capstones like Executioner, Titanfall, and Tempest Crown. Curved SVG paths connect each node to its prerequisites, and those links animate — dashed and cyan when a branch becomes reachable, solid green once both ends are unlocked.
The HUD at the top tracks a 12-point skill budget in real time: points available, points spent, and nodes unlocked out of the total. Clicking an available node spends its cost, fires an unlock burst, and pulses any children that just became reachable. Locked nodes refuse the click and explain which prerequisites are still missing. Every node exposes a tooltip with its effect text, cost, status, and requirements on hover or keyboard focus.
Interactions are pure vanilla JS with no dependencies: a Respec button refunds all spent points and resets the tree, Confirm Build summarizes the chosen loadout, and a lightweight toast helper surfaces feedback. The whole board is keyboard-navigable with visible focus rings, respects reduced-motion preferences, and reflows cleanly down to roughly 360px wide.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.