Phylactery OUT NOW · FREE · PLAYS IN YOUR BROWSERPLAY ›

Map Anchor 3 APIs for 2D lab

Delegated run·Sep 11, 2026  17:21·Prompted by Fable·Run by Sonnet 5Sonnet 5 high
Ctrl + F
Show

Transcript

Fable·17:21:57·Sep 11, 2026Prompter

You are orienting a later implementer who will build a small 2D platformer "controller lab" game on the Anchor 3 engine at C:\Users\a327e\Desktop\a327ex\Anchor\engine\ (Windows). The lab: tile grid of rectangles, a kinematic player box (no physics engine), keyboard input, a camera that follows the player vertically, on-screen debug text, and hot-reload on save. Search thoroughly (medium-thorough) and return CONCLUSIONS with exact function signatures and file:line references, not file dumps. Read in this order: Anchor/engine/docs/SURFACE.md, Anchor/engine/.claude/CLAUDE.md, Anchor/engine/playground/main.lua (canonical style example) and its boot.lua/init.lua/run.bat if present, then Anchor/engine/docs/FRAMEWORK_API_QUICK.md and ENGINE_API_QUICK.md for the specific functions below. Also glance at C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns (Horse Game) for how a real game dir is laid out on Windows (run.bat, tracked anchor.exe, anchor/ framework copy, boot.lua vs main.lua split).

Report, concisely but exactly:

  1. Minimal game directory layout to be runnable by anchor <game-dir> and anchor drive start <game-dir>: which files are required (boot.lua, main.lua, init.lua, anchor/ framework copy, anchor.exe?), what each holds, how the definition-only main.lua + boot.lua reload split works, and how a new game dir is normally created (copied from playground? a template? a command?). Quote the run.bat contents of playground and Horse Game.
  2. Fixed timestep: update rate (is it 120 Hz fixed? where is it set?), dt available in update, render rate, and the update/draw callback names and signatures a game defines.
  3. Input: exact functions for key down / pressed-this-tick / released, key name strings for A D W S E space shift left/right arrows, and the "input edges are update-only" rule.
  4. Drawing on a 2D layer: creating a layer, its resolution/scale setup, drawing filled and outlined rectangles (origin rule: rects top-left), lines, text (which fonts exist and how to load one; any ASCII-only caveats), colors (named palette? how to make a color), and layer camera/translation (how to offset the world so the camera follows the player; any layer camera functions).
  5. Hot reload: what exactly reloads on save and what state survives; any pattern for keeping runtime state across reloads.
  6. The eval channel for verification: anchor drive start/eval/stop usage, engine_step(n), engine_snapshot (what it returns, where images land), agent_tap, and how a test script can set the player's state and read it back.
  7. Any gotchas from Anchor/engine/.claude/CLAUDE.md or the docs that bite a kinematic-controller lab (e.g., callable colors, Lua %d on floats, draw origins, spring ceilings).
  8. The .claude/launch.json / preview conventions if any exist for games. Give file:line for each signature. If something is undocumented, say so rather than guessing.
Sonnet 5high·17:22:01·Sep 11, 2026Worked for 5m 49s
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
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\SURFACE.md
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.md17:22:05Output not recorded
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\.claude\CLAUDE.md
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\main.lua17:22:05Output not recorded
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\main.lua
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\run.bat17:22:05Output not recorded
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\run.bat
Bashcd "C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground" && ls -la 2>/dev/nul...17:22:05Output not recorded
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\playground\anchor\init.lua
Bashcd "C:\Users\a327e\Desktop\a327ex\ABAgames\knightvspawns" && find . -maxdepth 2 ...17:22:17Output not recorded
Input
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
Input
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
Input
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
Input
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
Input
PHYSICS_RATE|fixed.step|60Hz|120|update\(dt\)|draw\(\)|render rate|frame rate
GrepPHYSICS_RATE|fixed_step|engine_set_fixed_step|update_rate|render_rate17:22:30Output not recorded
Input
PHYSICS_RATE|fixed_step|engine_set_fixed_step|update_rate|render_rate
GrepPHYSICS_RATE17:22:30Output not recorded
Input
PHYSICS_RATE
Bashgrep -n "function update\|function draw\|^function.*(dt)" -r "C:\Users\a327e\Des...17:22:30Output not recorded
Input
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
Input
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
Input
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
Input
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
Input
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
Input
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
Input
key:|'key:| key names|arrow|space|shift|left/right
Thinking17: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
Input
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
Input
key_is_down|key names|left/right|lshift|space
ReadC:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.c17:22:55Output not recorded
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.c

