Anchor Engine Overview
Anchor is a 2D game engine for solo indie developers. It combines a C core with Lua for game logic. The engine handles rendering, physics, audio, and input while the framework provides high-level abstractions for building games quickly.
Core Philosophy
Locality — Everything about a game object lives in one place. No scattered configuration files, no registering components in separate systems. A class definition contains its properties, behaviors, children, and cleanup logic together.
No bureaucracy — No imports, no dependency injection, no boilerplate. Resources are registered once and accessed globally. Things work without setup.
Lua — A lightweight scripting language with simple syntax. Classes via the framework's object system, clean table-based data, easy to read and write.
Architecture
┌─────────────────────────────────────────┐
│ Game Code (Lua) │
│ - Your game objects and logic │
├─────────────────────────────────────────┤
│ Framework (Lua) │
│ - object, layer, timer, collider, etc. │
├─────────────────────────────────────────┤
│ Engine (C) │
│ - SDL2, OpenGL, Box2D, miniaudio │
└─────────────────────────────────────────┘
The C engine is a single ~10500-line file. It handles low-level operations and exposes functions to Lua. The framework wraps these into pleasant classes. Game code uses the framework without touching C.
Object System
Everything is an object in a tree. The root object an holds all game objects. Objects can have children (timer, collider, spring) that die automatically when the parent dies.
enemy = object:extend()
function enemy:new(x, y)
object.new(self)
self.x, self.y = x, y
self.hp = 100
self:add(collider('enemy', 'dynamic', 'circle', 16))
self:add(timer())
end
function enemy:update(dt)
self.x = self.x + self.vx*dt
if self.hp <= 0 then
self:kill()
end
game:circle(self.x, self.y, 16, red())
end
Key features:
- Automatic cleanup — Dead objects and their children are removed at frame end
- Tags — Objects can have multiple tags for querying (
self:tag('enemy', 'flying')) - Links — Objects can link to others and react when they die
- Action phases — Early, main, and late update phases for ordering
Inlined Objects
For quick, self-contained objects that don't need a class definition — effects, UI elements, background decorations — you can create objects inline using object() and configure them with chainable methods.
-- Create an inline cloud object
cloud = object()
cloud:set({
x = position.x,
y = position.y,
scale = cloud_base_scale*an.random:float(0.8, 1.2),
flip = an.random:sign(),
speed = cloud_speed,
draw_color = color(255, 255, 255, cloud_alpha),
arena_left = cloud_area_x,
arena_right = self.x + self.w + 30,
})
cloud:action(function(self, dt)
self.x = self.x + self.speed*dt
if self.x > self.arena_right then
self.x = self.arena_left - 20
end
bg:push(self.x, self.y, 0, self.flip*self.scale, self.scale)
bg:image(cloud_image, 0, 0, self.draw_color())
bg:pop()
end)
cloud:flow_to(self)
Methods
Property Setting:
- set(properties) — Batch-sets properties from a table
- build(fn) — Runs a function with the object as argument for complex initialization
obj:set({x = 100, y = 200, hp = 50})
obj:build(function(self)
self.velocity = math.sqrt(self.vx*self.vx + self.vy*self.vy)
end)
Actions (per-frame callbacks):
- early_action(fn) — Runs before main update
- action(fn) — Runs during main update
- late_action(fn) — Runs after main update
obj:action(function(self, dt)
self.x = self.x + self.speed*dt
layer:circle(self.x, self.y, 10, white())
end)
Named actions can be replaced:
obj:action('move', function(self, dt) self.x = self.x + self.speed*dt end)
obj:action('move', function(self, dt) self.x = self.x + self.speed*dt*2 end) -- replaces previous
Actions that return true are removed at end of frame (one-shot):
obj:action(function(self, dt)
self.lifetime = self.lifetime - dt
return self.lifetime <= 0 -- returns true when expired, action removed
end)
Lifecycle:
- flow_to(parent) — Adds this object to a parent (reverse of
add) - link(target, callback?) — Reacts when target dies
- tag(tags...) — Adds tags for querying
- kill() — Marks object for removal
bullet:flow_to(projectiles) -- same as: projectiles:add(bullet)
bullet:link(target, function(self) self.homing = false end) -- callback when target dies
bullet:link(target) -- kill self when target dies
bullet:tag('projectile', 'dangerous')
Args Table Pattern
A common idiom for optional parameters in constructors:
projectile = object:extend()
function projectile:new(x, y, args)
object.new(self)
args = args or {}
self.x, self.y = x, y
self.velocity = args.velocity or 10
self.direction = args.direction or 0
self.team = args.team
self.flash_on_spawn = args.flash_on_spawn
end
-- Usage: positional args first, then optional table
self.projectiles:add(projectile(x, y, {
velocity = 15,
direction = angle,
team = 'player',
flash_on_spawn = true,
}))
The args or {} default means callers can omit the table entirely. Access fields with args.field or default for values that need defaults, or just args.field for optional values that can be nil.
Container Objects
Use object children as containers to group related objects:
-- In your arena/game class
self:add(object('effects'))
self:add(object('projectiles'))
-- Spawn into containers
self.effects:add(hit_effect(x, y, {scale = 1.5}))
self.projectiles:add(projectile(x, y, {direction = angle}))
-- Kill all projectiles at once
self.projectiles:kill()
Containers provide organization and bulk lifecycle control. When the container dies, all its children die.
Physics (Box2D)
Physics bodies are wrapped as collider child objects. The engine handles the Box2D integration.
-- Create a dynamic circle collider
self:add(collider('player', 'dynamic', 'circle', 16))
-- Create a static box collider
self:add(collider('wall', 'static', 'box', 100, 20))
-- Apply forces
self.collider:apply_impulse(0, -200) -- jump
Collision handling uses events rather than callbacks:
-- In your update loop
for _, event in ipairs(an:collision_begin_events('player', 'enemy')) do
event.a:take_damage() -- player
event.b:knockback() -- enemy
end
Event types:
- Collision — Physical collision (bodies bounce)
- Sensor — Overlap detection (bodies pass through)
- Hit — Collision with approach speed (for impact effects)
Spatial queries:
query_point,query_circle,query_aabb,query_box,query_capsule,query_polygonraycast,raycast_all
Rendering (OpenGL)
Layers are framebuffer objects (FBOs) that accumulate draw commands during update and composite to screen at frame end.
Basic Drawing
-- Register layers
game = an:layer('game')
ui = an:layer('ui')
-- Queue draw commands (during update)
game:circle(self.x, self.y, 16, white())
game:rectangle(0, 0, 100, 50, red())
game:image(an.images.player, self.x, self.y)
game:text("Score: " .. self.score, 'main', 10, 10, white())
Rendering Pipeline
The pipeline has two phases: render (commands → FBO) and draw (FBO → screen).
draw = function()
-- Phase 1: Render queued commands to each layer's FBO
game:render()
effects:render()
ui:render()
-- Phase 2: Composite layers to screen (in order)
game:draw()
effects:draw()
ui:draw()
end
render() — Processes all queued draw commands to the layer's FBO. Clears the FBO first.
draw() — Queues the layer to be composited to screen at frame end. Optional offset: shadow:draw(4, 4)
Layer-to-Layer Effects
Use draw_from to copy one layer's contents to another, optionally through a shader:
draw = function()
game:render()
effects:render()
-- Create shadow layer from game layer through shadow shader
shadow:clear()
shadow:draw_from(game, an.shaders.shadow)
-- Create outline layer from game layer through outline shader
outline:clear()
outline:draw_from(game, an.shaders.outline)
-- Composite in order (shadow behind, outline behind, then game)
shadow:draw(4, 4) -- offset for drop shadow effect
outline:draw()
game:draw()
end
clear() — Clears a layer's FBO to transparent. Use before draw_from if you want to replace contents (otherwise draw_from accumulates with alpha blending).
draw_from(source, shader?) — Draws source layer's texture to this layer's FBO. If shader provided, applies it during the copy.
Shader Uniforms
Two ways to set shader uniforms:
Layer-based (deferred, for apply_shader):
game:shader_set_float(an.shaders.blur, 'u_radius', 5)
game:shader_set_vec2(an.shaders.blur, 'u_direction', 1, 0)
game:apply_shader(an.shaders.blur)
Immediate (for draw_from):
shader_set_vec2_immediate(an.shaders.outline, "u_pixel_size", 1/gw, 1/gh)
outline:draw_from(game, an.shaders.outline)
Use shader_set_*_immediate before draw_from since draw_from applies the shader immediately. The layer-based versions are for apply_shader which processes shaders via ping-pong rendering on a single layer.
Stencil Operations
Stencil masking for clipping effects — draw to stencil buffer first, then only render where the stencil was written:
-- Define mask shape (writes to stencil only, not visible)
game:stencil_mask()
game:circle(self.x, self.y, 50, white())
-- Draw with stencil test (only visible inside mask)
game:stencil_test()
game:rectangle(0, 0, 200, 200, red())
-- Done — return to normal drawing
game:stencil_off()
Also available: stencil_test_inverse() — draw only where stencil was NOT written.
Blend Modes
game:set_blend_mode('additive') -- additive blending (for glow/light effects)
game:circle(self.x, self.y, 30, yellow())
game:set_blend_mode('alpha') -- restore normal alpha blending
Custom Draw Shader
The engine's default fragment shader handles SDF shape rendering. You can replace it with a custom version that adds game-specific logic (e.g., per-object color effects):
-- Load custom draw shader (same vertex shader, custom fragment)
set_draw_shader('assets/draw_shader.frag')
draw_shader = get_draw_shader()
-- Set uniforms per-object before drawing
game:shader_set_float(draw_shader, 'u_edition', 7)
game:circle(self.x, self.y, 10, white())
game:shader_set_float(draw_shader, 'u_edition', 0) -- reset
The custom shader receives all the same vertex attributes (position, UV, color, shape data) as the engine's default shader. Uniforms set via shader_set_float are inserted into the layer's command queue, so different objects can have different uniform values within the same frame.
Disabling Camera
Layers follow the camera by default. For UI layers that shouldn't shake/move:
ui = an:layer('ui')
ui.camera = nil
Timer System
Timers are child objects with multiple scheduling modes:
self:add(timer())
-- One-shot
self.timer:after(2, function(self) self:explode() end)
-- Repeating
self.timer:every(0.5, function(self) self:shoot() end)
-- Duration-based (called every frame)
self.timer:during(1, function(self, t, progress) self.alpha = 1 - progress end)
-- Tweening
self.timer:tween(0.5, self, {x = 100, y = 200}, math.quad_out)
-- Conditional
self.timer:watch(function() return self.hp <= 0 end, function(self) self:die() end)
self.timer:when(function() return self.on_fire end, function(self) self.hp = self.hp - 1 end)
-- Cooldown (returns true once per interval)
if self.timer:cooldown(0.5, 'shoot') then
self:shoot()
end
Animation Primitives
Spring — Damped spring for juicy motion:
self:add(spring())
-- Default 'main' spring (created automatically at value 1)
self.spring:pull('main', 0.3) -- add impulse
scale = self.spring.main.x -- use in draw
Named springs — Add multiple springs for different effects:
self:add(spring())
self.spring:add('squash_x', 1) -- horizontal squash (starts at 1)
self.spring:add('squash_y', 1) -- vertical squash
self.spring:add('hit', 1) -- hit feedback
self.spring:add('weapon', 1) -- weapon feedback
self.spring:add('rotation', 0) -- rotation wobble (starts at 0)
-- Pull individual springs
self.spring:pull('squash_x', -0.3) -- squash horizontally
self.spring:pull('squash_y', 0.2) -- stretch vertically
self.spring:pull('hit', 0.3, 3, 0.7) -- with custom frequency and bounce
-- Use in draw
game:push(self.x, self.y, self.spring.rotation.x, self.spring.squash_x.x, self.spring.squash_y.x)
game:image(self.image, 0, 0)
game:pop()
Parameters for add/pull:
name— Spring identifiervalue— Initial/target valuefrequency— Oscillations per second (default 5)bounce— 0-1, where 0 = no overshoot, 1 = infinite oscillation (default 0.5)
Shake — Camera shake effects:
an.camera:add(shake())
an.camera.shake:trauma(0.5, 0.3) -- Perlin noise shake
an.camera.shake:push(angle, 20) -- Directional spring
an.camera.shake:shake(15, 0.5) -- Random jitter
Spritesheet & Animation
Frame-based animations from sprite sheets.
Registration
-- Register a spritesheet: name, path, frame_width, frame_height
an:spritesheet('hit1', 'assets/hit1.png', 96, 48)
Frames are indexed 1-based, read left-to-right, top-to-bottom. Access via an.spritesheets.name.
Creating Animations
Add an animation as a child object:
-- Basic: spritesheet name, delay per frame, loop mode
self:add(animation('hit1', 0.05, 'once'))
-- With callbacks: frame number → function (0 = completion)
self:add(animation('hit1', 0.05, 'once', {
[3] = function(self) self:flash() end, -- called on frame 3
[0] = function(self) self:kill() end, -- called when animation completes
}))
Loop modes:
'once'— Plays once, then kills itself'loop'— Repeats indefinitely'bounce'— Ping-pongs back and forth
Updating & Drawing
function enemy:update(dt)
self.hit1:update(dt)
layer:push(self.x, self.y, self.rotation, self.scale, self.scale)
layer:animation(self.hit1, 0, 0)
layer:pop()
end
The animation name becomes the property name (self.hit1 for 'hit1').
Animation Methods
self.hit1:play() -- resume playback
self.hit1:stop() -- pause
self.hit1:reset() -- restart from frame 1
self.hit1:set_frame(3) -- jump to specific frame
Drawing Spritesheets Directly
For static frames or manual control:
layer:spritesheet(an.spritesheets.hit1, frame_number, x, y)
Audio (miniaudio)
-- Register
an:sound('jump', 'assets/jump.ogg')
an:music('bgm', 'assets/music.ogg')
-- Play
an:sound_play('jump')
an:sound_play('hit', 0.5, 1.2) -- volume, pitch
an:music_play('bgm')
an:music_crossfade('boss_theme', 2) -- 2 second crossfade
Playlist system for background music with shuffle and crossfade support.
Input
Action-based input binding:
an:bind('jump', 'space')
an:bind('jump', 'gamepad_a') -- multiple bindings per action
if an:is_pressed('jump') then
self:jump()
end
if an:is_down('fire') then
self:shoot()
end
-- Axis and vector helpers
horizontal = an:get_axis('left', 'right')
dx, dy = an:get_vector('left', 'right', 'up', 'down')
Supports keyboard, mouse, and gamepad. Advanced features: chords, sequences, hold detection.
Camera
an:add(camera())
-- Follow a target with smoothing
an.camera:follow(player, 0.9, 0.5)
-- Set bounds
an.camera:set_bounds(0, 0, level_width, level_height)
-- Coordinate conversion
world_x, world_y = an.camera:to_world(screen_x, screen_y)
-- Mouse in world coordinates
an.camera.mouse.x, an.camera.mouse.y
Utilities
Color — Mutable color with RGB and HSL:
red = color(255, 0, 0)
red.l = 0.8 -- lighten via HSL
red = red*0.5 -- darken
layer:circle(x, y, r, red())
Math — Interpolation and easing:
math.lerp(0.5, a, b)
math.lerp_dt(0.9, 0.5, dt, current, target) -- framerate-independent
math.quad_out(t), math.bounce_out(t), math.elastic_in_out(t), ...
Random — Seeded RNG:
self:add(random(12345)) -- deterministic seed
self.random:float(0, 100)
self.random:choice(enemies)
self.random:weighted({1, 2, 7}) -- 10%, 20%, 70%
Array — Utilities for array manipulation.
System Integration
Clipboard — Read/write system clipboard:
local text = clipboard_get()
clipboard_set("copied text")
Global Hotkeys (Windows) — System-wide hotkeys that work even when the window is unfocused:
hotkey_register(1, 2, 0xBA) -- Ctrl+; (MOD_CONTROL=2, VK_OEM_1=0xBA)
if hotkey_is_pressed(1) then
local text = clipboard_get()
end
Process Execution — Run system commands and capture output:
local output, status = os_popen('curl -s http://api.example.com/data')
Headless & Render Mode
Headless mode — Run without a window for automated testing, balance simulations, or CI:
engine_set_headless(true) -- or pass --headless on command line
No rendering, no audio, runs at maximum speed. Physics and game logic still execute normally.
Render mode — Deterministic timing for frame-perfect video capture:
./anchor.exe . --render-mode
Disables vsync, uses fixed timestep for consistent frame output.
Live recording — Pipe frames directly to ffmpeg:
engine_record_start('output.mp4')
-- ... game runs, each frame is captured ...
engine_record_stop()
Performance timing:
local start = perf_time()
-- ... expensive operation ...
local elapsed = perf_time() - start
print(string.format("Took %.3f ms", elapsed * 1000))
Platforms
- Desktop — Windows (primary), Linux/Mac possible
- Web — Emscripten build, runs in browser
Asset packaging embeds all game files into a single executable for distribution.
File Structure
Anchor/
├── engine/src/anchor.c # C engine (single file)
├── framework/anchor/ # Lua framework classes
│ ├── init.lua # Root object, resources, physics, input
│ ├── object.lua # Base object class
│ ├── layer.lua # Rendering layers
│ ├── collider.lua # Physics bodies
│ ├── timer.lua # Scheduling
│ ├── camera.lua # Viewport
│ ├── spring.lua # Spring animation
│ ├── shake.lua # Screen shake
│ └── ...
└── docs/
├── ENGINE_API.md # C function documentation
└── FRAMEWORK_API.md # Framework class documentation
Example Game Object
player = object:extend()
function player:new(x, y)
object.new(self)
self.x, self.y = x, y
self.vx, self.vy = 0, 0
self:tag('player')
self:add(collider('player', 'dynamic', 'circle', 12))
self:add(timer())
self:add(spring())
end
function player:update(dt)
-- Input
self.vx = an:get_axis('left', 'right')*200
if an:is_pressed('jump') then
self.collider:apply_impulse(0, -300)
self.spring:pull('main', 0.3)
end
-- Draw
scale = self.spring.main.x
game:push(self.x, self.y, 0, scale, scale)
game:image(an.images.player, 0, 0)
game:pop()
end
Summary
Anchor is designed for solo indie developers making 2D action games. It prioritizes:
- Simplicity — One C file, one framework, no complex build systems
- Locality — Game objects are self-contained
- Convenience — Things work without configuration
- Juice — Built-in springs, shakes, tweens, easing functions
The tradeoff is flexibility — it's opinionated about how games should be structured. If you want an ECS or a different paradigm, this isn't for you. If you want to make games quickly with minimal friction, it fits well.