design-skill-progression-trees
Skill & Progression Trees
Purpose
DAG-based progression systems for any game genre — skill trees, talent trees, ability unlocks, passive stat graphs, and prestige layers. Covers data modeling, server-authoritative validation, cost scaling, and respec mechanics.
When to Use
Trigger: skill tree, talent tree, ability tree, progression system, unlock system, prestige, node graph, skill points, ability unlock, passive tree, talent build, respec, skill reset
Prerequisites
game-design-fundamentals— core loop and reward system knowledgepostgres-game-schema— player and currency data modelsgame-economy-design— cost balancing and currency integration
Core Principles
Chris Taylor (Supreme Commander): "A good tech tree gives the player the illusion of infinite choice while keeping every path viable." Will Wright (The Sims, Spore): "Emergent systems arise when simple rules interact — skill trees should compose, not prescribe." Jonathan Blow (Braid, The Witness): "Every unlock should feel like a genuine insight, not a checkbox."
- DAG not tree — prerequisites form a directed acyclic graph; cycles break validation, break rollback, break everything. Enforce acyclicity at admin write time.
- Server-authoritative unlock validation — never trust client unlock requests. Validate prerequisites, currency, and cooldowns server-side before writing.
- Prerequisite snapshot for rollback — store the full prerequisite state at unlock time. If a respec or patch changes the graph, you can audit what was valid when the player unlocked it.
- Cost scaling follows exponential curve — flat costs make endgame nodes trivially cheap relative to player income. Use
cost = baseCost * tier^exponent(typically exponent 1.5-2.0). - Respec is player-friendly — always provide a reset mechanism. Free with cooldown, or paid with soft currency. Never lock players into permanent bad choices.
- Effects are data, not code — store effects as JSONB (
{ "stat": "attack", "modifier": "+10%" }). Apply server-side. Enables hot reload, A/B testing, and balance patches without deploys. - Progressive reveal — don't show all nodes at once. Reveal nodes as prerequisites are met. Reduces overwhelm and creates discovery moments (Jonathan Blow).
Step-by-Step Instructions
1. Define Node Categories
Classify every node: active skills (abilities the player triggers), passive stats (always-on bonuses), unlocks (gates to content or mechanics), prestige (post-endgame reset bonuses).
2. Model the DAG
Use an adjacency list stored in JSONB. Each node has a prerequisites array of node IDs. Validate acyclicity on every admin write.
3. Write Drizzle Schema
Create skill_nodes (tree definition) and player_skills (player state) tables. See boilerplate for full implementation.
4. Implement Unlock API
Endpoint: POST /skills/unlock/:nodeId. Flow: check prerequisites met, check currency available, atomic transaction (deduct currency + insert player_skills + snapshot).
5. Cache Unlocked Nodes
Store unlocked node IDs in Redis per player session. Invalidate on unlock or respec. Tree lookups are frequent — database per-request is too slow.
6. Add Respec Endpoint
Endpoint: POST /skills/respec. Flow: deduct respec fee (or check cooldown), delete all player_skills rows, refund skill points to player wallet, invalidate Redis cache.
7. Add Tree Health Checks
Admin-only utilities: cycle detection (DFS on prerequisite graph), orphaned node detection (nodes with prerequisites referencing deleted nodes), unreachable node detection.
Drizzle Schema
// skill_nodes — defines the tree structure (admin-managed)
export const skillNodes = pgTable("skill_nodes", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
description: text("description"),
category: text("category").notNull(), // 'active' | 'passive' | 'unlock' | 'prestige'
tier: integer("tier").notNull().default(1),
prerequisites: jsonb("prerequisites").$type<string[]>().default([]),
cost: integer("cost").notNull().default(1),
effects: jsonb("effects").$type<Record<string, unknown>[]>().default([]),
isVisible: boolean("is_visible").notNull().default(true),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// player_skills — tracks which nodes each player has unlocked
export const playerSkills = pgTable("player_skills", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
playerId: text("player_id").notNull().references(() => players.id, { onDelete: "cascade" }),
nodeId: text("node_id").notNull().references(() => skillNodes.id),
unlockedAt: timestamp("unlocked_at").notNull().defaultNow(),
snapshot: jsonb("snapshot").$type<Record<string, unknown>>().default({}),
});
API Patterns
| Endpoint | Method | Description |
|---|---|---|
/skills/tree |
GET | Full tree with player unlock status per node |
/skills/unlock/:nodeId |
POST | Unlock node — validates prerequisites, deducts cost, atomic write |
/skills/respec |
POST | Full reset — deducts fee, deletes player_skills, refunds points |
Cross-References
game-economy-design— cost balancing, currency sinks for skill pointspostgres-game-schema— player and currency table schemasredis-game-patterns— caching unlocked node sets per sessiongame-design-fundamentals— core loop integration and progression pacing
Pitfalls & Anti-Patterns
- "Client-side tree state" — storing unlock state in the client; trivially hackable, desync guaranteed
- "No respec mechanic" — players who make bad choices quit; always provide a reset path
- "Flat cost curves" — every node costs the same; endgame nodes become trivially cheap as income scales
- "Missing cycle detection" — one circular prerequisite creates infinite loops in validation; enforce DAG at write time
- "Hardcoded effects" — effects baked into code instead of JSONB data; every balance change requires a deploy
- "All nodes visible" — showing the entire tree upfront overwhelms new players and spoils discovery
Designer Philosophy
Chris Taylor (Supreme Commander, Total Annihilation): Tech trees should give players the feeling of building toward something powerful. Every branch should feel like it opens new strategic possibilities. If a player looks at two branches and one is obviously better, the tree has failed.
Will Wright (The Sims, SimCity, Spore): The best progression systems are ones where simple rules interact to create emergent builds. Don't design 50 unique abilities — design 10 composable ones that combine in 50 ways.
Jonathan Blow (Braid, The Witness): Unlocking a skill should feel like understanding something new, not like filling a progress bar. If the player doesn't feel smarter or more capable after an unlock, the node is wasted.
Sources
- "Designing Tech Trees and Progression Systems" — GDC 2016, Chris Taylor
- "The Art of Game Design" — Jesse Schell, Chapters on Skill and Chance
- "Balancing Ability Systems in Multiplayer Games" — GDC 2019
- "Emergent Gameplay from Simple Systems" — Will Wright, GDC Classic
- "Player Agency in Progression Design" — GDC 2020