a327ex.com

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:


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:

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):

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:

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:

Spatial queries:


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:

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:

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

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:

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.