Anchor v1 answered "how do entities own other entities?" with one structure: a tree with a root called an, where every timer, collider, enemy and effect was somebody's child. This page explains what that tree did each frame, why it felt so good for a few thousand lines, where it stopped being the right tool, and what Anchor 2 uses instead.
Everything in a v1 game was an object, and every object except the root had exactly one parent. self:add(child) did three things at once: it appended the child to self.children, it set child.parent, and, if either side had a name, it wired a field in both directions so a player could say self.timer and the timer could say self.player.
function object:add(child)
table.insert(self.children, child)
child.parent = self
if child.name then
if self[child.name] then self[child.name]:kill() end -- same-named child: the old one dies
self[child.name] = child
end
if self.name then child[self.name] = self end -- and the child can reach its parent by name
return self
end
That is the entire mechanism. Below is a small but honest game as v1 would hold it. Click any node: that runs the real kill semantics on it, marking the node and everything under it, and the sweep removes them the way the end of a frame would.
kill() it.
0 marked dead
player.timer, timer.player). Killing a node marks its whole subtree; nothing leaves the tree until the frame's cleanup runs, and cleanup destroys the deepest nodes first.The tree was also the update order. The C engine called a single Lua update(dt) every physics tick, and that function walked the tree rather than the game. First it flattened an and all its descendants into one array with a depth-first search. Then it ran three passes over that same array: an early phase, a main phase, a late phase. Then it swept.
function update(dt)
local all_objects = {an}
for _, obj in ipairs(an:all()) do table.insert(all_objects, obj) end -- DFS flatten, every tick
for _, obj in ipairs(all_objects) do obj:_early_update(an:get_dt_for(obj)) end
for _, obj in ipairs(all_objects) do obj:_update(an:get_dt_for(obj)) end
for _, obj in ipairs(all_objects) do obj:_late_update(an:get_dt_for(obj)) end
an:cleanup() -- flatten AGAIN; drop finished actions; remove dead children, deepest first
end
player:update ran because the traversal reached it, between whichever siblings the DFS put around it. The three phases existed so a game could still express "input before movement before drawing" without controlling the order directly.Two details of that loop matter later. an:all() allocated a fresh array and a fresh stack on every call, and the frame called it twice, at 144 ticks per second. And the phases were the only ordering tool: to make something happen before something else, you moved it to an earlier phase.
For the case it was designed around, composition, it was close to ideal, and it is worth being precise about why, because the same properties are what made it hard to leave.
A player owned a timer, a collider and a spring. When the player died, so did they, in the right order, with destroy called on each so the Box2D body was actually released. No game code tracked any of it.
enemy = object:extend()
function enemy:new(x, y)
object.new(self)
self.x, self.y = x, y
self:add(collider('enemy', 'dynamic', 'circle', 16)) -- self.collider, dies with self
self:add(timer()) -- self.timer, dies with self
end
function enemy:update(dt)
if self.hp <= 0 then self:kill() end -- one call, whole subtree, resources included
end
The class was the whole description of the entity: its fields, its children, its behavior, its cleanup, in one place. That was the first principle of v1 and the tree is what made it cheap to honor.
An object('projectiles') child was a group. self.projectiles:kill() emptied it. Named children replaced their predecessor automatically, so re-adding a 'shield' swapped the old one out.
Effects and decorations didn't need a class. An inline object with a one-shot action removed itself by returning true, and flow_to let you build the thing and then say where it lived.
cloud = object()
cloud:set({ x = px, y = py, speed = 12 })
cloud:action(function(self, dt)
self.x = self.x + self.speed*dt
bg:image(cloud_image, self.x, self.y)
end)
cloud:flow_to(self) -- now it updates, draws and dies with its parent
For a game of a thousand lines, held by one person, this is a genuinely good model. The framework does the bookkeeping and the game reads like a description.
A tree encodes one relation: owns. A game needs about five, and the other four were the ones that grew with the codebase.
an:all('enemy') for aggregates, direct table references for back-links, physics queries for space, link for death events, flow_to for moving between states. Orblike ended up with all five patterns in one codebase and no rule for which to use when.an:all('enemy') answered it by walking the entire tree and filtering, every time it was asked.link: a bidirectional subscription list on both objects, with cleanup code on both sides, so that a callback could fire from inside someone else's kill().flow_to grew from a chaining convenience into a state-machine tool.Because children never outlive parents, the tree forced a decision every time something was created: whose subtree does it live in? A bullet the player fires is the obvious example. Add it to the player and it vanishes the moment the player dies, mid-flight. So it goes into a projectiles container instead, and now its relationship to the player, the thing that actually matters for scoring and team, is a plain field with no lifetime story at all.
This is the deeper problem, and the one that scaled worst. Look at a v1 player:update(dt) and you cannot tell what happened that frame. Its timer ticked, but not in that function; the framework reached the timer child on its own traversal. Its collider synced positions in a phase you can't see from the source. An action registered somewhere else replaced self.move. A link from an enemy fired a callback on the player during the enemy's death. To debug, you held the traversal order in your head.
The frame's own edge cases show how much was being carried. From the code's documentation: adding the same child twice puts it in children twice and kills it twice; adding a child that already has a parent puts it in two arrays; all() returns dead objects and leaves the check to the caller; named actions live at self[name] and get nilled by cleanup. Each one is reasonable. Together they are a lot of rules for "a timer belongs to the player."
Every one of these problems got worse when an AI wrote most of the code. Claude made locally correct choices with globally wrong consequences: self:add for what should have been a field, link for what should have been polled, a different phase to paper over a timing bug. Each decision was defensible. The sum was a codebase whose implicit work nobody, human or model, could track at a glance.
The observation behind Anchor 2 is that most of the tree's machinery existed to solve problems the tree had created. The lifetime tracker, the subscription system, the phase ordering: each was a sophisticated answer to a question a flatter design never asks. So the rewrite replaced systems with disciplines.
nil, which is the whole cleanup story.function player:new(x, y)
object.new(self)
self:add(timer())
self:add(spring())
self:add(collider('player', 'dynamic', 'circle', 12))
self:link(target, function(s) s.homing = false end)
end
function player:update(dt)
-- timer, spring, collider updated elsewhere
-- by the traversal, before or after this
...
end
function player:new(x, y)
make_entity(self) -- self.id, registered
self.timer = timer_new()
self.spring = spring_new()
self.collider = collider(self, 'player', 'dynamic', 'box', 12, 12)
self.target_id = target.id -- an id, not a pointer
end
function player:update(dt)
timer_update(self.timer, dt) -- every frame's work is here
spring_update(self.spring, dt)
local t = entities[self.target_id]
if not t then self.homing = false end -- poll, don't subscribe
self.collider:sync()
end
function player:destroy()
self.collider:destroy() -- you list what you own
end
Each v1 feature has a v2 answer, and every answer is more verbose on purpose:
| v1 mechanism | What it solved | v2 discipline |
|---|---|---|
| Child update by traversal | sub-objects tick without being mentioned | Plain fields; timer_update(self.timer, dt) written where it runs |
| Kill cascade | children never outlive parents | kill() queues; process_destroy_queue() calls each entity's own destroy at frame end |
Tags + an:all('enemy') | aggregates | enemies = {} you maintain; collection_update compacts the dead |
| Direct references | back-links | Integer ids resolved through entities[id]; a dead id is nil |
link | react when something dies | Poll its state in your own update |
flow_to | re-parent, change state | Conditional logic; string dispatch |
| Early / main / late phases | ordering | Write update(dt) in the order you want |
The an root | one place for everything | Plain global tables and an explicit main loop the engine calls directly |
The trade is exactly the one it looks like. v2 game code is longer because it does what the framework used to hide, and that is why it can be read top to bottom by someone who doesn't know the framework, including a model generating the next thousand lines of it. The rewrite wasn't a philosophy. It came from watching a real codebase pass the size where implicit work could still be tracked.
So, the original question. "Parent–child" bundles at least four separate questions, and each design below answers them with a different mix. The v1 tree's real mistake was answering all four with one edge.
| Approach | Lifetime | Update order | Cross-references | Shines | Hurts |
|---|---|---|---|---|---|
| Ownership tree (Anchor v1) | automatic cascade | traversal + phases | bolt-ons per need | composition, containers, tiny inline objects | everything that isn't ownership; invisible work at scale |
| Scene graph (Godot, Unity transforms) | cascade | traversal, with hooks | node paths, signals, groups | hierarchies where the parent's transform must apply to children: UI, rigs, vehicles | gameplay relations that aren't spatial; nodes become the unit of everything |
| Flat map + ids (Anchor 2) | explicit destroy, deferred queue | the code you wrote | ids resolved at use; stale is impossible | gameplay entities of a few dozen to a few thousand; readable frames | verbosity; you maintain your own arrays |
| Handles with generations | slot reuse without stale access | explicit | handle = index + generation | the same as ids, with slot reuse for high churn | more machinery than a growing integer needs at Anchor's scale |
| ECS | per-component removal | systems in a fixed order | entity ids; relations are components | thousands of uniform things; cache-friendly iteration | an entity's behavior is spread across systems; locality is gone by design |
| Parent pointer only | child checks parent | explicit | one upward link | attachments that follow a host (a weapon, a status) | no downward iteration; containers need a second structure |
The way to choose is to ask which question is doing the work. If children must inherit a parent's transform, you want a scene graph, and only for those objects. If entities need to know about each other across the whole game, you want ids in a flat map. If you have ten thousand of the same thing, you want an ECS. Anchor 2 keeps one class as an exception to its own rule, collider, because a body, a shape and a tag really are one thing; the principle is not "never a hierarchy," it's "a hierarchy only where the parent relationship is the truth of the object."
The v1 tree was a good answer to the first question that got asked to answer all of them. Anchor 2 is what it looks like to separate the questions again.
Sources · archive/Anchor/framework/anchor/object.lua and init.lua · Anchor Engine Overview (Jan 2026) · Anchor 2 Engine Overview (Apr 2026) · Anchor2/reference/anchor2_plan.md