Map Anchor 3 APIs for 2D lab
Transcript
Thinking17:22:01
I should look into the engine files as requested.
Bashcd "C:\Users\a327e\Desktop\a327ex\Anchor\engine" && ls && echo "---docs---" && l...17:22:02Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\Anchor\engine" && ls && echo "---docs---" && l...Thinking17:22:05
I should start by looking at the key files to understand the codebase.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\SURFACE.md17:22:05Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\SURFACE.mdReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.md17:22:05Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.mdReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\main.lua17:22:05Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\main.luaReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\run.bat17:22:05Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\run.batBashcd "C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground" && ls -la 2>/dev/nul...17:22:05Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground" && ls -la 2>/dev/nul...Thinking17:22:17
Confirmed — playground has no boot.lua/init.lua, keeping everything in main.lua since sketches don't name boot files. Next I want to check the framework's own init.lua under anchor/, then look at Horse Game's layout, run.bat, and boot setup.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\anchor\init.lua17:22:17Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\anchor\init.luaBashcd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && find . -maxdepth 2 ...17:22:17Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && find . -maxdepth 2 ...Thinking17:22:22
I should check knightvspawns' main.lua and boot.lua for the same split pattern, plus its CLAUDE.md for any notes about anchor.exe.
Bashcd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && wc -l main.lua boot...17:22:22Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && wc -l main.lua boot...Bashcd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && head -n 100 boot.lu...17:22:22Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && head -n 100 boot.lu...Bashcd "C:\Users\a327e\Desktop\a327ex\Anchor\engine" && sed -n '1,50p' docs/FRAMEWOR...17:22:22Output not recorded
cd "C:\Users\a327e\Desktop\a327ex\Anchor\engine" && sed -n '1,50p' docs/FRAMEWOR...Now let me pull the specific API sections: input, layer/draw, camera, timestep/update rate, and the agent/eval channel docs.
GrepPHYSICS_RATE|fixed.step|60Hz|120|update\(dt\)|draw\(\)|render rate|frame rate17:22:30Output not recorded
PHYSICS_RATE|fixed.step|60Hz|120|update\(dt\)|draw\(\)|render rate|frame rateGrepPHYSICS_RATE|fixed_step|engine_set_fixed_step|update_rate|render_rate17:22:30Output not recorded
PHYSICS_RATE|fixed_step|engine_set_fixed_step|update_rate|render_rateGrepPHYSICS_RATE17:22:30Output not recorded
PHYSICS_RATEBashgrep -n "function update\|function draw\|^function.*(dt)" -r "C:\Users\a327e\Des...17:22:30Output not recorded
grep -n "function update\|function draw\|^function.*(dt)" -r "C:\Users\a327e\Des...Confirmed: 120 Hz fixed timestep at Anchor/engine/engine/src/anchor.c:339 (#define PHYSICS_RATE (1.0 / 120.0)). Now the input, layer/drawing, camera sections in FRAMEWORK_API_QUICK.md and ENGINE_API_QUICK.md.
Bashsed -n '85,140p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_API...17:22:40Output not recorded
sed -n '85,140p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_API...Bashgrep -n "^## \|^### " "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWOR...17:22:40Output not recorded
grep -n "^## \|^### " "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWOR...Bashsed -n '156,360p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...17:22:40Output not recorded
sed -n '156,360p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...Bashsed -n '343,400p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...17:22:40Output not recorded
sed -n '343,400p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...Thinking17:22:49
Good, that confirms the 2D camera API is what applies to our platformer lab, not camera3. Next I should check color creation, key name strings, the edges-are-update-only rule, eval channel details in AGENT.md, CLAUDE.md gotchas, ENGINE_API_QUICK.md for engine_step/engine_snapshot/agent_tap, and launch.json conventions.
Bashsed -n '640,680p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...17:22:49Output not recorded
sed -n '640,680p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\FRAMEWORK_AP...Grepkey:|'key:| key names|arrow|space|shift|left/right17:22:49Output not recorded
key:|'key:| key names|arrow|space|shift|left/rightThinking17:22:55
Since the key names aren't in ENGINE_API_QUICK, I should search input.lua and the engine source for key name strings, and check ENGINE_API.md/AGENT.md for the edge input query rules and key name list.
Bashgrep -n "key_is_down\|key_name\|KEY_\|scancode\|'a'\|\"a\"" "C:\Users\a327e\Desk...17:22:55Output not recorded
grep -n "key_is_down\|key_name\|KEY_\|scancode\|'a'\|\"a\"" "C:\Users\a327e\Desk...Grepkey_is_down|key names|left/right|lshift|space17:22:55Output not recorded
key_is_down|key names|left/right|lshift|spaceReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.c17:22:55Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.cGood, confirmed key name scheme. Now the "edges are update-only" rule and eval channel functions (engine_step, engine_snapshot, agent_tap) in ENGINE_API_QUICK.md and AGENT.md.
Bashgrep -n "key_is_pressed\|key_is_released\|edge\|RAISE\|draw()" "C:\Users\a327e\D...17:23:04Output not recorded
grep -n "key_is_pressed\|key_is_released\|edge\|RAISE\|draw()" "C:\Users\a327e\D...Bashsed -n '555,585p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\ENGINE_API_Q...17:23:04Output not recorded
sed -n '555,585p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\ENGINE_API_Q...This confirms rule #3's edge queries note: "edge queries are update-only" — key/mouse/input pressed/released called from draw() raise a Lua error on desktop. Now let's look at AGENT.md for the eval channel details, engine_snapshot, agent_tap, and CLAUDE.md gotchas around colors/%d.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\AGENT.md17:23:08Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\AGENT.mdThinking17:23:13
I should check CLAUDE.md for known gotchas, like using %d on floats or treating colors as callable, and look through the framework and engine API quick references for similar warnings about Lua's %d formatting issue.
Grep%d|callable|math.floor|string.format17:23:13Output not recorded
%d|callable|math.floor|string.formatBashgrep -rn "%d.*float\|math.floor.*fps\|Color table\|color()\|callable" "C:\Users\...17:23:13Output not recorded
grep -rn "%d.*float\|math.floor.*fps\|Color table\|color()\|callable" "C:\Users\...Thinking17:23:19
I don't see that specific gotcha directly mentioned, so I should search CLAUDE.md's full content for warning markers or notes, and also check for anything about ASCII-only fonts or font loading caveats.
Grep⚠|gotcha|ASCII|ascii17:23:19Output not recorded
⚠|gotcha|ASCII|asciiGrepASCII|ascii|codepoint|font_register17:23:19Output not recorded
ASCII|ascii|codepoint|font_registerThinking17:23:24
The quick docs don't mention an ASCII-only limitation for fonts, so I should check the engine API's font_register function in C and look through ENGINE_API.md for any character range restrictions.
GrepASCII|ascii|32.*126|codepoint range|glyph range|0x20|12717:23:24Output not recorded
ASCII|ascii|32.*126|codepoint range|glyph range|0x20|127Grep%d|string\.format.*%d|floor17:23:24Output not recorded
%d|string\.format.*%d|floorThinking17:23:30
I don't see documentation of an ASCII font caveat or %d-float issue, so I'll check the engine's C source for the font atlas glyph load range to confirm whether it's ASCII-only.
Grepfont_register|FONT_FIRST_CHAR|FONT_NUM_CHARS|stbtt_BakeFontBitmap|first_char|char_range17:23:30Output not recorded
font_register|FONT_FIRST_CHAR|FONT_NUM_CHARS|stbtt_BakeFontBitmap|first_char|char_rangeThinking17:23:34
Good, that confirms the ASCII pre-warm range of 32-127. Now I want to check whether it's strictly ASCII-only or if non-ASCII glyphs get baked lazily on demand, so I'll look for more context on that.
Read17:23:34Output not recorded
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.c17:23:34Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.cThinking17:23:44
So it's not truly ASCII-only — fonts pre-warm ASCII but lazily rasterize non-ASCII on first use, meaning the real caveat is just first-frame latency rather than a hard restriction. That's worth reporting accurately instead of the assumed limitation. Now I want to check whether there's a gotcha with %d formatting on floats, like in Lua's string.format.
Since Lua 5.3+ requires an integer-representable value for %d, I should search the framework, docs, and CLAUDE.md thoroughly for any mention of this specific formatting issue.
Bashgrep -rn "%d" "C:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.md" "C...17:23:48Output not recorded
grep -rn "%d" "C:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.md" "C...Confirmed: Anchor/engine/reference/plan.md:48 mentions the %d+float gotcha explicitly. Let me pull surrounding context there.
Bashsed -n '30,55p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\reference\plan.md"17:23:51Output not recorded
sed -n '30,55p' "C:\Users\a327e\Desktop\a327ex\Anchor\engine\reference\plan.md"This confirms the fix already applied for a %d+float crash found in HUD text (e.g. string.format('%d', physics3_get_body_count()) where a Lua number without math.floor broke %d). Now let's check .claude/launch.json conventions and check for spring ceiling gotcha mentioned in ENGINE_API_QUICK (SPRING_MAX_FREQUENCY 12).
Bashfind "C:\Users\a327e\Desktop\a327ex" -iname "launch.json" 2>/dev/null echo "---"...17:23:57Output not recorded
find "C:\Users\a327e\Desktop\a327ex" -iname "launch.json" 2>/dev/null echo "---"...Grepspring_add|spring_pull|SPRING_MAX_FREQUENCY17:23:57Output not recorded
spring_add|spring_pull|SPRING_MAX_FREQUENCYThinking17:24:01
There's also a launch.json sitting at the top level of the project rather than inside Anchor/engine, which looks like a Windows working directory preview convention worth checking.
ReadC:\Users\a327e\Desktop\a327ex\.claude\launch.json17:24:02Output not recorded
C:\Users\a327e\Desktop\a327ex\.claude\launch.jsonThinking17:24:12
This confirms the top-level launch.json is only for renderer-web, and games use their own run.bat instead of a .claude/launch.json convention — that answers question 8. Now I want to check on the engine_snapshot details and whether anchor.exe needs to be copied per game or comes bundled with the engine.
I'm noticing a layout distinction: playground's run.bat references the shared engine build's anchor.exe via a relative path, while the Horse Game's run.bat uses a local, tracked anchor.exe copy within the game directory itself. So I need to figure out how the actual "anchor" CLI command works when invoked directly — whether there's a global wrapper on PATH that dispatches to the engine build with the game directory as an argument, since CLAUDE.md references commands like "anchor drive start <game-dir>". I should search for an anchor executable or script somewhere in the engine scripts or launcher directories.
Bashfind "C:\Users\a327e\Desktop\a327ex\Anchor" -iname "anchor" -o -iname "anchor.ba...17:24:13Output not recorded
find "C:\Users\a327e\Desktop\a327ex\Anchor" -iname "anchor" -o -iname "anchor.ba...Thinking17:24:17
I notice `Anchor/workflow/anchor` and `anchor.cmd` files, which look like the CLI dispatcher implementing the "anchor" command used throughout the docs. I should read these to see how they're implemented, likely as a shell or python script.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.cmd17:24:17Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.cmdBashhead -n 60 "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor"17:24:17Output not recorded
head -n 60 "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor"Bashgrep -n "^def cmd_run\|def cmd_drive\|def run_game\|game_dir\|def cmd_check\|def...17:24:17Output not recorded
grep -n "^def cmd_run\|def cmd_drive\|def run_game\|game_dir\|def cmd_check\|def...Bashls "C:\Users\a327e\Desktop\a327ex\Anchor\workflow" | head -5017:24:17Output not recorded
ls "C:\Users\a327e\Desktop\a327ex\Anchor\workflow" | head -50Thinking17:24:28
That's odd that grep found nothing even though anchor.py exists—let me look at its contents directly.
Bashwc -l "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py"; grep -n "drive\...17:24:29Output not recorded
wc -l "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py"; grep -n "drive\...Thinking17:24:36
I'm realizing there's a distinction between the "anchor" workflow CLI (SPR) and the anchor.exe binary that actually launches the game window, and I need to figure out which one SURFACE.md is referring to when it says `anchor <game folder>`.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py17:24:36Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.pyThinking17:24:44
So this argparse tool only handles drive start/eval/stop/status/log — there's no launch-window subcommand here. The actual windowed launch must come from somewhere else, like each game's own run.bat or the Caps Lock launcher mentioned in CLAUDE.md.
Now I want to check drive.py's implementation to see how it locates anchor.exe for a given game directory — whether it's a shared engine build or a per-game copy.
Bashgrep -n "anchor.exe\|anchor_exe\|def find_anchor\|engine/build\|ANCHOR_EXE\|subp...17:24:44Output not recorded
grep -n "anchor.exe\|anchor_exe\|def find_anchor\|engine/build\|ANCHOR_EXE\|subp...Bashgrep -n "^def \|exe =\|_find\|resolve_exe\|anchor.exe\|windows\|win32\|platform"...17:24:44Output not recorded
grep -n "^def \|exe =\|_find\|resolve_exe\|anchor.exe\|windows\|win32\|platform"...Bashgrep -n "ENGINE_BIN" "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\lib\drive.py...17:24:44Output not recorded
grep -n "ENGINE_BIN" "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\lib\drive.py...Thinking17:25:03
So `anchor drive start <game-dir>` always uses the shared engine build at the fixed path rather than a per-game copy — meaning game directories don't need their own anchor.exe for drive start/eval/stop in this repo, since the shared binary handles it.
This is different from Horse Game, which keeps its own anchor.exe and anchor-steam.exe copies for distribution to players, not for engine-repo dev workflows — worth flagging as a distinct pattern.
I'm also realizing the plain `anchor <game-dir>` command from SURFACE.md refers to invoking the engine's own executable directly (anchor.exe), not the workflow CLI script — a naming collision between the binary and the Python tool that share the "anchor" name, which seems important to call out clearly.
ReadC:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py17:25:03Output not recorded
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.pyThinking17:25:20
I'm confirming that this "anchor" workflow CLI lacks any game-launch subcommand, so the "anchor <game-dir>" described in SURFACE.md must refer to a separate binary — the engine executable itself, built as anchor.exe on Windows or anchor on Linux.
On closer inspection, though, the Python workflow CLI is also symlinked as "anchor" into ~/.local/bin, which means on Linux "anchor playground" would actually invoke that Python script rather than the engine binary — but argparse would reject "playground" as an invalid subcommand choice, contradicting CLAUDE.md's example. This suggests either an inconsistency or that the Linux documentation is simply stale, since the workspace seems to have moved back to Windows around early September 2026.
Rather than resolve this ambiguity by guessing, I think it's worth flagging as a naming collision — there may be a separate, older anchor CLI tied to the engine repo itself, distinct from the workflow tool. Given that engine/scripts has no anchor launcher script, and the Windows run.bat pattern is what actually opens the visible game window, I'm leaning toward run.bat (invoking anchor.exe with the game folder as an argument) being the practical answer, while the "anchor <game-dir>" command in SURFACE.md is either aspirational or assumes a PATH alias that isn't actually wired up in this repo.
Given my role as a search subagent, I think I have enough now, though I want to do one more quick check on how new game directories get created — searching for template, scaffold, or "new game" conventions in the workflow or engine scripts.
Grepnew game|scaffold|copy.*playground|template|fork17:25:20Output not recorded
new game|scaffold|copy.*playground|template|forkBashgrep -rn "snkrx-template\|ricochet-template\|emoji-template\|forkable" "C:\Users...17:25:20Output not recorded
grep -rn "snkrx-template\|ricochet-template\|emoji-template\|forkable" "C:\Users...Thinking17:26:35
This confirms there's no dedicated "new game" command — new games are created by forking a template directory or copying the playground folder directly, each following the same pattern noted in Horse Game's comments. I now have enough to write the final report, though I want to double-check the boot file first.
Also confirming init.lua's requirement that boot.lua be the last line of main.lua, checking the anchor/VERSION file's purpose, and verifying the layer camera functions (camera_attach/camera_detached) are already covered. Now I'm compiling the final answer with exact file:line citations for layout and running conventions — comparing the playground setup (main.lua plus a copied anchor framework, no boot.lua or standalone anchor.exe) against the Horse Game setup (main.lua, boot.lua, its own anchor.exe for distribution, and a run.bat script).
I'm also working through what a new game directory would need: there's no scaffold command, so the approach is to fork an existing template or copy the playground folder, since run.bat is written to be folder-agnostic. On the anchor.exe question, I'm distinguishing between dev workflows inside the engine repo (which use a shared build via drive.py's ENGINE_BIN) versus shipping games like Horse Game that track their own executable copies for players — that's a distribution-time artifact, not a dev requirement, though SURFACE.md's literal phrasing leaves some ambiguity there.
Now I realize there's a naming collision: the `anchor <game folder>` command described for opening a visible window is different from the separate Python workflow CLI also called `anchor`, which is symlinked into the user's local bin but has no subcommand for launching a game window at all — its subparsers only cover things like doctor, install, runs, drive, and check. So practically, opening a visible window on this machine happens through each game's own run.bat calling the engine binary directly, not through a bare `anchor <dir>` command — I should flag this as unresolved rather than guess at an answer. That leads me into the timestep question, starting with the physics rate constant defined in the engine.
I confirm the engine runs both physics and updates at a fixed 120Hz timestep, and the dt passed into update(dt) is always this unscaled fixed value, not scaled by time_scale — a comment in init.lua clarifies that if scaled dt is needed, it must be computed manually or fetched via engine_get_dt(), while the unscaled global mirrors this same constant through engine_get_unscaled_dt(). Meanwhile rendering is capped at 60Hz by default, since render_uncapped defaults to false.
Looking at FRAMEWORK_API_QUICK.md and the accumulator loop in anchor.c, this confirms the standard fixed-timestep pattern where physics_lag tracks accumulated time against MAX_UPDATES. I'm also noting that in fixed-step/agent mode, the loop runs two fixed updates per rendered frame, per engine_set_fixed_step documentation and the AGENT.md references to pump cycles.
I'm now checking the update(dt)/draw() function signatures documented in FRAMEWORK_API_QUICK.md and referenced in playground/main.lua, noting draw() takes no arguments. Then I'm reviewing the input API -- bind(), input_down/pressed/released, input_axis, input_vector, and the raw key state functions -- cross-referencing FRAMEWORK_API_QUICK.md with the more detailed ENGINE_API docs.
For the actual key name strings, I trace key_name_to_scancode in engine/src/anchor.c to find the exact naming convention: lowercase single letters for A-Z, digits, "space", "enter"/"return", "escape"/"esc", arrow keys as "left"/"right"/"up"/"down", modifier keys like "lshift"/"shift", function keys f1-f12, and numpad keys. This confirms the exact strings needed for binding keys like A and D.
I also find that the bind() control string format follows 'key:<name>' pattern, with examples in playground/main.lua showing bindings like key:b, key:r, key:space, key:f1, and mouse:1. Additionally, I note an important constraint from ENGINE_API_QUICK.md: input edge queries (pressed/released) are update-only and silently return false when called from draw() on desktop, with a console warning on web — this is likely enforced via an internal flag in anchor.c.
Now I'm moving to 2D layer drawing: layer_new() creates layers stored in a lookup table, resolution/scale gets configured through the init table passed to require('anchor'), and there's a key origin rule where rectangles/text use top-left origin while images/sprites/circles use center origin. I'm also noting the layer_rectangle and layer_line drawing functions.
For text and fonts, layer_text() takes a font that can be a table or name string, fonts get registered via font_register() with an example loading a monogram ttf at size 32, and I'm checking the Horse Game assets which include several other ttf files. There's also an ASCII limitation in the engine source — fonts only support the standard 96 ASCII characters starting from char 32.
Actually the ASCII caveat isn't a hard limitation — non-ASCII glyphs just re-rasterize lazily on first use, so there's only a minor one-time latency for novel characters, which doesn't matter for a plain ASCII debug HUD. For colors, I need to remember that color-producing values are callable, so I must invoke them as functions when passing to draw calls rather than passing the table itself.
Beyond colors, there's the camera system: creating a 2D camera, updating it, having it follow a target with optional lerp and lead, setting bounds, and attaching/detaching it to layers with parallax factors.
Now I'm looking at hot reload rules: main.lua definitions can be reloaded safely since functions rebind, UPPER_CASE constants stay live, and class() tables merge in place so existing instances pick up new methods, per AGENT.md's guidance, with an idempotency guard preventing re-booting anchor. Whatever boot.lua originally created persists across reloads.
The framework itself, boot files, and packaged builds are never reloadable, and assets reload in-place while keeping the same texture or sound handle. For registries needing to survive reload, I'm noting the `X = X or {}` guard pattern combined with an `on_reload(path)` hook that reruns after successful reloads, useful for re-registering things like timer_every schedules. I'm also seeing a visible instance watching saves at 2Hz.
I'm also mapping the eval channel: errors show a traceback overlay on the last good frame instead of crashing, agent instances only reload via an explicit engine_reload() call, and there's a drive workflow with start/evaluate/stop/status functions exposed via CLI, plus engine_step(n) to advance rendered frames and engine_snapshot(path) to capture the current frame as a PNG.
Now I'm looking at the agent.lua kit helpers -- agent_shot defaults to a replays/shots path, and there's agent_tap, agent_click, agent_wait_until, agent_dump, and agent_globals for scripting interactions with the running instance, since eval scripts run arbitrary Lua that can directly read and mutate game state through globals.
There's no dedicated player-state API — a script sets fields like player.x directly, steps the engine, then reads values back through the expression's return channel, so the game just needs to expose entities as accessible globals (like playground's the_ground, balls, crates) for a driver to reach them. Separately, input_inject_key/mouse/text functions route through the real SDL pump so update-time edge queries treat them as genuine input, useful for simulating real keydown events.
Now I'm noting some known gotchas: colors need to be called as functions (color()) since forgetting the parentheses passes a table where an integer is expected, and Lua's string.format with %d and a float value misbehaves — documented in plan.md as a real HUD-text bug from playground development — so counters need math.floor() wrapping before formatting.
I'm also flagging the origin inconsistency between layer_rectangle (top-left origin) and other framework elements, which means collision-box math needs to stay consistent with whatever AABB convention the lab code uses, plus noting spring_add/spring_pull throws a Lua error above frequency 12.
Other loud-failure changes worth flagging: wrong-typed engine handles now raise argument errors instead of segfaulting, and edge queries like key/mouse pressed/released must only be called from update(), not draw(), or they raise on desktop.
There's also a reload-survival gotcha: only state created in boot.lua persists across saves, so anything at main.lua's top level re-runs on load and can wipe run state — meaning the tile grid, player instance, camera, and layer creation belong in boot.lua, while update/draw methods and tuning constants stay in main.lua. Framework copy drift is another concern — a game's anchor/ folder can diverge from framework/anchor/, so if a function isn't where the docs say it should be...
Instead each game has its own run.bat that the owner runs manually, and CLAUDE.md is explicit that Claude should never launch the windowed executable itself—verification happens through `anchor drive start/eval/stop` on the eval channel per SURFACE.md/AGENT.md. I feel like I have solid coverage now and can write the final answer matching the 8 requested items with file:line references throughout.
Findings: Anchor 3 platformer controller-lab orientation
All paths below are relative to C:\Users\a327e\Desktop\a327ex\ unless given in full.
1. Minimal game directory layout
Files needed to be driven by anchor drive start/eval/stop <game-dir> (the Python workflow CLI's drive subcommand, Anchor\workflow\lib\drive.py):
main.lua— required. Must end withrequire('anchor')({...})and, if the game is split,require('boot')as its last line.anchor/— a copy of the framework (init.lua,class.lua,input.lua,layer.lua,camera.lua,color.lua, …; 30 files, same set inplayground\anchor\andABAgames\knightvspawns\anchor\). Required —require('anchor')resolves into this folder.boot.lua— optional. Only needed if you split one-time work out ofmain.lua(see below). Playground has none; a sketch keeps everything inmain.luaand that's fine (Anchor\engine\docs\AGENT.md:192-193).anchor.exein the game dir — not required for the engine-repo dev flow.Anchor\workflow\lib\drive.py:29:ENGINE_BIN = engine/engine/build/anchor.exe, a fixed path —drive.start()(drive.py:140-160) always launches the shared engine binary with the game dir ascwd, never a per-game copy. Horse Game (ABAgames\knightvspawns\anchor.exe,anchor-steam.exe) tracks its own exe copies only because it's a standalone shipped game outside the engine repo — that's a distribution artifact, not a dev requirement.- No
init.luaof your own —init.lualives insideanchor/(the framework copy) and is not something a game authors;anchor\init.lua:135is thefunction(config)returned byrequire('anchor'), called from yourmain.lua.
run.bat contents, quoted exactly:
Playground (Anchor\engine\playground\run.bat):
@echo off
cd /d "%~dp0"
..\engine\build\anchor.exe .Horse Game (ABAgames\knightvspawns\run.bat):
@echo off
rem Folder-agnostic runner: derives the game name from this folder's name, so a
rem copied/renamed fork runs without editing this file (same pattern as
rem snkrx-template / ricochet-template).
cd /d "%~dp0.."
for %%I in ("%~dp0.") do set "GAME=%%~nxI"
"%~dp0anchor.exe" "%GAME%"The Horse Game pattern is deliberately folder-agnostic (its own comment says so) — that's the intended way to fork a new game dir: copy a template/existing game dir, rename it, and run.bat still works.
Definition-only main.lua + boot.lua split (Anchor\engine\docs\AGENT.md:138-217, confirmed live in ABAgames\knightvspawns\main.lua:29-32 and boot.lua:1-8):
require('anchor')({ width = 480, height = 270, title = 'kvp', boot = { 'boot.lua' } })
require('boot') -- assets, layers, binds, state — runs once, cached by require
-- everything below: definitions, reloadablemain.lua holds only functions, UPPER_CASE/literal-table constants, class() definitions, colors, requires. boot.lua holds asset loads, layer_new, bind, state tables/scalars, springs/timers/cameras, the startup call sequence. On save, main.lua re-runs in the globals (functions rebind, class() tables merge in place into live instances); boot.lua never re-runs (cached by require).
New game dir creation: no scaffold/anchor new command exists (confirmed — Anchor\workflow\anchor.py:15-266 has no such subcommand). The normal path is forking a template (snkrx-template/, ricochet-template/, emoji-template/, each with its own .claude/CLAUDE.md) or copying playground/ directly, per the top-level C:\Users\a327e\Desktop\a327ex\.claude\CLAUDE.md "Templates" line and the Horse Game run.bat comment above.
Ambiguity worth flagging, not guessed away: Anchor\engine\docs\SURFACE.md:9-17 documents anchor <game folder> as the way to open a visible window. But the only anchor CLI actually on this machine is the Python workflow tool (Anchor\workflow\anchor:1-3, symlinked as anchor), whose argparse subcommands (anchor.py:15-266) are doctor/install/runs/end/continue/.../framework/drive/check — there is no subcommand that launches a windowed game instance. So a bare anchor playground as shown in the top-level CLAUDE.md's bash example does not correspond to any command implemented in this repo's Python CLI; the actual mechanism for a visible run on this Windows machine is each game's run.bat, which invokes engine\build\anchor.exe directly. I did not find a resolving alias — treat "how a person literally runs anchor <game-dir>" as undocumented/unverified here rather than assume it works.
2. Fixed timestep
- 120 Hz fixed physics/input/update timestep, set at
Anchor\engine\engine\src\anchor.c:339:#define PHYSICS_RATE (1.0 / 120.0). dtpassed toupdate(dt)is this fixed unscaledPHYSICS_RATE(constant every call) — documented atframework\anchor\init.lua:95-107: "thedtparameter... is the UNSCALED fixed physics timestep (PHYSICS_RATE). If you want scaled dt..., computedt * time_scalelocally, or callengine_get_dt()."- Render rate: capped at 60 Hz by default, with 2 fixed updates per rendered frame (
docs\ENGINE_API_QUICK.md:564:engine_set_fixed_step(on) -- 2 fixed updates + 1 rendered frame per iteration;docs\AGENT.md:120-121: an agent-driven frame ispump_sdl_events → 2× fixed_update_step → render_frame_pass).render_uncapped = truein the init table opts out of the 60 Hz cap for non-pixel-art games (docs\FRAMEWORK_API_QUICK.md:27-34). - Callback signatures:
function update(dt)andfunction draw()— globals the C engine calls directly each frame, no other args (docs\FRAMEWORK_API_QUICK.md:106-116; exampleplayground\main.lua:194/:279).
3. Input
bind(action, control)— control strings are'key:<name>'/'mouse:<n>'(docs\FRAMEWORK_API_QUICK.md:158; live examplesplayground\main.lua:64-68:bind('spawn_ball','key:b'),bind('reset','key:r'),bind('shockwave','key:space'),bind('debug','key:f1'),bind('grab','mouse:1')).input_down(action) -> bool,input_pressed(action) -> bool(this-tick edge),input_released(action) -> bool, plusinput_axis,input_vector—docs\FRAMEWORK_API_QUICK.md:166-170.- Raw (always available, no bind needed):
key_is_down(key),key_is_pressed(key),key_is_released(key)—docs\ENGINE_API_QUICK.md:574-590, examplesdocs\ENGINE_API.md:3623-3651. - Exact key-name strings, from
key_name_to_scancodeatAnchor\engine\engine\src\anchor.c:4780-4871:- Letters: single lowercase letter, e.g.
"a","d","w","s","e"(case-insensitive on input;scancode_to_key_nameatanchor.c:4876-4909always emits lowercase). "space"(line 4794); arrows:"left","right","up","down"(4802-4805); shift:"lshift","rshift", or bare"shift"which defaults to left (4808-4810).- So for this lab:
bind('left','key:a'),bind('right','key:d'),bind('jump','key:space'),bind('run','key:shift'), orbind('left','key:left')/bind('right','key:right')for the arrow-key variant.
- Letters: single lowercase letter, e.g.
- Edges are update-only rule:
docs\ENGINE_API_QUICK.md:578-580— "key/mouse/input pressed/released called fromdraw()RAISE on desktop ('edge queries are update-only') — they were silently always-false there. Web: one console warning." All jump/attack edge logic must live inupdate(dt), neverdraw().
4. Drawing on a 2D layer
layer_new(name, filter?, w?, h?) -> layer(stores inlayers[name]) —docs\FRAMEWORK_API_QUICK.md:246-254. Resolution/scale for the whole game is set once viarequire('anchor')({ width=, height=, scale=, filter= })(framework\anchor\init.lua:9-20; live exampleplayground\main.lua:17-23:width=960, height=540, scale=2, filter='smooth').- Rect drawing:
layer_rectangle(lyr,x,y,w,h,color)filled,layer_rectangle_line(lyr,x,y,w,h,color,line_width?)outlined —docs\FRAMEWORK_API_QUICK.md:263-264. Origin rule (docs\FRAMEWORK_API_QUICK.md:257-260): rectangles and text use TOP-LEFT(x,y); images/sprites/circles use CENTER(x,y). A kinematic player box drawn withlayer_rectangleshould keep its stored position as the top-left corner to match, or convert consistently. - Lines:
layer_line(lyr,x1,y1,x2,y2,width,color)— same block, line 268. - Text:
layer_text(lyr, text, font, x, y, color), wherefontmay be a font table or a registered name string (docs\FRAMEWORK_API_QUICK.md:279-283). Fonts are loaded withfont_register(name, path, size, filter?) -> font(docs\FRAMEWORK_API_QUICK.md:196-203; exampleplayground\main.lua:76:font_register('main', 'assets/monogram.ttf', 32)). Font caveat, not "ASCII-only" as commonly assumed:Anchor\engine\engine\src\anchor.c:1761-1762pre-warms only ASCII 32..127 (FONT_FIRST_CHAR/FONT_NUM_CHARS) at load, butanchor.c:2096-2098documents that "non-ASCII glyphs re-rasterize lazily on next use" — so non-ASCII text works, just with a first-use rasterization cost, not a hard restriction. For debug HUD text (plain ASCII) this is a non-issue either way. - Colors:
color(r,g,b,a) -> color/color_new(...), a plain{r,g,b,a}table with a__callmetamethod returning the packed rgba integer (docs\FRAMEWORK_API_QUICK.md:645-656). No engine-provided named palette — games define their own convention, e.g.playground\main.lua:31-32,42-50:bg_variants,ground_color = color(105,110,125), etc. Gotcha: colors are callable and must be invoked (some_color()), not passed as the bare table, when handed to draw functions —docs\ENGINE_API_QUICK.md:581. - 2D camera (for vertical player-follow):
camera_new(w?, h?) -> camera,camera_update(c, dt),camera_follow(c, target, lerp?, lerp_time?, lead?)(target can be an entity, a plain{x,y}table, or nil to stop),camera_set_bounds(c, min_x, max_x, min_y, max_y),camera_attach(c, layer, parallax_x?, parallax_y?)/camera_detach(c, layer)—docs\FRAMEWORK_API_QUICK.md:344-361. Standard draw flow (docs\FRAMEWORK_API_QUICK.md:329-341):
function draw()
layer_rectangle(game_layer, 0, 0, width, height, bg_color())
camera_attach(main_camera, game_layer)
-- draw entities to game_layer here (world space)
camera_detach(main_camera, game_layer)
layer_render(game_layer)
layer_render(ui_layer)
layer_draw(game_layer)
layer_draw(ui_layer)
endFor vertical-only follow, call camera_follow(cam, player) once (or every update) and let camera_update(cam, dt) (called in update(dt)) do the lerp; camera_set_bounds can pin the x-axis if you want vertical-only tracking. camera_attach/camera_detach is the "offset the world" mechanism — it pushes/pops the camera's transform onto the given layer around your world-space draw calls; a separate ui_layer drawn without attach/detach stays screen-space for debug text (as playground\main.lua:288-297 does with ui_layer).
5. Hot reload
- Rule: a reloadable file holds only definitions; one-time work lives in files the init table names as
boot—docs\SURFACE.md:66-77,docs\AGENT.md:138-217. - What re-runs on save:
main.lua(and any non-frameworkrequired module) — functions rebind (call sites resolve names at call time),UPPER_CASE/literal-table constants go live,class()tables are reconciled (new methods merge into the old class table so live instances pick them up) —docs\AGENT.md:152-156. - What survives: anything
boot.luacreated, becauserequire('boot')is cached and never re-executes — asset handles, layers, binds, state tables/scalars, springs/timers/cameras (docs\AGENT.md:140-150, 198-217). - Not reloadable at all: the framework copy itself (
anchor/— e.g.layer.luacaptures raw bindings at load), boot files, packaged builds —docs\AGENT.md:174-176. - Assets (textures/sounds/shaders) also hot-reload in place into the same GL handle —
docs\AGENT.md:177-188. - Pattern for state that must survive a module reload without a boot file:
REGISTRY = REGISTRY or {}guards (docs\AGENT.md:206-207), and an optionalon_reload(path)hook the game can define, run after every successful reload, to re-derive caches or re-run one-time registration in place (docs\AGENT.md:169-171, 214-216). - Visible instances: saves watched at 2 Hz; a game error shows a traceback overlay over the last good frame instead of freezing (
docs\SURFACE.md:71-72,docs\AGENT.md:159-162). Agent instances: reload only on explicitengine_reload()call, for determinism (docs\AGENT.md:163-166). - For the lab: put the tile grid construction, the player instance, layer/camera creation, and
bind()calls inboot.lua; keep the update/draw methods, movement-tuning constants, and class defs inmain.luaso tuning a jump constant hot-reloads without resetting the player's position.
6. Eval channel
- Surface:
anchor drive start <game-dir>,anchor drive eval <game-dir> '<lua>'(or--file f.lua),anchor drive stop <game-dir>, plusstatus/log— declared atAnchor\workflow\anchor.py:171-176, implemented inAnchor\workflow\lib\drive.py(start()line 140,evaluate()line 183,stop()line 195), documented indocs\AGENT.md:36-51. engine_step(n) -> n— advances n rendered frames (2 fixed updates each); agent instances only (docs\ENGINE_API_QUICK.md:568;docs\AGENT.md:71).engine_snapshot(path)— writes the current frame as a PNG topathon demand (docs\AGENT.md:75). Theagent_tap/agent_shotkit convenience (framework\anchor\agent.lua) defaults its own snapshots toreplays/shots/<frame>.png(docs\AGENT.md:43).agent_tap(direction_or_control)and siblingsagent_click,agent_wait_until(pred, max_steps),agent_dump,agent_globals,agent_shot— all from the agent kit loaded by the framework,framework\anchor\agent.lua, listed atdocs\AGENT.md:80-82; example usageagent_tap("right")(docs\AGENT.md:42).- Setting/reading player state from a test script: there is no dedicated player-state API — an eval is arbitrary Lua run in the live instance's globals, and an expression's return values come back over the channel (
docs\AGENT.md:64-66: "An expression (knight.x,#pawns,engine_state()) returns its values"). So a controller-lab test script just doesplayer.x = 100; player.vy = -200thenengine_step(30)thenreturn player.x, player.y, player.grounded— as long as the game exposesplayer(or whatever) as a reachable global, the same conventionplayground\main.luauses forthe_ground,balls,crates. - Also useful for driving the controller directly like a person would:
input_inject_key(name, down),input_inject_mouse_move(x,y),input_inject_mouse_button(n,down),input_inject_text(s)— go through the real SDL pump so update-time edge queries (input_pressed) see them as genuine input (docs\AGENT.md:72-74). engine_state()returns frame/step/time/dt/agent/visible/recording/sizes — note the field isframe, notframe_num(docs\AGENT.md:76).
7. Gotchas that bite this lab specifically
- Edge queries are update-only (
docs\ENGINE_API_QUICK.md:578-580):key_is_pressed/input_pressed/mouse-pressed/-released raise a Lua error if called fromdraw()on desktop. All jump-buffer/one-shot logic must be inupdate(dt). - Colors are callable: pass
some_color(), not the barecolortable, to anylayer_*draw call (docs\ENGINE_API_QUICK.md:581,docs\FRAMEWORK_API_QUICK.md:656). string.format('%d', ...)on a float value is a real, previously-hit bug in this exact codebase:Anchor\engine\reference\plan.md:48records "Windowed fixes from the first run:%d+float in HUD text" as a fix made during playground development.playground\main.lua:291-292now guards this withmath.floor(fps or 0)before formatting. Any debug-text HUD in the lab (fps, player velocity, tile coords) needs the samemath.floor()wrap before%d.- Draw origin split: rectangles/text are top-left-anchored, images/sprites/circles are center-anchored (
docs\FRAMEWORK_API_QUICK.md:257-260) — easy to double-offset an image if you subtract half-size expecting a top-left convention. - Springs raise above frequency 12 instead of the old silent divergence into a screen-filling sprite (
docs\ENGINE_API_QUICK.md:582-583; top-level engine.claude/CLAUDE.md:96) — relevant if camera smoothing is done withspring_pullinstead ofcamera_follow's built-in lerp. - Loud failures generally: any binding taking an engine handle now raises a typed Lua error instead of segfaulting on a wrong-typed argument (
docs\ENGINE_API_QUICK.md:576-577) — useful for catching a bug where you pass a raw table instead of a layer handle. - Reload-survival discipline: anything created outside
boot.lua(e.g., aplayer = player()line left atmain.lua's top level) re-runs and resets on every save — keep entity construction, layer/camera setup, andbind()calls inboot.luaonly (docs\AGENT.md:140-150, 198-217). - Framework-copy drift: a game's own
anchor/folder can diverge fromframework\anchor\; if a function isn't where the docs say, the game's local copy is ground truth (top-level.claude/CLAUDE.md, "Structure" section). Useanchor framework status|diff|upgrade|stamp <game-dir>(Anchor\workflow\anchor.py:165-169) to manage that copy rather than hand-copying files.
8. .claude/launch.json / preview conventions
Only one launch.json exists anywhere on the machine: C:\Users\a327e\Desktop\a327ex\.claude\launch.json, and it configures a single entry, "renderer-web" (a Python serve-web.py static server for the site's renderer) — nothing game-related, and no such file exists under Anchor\engine\, Anchor\engine\playground\, or ABAgames\knightvspawns\. There is no launch.json-based preview convention for Anchor games. The documented convention instead (top-level .claude/CLAUDE.md, "Building and Running") is: never run the windowed executable yourself — the user runs it via their own run.bat; an agent (Claude) exercises a game only through anchor drive start/eval/stop (the eval channel described in section 6). For this lab, verification should be built as anchor drive eval <game-dir> '...' scripts (per docs\AGENT.md's scripted-run pattern), not as a browser/preview pane.