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.


A single bright accent-green root node low at the center of a dark canvas with edges fanning upward and outward into a branching cluster of dimmer nodes that then re-converge into one bright trunk node near the top — every unlockable in the game is reachable from the one root, and the trunk is where the branches are forced to meet, with the overall shape forming the argument of the piece
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.

A single root node labelled new_game_clicked order 1 zero prereqs on the left of a dark canvas with thirteen accent-green edges fanning outward to a vertical stack of thirteen labelled branch chips on the right, each chip showing a branch name such as PIPELINE FOUNDATION DISCOVERY PLAYER_LEVEL ECONOMY and a node-count badge, with a summary label reading 254 nodes reachable from one root, illustrating that every unlockable in the game descends from a single root node
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.

A before-and-after diagram with the left side labeled before showing four separate slightly misaligned drifting boxes reading Tutorial sequencing, App gating, Content unlock, and Economy milestones drawn as disconnected, a bold arrow in the center, and the right side labeled after showing one DAG node-schema box listing the fields unlocks_app, gates_app, unlocks_chip, triggers_flow, and reward, with a footer reading four authorities to one node
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.

A diagram showing several labeled branch streams reading ECONOMY, PIPELINE, CAREER, and DISCOVERY on the left feeding rightward into a vertical PLAYER_LEVEL trunk of stacked level gates labeled L5, L10, L15, and L20, each gate tagged prereqs_any 3-of-7, with a small contrast inset showing a thin single line labeled Civ single-path avoided, and a footer reading breadth required not 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.


macrocode·proudly crafted with AIpowered by Claude Opus 4.6