Anchor 2 Engine Overview
Anchor 2 is a rewrite of Anchor's Lua framework layer. The C engine — the ~10500-line anchor.c file that handles SDL2, OpenGL, Box2D, and miniaudio — is unchanged. What changed is how Lua code is organized on top of it, and why.
This post is a tour of v2: what happened, why it happened, and how the systems work. If you haven't read the original Anchor Engine Overview, start there — this post assumes you know what v1 looked like and focuses on what's different.
What happened
Anchor 1 built its framework around a single idea: a root object an that owns a tree of child objects, with traversal-based update/draw, tag-based queries, action phases (early_action/action/late_action), and late-added link/flow_to helpers for cross-entity references and state machines. It worked. Orblike and Emoji Ball Battles were being made on it. The framework was a real product.
It also had two problems that kept getting worse as the codebases grew.
The tree was great for one thing and mediocre at everything else. It was designed for compositional ownership: a player object contains a timer, a collider, a spring. That case is handled beautifully. But real games also need aggregates (all enemies), back-references (the beam's source orb), spatial queries (who's near me), event reactions (fire when a target dies), and state-machine transitions between game states. The tree is fine for composition and awkward for everything else. Orblike ended up using the tree for composition and plain tables for everything else, producing a codebase with five different reference patterns — tree parentage, tag queries, direct table references, the link helper, the flow_to helper — and no consistent story about which to use when.
The framework did work invisibly. Auto-update of children, hook dispatch, cleanup cascades, flow_to state transitions, link callback firing — all of it happened inside the framework as you went through each frame, without any mention in game code. That was the point, and for the first few thousand lines it felt great. Past that, it started making the code harder to read. You couldn't look at a player:update(dt) function and know what happened that frame, because half of it was the framework traversing children you couldn't see from the source. Debugging meant holding the framework's traversal order in your head.
Both problems compound when an AI is writing the code. Claude would make locally correct decisions that were globally suboptimal because it couldn't see the framework-level consequences of its choices. It would reach for self:add(...) for things that should have been plain fields, or use link for things that should have been polled, or stick something in a different action phase to paper over a timing bug. Each individual decision was reasonable. The cumulative result was a codebase where the framework's implicit work was doing more than anyone — human or AI — could easily track.
Why the rewrite
The observation that produced v2 is that most of the framework's complexity existed to solve problems that didn't have to exist in the first place. The tree was a sophisticated reference-lifetime system. link was a sophisticated subscription system. The action phases were a sophisticated ordering system. Each one solved a real problem, but the problems came from the framework's own design choices.
So the rewrite replaces systems with disciplines. Instead of building a sophisticated reference-lifetime tracker, use IDs and look them up. Instead of building a subscription system, poll each frame. Instead of hook-based ordering, write your update function in the order you want. The framework shrinks dramatically, explicit code grows a little, and correctness becomes local to each call site.
The result, after one overnight session and ~4500 lines, is 18 framework files that parse clean and a test game (arena/main.lua) that validates every framework module against the real engine. The v1 framework's main sources of bug-class complexity are gone: no tree, no hooks, no subscriptions, no flow_to, no an god-object. What's left is smaller, more explicit, and reads top-to-bottom.
Core philosophy
Minimal framework, maximal explicitness. Code reads top-to-bottom. There's no hidden work. Every frame's operations are visible in one update(dt) function.
Procedural by default. Most framework modules — timer, spring, camera, shake, animation, physics, layer — are plain functions that take state as the first argument. timer_update(self.timer, dt), not self.timer:update(dt). This makes call sites explicit and eliminates per-module inheritance chains.
Classes where they earn it. collider stays a class because it bundles body + shape + tag and hosts steering behaviors — the grouping is natural and method syntax pays off when you call four steering functions in a row. font, spritesheet, and image are thin class wrappers around resource handles. Everything else is functions.
IDs, not pointers. Cross-entity references are stored as integer IDs and resolved via a global entities table at use time. Stale references are impossible because there are no references — only IDs, and entities[dead_id] returns nil.
Poll, don't subscribe. If entity A needs to react to entity B's state, A checks B each frame in its own update function. No subscription system, no callbacks, no bookkeeping.
The game owns the main loop. update(dt) and draw() are top-level Lua globals that the C engine calls directly. Your update loop is not hidden under framework magic. You can read it and know what happens each frame.
Architecture
┌─────────────────────────────────────────┐
│ Game Code (Lua) │
│ - Your entity classes and logic │
├─────────────────────────────────────────┤
│ Framework (Lua) │
│ - timer, spring, camera, shake, │
│ layer, physics, collider, etc. │
├─────────────────────────────────────────┤
│ Engine (C, unchanged from v1) │
│ - SDL2, OpenGL, Box2D, miniaudio │
└─────────────────────────────────────────┘
The C engine is identical to v1. All the changes are in the middle layer. Game code talks to the framework, the framework talks to the engine.
Framework modules, in load order:
class → math → array → color → object → helpers → input →
timer → spring → animation → font → image → spritesheet →
layer → shake → camera → collider → physics
Total: ~4500 lines across 19 files, compared to v1's ~2500 lines across ~15 files. V2 is actually slightly bigger in raw lines — most of the growth is in math.lua and array.lua ports, plus the explicit procedural wrappers that used to live as class methods. The complexity budget is significantly smaller: no tree, no hooks, no flow_to, no link, no subscribe, no auto-cleanup cascade.
Main loop
The game defines two globals; the engine calls them directly each frame:
function update(dt)
sync_engine_globals()
-- Input-driven state transitions
if input_pressed('reset') then reset_game(); return end
-- Entity updates (the game owns the loop)
if p1 and not p1._dead then p1:update(dt) end
collection_update(enemies, dt)
collection_update(projectiles, dt)
collection_update(effects, dt)
-- Physics events (engine already stepped physics before calling update)
for _, ev in ipairs(sensor_entities_begin('projectile', 'enemy')) do
if not ev.a._dead and not ev.b._dead then
ev.b:hit(1)
ev.a:kill()
end
end
camera_update(main_camera, dt)
process_destroy_queue()
end
function draw()
layer_rectangle(game_layer, 0, 0, width, height, bg_color())
camera_attach(main_camera, game_layer)
for _, e in ipairs(enemies) do e:draw() end
if p1 and not p1._dead then p1:draw() end
for _, fx in ipairs(effects) do fx:draw() end
camera_detach(main_camera, game_layer)
layer_render(game_layer)
layer_draw(game_layer)
end
A few things to notice:
sync_engine_globals()is called explicitly at the top ofupdate. Every line of framework-level bookkeeping is visible in game code.- Physics is stepped by the C engine before
update(dt)runs. There is nophysics_stepin Lua. Your update reads events that the engine already has queued. collection_update(list, dt)reverse-iterates a list, removes entries with_dead, and calls:update(dt)on the rest. It's the only "framework does it for you" helper, and even it's a 12-line function you could rewrite inline.process_destroy_queue()drains the deferred kill queue at frame end. Entities killed during this frame are still queryable until this call runs, which prevents mid-frame state divergence bugs.
Object system
Entities are plain tables with integer IDs, stored in a global entities map. There is no tree, no parent-child hierarchy, no tag system, no traversal.
player = class()
function player:new(x, y)
self.x, self.y = x, y
self.hp = 5
make_entity(self) -- assigns self.id, registers
-- Compositional sub-objects are plain fields
self.timer = timer_new()
self.spring = spring_new()
spring_add(self.spring, 'hit', 1)
self.collider = collider(self, 'player', 'dynamic', 'box', 12, 12)
self.collider:set_gravity_scale(0)
end
function player:update(dt)
timer_update(self.timer, dt) -- explicit update of sub-objects
spring_update(self.spring, dt)
-- ...gameplay logic
self.collider:sync() -- sync physics -> self.x/y
end
function player:destroy() -- called from process_destroy_queue
if self.collider then self.collider:destroy() end
end
class() is 15 lines of Lua. It creates a table with an __index metatable and a constructor __call. No inheritance; if you want a variant, copy the class and modify. Deliberately minimal.
make_entity(e) assigns e.id (a never-reused auto-incrementing integer) and registers e in entities[e.id]. Also installs a default kill method. Returns e for chaining.
Every entity gets a default :kill method that marks _dying = true and adds the entity to a destruction queue. The queue drains at end of frame via process_destroy_queue(), which calls each entity's :destroy method (if defined) and removes it from the entities map.
Cross-entity references use IDs
This is the single most important pattern:
-- Don't do this — stale references
self.target = other_entity
-- Do this — ID, resolve at use time
self.target_id = other_entity.id
function seeker:update(dt)
local t = entities[self.target_id]
if t then
local dx, dy = t.x - self.x, t.y - self.y
-- t is guaranteed alive this frame
...
end
end
Stale references cannot exist. entities[dead_id] returns nil. Cleanup is automatic — when an entity is destroyed, it's removed from entities, and every ID referencing it starts returning nil on lookup. No cascade bookkeeping, no subscription management, no back-references to keep in sync.
Aggregates are plain arrays
If you need all enemies, maintain an array in the entity's constructor and destructor:
enemies = {}
function enemy:new(x, y)
self.x, self.y = x, y
make_entity(self)
enemies[#enemies + 1] = self
end
Then collection_update(enemies, dt) each frame compacts out dead entries automatically. No an:all('enemy') query, no tag system. You maintain what you need.
Physics (Box2D)
The engine C code handles physics stepping. It runs b2World_Step internally before calling your update(dt) each frame. You just read events.
Setup
physics_init()
physics_set_gravity(0, 0)
physics_register_tag('player')
physics_register_tag('enemy')
physics_register_tag('projectile')
physics_enable_collision('player', 'enemy')
physics_enable_collision('enemy', 'enemy')
physics_enable_sensor('projectile', 'enemy')
Not auto-initialized. Games that don't need physics don't pay for it.
Colliders
The collider class is the one framework module that's still OOP. It bundles a Box2D body with its shape and tag, and hosts steering behaviors.
-- In player:new, after make_entity:
self.collider = collider(self, 'player', 'dynamic', 'box', 12, 12)
self.collider:set_gravity_scale(0)
self.collider:set_fixed_rotation(true)
self.collider:set_position(x, y)
The collider stores self.owner pointing back to the entity, and sets the body's user_data to self.owner.id. This is what makes entity-resolving physics queries work — queries return bodies, the framework resolves entities[body.user_data] to get the owning entity.
Destroy is explicit:
function player:destroy()
if self.collider then self.collider:destroy() end
end
The entity's destroy method has to list each sub-object that owns engine resources. It's a few lines of boilerplate per class, and it's worth it — you always know exactly what an entity owns.
Collision events are entity-resolving
The raw engine events return bodies. The framework wraps them with helpers that resolve bodies to entities and normalize ordering so ev.a always matches the first query tag:
for _, ev in ipairs(collision_entities_begin('player', 'enemy')) do
local pl, e = ev.a, ev.b -- guaranteed: a is player, b is enemy
if not pl._dead and not e._dead then
pl:hit(e.contact_damage or 1, ev.x, ev.y)
local r = math.angle_to_point(pl.x, pl.y, e.x, e.y)
e:push(r, 6)
end
end
for _, ev in ipairs(sensor_entities_begin('projectile', 'enemy')) do
if not ev.a._dead and not ev.b._dead then
ev.b:hit(1)
ev.a:kill()
end
end
Always check _dead on both entities. Events are processed in batches; earlier events in the same batch may have killed an entity that appears in later events.
Available event queries: collision_entities_begin/end, sensor_entities_begin/end, hit_entities (with approach speed for impact-based damage).
Spatial queries
Same entity-resolution wrappers:
local nearby = query_entities_circle(p1.x, p1.y, 50, {'enemy'})
for _, e in ipairs(nearby) do e:hit(1) end
Shapes: query_entities_point, query_entities_circle, query_entities_aabb, query_entities_box. Raycasts: raycast_entity (first hit), raycast_entities_all (all hits along the ray).
Steering behaviors
On the collider class, returning (fx, fy) force vectors that you combine and apply:
function enemy:update(dt)
local sx, sy = self.collider:steering_seek(p1.x, p1.y, self.speed, 200)
local wx, wy = self.collider:steering_wander(64, 32, 16, dt, self.speed, 200)
local rx, ry = self.collider:steering_separate(16, enemies, self.speed, 200)
local fx, fy = math.limit(sx + wx + rx, sy + wy + ry, 200)
self.collider:apply_force(fx, fy)
self.collider:sync()
end
Available: seek, flee, arrive, pursuit, evade, wander, separate, align, cohesion. math.limit(x, y, max) caps a vector to max length — useful for summing steering outputs without overshooting the max-force budget.
Rendering
Same two-phase pipeline as v1: layers accumulate draw commands, layer_render processes them into the layer's FBO, layer_draw composites FBOs to screen.
Basic drawing
game_layer = layer_new('game')
ui_layer = layer_new('ui')
function draw()
layer_rectangle(game_layer, 0, 0, width, height, bg_color())
layer_circle(game_layer, 100, 100, 16, red())
layer_text(ui_layer, 'Score: 0', fonts.main, 4, 2, fg_color())
layer_render(game_layer)
layer_render(ui_layer)
layer_draw(game_layer)
layer_draw(ui_layer)
end
Every layer function takes a layer table (from layer_new) as its first argument. The table is a plain {name, handle, parallax_x, parallax_y} state. Internally the functions forward to the engine's C bindings using layer.handle.
Same primitives as v1: rectangle, rectangle_line, rounded_rectangle, circle, circle_line, capsule, line, triangle, polygon, plus gradient variants and image, spritesheet, animation, text, texture.
Transform stack
function player:draw()
local s = self.spring.hit.x -- 1.0 neutral, pulses on hit
layer_push(game_layer, self.x, self.y, self.r, s, s)
layer_rounded_rectangle(game_layer, -6, -6, 12, 12, 2, player_color())
layer_pop(game_layer)
end
layer_push translates, rotates, scales. layer_pop undoes it. Nested pushes stack.
Camera
Created via camera_new, integrated shake lives as a sub-structure at camera.shake. No tree wiring — the camera is a plain state table you pass explicitly.
main_camera = camera_new() -- defaults to global width/height
camera_follow(main_camera, p1) -- follow an entity (stored by ID)
camera_set_bounds(main_camera, 0, map_w, 0, map_h)
function update(dt)
-- ...
camera_update(main_camera, dt) -- advances follow, bounds, mouse, shake
end
function draw()
camera_attach(main_camera, game_layer)
-- ...draws get camera-transformed
camera_detach(main_camera, game_layer)
end
camera_attach(c, layer, parallax_x?, parallax_y?) pushes the camera's transform onto a layer's matrix stack. camera_detach pops it. Parallax values <1 make the layer scroll slower.
Mouse-in-world is available as camera.mouse.x, camera.mouse.y (refreshed each frame by camera_update).
Shake
All the v1 shake types, now functions on a shake state instead of methods:
shake_push(main_camera.shake, aim_angle, 3) -- directional spring
shake_trauma(main_camera.shake, 0.7, 0.4) -- Perlin noise shake
shake_shake(main_camera.shake, 15, 0.5) -- random jitter
shake_sine(main_camera.shake, angle, 10, 5, 0.3) -- sine wave
shake_handcam(main_camera.shake, true) -- continuous handcam noise
The shake state aggregates all active sources each frame and produces (ox, oy, r, z) offsets that camera_attach applies automatically.
Layer-to-layer effects and shaders
Same as v1. layer_draw_from copies one layer's texture into another, optionally through a shader. layer_apply_shader applies post-process effects. Stencil operations are unchanged: layer_stencil_mask, layer_stencil_test, layer_stencil_test_inverse, layer_stencil_off.
Timer system
Procedural scheduler. All nine v1 modes, now as free functions that take a timer state as the first argument.
function player:new(x, y)
self.timer = timer_new()
-- schedule things...
end
function player:update(dt)
timer_update(self.timer, dt)
-- ...
end
Modes
-- Fire once after delay
timer_after(self.timer, 1.0, function() self:explode() end)
-- Fire repeatedly; optional times + after callback
timer_every(self.timer, 0.5, 'attack', function() self:attack() end)
-- Fire every frame for duration with progress 0..1
timer_during(self.timer, 1.0, function(dt, p)
self.alpha = 1 - p
end)
-- Tween target fields with easing
timer_tween(self.timer, 0.5, self, { y = self.y - 18 }, math.cubic_out)
-- Fire when a field changes
timer_watch(self.timer, self, 'hp', function(cur, prev) on_hp_change() end)
-- Edge-triggered: fire on false->true transitions
timer_when(game_timer, function()
return p1 and p1._dead and not game_over
end, function() game_over = true end)
-- Fire every delay while condition is true; reset on false->true
timer_cooldown(self.timer, 0.2, function() return input_down('shoot') end, 'shoot',
function() self:shoot() end)
-- Fire N times with delays interpolating start->end
timer_every_step(self.timer, 0.05, 0.5, 5, 'wave', spawn_enemy, math.cubic_out)
Every schedule function accepts an optional name string before the callback. Named entries can be cancelled (timer_cancel), triggered immediately (timer_trigger), and replace any previous entry with the same name.
The timer_when edge-trigger deserves a note: it fires when condition_fn() transitions from false to true, then re-arms on the next false transition. This makes it possible to write a self-re-arming watcher with a condition like p1._dead and not game_over — once the callback sets game_over = true, the condition goes false next frame and the edge re-arms for the next death cycle. No re-registration needed across game resets.
Springs
Damped spring animation for anything that should feel organic.
function player:new(x, y)
self.spring = spring_new() -- creates default 'main' spring at 1
spring_add(self.spring, 'hit', 1)
spring_add(self.spring, 'shoot', 1)
end
function player:hit(damage)
spring_pull(self.spring, 'hit', 0.2) -- apply an impulse
end
function player:update(dt)
spring_update(self.spring, dt)
end
function player:draw()
local s = self.spring.hit.x*self.spring.shoot.x
layer_push(game_layer, self.x, self.y, 0, s, s)
-- ...
layer_pop(game_layer)
end
Each named spring has .x (current value), .target_x, and .v (velocity). spring_pull(s, name, force, frequency?, bounce?) applies an impulse — optionally updating the spring's frequency and bounce so different hits can use different response profiles.
Parameters: frequency (oscillations per second, default 5) and bounce (0..1, where 0 is critically damped and 1 is infinite oscillation, default 0.5).
Animation
Frame-based spritesheet animation. In v2, an animation is just a state table — not an object child.
-- Register the spritesheet at framework init
spritesheets.hit1 = spritesheet_register('hit1', 'assets/hit1.png', 96, 48)
hit_effect = class()
function hit_effect:new(x, y, args)
make_entity(self)
self.x, self.y = x, y
self.s = (args and args.s) or 1
self.r = random_float(0, 2*math.pi)
self.anim = animation_new('hit1', 0.04, 'once', {
[0] = function() self:kill() end, -- self-kill on completion
})
effects[#effects + 1] = self
end
function hit_effect:update(dt)
animation_update(self.anim, dt)
end
function hit_effect:draw()
layer_push(game_layer, self.x, self.y, self.r, self.s, self.s)
layer_animation(game_layer, self.anim, 0, 0)
layer_pop(game_layer)
end
Loop modes: 'once', 'loop', 'bounce'. The actions table fires callbacks when specific frames become active; index [0] is "on completion" (once mode) or "on loop boundary" (loop/bounce modes).
Resources
Plain global tables populated by the resource loaders. No an:layer, no an:font. Just tables you write to.
fonts.main = font_register('main', 'assets/LanaPixel.ttf', 11)
spritesheets.hit1 = spritesheet_register('hit1', 'assets/hit1.png', 96, 48)
images.player = image_load('player', 'assets/player.png')
sounds.shoot = sound_load('assets/shoot.ogg')
sounds.enemy_die_variants = {
sound_load('assets/enemy_die_1.ogg'),
sound_load('assets/enemy_die_2.ogg'),
sound_load('assets/enemy_die_3.ogg'),
}
Font wrappers expose :text_width(text), :char_width(codepoint), and :glyph_metrics(codepoint) for layout math. Spritesheets expose .frame_width, .frame_height, .frames. Images are {handle, width, height}.
Sounds are raw engine userdata handles — no framework wrapper. sound_play(handle, volume?, pitch?) plays them. Games typically wrap sound_play in a helper that adds pitch jitter:
function sfx(handle, volume, pitch)
if not handle then return end
pitch = pitch or random_float(0.95, 1.05)
sound_play(handle, volume or 1, pitch)
end
Input
Thin function wrappers over the engine's action binding system. No state is kept in Lua; all queries forward to engine C functions.
bind('left', 'key:a')
bind('left', 'key:left') -- multiple bindings per action
bind('shoot', 'mouse:1')
bind('reset', 'key:r')
if input_down('left') then ... end
if input_pressed('shoot') then ... end
local dx, dy = input_vector('left', 'right', 'up', 'down')
Bind strings: 'key:<name>' for keyboard, 'mouse:<num>' for mouse buttons. Gamepad button bindings aren't supported in the current engine (only gamepad_is_connected() and gamepad_get_axis(axis) are exposed). Advanced features — chords, sequences, holds, rebind capture — are all here as bind_chord, bind_sequence, bind_hold, input_capture_*.
Raw engine queries (key_is_down, mouse_is_pressed, etc.) are always available alongside the binding system.
Color, math, array, random
These are utility modules that mostly ported over from v1 unchanged, with small adjustments.
Color is a plain table {r, g, b, a} with __call returning the packed rgba integer. Operations like color_mix, color_clone, color_darken, color_lighten, color_invert are procedural functions that return new colors (not mutating methods). HSL conversion via color_to_hsl and color_from_hsl. Mutate fields directly — c.a = 128.
Math extensions live alongside standard Lua math. All the v1 easing functions are here: linear, sine, quad, cubic, quart, quint, expo, circ, bounce, back, elastic — each with _in, _out, _in_out, _out_in variants. Plus math.lerp, math.lerp_dt (framerate-independent), math.clamp, math.remap, math.angle_to_point, math.distance, math.normalize, math.limit, math.rotate, math.reflect.
Array utilities on a plain array namespace: all, any, has, count, sum, average, max, index, get, delete, remove, reverse, rotate, shuffle, random, remove_random, flatten, join, print.
Random has no framework wrapper at all. The C engine provides random_float, random_int, random_angle, random_sign, random_bool, random_normal, random_choice, random_choices, random_weighted, plus noise(x, y, z) for Perlin noise — all as plain globals. An internal global_rng is used by default; create your own via random_create(seed) for deterministic sequences.
What's gone from v1
Things you could do in v1 that you cannot do in v2:
angod-objectself:add(child)and automatic child update/drawself:tag('foo')/an:all('foo')self:link(target, callback)— subscribe to death eventsself:flow_to(parent)— reparent at runtimeearly_action/action/late_action— named update hooks- Action phases — the framework dispatching update in early/main/late order
- Automatic cleanup cascade when a parent dies
Each of these solved a real problem. V2's answer to each of them:
- The god-object is replaced by plain global tables (
layers,fonts,sounds, etc.) and the explicit main loop. - Self-add and child update are replaced by plain fields and explicit
timer_update(self.timer, dt)calls. - Tags and
an:allare replaced by maintaining your own arrays (enemies = {}) andcollection_update. - Link and subscription are replaced by polling — check the target's state in your own update each frame.
- Flow_to and state machines are replaced by string dispatch conventions or just conditional logic in update.
- Action phases are replaced by writing your update function in the order you want things to happen.
- Automatic cleanup cascade is replaced by each entity's
:destroymethod explicitly destroying its sub-objects.
Every v2 pattern is more verbose than the v1 equivalent. That's the point. The verbosity is what makes the code readable — you can look at a function and know what it does without knowing the framework.
Example entity
A complete player entity, as it appears in the arena test game:
player = class()
function player:new(x, y)
self.x, self.y = x, y
self.hp = 5
self.base_speed = 100
self.flashing = false
make_entity(self)
self.timer = timer_new()
self.spring = spring_new()
spring_add(self.spring, 'hit', 1)
spring_add(self.spring, 'shoot', 1)
self.collider = collider(self, 'player', 'dynamic', 'box', 10, 10)
self.collider:set_gravity_scale(0)
self.collider:set_fixed_rotation(true)
self.collider:set_position(x, y)
-- Hold-to-fire with an immediate first shot on press
timer_cooldown(self.timer, 0.2,
function() return input_down('shoot') end, 'shoot',
function() self:shoot() end)
end
function player:update(dt)
timer_update(self.timer, dt)
spring_update(self.spring, dt)
if input_pressed('shoot') then
timer_trigger(self.timer, 'shoot') -- fire immediately on press
end
-- 8-way movement from bindings
local vx, vy = self.collider:get_velocity()
local l = input_down('left')
local r = input_down('right')
local u = input_down('up')
local d = input_down('down')
if l or r or u or d then
local r_angle = math.atan((d and 1 or 0) - (u and 1 or 0),
(r and 1 or 0) - (l and 1 or 0))
vx = self.base_speed*math.cos(r_angle)
vy = self.base_speed*math.sin(r_angle)
end
if not l and not r then vx = vx*0.8 end
if not u and not d then vy = vy*0.8 end
self.collider:set_velocity(vx, vy)
self.collider:sync()
end
function player:hit(damage)
if self._dead then return end
self.hp = self.hp - damage
spring_pull(self.spring, 'hit', 0.2)
self.flashing = true
timer_after(self.timer, 0.1, 'p_hit_flash',
function() self.flashing = false end)
if self.hp <= 0 then self:kill() end
end
function player:draw()
local s = self.spring.hit.x*self.spring.shoot.x
local col = self.flashing and fg_color or player_color
layer_push(game_layer, self.x, self.y, 0, s, s)
layer_rounded_rectangle(game_layer, -5, -5, 10, 10, 2, col())
layer_pop(game_layer)
end
function player:destroy()
if self.collider then self.collider:destroy() end
end
This is ~60 lines of game code. It uses six framework modules (timer, spring, collider, input, layer, math). Every line of what happens each frame is visible in the :update method. No framework magic, no hidden traversals, no hook dispatch. If you want to know what happens on shoot, you read the cooldown setup and the :shoot method. That's it.
File structure
Anchor2/
├── engine/src/anchor.c # C engine (unchanged from v1)
├── framework/anchor/ # v2 framework files (source of truth)
│ ├── class.lua # minimal class helper
│ ├── object.lua # entities table, make_entity, kill queue
│ ├── helpers.lua # collection_update
│ ├── input.lua # bind + input_down/pressed/released wrappers
│ ├── timer.lua # all schedule modes
│ ├── spring.lua # spring_new/add/pull/update
│ ├── animation.lua # animation_new/update
│ ├── font.lua # font_register + thin wrapper
│ ├── image.lua # image_load + thin wrapper
│ ├── spritesheet.lua # spritesheet_register + thin wrapper
│ ├── layer.lua # layer_new + draw functions
│ ├── shake.lua # shake_push/trauma/etc
│ ├── camera.lua # camera_new/update/follow/attach
│ ├── collider.lua # class + steering behaviors
│ ├── physics.lua # entity-resolving event helpers
│ ├── math.lua # easing + vector/angle utilities
│ ├── array.lua # array utilities
│ ├── color.lua # color, color_mix, color_to_hsl, etc.
│ └── init.lua # module loader, sync_engine_globals
├── arena/ # validated test game (canonical code example)
│ ├── main.lua # ~750 lines, exercises every framework module
│ └── ARENA_PROGRESS.md # build history
├── reference/
│ ├── Anchor_v1/ # archived v1 framework for comparison
│ ├── anchor2_plan.md # rewrite plan
│ ├── progress.md # rewrite progress
│ └── questions_for_user.md # design decisions
└── docs/
├── ENGINE_API.md # C function documentation (unchanged)
├── ENGINE_API_QUICK.md
├── FRAMEWORK_API.md # v2 framework documentation
└── FRAMEWORK_API_QUICK.md
The arena test game is the canonical example. ~750 lines of v2 code that exercises every framework module except image.lua, validated against the real engine end-to-end. New games should read arena/main.lua to see how v2 feels in practice.
Summary
Anchor 2 is Anchor 1 with the Lua framework layer replaced. Same C engine, same performance, same deployment pipeline. What changed is the programming model:
- Minimal framework, maximal explicitness. Code reads top-to-bottom.
- Procedural modules, not classes.
timer_update(self.timer, dt), notself.timer:update(dt). - IDs, not pointers, for cross-entity references. Stale references are impossible.
- Poll, don't subscribe. Check other entities' state in your own update.
- Explicit main loop.
update(dt)anddraw()are your own functions, and the engine calls them directly. - Deferred destruction.
kill()queues,process_destroy_queue()drains at frame end.
The tradeoff is verbosity. V2 game code is a little longer than v1 equivalents because it does things the framework used to hide. The benefit is that the code is readable — by humans and by AI — without holding framework internals in your head. Every line of what happens each frame is visible in one place.
Whether this is worth it depends on how you work. If you're writing ~1000-line games with a human holding the whole thing in memory, v1 is probably more ergonomic. If you're writing ~10000-line games where an AI is doing most of the writing and you need to verify the result at a glance, v2 is significantly better. The rewrite wasn't driven by abstract philosophy — it was driven by watching a real codebase grow past the point where v1's implicit work could be reliably tracked.