DL-025: Progression Is a Graph, Not a List
Most games store progression as a list — level 1, level 2, level 3. This one stores it as a single-root directed acyclic graph: 254 nodes that govern tutorial, app unlocks, economy, and player level from one authority. The list you see in the HUD is just a projection of the graph.
Figure 1: One bright root node, edges fanning outward into a branching graph that re-converges into a single trunk. Every unlockable in the game is reachable from that one root, and the trunk is where the branches are forced to meet. The shape is the whole argument.
What Happened — One Root, 254 Nodes
The deep dive's DAG sidecar described the achievement DAG as the single unlock authority for the entire game, and the more I read the more literal "single" turned out to be. There is one root: new_game_clicked, order 1, no prerequisites. Every reachable piece of content traces back to it. That single-root invariant is enforced by lint, and it is a reachability guarantee — no orphaned content, no dead-branch gating bug, by construction. From that one node hang 254 nodes across 13 branches, wired by roughly 305 prerequisite edges.
Figure 2: One root node fans out to all 13 branches; the count badges sum to 254. The single-root invariant is a reachability guarantee enforced by lint — every piece of content is reachable from new_game_clicked, so there is no orphaned content and no dead-branch gating bug by construction.
The Problem — Four Authorities Drifting
Before the DAG was the authority, the game had the problem every growing game has. Tutorial sequencing lived in one system. App and feature gating lived in another. Content unlocks — which chips you could buy — lived in a third. Economy milestones lived in a fourth. Four parallel unlock authorities, each with its own logic, each drifting out of sync with the others. "Where does this unlock live?" had four possible answers, and keeping them consistent was manual work that failed quietly.
The Insight — The DAG Is the Progression State
The move was to notice that if every game event can be named, then every named event can be a DAG node, and every DAG node can be the source of any unlock. One node schema carries it all: unlocks_app, gates_app, unlocks_view_tab, unlocks_command, unlocks_chip, triggers_flow, triggers_tour, reward.claimable, reward.unlocks_achievements.
{ "id": "first_task_docked", "order": 31, "branch": "FOUNDATION",
"prereqs_all": ["tasks_app_opened"], "prereqs_any": [],
"trigger": { "type": "EVENT", "event": "task_docked" },
"reward": { "unlocks_achievements": ["terminal_story_unlocked"] } }
The DAG is not a layer sitting on top of progression. It is the progression state. "Where does this unlock live?" collapses to one answer — a DAG node — and the four drifting authorities become one.
Figure 3: Four independent unlock authorities — tutorial sequencing, app gating, content unlock, economy milestones — each with its own logic and its own drift, collapsed into one node schema where a single node can carry any unlock. The architectural question "where does this unlock live?" stops having four answers.
The Runtime — Forward, Event-Driven, Stateless
The runner subscribes to a per-shell event registry. When an event fires, it increments that event's counter, then for each node triggered by the event it checks two things: whether the trigger is satisfied (an EVENT trigger always is; EVENT_COUNT compares the counter to a threshold; THRESHOLD reads a monotonic scalar) and whether the prerequisites are satisfied (prereqs_all requires all, prereqs_any requires at least one). If both pass, the node unlocks and emits.
func _on_event(event_id):
_event_counts[event_id] += 1
for node in _nodes_triggered_by(event_id):
if _trigger_satisfied(node) and _prereqs_satisfied(node):
_unlocked[node.id] = true
achievement_unlocked.emit(node.id)
There is no backward traversal, no re-evaluation sweep, no memoization cache. The runner is stateless except for two dictionaries — what's unlocked and how many of each event has fired. The whole thing fits in your head, which is the point.
The Convergence Trunk — Forcing Breadth
The PLAYER_LEVEL branch, 27 nodes, is the pacing spine. Its level-up nodes use prereqs_any to require breadth — a level node might demand a tier-1 achievement from three of seven branches before it unlocks. You can rush the ECONOMY branch, but you cannot pass level 10 without investing across the others. This is the Civilization tech-tree pattern — N things in one era before the next era opens — except it explicitly avoids Civ's single-path degeneracy by requiring breadth instead of depth.
Figure 4: Multiple branch streams feed into the PLAYER_LEVEL trunk, where each level gate requires breadth across branches before it opens. The contrast inset shows the single-path tech tree this design deliberately avoids — depth alone cannot carry a player up the trunk.
The List Is a Projection
Every node carries a globally unique order integer. That is a designer-authored linearization of the partial order — a total ordering imposed on a graph. The "next in line" goal the HUD shows is simply the minimum-order node whose prerequisites are satisfied but which is not yet unlocked. The flat list of next goals a player sees is computed from the graph, the same way a spaced-repetition scheduler derives a queue from a dependency structure. The graph is primary; the list is a view of it.
What I Noticed
The curriculum parallel is exact. The main branches fan out like subject disciplines; the PLAYER_LEVEL trunk is the convergence requirement, a graduation rule that demands credits across all areas before it lets you advance. The DISCOVERY branch — 31 nodes of "player opened app X for the first time" — is a first-touch registry, the same bookkeeping adaptive-learning systems keep before deciding what to show a learner next. And the dual model, where the DAG handles event-driven one-shot unlocks and a separate ActionGate handles compound runtime-state predicates like "current wave ≥ 5 and owns relic X," is the behavior-specification distinction between "when an event occurs" and "given this state."
Then there is the part I keep arriving at. macroplatform's own work is a graph of this exact shape. The CR lifecycle is a state machine; the cross-CR dependency graph is a DAG the orchestrator builds before every implementation wave; the aff-core spine is, literally, pipeline-graph.schema.json and api-transitions.schema.json. The games team modeled progression as a single-root DAG with a convergence trunk. The framework models software delivery as a DAG with gate convergence. Same structure, different domain, no shared code. This is the second time in two devlogs I have found the framework's instinct living in the games substrate without the framework. DL-024 was the editor as compiler; this is progression as a graph. The machinery was set aside. The way of seeing was not.
The honest note: the authoring is not finished. The DAG editor renders edges from loaded data, but designers cannot draw new ones yet — the connection_request signal is unwired, so prerequisite edges are hand-edited JSON until CR-180 closes the gap. And the second game's DAG is a five-node stub next to LOHOP's 254. The infrastructure is holistic; the second game's content is still bootstrapping.
By the Numbers
| Metric | Value |
|---|---|
| Total nodes (LOHOP) | 254 |
| Prerequisite edges | ~305 |
| Branches | 13 |
| Single root | new_game_clicked (order 1, zero inbound edges) |
| Largest branch | PIPELINE (66 nodes) |
| Convergence trunk | PLAYER_LEVEL (27 nodes) |
| Trigger types | 5 — EVENT, EVENT_COUNT, THRESHOLD, ACK, CASCADE |
| Concerns governed by one DAG | 6 — tutorial, app gating, content, economy, level, trophies |
| Cycle detection | DFS three-color, live on every save |
Tomorrow
Two devlogs in, I have found the same shape twice — the editor as compiler, progression as graph. The next thread is the harder one, and it is about the framework itself: it was set aside on purpose, and six weeks of watching what happened without it taught me which parts had latent value I could not see while they were still in place. That is the framework-experiment beat, and it starts next.
Latest Entries
From Single Project to Starter Kit: Extracting a Governed Framework
From Single Project to Starter Kit: Extracting a Governed Framework The hardest part of open-sourcing an internal framework is separating the generic from the specific. [...]
DL-025: Progression Is a Graph, Not a List
DL-025: Progression Is a Graph, Not a List Most games store progression as a list — level 1, level 2, level 3. This one stores [...]
DL-024: The Editor Is the Compiler
DL-024: The Editor Is the Compiler A node graph you wire on a canvas, then press Run and watch the output render live inside the [...]
DL-023: The Same Algorithm Made Three Different Things
DL-023: The Same Algorithm Made Three Different Things A 2007 Eurographics paper on growing trees. A 1964 Japanese paper on water transport in plant stems. [...]
DL-021 Part 2: The Rule That Caught Itself
DL-021 Part 2: The Rule That Caught Itself Everything went wrong, all at once, and every single failure was the pipeline catching itself doing the [...]
DL-021 Part 1: The Content Engine
DL-021 Part 1: The Content Engine We set out to publish yesterday's devlog. The website caught a compliance gap, the wrong fix took the site [...]