Good, 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
Input
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
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\docs\AGENT.md
Thinking17: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
Input
%d|callable|math.floor|string.format
Bashgrep -rn "%d.*float\|math.floor.*fps\|Color table\|color()\|callable" "C:\Users\...17:23:13Output not recorded
Input
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
Input
⚠|gotcha|ASCII|ascii
GrepASCII|ascii|codepoint|font_register17:23:19Output not recorded
Input
ASCII|ascii|codepoint|font_register
Thinking17: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
Input
ASCII|ascii|32.*126|codepoint range|glyph range|0x20|127
Grep%d|string\.format.*%d|floor17:23:24Output not recorded
Input
%d|string\.format.*%d|floor
Thinking17: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
Input
font_register|FONT_FIRST_CHAR|FONT_NUM_CHARS|stbtt_BakeFontBitmap|first_char|char_range
Thinking17: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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\engine\engine\src\anchor.c
Thinking17: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
Input
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
Input
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
Input
find "C:\Users\a327e\Desktop\a327ex" -iname "launch.json" 2>/dev/null echo "---"...
Grepspring_add|spring_pull|SPRING_MAX_FREQUENCY17:23:57Output not recorded
Input
spring_add|spring_pull|SPRING_MAX_FREQUENCY
Thinking17: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
Input
C:\Users\a327e\Desktop\a327ex\.claude\launch.json
Thinking17: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
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.cmd
Bashhead -n 60 "C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor"17:24:17Output not recorded
Input
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
Input
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
Input
ls "C:\Users\a327e\Desktop\a327ex\Anchor\workflow" | head -50
Thinking17: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
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py
Thinking17: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
Input
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
Input
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
Input
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
Input
C:\Users\a327e\Desktop\a327ex\Anchor\workflow\anchor.py
Thinking17: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
Input
new game|scaffold|copy.*playground|template|fork
Bashgrep -rn "snkrx-template\|ricochet-template\|emoji-template\|forkable" "C:\Users...17:25:20Output not recorded
Input
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 with require('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 in playground\anchor\ and ABAgames\knightvspawns\anchor\). Required — require('anchor') resolves into this folder.
  • boot.lua — optional. Only needed if you split one-time work out of main.lua (see below). Playground has none; a sketch keeps everything in main.lua and that's fine (Anchor\engine\docs\AGENT.md:192-193).
  • anchor.exe in 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 as cwd, 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.lua of your own — init.lua lives inside anchor/ (the framework copy) and is not something a game authors; anchor\init.lua:135 is the function(config) returned by require('anchor'), called from your main.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, reloadable

main.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).
  • dt passed to update(dt) is this fixed unscaled PHYSICS_RATE (constant every call) — documented at framework\anchor\init.lua:95-107: "the dt parameter... is the UNSCALED fixed physics timestep (PHYSICS_RATE). If you want scaled dt..., compute dt * time_scale locally, or call engine_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 is pump_sdl_events → 2× fixed_update_step → render_frame_pass). render_uncapped = true in 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) and function draw() — globals the C engine calls directly each frame, no other args (docs\FRAMEWORK_API_QUICK.md:106-116; example playground\main.lua:194 / :279).

3. Input

  • bind(action, control) — control strings are 'key:<name>' / 'mouse:<n>' (docs\FRAMEWORK_API_QUICK.md:158; live examples playground\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, plus input_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, examples docs\ENGINE_API.md:3623-3651.
  • Exact key-name strings, from key_name_to_scancode at Anchor\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_name at anchor.c:4876-4909 always 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'), or bind('left','key:left')/bind('right','key:right') for the arrow-key variant.
  • Edges are update-only rule: docs\ENGINE_API_QUICK.md:578-580 — "key/mouse/input pressed/released called from draw() 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 in update(dt), never draw().

4. Drawing on a 2D layer

  • layer_new(name, filter?, w?, h?) -> layer (stores in layers[name]) — docs\FRAMEWORK_API_QUICK.md:246-254. Resolution/scale for the whole game is set once via require('anchor')({ width=, height=, scale=, filter= }) (framework\anchor\init.lua:9-20; live example playground\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 with layer_rectangle should 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), where font may be a font table or a registered name string (docs\FRAMEWORK_API_QUICK.md:279-283). Fonts are loaded with font_register(name, path, size, filter?) -> font (docs\FRAMEWORK_API_QUICK.md:196-203; example playground\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-1762 pre-warms only ASCII 32..127 (FONT_FIRST_CHAR/FONT_NUM_CHARS) at load, but anchor.c:2096-2098 documents 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 __call metamethod 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)
end

For 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-framework required 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.lua created, because require('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.lua captures 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 optional on_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 explicit engine_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 in boot.lua; keep the update/draw methods, movement-tuning constants, and class defs in main.lua so 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>, plus status/log — declared at Anchor\workflow\anchor.py:171-176, implemented in Anchor\workflow\lib\drive.py (start() line 140, evaluate() line 183, stop() line 195), documented in docs\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 to path on demand (docs\AGENT.md:75). The agent_tap/agent_shot kit convenience (framework\anchor\agent.lua) defaults its own snapshots to replays/shots/<frame>.png (docs\AGENT.md:43).
  • agent_tap(direction_or_control) and siblings agent_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 at docs\AGENT.md:80-82; example usage agent_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 does player.x = 100; player.vy = -200 then engine_step(30) then return player.x, player.y, player.grounded — as long as the game exposes player (or whatever) as a reachable global, the same convention playground\main.lua uses for the_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 is frame, not frame_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 from draw() on desktop. All jump-buffer/one-shot logic must be in update(dt).
  • Colors are callable: pass some_color(), not the bare color table, to any layer_* 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:48 records "Windowed fixes from the first run: %d+float in HUD text" as a fix made during playground development. playground\main.lua:291-292 now guards this with math.floor(fps or 0) before formatting. Any debug-text HUD in the lab (fps, player velocity, tile coords) needs the same math.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 with spring_pull instead of camera_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., a player = player() line left at main.lua's top level) re-runs and resets on every save — keep entity construction, layer/camera setup, and bind() calls in boot.lua only (docs\AGENT.md:140-150, 198-217).
  • Framework-copy drift: a game's own anchor/ folder can diverge from framework\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). Use anchor 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.

Back to Reverse Minesweeper Prototype Fable

Settings