Attack System Generalization 4
Summary
Fourth session on E:/a327ex/attack-template/ — the self-contained Anchor 2 project porting Orblike v1's attack carriers as reusable visual modules. Shipped the fifth and final carrier (the polygonal big-rock attack), changed the project's working agreement from show-code-first to design-brief-first, then reorganized the whole project into a snkrx/ toolkit folder with a clean reference main.lua. By session end the carrier set is declared closed — further attacks (railgun/psylance/construct) are per-game, built from the five carriers, not added to the template.
Polygonal carrier — initial port (airborne.lua + prop.lua):
- Picked up after sessions 1-3 (projectile, area, beam, flicker carriers done). Next in the port order was the v1
big_rock_effect(line 11390) +rock_fragment(11696) — a jagged rock thrown in a real-physics parabola that shatters into Box2D fragments on landing. - Scoped via AskUserQuestion: build a generalizable
airborne.luanow; reusespawn_area(polygon mode) for landing damage; drop all mods (homing/magnetic_area/cast_on_apex per design decision #1); trigger keyR. - First design brief proposed
airborneowning x/y/z + scale. User rejected that framing.
Workflow change — design-brief before code:
- User asked to change the
attack-template/.claude/CLAUDE.mdhard rule (and later the globalE:/a327ex/.claude/CLAUDE.md"Code Changes" section) from "ALWAYS show code before writing it" to a design-brief workflow: post a conceptual brief (Approach / Interfaces / Functions — function names, what they do, what they call, general structure), iterate with the user, then write code directly without a second approval. Trivial changes skip the brief; CLAUDE.md doc edits specified in prose are exempt. - Both CLAUDE.md files were rewritten to this rule. The global one preserved the website (
a327ex-site/) full exemption and the trivial carve-out verbatim.
Airborne redesign (airborne.lua rewritten z-only):
- User reframed: airborne should NOT move x/y — the object keeps its normal horizontal movement; airborne handles only the z arc + draw scale + collision suppression. Goal: any entity (seeker, projectile, rock) can be made airborne; visually airborne = drawn scaled up.
- Z-math kept exactly as v1 big_rock (
gravity=2,v0=0.5*g*duration,height=v0*τ−0.5*g*τ²,max_height=v0²/2g); scale generalized to1+(apex_scale−1)*(height/max_height)(apex_scale=3 reproduces v1's1+2*(h/h_max)). - Collision: not a physics change. The engine has no clean runtime "collide with nothing" toggle (only
physics_shape_set_filter_group). User's solution: airborne is just the presence ofself.airborne; collision handlers guard withairborne_no_collide(a, b)— returns true when exactly one of the pair is aloft (airborne-vs-grounded never collide; both-airborne left to game-specific code). - Final
airborne.lua:airborne_new(opts),airborne_update(a, dt)(setsheight/scale/landed/landed_now),airborne_no_collide(a, b). Plain-table + free-function idiom like timer/spring.
Prop redesign (prop.lua — generalized from the rock):
- User:
polygonalshould be a general prop class (breakable vases/boxes), with the rock being "a prop that deals damage." Internal division left to Claude's choice as long as behavior/visuals match v1 exactly. - Structure chosen (prop-centric, not folded into
projectile.luasince the rock shares nothing mechanically with a projectile):propclass (jagged polygon, static or thrown — thrown mode lerps its own x/y + attachesairborne, lands →shatter()+on_landhook);debrisclass (one polygon shard with adebris-tagged Box2D collider);prop:shatter()runs v1's wedge-partition algorithm. The rock-attack layer is separatepolygonal_*functions:spawn_polygonal,polygonal_on_land(rocky particles + shake + sounds),polygonal_land_damage(the separated damage portion — aspawn_areapolygon AoE). The generic prop/debris never deal damage. - v1's mod-driven
many_piecesshatter branches dropped (target_pieces always 3-4 now); wedge algorithm itself unchanged. New physics tagdebris(collides with debris + walls only). 5 sound assets copied (Spell_Earth_02/04.ogg,Explosion Flesh 1/3/4.ogg).airborne_no_collideguards added to all 3 collision handlers in main.lua (inert today — nothing with a collider goes airborne).
Landing-area visibility fix:
- User reported the rock shape flashes on the ground after landing. Cause: the instant landing
areadraws its jagged polygon. v1 spawned ithidden. Fix: added aninvisibleflag toarea.lua(kept distinct from the existing blink-togglehiddenfield) —area:drawearly-returns when set, the damage lifecycle still runs.polygonal_land_damagepasseshidden = true. Debris is now the sole landing visual.
snkrx/ reorganization:
- User wanted the codebase prepared so a new instance can add attacks with minimal
main.luacode. Decided via AskUserQuestion: all non-main files move into asnkrx/folder;anchor/stays at root;main.luabecomes a clean reference scene withmain_old.luapreserved; player/seeker/wall live insnkrx/(editable in place or overridable at root); update/draw loops fully explicit per-list. - 19 files (18
.lua+findings.md) moved tosnkrx/;main.lua→main_old.lua(frozen, not runnable). Newsnkrx/init.luaaggregator:require('snkrx')loads every module and absorbs the plumbing (edition_setup, theset_editionshim + defaults,sfx,sounds/volumes+ all asset loading, spritesheet registration,hit_stop+ newhit_stop_update(dt)extracted from main.lua's inline countdown). - New
main.lua: clean reference scene —require('anchor')→require('snkrx')→ explicit PHYSICS / INPUT / LAYERS / camera / scene,update/drawwith all 8 carrier-list loops fully explicit and the 3 collision handlers. Spawns only a moving player; a documented ATTACKS comment block shows everyspawn_*call without firing them. F2/F3 kept. gradient.lua/symbols.lualeft present-but-unrequired (matching the pre-refactor main.lua, which never required them).- Global
.claude/CLAUDE.mdProjects list gained anattack-template/entry; attack-template.claude/CLAUDE.mdheavily revised (layout, read order, new "snkrx/ structure & adding an attack" section, file map, keybindings, carrier set marked closed).
assets/ relocation:
- Initially moved
assets/intosnkrx/assets/; user corrected — assets belong at root level (engine convention). Moved back to rootassets/, reverted all path strings ininit.lua,sound_tuning.lua(the DSP table keys must matchsound_get_path),sound_tuner.lua(font),edition.lua(default shader path), and the CLAUDE.md file map.
Final state: five carriers complete (projectile/area/beam/flicker/prop), the generalization closed. Project layout: root has main.lua / main_old.lua / anchor/ / anchor.exe / assets/ / run.bat / .claude/ / snkrx/. All files pass lua54.exe syntax checks; require('snkrx') path resolution unverified until the user runs it (flagged as the likely first-run failure point).
Let's continue on the attack system generalization, read the last log summary and the relevant files to get aligned.
I'll find the most recent session log to get aligned.
Bash (ls -t E:/a327ex/a327ex-site/logs/ 2>/dev/null | head -20; echo "---"; ls -t E:/a...)
attack-system-generalization-3.md
attack-system-generalization-2.md
attack-system-generalization-1.md
edition-system-generalization.md
emoji-aimer-8.md
invoker-spellcasting-tree-structure.md
emoji-aimer-7.md
anchor-app-phase-2-part-2.md
anchor-app-phase-2-part-1.md
invoker-hp-and-mana-orbs.md
emoji-aimer-6.md
emoji-aimer-5.md
orblike-to-invoker.md
emoji-aimer-4.md
anchor-app-phase-1-part-3.md
emoji-aimer-3.md
anchor-app-phase-1-part-2.md
emoji-aimer-2.md
codex-tests.md
emoji-aimer-1.md
---
Read (E:/a327ex/a327ex-site/logs/attack-system-generalization-3.md)
File content (387.8KB) exceeds maximum allowed size (256KB). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.
Read (E:/a327ex/a327ex-site/logs/attack-system-generalization-3.md)
1 Title: Attack System Generalization 3
2 Date: 2026-05-20 17:35:17
3
4 # Attack System Generalization 3
5
6 ## Summary
7
8 Third session on `E:/a327ex/attack-template/` — the self-contained Anchor 2 project that ports every Orblike v1 attack carrier as a reusable visual-only module. Picked up after sessions 1-2 (scaffold + projectile carrier + instant area carrier) and shipped: persistent (DoT) area mode, the full beam carrier (instant + persistent), the flicker-strike carrier, a five-layer render stack, an engine-backed hit-stop system, and an `fx_effect` primitive. By session end the template has four carriers done (projectile, area, beam, flicker); only polygonal/big-rock and the bespoke carriers remain.
9
10 **Persistent (DoT) areas:**
11 - Extended `area.lua` with a persistent mode selected by passing `duration` (+ optional `tick_rate`, default 0.5s). Instant mode unchanged.
12 - Persistent lifecycle: 0.25s tween-up (vs 0.05s instant), `timer_every(tick_rate, 'tick', ...)` damage loop with a per-area `hit_cooldowns[entity_id]` table gating re-hits, blink+kill after `duration`. Ticks continue through the blink phase (matches v1).
13 - Per-tick: `e:hit(damage, 0)` with `hit_vol=0` to silence the per-enemy headshot sound (the single `dot_tick` plays once per tick instead — v1 line 8685 parity). Lighter particles than instant (1-2, vel 30-80, w=4, no hit_effect sprite). On any hit: `dot_tick` sound, spring pull 0.1, 0.1s flash, arc-speed spike +1.25π capped at 3π.
14 - `dot_tick` sound ported (`Click sound 37 + Body Fall 18.ogg`, vol 0.49, v1 line 14). `N` key toggles instant↔persistent in the demo.
15
16 **Persistent-area visual iteration (arc decay + breathing):**
17 - User asked the circle's arc-spin to decay when no hits land recently. Added exponential decay of `arc_speed` toward a stored `arc_baseline` (rate k=3, ~95% decay/sec). Tick spikes raise it; decay drags it back.
18 - For non-smooth-circle persistent shapes, added a slow scale "breathing" (`1 + 0.05*sin(phase)`, ~0.5 Hz, random start phase) since they have no arcs as a life signal. Applied as a multiplier on the spring scale `s` at draw time, gated to skip the smooth circle.
19
20 **Beam carrier — instant (`beam.lua` new):**
21 - Scoped via AskUserQuestion: visual = v1 `railgun_flash` (clump-retract), no charge phase, `mouse:3` trigger.
22 - Beam is internally a polyline (`self.path`). Non-homing = 2 points; the shared pipeline (wall clip, damage, dash draw) walks the path.
23 - Damage: rotated-box query (`query_entities_box`) covering the beam, hit window `width*6` (±width*3 perp, v1 line 3982).
24 - Visual: `railgun_flash` — dashes built once, grouped into clumps of 2-4 whose `visible_start/visible_end` tween toward each clump's midpoint with staggered delays over `duration` (0.25s). Wall raycast clips the path per-segment.
25 - `spawn_beam(caster, opts)` mirrors `spawn_projectile`.
26
27 **Beam tip FX iteration:**
28 - Added a tip impact hit_circle + particles at the path endpoint.
29 - User: drop the beam_cast sound entirely → removed sound, asset, volume entry.
30 - User: particles should be radial not coned toward the caster → changed to `random_float(0, 2*math.pi)`.
... [90 more lines]
I have the session-3 summary. Now let me look at the current state of the attack-template project.
Bash (cd E:/a327ex/attack-template && ls -la && echo "---SRC---" && ls -R src 2>/dev/n...)
total 4422
drwxr-xr-x 1 adn 197121 0 May 20 17:15 ./
drwxr-xr-x 1 adn 197121 0 May 17 07:23 ../
drwxr-xr-x 1 adn 197121 0 May 20 17:34 .claude/
drwxr-xr-x 1 adn 197121 0 May 20 17:39 .git/
drwxr-xr-x 1 adn 197121 0 May 17 22:24 anchor/
-rwxr-xr-x 1 adn 197121 4185088 May 17 22:07 anchor.exe*
-rw-r--r-- 1 adn 197121 19223 May 18 08:43 area.lua
drwxr-xr-x 1 adn 197121 0 May 20 16:57 assets/
-rw-r--r-- 1 adn 197121 21375 May 20 16:04 beam.lua
-rw-r--r-- 1 adn 197121 20164 May 17 07:34 edition.lua
-rw-r--r-- 1 adn 197121 43373 May 17 13:57 findings.md
-rw-r--r-- 1 adn 197121 14512 May 20 17:15 flicker.lua
-rw-r--r-- 1 adn 197121 9235 May 20 16:58 fx.lua
-rw-r--r-- 1 adn 197121 19233 May 17 07:34 geometry.lua
-rw-r--r-- 1 adn 197121 30812 May 17 07:34 gradient.lua
-rw-r--r-- 1 adn 197121 26864 May 20 16:59 main.lua
-rw-r--r-- 1 adn 197121 11602 May 17 07:34 palette.lua
-rw-r--r-- 1 adn 197121 7824 May 17 12:46 player.lua
-rw-r--r-- 1 adn 197121 10661 May 17 20:26 projectile.lua
-rw-r--r-- 1 adn 197121 206 May 17 07:36 run.bat
-rw-r--r-- 1 adn 197121 15495 May 18 11:51 seeker.lua
-rw-r--r-- 1 adn 197121 5576 May 17 20:41 sound_tuner.lua
-rw-r--r-- 1 adn 197121 691 May 18 08:46 sound_tuning.lua
-rw-r--r-- 1 adn 197121 12728 May 17 08:06 stats.lua
-rw-r--r-- 1 adn 197121 14481 May 17 07:34 symbols.lua
-rw-r--r-- 1 adn 197121 3649 May 17 08:27 wall.lua
---SRC---
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
1 # attack-template — orientation for new instances
2
3 ## ⛔ HARD RULE — ALWAYS SHOW CODE BEFORE WRITING IT ⛔
4
5 **This rule has no exceptions and is valid for every single edit, every
6 session, every file in this project.**
7
8 Before calling `Write` or `Edit` on ANY file in `attack-template/`,
9 post the full code (or the exact diff for `Edit`) in a normal chat
10 message and explicitly ask "does this look right?" / "should I commit
11 this?" / equivalent. Wait for the user's go-ahead. Then write.
12
13 This applies to:
14 - New files (show the entire file contents).
15 - Edits to existing files (show old → new with enough context to read).
16 - Even one-line edits. Even "obvious" fixes. Even when the user has
17 already approved the broader design — each individual file
18 operation is its own checkpoint.
19
20 Tiny exceptions, narrowly scoped:
21 - Pure typo / docstring fixes the user explicitly asks you to make
22 inline.
23 - Edits to this CLAUDE.md or `findings.md` (docs about the project,
24 not project code) when the user has already specified the change in
25 prose.
26
27 If you are unsure whether something counts, treat it as "show first,
28 then write."
29
30 Why this rule exists: the user wants to review every code change
... [520 more lines]
The CLAUDE.md is thorough. Next up per the port order is polygonal.lua — the big-rock parabolic-flight carrier. Let me read findings.md section 6.
Grep (^#+ .*[0-9])
1:# attack-template — findings from Orblike v1
21:## 1. The carrier taxonomy
55:## 2. The ability roster
110:## 3. Projectile system (deep dive)
209:## 4. Area system (deep dive)
236:### Shapes (with visuals, line 8954+)
281:## 5. Beam system (deep dive)
349:## 6. Polygonal projectile (Big Rock)
379:## 7. Movement abilities
392:### `dash_strike` (line 2320)
403:### `asal` (line 3160)
418:### `flicker_strike` (line 4450, also `dash_clone` `mode='flicker'`)
429:## 8. Effect primitive library
459:## 9. Modifier vocabulary
500:## 10. Sound bank (the elemental flavor knob)
533:## 11. Cast log / damage system
552:## 12. Recommended scope for attack-template/
626:## 13. Open questions for the user
Read (E:/a327ex/attack-template/findings.md)
349 ## 6. Polygonal projectile (Big Rock)
350
351 **Class location:** `big_rock_effect` lines 11390–11692. Supported by `rock_fragment` (11696) and `rock_debris` (11780).
352
353 ### Flight
354
355 - 8-12 vertex jagged polygon generated at spawn (same algo as jagged_circle area).
356 - Real physics parabola: `gravity=2`, `v0=0.5*g*flight_duration`, max height at `t=0.5`. Visual scale `1 + 2*(h/h_max)` so the rock reads bigger at apex.
357 - Position: linear interp from origin to target. X marker at target blinks with accelerating rate (`math.lerp(t, 0.3, 0.04)`).
358 - Optional homing: target steers toward closest enemy at `base_steer = 40 units/s` (modifier-adjustable).
359 - `cast_on_apex` fires at t=0.5 (but skips `self_area` carrier since the rock is in the air).
360 - `magnetic_area` mod: during last 30% of flight, pulls enemies toward target every 0.3s + shockwave.
361
362 ### Landing
363
364 1. Spawn a `polygon` shape `area` with `polygon_verts` (the same jagged verts), `outer_radius = area_radius*2`. Full damage inside polygon, falloff (`0.75 → 0.1` mult by distance ratio) in outer ring with push proportional to remap.
365 2. Shatter into 3-4 polygon fragments (`rock_fragment`) via a wedge-partitioning algorithm:
366 - Pre-generate "radial jag points" along each outer-vertex-to-center line (1-3 jag points per radial with random offsets in `±35%, ±18%, or ±8%` budget).
367 - Build wedges by walking edges, going center → radial-jag → outer-vertex → … → outer-vertex → reverse-radial-jag → center.
368 - Each fragment becomes a Box2D polygon collider (max 8 vertices, simplified if needed), launched in its centroid direction at random 80–200 speed plus inherited throw velocity (×0.3).
369 - Random angular velocity ±10. Blinks at `(0.4–0.8) × duration`, kills after `0.5–1.0s`.
370 3. 8-14 rocky `hit_particle` blasts in random angles (size 3-6 × 2-4).
371 4. `add_shockwave(target, light=false, scale=area_radius/25)`.
372 5. Camera shake (push by throw direction + 8 magnitude + shake(5, 0.2)).
373 6. `area_sound` (fissure_cast_2) + random `explosion_flesh_1/3/4`.
374
375 The shatter algorithm is bespoke and pretty pretty — worth preserving as-is.
376
377 ---
378
... [15 more lines]
Let me read the v1 source for big_rock_effect.
Bash (cd E:/a327ex/Invoker && ls v1/ 2>/dev/null && echo "---" && wc -l v1/main.lua 2>...)
anchor/
assets/
main.lua
profiler.lua
profiler_log.txt
recordings/
run.bat
---
13268 v1/main.lua
Grep (big_rock|rock_fragment|rock_debris)
929: big_rock = {
932: carrier = 'big_rock',
1091: 'big_rock', 'railgun', 'psylance', 'wind_slash', 'righteous_fire',
1798: elseif def.carrier == 'big_rock' then
1851: an:add(big_rock_effect(fx, fy, {
4947:an:physics_tag('rock_fragment')
4949:an:physics_collision('rock_fragment', 'rock_fragment')
4953:an:physics_collision('rock_fragment', 'wall')
11388:-- Effect: big_rock_effect
11390:big_rock_effect = object:extend()
11391:function big_rock_effect:new(x, y, args)
11404: self.ability = args.ability or 'big_rock'
11430:function big_rock_effect:update(dt)
11660: an:add(rock_fragment(self.target_x + centroid_x + nudge_x, self.target_y + centroid_y + nudge_y, {
11694:-- Effect: rock_fragment
11696:rock_fragment = object:extend()
11697:function rock_fragment:new(x, y, args)
11726: self:add(collider('rock_fragment', 'dynamic', 'polygon', flat_verts, {}))
11752:function rock_fragment:update(dt)
11778:-- Effect: rock_debris
11780:rock_debris = object:extend()
11781:function rock_debris:new(x, y, args)
11804:function rock_debris:update(dt)
12485: orbs[3]:add_ability('big_rock')
Read (E:/a327ex/Invoker/v1/main.lua)
11388 -- Effect: big_rock_effect
11389 -- Rock thrown in arc to target. X marker blinks at target, rock scales up at apex then down, AoE on landing.
11390 big_rock_effect = object:extend()
11391 function big_rock_effect:new(x, y, args)
11392 object.new(self)
11393 self.origin_x, self.origin_y = x, y
11394 self.target_x = args.target_x or x
11395 self.target_y = args.target_y or y
11396 self.flight_duration = args.flight_duration or 3
11397 self.area_radius = args.area_radius or 30
11398 self.damage = args.damage or 0
11399 self.element = args.element
11400 self.cast_log = args.cast_log
11401 if self.cast_log then self.cast_log:add_pending() end
11402 self.color = args.color or yellow
11403 self.mods = args.mods or {}
11404 self.ability = args.ability or 'big_rock'
11405 self.source_orb = args.source_orb
11406 self.area_sound = args.area_sound
11407 self.elapsed = 0
11408 self.landed = false
11409 self.apex_fired = false
11410 -- Generate jagged vertices once (like earthquake)
11411 local n = an.random:int(8, 12)
11412 self.jagged_verts = {}
11413 for i = 1, n do
11414 local base_a = (i - 1)*2*math.pi/n
11415 local a = base_a + an.random:float(-0.15, 0.15)
11416 local d = an.random:float(0.85, 1.1)
11417 table.insert(self.jagged_verts, {a = a, d = d})
... [420 more lines]
I'm aligned. Here's where things stand:
Done (sessions 1-3): projectile, area (instant + DoT), beam (instant + persistent + homing), flicker carriers — plus a five-layer render stack, hit-stop system, and fx_effect primitive.
Next per the port order: polygonal.lua — the v1 big_rock_effect: a jagged rock thrown in a real-physics parabola that, on landing, spawns a polygon damage area, shatters into 3-4 Box2D rock_fragment shards, and kicks out rocky particles + shockwave + shake. The summary also flagged wanting a generalizable "airborne" abstraction for parabola/arc flight.
Let me check how area.lua handles the polygon-area landing so I can scope this correctly.
Bash (cd E:/a327ex/attack-template && grep -n "outer_radius|polygon_verts|hidden|fu...)
36: polygon_verts, -- polygon only; list of {a, d} (angle in rad, distance as
96:local function build_polygon_verts(input, radius, poly_r, jagged)
156: self.hidden = false
179: if self.shape == 'polygon' then
180: self.verts = build_polygon_verts(args.polygon_verts, self.radius, args.polygon_r or 0, self.jagged)
181: elseif self.shape == 'circle' and self.jagged then
183: elseif self.shape == 'rect' and self.jagged then
245: timer_every(self.timer, 0.05, 'blink', function() self.hidden = not self.hidden end, 7, function()
259: timer_every(self.timer, 0.05, 'blink', function() self.hidden = not self.hidden end, 7, function()
269: if self.shape == 'rect' then
272: elseif self.shape == 'polygon' then
362: if self.hidden then return end
368: if self.duration and not (self.shape == 'circle' and not self.jagged) then
374: if self.shape == 'circle' and not self.jagged then
396: elseif self.shape == 'rect' and not self.jagged then
421: local push_r = (self.shape == 'rect') and self.r or 0
424: if self.shape == 'rect' then
470:function spawn_area(x, y, opts)
Read (E:/a327ex/attack-template/area.lua)
470 function spawn_area(x, y, opts)
471 opts = opts or {}
472 -- Auto-derive aim angle from caster → area direction so rect / polygon
473 -- shapes orient along the cast. Only applied when caster is present
474 -- and the caller hasn't passed an explicit rotation. Self-casts
475 -- (distance ≤ 4 px) skip — the direction is degenerate. Circle is
476 -- radially symmetric so rotation is unused.
477 if opts.caster then
478 local dx, dy = x - opts.caster.x, y - opts.caster.y
479 if math.length(dx, dy) > 4 then
480 local aim_r = math.atan(dy, dx)
481 if opts.r == nil then opts.r = aim_r end
482 if opts.polygon_r == nil then opts.polygon_r = aim_r end
483 end
484 end
485 local a = area(x, y, opts)
486 areas[#areas + 1] = a
487 return a
488 end
489
I have enough context. Let me confirm scope before building the polygonal carrier.
AskUserQuestion
User has answered your questions: "The summary flagged wanting a generalizable 'airborne' abstraction for parabola/arc flight. How should I handle that for this carrier?"="Build airborne.lua now", "How should the rock's landing damage be applied?"="Reuse spawn_area polygon", "Which mods-adjacent behaviors should be dropped vs kept? (Design decision #1 says no modifier system.)"="Drop all (homing, magnetic, cast_on_apex)", "Demo trigger key for the big-rock throw? (Mouse 1/2/3 + F are taken.)"="R". You can now continue with the user's answers in mind.
Both decisions are clear. Let me study the existing carrier patterns before designing the two new files.
Read (E:/a327ex/attack-template/projectile.lua)
1 --[[
2 projectile.lua — straight-line attack carrier (minimal v1 port).
3
4 Scope of this first pass (per session 2 decisions):
5 • Default rounded-rect shape (w=10, h=4). No shape variants yet
6 (ball_lightning circle, wind_slash blades, arc crescent come later).
7 • Straight-line travel only. No homing / linear-return / arc-return.
8 • Sensor box collider — projectiles pass through enemies/walls
9 visually. Enemy hits are surfaced via sensor_entities_begin in
10 main.lua. Walls are caught by a per-frame forward raycast (sensors
11 don't physically collide).
12 • Spawn juice: spring pull on 'main' + 0.2s white flash.
13 • Wall hit: 6r/0.15s hit_circle + 2 particles, then die.
14 • Enemy hit: full damage + hit_effect sprite burst, then die. No
15 pierce/chain in this first pass — that's gameplay-side anyway.
16 • Death: 8r/0.2s hit_circle + 2 particles, particle color =
17 edition_base_color(self.edition) or self.color.
18
19 Caller API (per session 2 decision — "everything in spawn_projectile,
20 caster passed in, everything else derived"):
21 spawn_projectile(caster, {
22 aim_r, -- optional, default: caster -> main_camera.mouse
23 spawn_offset, -- default 8 (v1's hardcoded muzzle offset)
24 color, -- default red
25 edition, -- default caster.edition
26 max_range, -- default nil = unlimited
27 damage, -- default 1
28 speed, -- default 190
29 w, h, -- default 10, 4
30 })
... [255 more lines]
Read (E:/a327ex/attack-template/flicker.lua)
1 --[[
2 flicker.lua — flicker-strike line/trail carrier (v1 flicker_strike).
3
4 A self-contained moving entity. NOT the player — the caster only
5 supplies a spawn position, an aim direction, and an edition tint;
6 after that the flicker is autonomous. It hops between enemies along
7 cubic-bezier curves, leaving a fading dashed trail and spawning the
8 full v1 flicker-strike hit FX at each enemy it reaches. Port of v1's
9 flicker_strike (line 4450) — visuals + sounds, no player movement,
10 no modifiers (multistrike / melee_splash / echo are gameplay-side).
11
12 Lifecycle:
13 1. Find an initial target: closest enemy to the click position
14 (target_x/target_y) within `range` of it — v1 flicker_strike's
15 first jump (closest enemy to the cursor). No target → fizzle.
16 2. Hop to it via a cubic bezier (control points 80% along the
17 entry/exit angles; exit angle pre-bends toward the next target),
18 lerped over `hop_delay`. Departure puff + 2 particles + shake.
19 3. On arrival: per-enemy hit FX (fx_energy_122 burst, hit_circle,
20 4 zag particles) + arrival juice (slash sounds, shake, body
21 flash + spring pulse).
22 4. After `hits` enemies are struck (or targets run out), stop with
23 the final burst (hit_stop, slash_final, AoE knockback, big FX).
24 The trail fades; the entity self-destructs once it's empty.
25
26 Caller API:
27 spawn_flicker(caster, {
28 target_x, -- click position; first hop targets the enemy
29 target_y, -- closest to it. default: main_camera.mouse
30 aim_r, -- optional, default: caster → click position
... [364 more lines]
Read (E:/a327ex/attack-template/main.lua)
1 --[[
2 attack-template/main.lua
3
4 Smoke test for the attack-template scaffold. Drops a player (WASD-driven,
5 ported from Invoker/player.lua) in the middle of a small arena with a
6 static interior wall (Invoker/v1/main.lua wall_object) and 5 seekers
7 (Invoker/seeker.lua) chasing the player. Edition shimmer on every entity
8 via the same draw shader the edition-template uses.
9
10 KEY BINDINGS
11 W A S D / arrows move player
12 0 / F1 cycle player edition forward / backward
13 — prints the new mode's name to console
14 ]]
15
16 require('anchor')({
17 width = 480,
18 height = 270,
19 title = 'attack-template — smoke test',
20 scale = 3,
21 filter = 'rough',
22 })
23
24 require('palette')
25 require('edition')
26
27 edition_setup({ draw_shader = 'assets/draw_shader.frag' })
28
29 require('geometry')
30 require('stats')
... [633 more lines]
Read (E:/a327ex/attack-template/fx.lua)
1 --[[
2 fx.lua — one-shot visual effects.
3
4 Two entity types ported from v1's hit_circle / hit_particle. They
5 exist for a brief duration, draw something, and auto-kill. Spawned
6 by gameplay code (projectile spawn, wall hit, area damage in the
7 future) via spawn_hit_circle / spawn_particle. Both live in the
8 global fxs[] array, updated and drawn from main.lua.
9
10 fx_hit_circle — filled circle that SHRINKS from initial radius to
11 0 over `duration` (cubic_in_out). Starts white (the `text`
12 palette token); the optional :change_color(fraction, target)
13 chainable swaps it to the spell color at the given fraction of
14 duration, giving the v1 "flash white, then bleed into element
15 color" feel.
16
17 fx_particle — rotated rounded rectangle that flies outward at
18 self.angle, with w/h/velocity tweening down to {2, 2, 0} over
19 `duration` (sine_in_out). Same white-then-color flash via
20 :change_color. Used for cast bursts, wall impact debris, and
21 (future) on-hit particles.
22
23 Both kinds match v1 visuals byte-for-byte where possible —
24 ability_layer is replaced with the game_layer caller, the object
25 tree is replaced with explicit fxs[] iteration, an.random calls
26 become random_float / random_choice. Otherwise the math, easings,
27 defaults, and feel are unchanged.
28 ]]
29
30
... [262 more lines]
Bash (cd E:/a327ex/Invoker/v1 && grep -n "function add_shockwave|shockwave =|shockwa...)
15: shockwave = 0.5,
12511:shockwaves = {}
12512:function add_shockwave(wx, wy, light, scale)
===
675: add_shockwave(x, y, true, base_radius/25)
7416: add_shockwave(closest_orb.x, closest_orb.y, true, 0.5)
8705: add_shockwave(self.x, self.y, true, self.radius/25)
Read (E:/a327ex/Invoker/v1/main.lua)
12511 shockwaves = {}
12512 function add_shockwave(wx, wy, light, scale)
12513 scale = scale or 1
12514 if light then
12515 table.insert(shockwaves, {wx = wx, wy = wy, radius = 32*scale, max_radius = 32*scale, speed = -400, strength = -0.05, thickness = 0.08})
12516 else
12517 table.insert(shockwaves, {wx = wx, wy = wy, radius = 64*scale, max_radius = 64*scale, speed = -350, strength = -0.06, thickness = 0.12})
12518 sfx('shockwave', volumes.shockwave)
12519 end
12520 end
12521
12522 -- Cache enemy list once per frame (avoids O(N) tree traversal per enemy)
12523 cached_enemies = {}
12524 enemy_spatial_hash = nil
12525 function build_enemy_spatial_hash(enemies, cell_size)
12526 local cells = {}
12527 for i = 1, #enemies do
12528 local e = enemies[i]
12529 local cx = math.floor(e.x/cell_size)
12530 local cy = math.floor(e.y/cell_size)
12531 local key = cx*73856093 + cy*19349663
12532 if not cells[key] then cells[key] = {} end
12533 cells[key][#cells[key] + 1] = e
12534 end
12535 return {cells = cells, cell_size = cell_size}
12536 end
12537 an:early_action(function()
12538 cached_enemies = an:all('enemy')
12539 enemy_spatial_hash = build_enemy_spatial_hash(cached_enemies, 32)
12540 packs_update()
... [25 more lines]
Read (E:/a327ex/Invoker/v1/main.lua)
927 hit_delay_per = 0.02,
928 },
929 big_rock = {
930 activation = 'manual',
931 tags = {'area', 'physical', 'earth'},
932 carrier = 'big_rock',
933 color = yellow,
934 cooldown = 5,
935 area_radius = 30,
936 damage = 12,
937 element = 'earth',
938 flight_duration = 3,
939 fire_sound = 'fissure_cast_1',
940 area_sound = 'fissure_cast_2',
941 },
942 railgun = {
943 activation = 'manual',
944 tags = {'beam', 'physical'},
945 carrier = 'railgun',
946 color = fg_color,
Read (E:/a327ex/Invoker/v1/main.lua)
1798 elseif def.carrier == 'big_rock' then
1799 local mx, my = an.camera.mouse.x, an.camera.mouse.y
1800 local dist = math.distance(fire_x, fire_y, mx, my)
1801 local aim_r = math.angle_to_point(fire_x, fire_y, mx, my)
1802
1803 -- Build direction angles
1804 local br_angles = {}
1805 local s_nova = mods.nova or mods.area_nova
1806 local s_spread = mods.spread or mods.area_spread
1807 local s_back = mods.back or mods.area_back
1808 local s_sides = mods.sides or mods.area_sides
1809 if s_nova then
1810 for i = 0, 7 do table.insert(br_angles, aim_r + i*math.pi/4) end
1811 else
1812 if s_spread and s_spread >= 2 then
1813 local n = s_spread
1814 local fan = math.pi/6 + (n - 2)*math.pi/12
1815 for i = 1, n do
1816 local t = (i - 1)/(n - 1)
1817 table.insert(br_angles, aim_r + fan*(t - 0.5))
1818 end
1819 else
1820 table.insert(br_angles, aim_r)
1821 end
1822 if s_back then table.insert(br_angles, aim_r + math.pi) end
1823 if s_sides then
1824 table.insert(br_angles, aim_r + math.pi/2)
1825 table.insert(br_angles, aim_r - math.pi/2)
1826 end
1827 end
... [40 more lines]
Bash (cd E:/a327ex/Invoker/v1/assets && ls | grep -iE "fissure|explosion" ; echo "===A...)
Explosion Flesh 1.ogg
Explosion Flesh 1.wav
Explosion Flesh 1_lightmed.ogg
Explosion Flesh 3.ogg
Explosion Flesh 3.wav
Explosion Flesh 3_lightmed.ogg
Explosion Flesh 4.ogg
Explosion Flesh 4.wav
Explosion Flesh 4_lightmed.ogg
RPG3_FireMagic_Explosion01.ogg
RPG3_FireMagic_Explosion01.wav
RPG3_FireMagic_Explosion01_lightmed.ogg
===ATTACK-TEMPLATE ASSETS===
ARROW_Hit_Wood_Shield_stereo.ogg
Bloody punches 10.ogg
Bloody punches 3.ogg
Bloody punches 7.ogg
Body Fall 2.ogg
Body Head (Headshot) 1.ogg
Click sound 37 + Body Fall 18.ogg
PM_GENERIC_Slash02.ogg
PM_GENERIC_Slash04_P2.ogg
RPG3_GenericArrow_Shoot01.ogg
Sword_Slash_1_v1.ogg
Sword_Slash_1_v2.ogg
Sword_Slash_1_v3.ogg
Sword_Slash_1_v4.ogg
draw_shader.frag
fx_energy_122.png
hit1.png
... [1 more lines]
Bash (cd E:/a327ex/Invoker/v1 && grep -n "fissure_cast" main.lua | head; echo "===vols...)
16: fissure_cast_1 = 0.5,
17: fissure_cast_2 = 0.55,
146:an:sound('fissure_cast_1', 'assets/Spell_Earth_02.ogg')
147:an:sound('fissure_cast_2', 'assets/Spell_Earth_04.ogg')
508: {'fissure_cast_1', 'assets/Spell_Earth_02'},
509: {'fissure_cast_2', 'assets/Spell_Earth_04'},
739: cast_sounds = {'fissure_cast_1'},
801: cast_sound = 'fissure_cast_1',
802: area_sound = 'fissure_cast_2',
804: aftershock_sound = 'fissure_cast_1',
===vols===
16: fissure_cast_1 = 0.5,
17: fissure_cast_2 = 0.55,
41: explosion_flesh_1 = 0.52,
Bash (cd E:/a327ex/Invoker/v1/assets && ls | grep -iE "Spell_Earth")
Spell_Earth_02 (2).wav
Spell_Earth_02.ogg
Spell_Earth_02.wav
Spell_Earth_02_lightmed.ogg
Spell_Earth_04.ogg
Spell_Earth_04.wav
Spell_Earth_04_lightmed.ogg
Spell_Earth_Impact_Critical.wav
Bash (cd E:/a327ex/attack-template && grep -nE "^function collider|collider = class|fu...)
33:collider = class()
35:function collider:new(owner, tag, body_type, shape_type, ...)
63: elseif shape_type == 'polygon' then
73:function collider:destroy()
85:function collider:sync()
92:function collider:get_position() return physics_get_position(self.body) end
93:function collider:set_position(x, y) physics_set_position(self.body, x, y) end
94:function collider:get_angle() return physics_get_angle(self.body) end
95:function collider:set_angle(angle) physics_set_angle(self.body, angle) end
98:function collider:get_velocity() return physics_get_velocity(self.body) end
99:function collider:set_velocity(vx, vy) physics_set_velocity(self.body, vx, vy) end
100:function collider:get_angular_velocity() return physics_get_angular_velocity(self.body) end
101:function collider:set_angular_velocity(av) physics_set_angular_velocity(self.body, av) end
104:function collider:apply_force(fx, fy) physics_apply_force(self.body, fx, fy) end
105:function collider:apply_force_at(fx, fy, px, py) physics_apply_force_at(self.body, fx, fy, px, py) end
106:function collider:apply_impulse(ix, iy) physics_apply_impulse(self.body, ix, iy) end
107:function collider:apply_impulse_at(ix, iy, px, py) physics_apply_impulse_at(self.body, ix, iy, px, py) end
108:function collider:apply_torque(torque) physics_apply_torque(self.body, torque) end
109:function collider:apply_angular_impulse(impulse) physics_apply_angular_impulse(self.body, impulse) end
112:function collider:set_linear_damping(damping) physics_set_linear_damping(self.body, damping) end
113:function collider:set_angular_damping(damping) physics_set_angular_damping(self.body, damping) end
114:function collider:set_gravity_scale(scale) physics_set_gravity_scale(self.body, scale) end
115:function collider:set_fixed_rotation(fixed) physics_set_fixed_rotation(self.body, fixed) end
116:function collider:set_bullet(bullet) physics_set_bullet(self.body, bullet) end
119:function collider:set_friction(friction, shape) physics_shape_set_friction(shape or self.shape, friction) end
120:function collider:get_friction(shape) return physics_shape_get_friction(shape or self.shape) end
121:function collider:set_restitution(restitution, shape) physics_shape_set_restitution(shape or self.shape, restitution) end
122:function collider:get_restitution(shape) return physics_shape_get_restitution(shape or self.shape) end
123:function collider:set_density(density, shape) physics_shape_set_density(shape or self.shape, density) end
124:function collider:get_density(shape) return physics_shape_get_density(shape or self.shape) end
... [10 more lines]
Read (E:/a327ex/attack-template/anchor/collider.lua)
35 function collider:new(owner, tag, body_type, shape_type, ...)
36 self.owner = owner
37 self.tag = tag
38 self.body_type = body_type
39 self.shape_type = shape_type
40 self.body = physics_create_body(body_type, 0, 0)
41
42 -- Set user_data to the owner's ID so physics queries resolve via entities[id]
43 if owner and owner.id then
44 physics_set_user_data(self.body, owner.id)
45 end
46
47 -- Add initial shape based on shape_type
48 -- Last arg can be an opts table (e.g. {sensor = true})
49 local shape_args = {...}
50 if shape_type == 'chain' then
51 self.chain = physics_add_chain(self.body, tag, shape_args[1], shape_args[2] or true)
52 else
53 local opts = {}
54 if type(shape_args[#shape_args]) == 'table' then
55 opts = table.remove(shape_args)
56 end
57 if shape_type == 'circle' then
58 self.shape = physics_add_circle(self.body, tag, shape_args[1], opts)
59 elseif shape_type == 'box' then
60 self.shape = physics_add_box(self.body, tag, shape_args[1], shape_args[2], opts)
61 elseif shape_type == 'capsule' then
62 self.shape = physics_add_capsule(self.body, tag, shape_args[1], shape_args[2], opts)
63 elseif shape_type == 'polygon' then
64 self.shape = physics_add_polygon(self.body, tag, shape_args[1], opts)
... [8 more lines]
Bash (cd E:/a327ex/attack-template && grep -rnE "function random_bool|random_bool|func...)
anchor/math.lua:55:function math.lerp(t, source, destination)
anchor/math.lua:79:function math.lerp_dt(p, t, dt, source, destination)
anchor/math.lua:142:function math.lerp_angle(t, source, destination)
anchor/math.lua:167:function math.lerp_angle_dt(p, t, dt, source, destination)
anchor/math.lua:222:function math.clamp(value, min, max)
anchor/physics.lua:63: query_entities_polygon(x, y, verts_flat, tags)
anchor/physics.lua:68:function query_entities_polygon(x, y, verts_flat, tags)
Bash (cd E:/a327ex/attack-template && grep -rnE "^function random|^random" anchor/*....)
(Bash completed with no output)
Bash (cd E:/a327ex/attack-template && grep -rnE "random_float|random_int|random_choice...)
anchor/array.lua:421: local i = random_int(1, #t, rng)
anchor/array.lua:559: local j = random_int(1, i, rng)
anchor/collider.lua:228: self.wander_r = self.wander_r + random_float(-jitter*dt, jitter*dt)
anchor/shake.lua:263: inst.current_x = decay*inst.amplitude*random_float(-1, 1)
anchor/shake.lua:264: inst.current_y = decay*inst.amplitude*random_float(-1, 1)
Grep (random_float|random_int|random_choice|random_bool)
<tool_use_error>Path does not exist: E:/a327ex/attack-template/anchor/random.lua. Note: your current working directory is E:\a327ex\attack-template.</tool_use_error>
Bash (cd E:/a327ex/attack-template && grep -rlE "function random_float" anchor/ && ech...)
(Bash completed with no output)
Bash (cd E:/a327ex/attack-template && grep -rln "random_float" anchor/ && echo "===" &...)
anchor/collider.lua
anchor/shake.lua
===
Bash (cd E:/a327ex/attack-template && ls anchor/ && echo "===" && grep -rln "random_fl...)
animation.lua
array.lua
camera.lua
class.lua
collider.lua
color.lua
font.lua
helpers.lua
image.lua
init.lua
input.lua
layer.lua
math.lua
memory.lua
object.lua
physics.lua
shake.lua
spring.lua
spritesheet.lua
timer.lua
===
area.lua
beam.lua
flicker.lua
fx.lua
geometry.lua
main.lua
player.lua
projectile.lua
seeker.lua
Bash (cd E:/a327ex/attack-template && grep -nE "random_float|random_int|random_choice|...)
(Bash completed with no output)
Bash (cd E:/a327ex/attack-template && grep -rn "random_float" anchor/math.lua; echo "=...)
==search all==
==maybe global in math==
Bash (cd E:/a327ex/attack-template && grep -rn "random" anchor/init.lua anchor/helpers...)
anchor/init.lua:159: -- Set up default random number generator (global_rng is provided by the C engine,
anchor/init.lua:160: -- accessible by passing nil or omitting the rng argument to random_* functions).
anchor/init.lua:161: -- If you want a seeded rng for determinism, create one with random_create(seed).
Read (E:/a327ex/attack-template/anchor/init.lua)
155 frame_num = 0
156 step_num = 0
157 time_scale = 1.0
158
159 -- Set up default random number generator (global_rng is provided by the C engine,
160 -- accessible by passing nil or omitting the rng argument to random_* functions).
161 -- If you want a seeded rng for determinism, create one with random_create(seed).
162
163 -- Physics is not initialized automatically. Games that need physics should call
164 -- physics_init() themselves, then register tags and collision pairs:
165 -- physics_init()
166 -- physics_register_tag('player')
167 -- physics_register_tag('enemy')
168 -- physics_enable_collision('player', 'enemy')
169 end
170
Bash (grep -nE "random_bool|random_float|random_int|random_choice|random_sign|random_a...)
218:random_float_01(rng?) -> number
219:random_float(min, max, rng?) -> number
220:random_int(min, max, rng?) -> int
221:random_angle(rng?) -> number
222:random_sign(chance?, rng?) -> -1 | 1
223:random_bool(chance?, rng?) -> bool
225:random_choice(array, rng?) -> element
226:random_choices(array, n, rng?) -> [element, ...]
Bash (grep -nA3 "random_bool" E:/a327ex/Anchor2/docs/ENGINE_API.md | head -12)
1653:### random_bool
1654-
1655:`random_bool(chance?, rng?) -> bool`
1656-
1657-Returns true or false. Chance (0-100) is probability of true (default 50).
1658-
--
1660:if random_bool(10) then
1661- -- 10% chance
1662-end
1663-```
Read (E:/a327ex/attack-template/anchor/collider.lua)
79
80 --[[
81 collider:sync()
82 Copies the body's position to owner.x/y. Convenience for entities that
83 want their logical position to track the physics position each frame.
84 ]]
85 function collider:sync()
86 if self.owner and self.body then
87 self.owner.x, self.owner.y = physics_get_position(self.body)
88 end
89 end
90
Read (E:/a327ex/attack-template/area.lua)
96 local function build_polygon_verts(input, radius, poly_r, jagged)
97 poly_r = poly_r or 0
98 if not input or #input == 0 then
99 input = {}
100 for i = 1, 6 do
101 input[#input + 1] = {a = (i - 1)*2*math.pi/6, d = 1.0}
102 end
103 end
104 local verts = {}
105 local n = math.min(#input, POLYGON_MAX_VERTS)
106 for i = 1, n do
107 local v = input[i]
108 local a = v.a + poly_r
109 local d = v.d
110 if jagged then
111 a = a + random_float(-0.15, 0.15)
112 d = d*random_float(0.75, 1.1)
113 end
114 verts[#verts + 1] = {wx = radius*d*math.cos(a), wy = radius*d*math.sin(a)}
115 end
116 return verts
117 end
118
119
120 -- Convert {wx,wy} verts to a flat {x1,y1,x2,y2,...} array for the
121 -- engine query (and for layer_polygon_line if we used it).
122 local function verts_to_flat(verts)
123 local flat = {}
124 for _, v in ipairs(verts) do
125 flat[#flat + 1] = v.wx
... [30 more lines]
I have everything I need. Here's my plan, then the code for review.
Plan
Two new files + main.lua wiring for the polygonal (big-rock) carrier:
airborne.lua— a procedural arc-flight helper (Anchor 2 style: plain table + free functions, liketimer/spring). Owns only the parabola math: horizontal lerp + real-physics vertical parabola + apex/landing edge events. The shared abstraction for any lobbed attack.polygonal.lua— thebig_rockcarrier (polygonalclass) +rock_fragmentshard class. Jagged rock flies anairbornearc with a blinking X-marker at the target; on landing it spawns a polygonarea(reusingspawn_area), runs v1's wedge-partition shatter into 3-4 Box2D fragments, kicks out rocky particles, shakes the camera, plays sounds.
Scope notes:
- Mods dropped (homing/magnetic/cast_on_apex) per design decision #1.
- The shatter's dead
many_piecesbranches are dropped — they only fired for mod-driven piece counts >4, which can't happen now (target_piecesis always 3-4). The wedge algorithm itself is preserved verbatim. - The landing
areawill be visible (a jagged polygon flashing + blinking out — reads as the impact crater). v1 hid it; attack-template'sarea.luahas nohiddenflag. I think visible looks good here, but flag this if you want it hidden (would need anarea.luachange). - No shockwave — attack-template never ported v1's shockwave shader system. Skipping it; the landing still has the area flash + fragments + particles + shake.
- 5 sound assets copied in:
Spell_Earth_02/04.ogg(throw/landing) +Explosion Flesh 1/3/4.ogg.
Let me show the code.
airborne.lua (new file)
--[[
airborne.lua — parabolic arc-flight helper.
A procedural state object (Anchor 2 style: a plain table advanced by
free functions, exactly like timer / spring) that tracks something
thrown from an origin to a target over a fixed duration. The
horizontal path is a straight lerp; the "height" follows a real-
physics vertical parabola — h(τ) = v0·τ − ½·g·τ² — so the thrown
thing rises, peaks at the midpoint, and comes back down to land
exactly at flight_duration. A visual `scale` is derived from height
so a lobbed object reads larger near its apex.
This is the shared abstraction behind the big-rock carrier and any
future arced/lobbed attack (grenade, mortar, etc.). It owns ONLY the
arc math: the carrier owns the entity, the visuals, and the landing.
Usage:
self.arc = airborne_new{
origin_x = ox, origin_y = oy,
target_x = tx, target_y = ty,
flight_duration = 3, -- seconds, origin → target
gravity = 2, -- parabola steepness (default 2)
apex_scale = 3, -- visual scale multiplier at the peak
}
-- each frame:
airborne_update(self.arc, dt)
-- then read:
-- arc.x, arc.y current world position along the arc
-- arc.height parabola height (0 at ground, max at apex)
-- arc.scale 1 at ground → apex_scale at the peak
-- arc.t 0-1 flight progress
-- arc.elapsed raw seconds since launch
-- arc.apex_now true ONLY on the frame the midpoint is crossed
-- arc.landed true once the target is reached
-- arc.landed_now true ONLY on the frame landing happens
target_x / target_y are plain fields — a carrier that wants homing
can mutate them between updates and the path retargets. Homing logic
itself is a carrier concern, deliberately not built in here.
]]
function airborne_new(opts)
local a = {}
a.origin_x = opts.origin_x
a.origin_y = opts.origin_y
a.target_x = opts.target_x or opts.origin_x
a.target_y = opts.target_y or opts.origin_y
a.flight_duration = opts.flight_duration or 1
a.gravity = opts.gravity or 2
a.apex_scale = opts.apex_scale or 3
-- v0 chosen so height returns to 0 exactly at flight_duration:
-- h(T) = v0·T − ½·g·T² = 0 ⇒ v0 = ½·g·T (peak at τ = T/2)
a.v0 = 0.5*a.gravity*a.flight_duration
a.max_height = a.v0*a.v0/(2*a.gravity)
a.elapsed = 0
a.t = 0
a.x, a.y = a.origin_x, a.origin_y
a.height = 0
a.scale = 1
a.apex_passed = false
a.apex_now = false
a.landed = false
a.landed_now = false
return a
end
function airborne_update(a, dt)
-- Edge flags — cleared every update, set only on the crossing frame
-- so a carrier can react to "apex" / "landed" exactly once.
a.apex_now = false
a.landed_now = false
if a.landed then return end
a.elapsed = a.elapsed + dt
a.t = math.clamp(a.elapsed/a.flight_duration, 0, 1)
-- Horizontal: straight lerp origin → (current) target.
a.x = math.lerp(a.t, a.origin_x, a.target_x)
a.y = math.lerp(a.t, a.origin_y, a.target_y)
-- Vertical parabola: h(τ) = v0·τ − ½·g·τ², clamped at flight_duration.
local tau = math.min(a.elapsed, a.flight_duration)
a.height = a.v0*tau - 0.5*a.gravity*tau*tau
a.scale = 1 + (a.apex_scale - 1)*(a.height/a.max_height)
-- Apex crossing (the parabola midpoint).
if not a.apex_passed and a.t >= 0.5 then
a.apex_passed = true
a.apex_now = true
end
-- Landing.
if a.t >= 1 then
a.landed = true
a.landed_now = true
a.height = 0
a.scale = 1
end
end
polygonal.lua (new file)
--[[
polygonal.lua — lobbed jagged-rock carrier (v1 big_rock_effect).
A jagged polygon thrown in a parabolic arc from a caster to a target
point. While airborne it reads as a spinning rock that scales up
toward the apex; an X-marker blinks at the target, accelerating as
the rock nears. On landing it:
1. spawns a polygon `area` carrying the rock's own jagged verts
(instant single damage pass + blink-out — reused via spawn_area),
2. shatters into 3-4 Box2D polygon fragments via v1's bespoke
wedge-partition algorithm (rock_fragment, below),
3. throws out 8-14 rocky particles,
4. shakes the camera and plays the landing sound stack.
Port of v1 big_rock_effect (line 11390) + rock_fragment (11696).
Visuals + sounds only — homing / magnetic_area / cast_on_apex are
gameplay mods and are dropped (template design decision #1). The arc
math lives in the shared airborne helper (airborne.lua); v1's
mod-driven shatter branches for piece counts >4 are dropped since
target_pieces is always 3-4 now — the wedge algorithm is unchanged.
v1 spawns the landing area `hidden`; here it stays visible (a brief
jagged polygon flash — reads as the impact crater). attack-template's
area.lua has no hidden flag; add one there if a host wants it gone.
Caller API:
spawn_polygonal(caster, {
target_x, -- landing point. default: main_camera.mouse
target_y,
flight_duration, -- seconds origin → target, default 3
area_radius, -- rock / landing-area radius, default 30
damage, -- landing damage, default 0
gravity, -- arc steepness, default 2
color, -- default yellow
edition, -- default caster.edition
})
]]
polygonals = {}
rock_fragments = {}
-- ============================================================
-- rock_fragment — polygon shard with a physics collider
-- ============================================================
--
-- A single shatter shard. Built from points relative to its own
-- centroid, given a Box2D dynamic polygon collider, and launched
-- outward (centroid direction + a fraction of the rock's inherited
-- throw velocity). Flashes white briefly, then blinks out and dies.
rock_fragment = class()
function rock_fragment:new(x, y, args)
args = args or {}
self.x, self.y = x, y
self.points = args.points or {{x = -4, y = -4}, {x = 4, y = -4}, {x = 0, y = 4}}
self.duration = args.duration or 0.6
self.color = args.color and color_clone(args.color) or color_clone(yellow)
self.edition = args.edition or 0
self.r = 0
self.flashing = true
self.hidden = false
make_entity(self)
-- Box2D polygon colliders cap at 8 vertices. A fragment with jag
-- points can exceed that, so simplify by dropping near-coincident
-- points (v1 line 11709). The full self.points list is still used
-- for drawing — only the collider runs on the simplified shape.
local pts = self.points
if #pts > 8 then
local simplified = {pts[1]}
for i = 2, #pts do
local prev = simplified[#simplified]
local d = math.length(pts[i].x - prev.x, pts[i].y - prev.y)
if d > 3 or #simplified < 8 then
simplified[#simplified + 1] = pts[i]
end
if #simplified >= 8 then break end
end
pts = simplified
end
local flat = {}
for _, pt in ipairs(pts) do
flat[#flat + 1] = pt.x
flat[#flat + 1] = pt.y
end
self.collider = collider(self, 'rock_fragment', 'dynamic', 'polygon', flat, {})
self.collider:set_gravity_scale(0)
self.collider:set_linear_damping(1.5)
self.collider:set_angular_damping(1)
self.collider:set_position(x, y)
self.collider:set_restitution(0.5)
self.collider:set_bullet(true)
-- Launch impulse: centroid direction + 0.3× the rock's throw
-- velocity, both scaled to impulse units (v1 line 11738).
local launch_r = args.launch_r or 0
local launch_speed = args.launch_speed or 150
local throw_r = args.throw_r or 0
local throw_speed = (args.throw_speed or 30)*0.3
local ix = (launch_speed*math.cos(launch_r) + throw_speed*math.cos(throw_r))*0.005
local iy = (launch_speed*math.sin(launch_r) + throw_speed*math.sin(throw_r))*0.005
self.collider:apply_impulse(ix, iy)
self.collider:set_angular_velocity(random_float(-10, 10))
self.timer = timer_new()
timer_after(self.timer, 0.1, 'flash', function() self.flashing = false end)
-- Blink out over the back 20-60% of life, then die.
local blink_start = self.duration*(0.4 + random_float(0, 0.4))
timer_after(self.timer, blink_start, function()
timer_every(self.timer, 0.05, 'blink',
function() self.hidden = not self.hidden end,
random_int(5, 10),
function() entity_kill(self) end)
end)
end
function rock_fragment:update(dt)
timer_update(self.timer, dt)
self.collider:sync()
self.r = self.collider:get_angle()
end
function rock_fragment:destroy()
if self.collider then self.collider:destroy() end
end
function rock_fragment:draw(layer)
if self.hidden then return end
set_edition(layer, self.edition)
local c = self.flashing and fg_color() or self.color()
local cos_r, sin_r = math.cos(self.r), math.sin(self.r)
local pts = self.points
for i = 1, #pts do
local p1 = pts[i]
local p2 = pts[i % #pts + 1]
local x1 = self.x + p1.x*cos_r - p1.y*sin_r
local y1 = self.y + p1.x*sin_r + p1.y*cos_r
local x2 = self.x + p2.x*cos_r - p2.y*sin_r
local y2 = self.y + p2.x*sin_r + p2.y*cos_r
layer_line(layer, x1, y1, x2, y2, 2, c)
end
set_edition(layer, 0)
end
-- ============================================================
-- polygonal — the lobbed rock carrier
-- ============================================================
polygonal = class()
function polygonal:new(x, y, args)
args = args or {}
self.origin_x, self.origin_y = x, y
self.target_x = args.target_x or x
self.target_y = args.target_y or y
self.flight_duration = args.flight_duration or 3
self.area_radius = args.area_radius or 30
self.damage = args.damage or 0
self.color = args.color and color_clone(args.color) or color_clone(yellow)
self.edition = args.edition or 0
self.landed = false
-- Jagged outline, generated once (v1: 8-12 verts, slight angular
-- jitter, distance 0.85-1.1). Stored as {a, d} — the exact form
-- area.lua's polygon mode consumes, so the landing area reuses
-- these verts directly.
local n = random_int(8, 12)
self.jagged_verts = {}
for i = 1, n do
self.jagged_verts[i] = {
a = (i - 1)*2*math.pi/n + random_float(-0.15, 0.15),
d = random_float(0.85, 1.1),
}
end
self.rock_spin = random_float(-3, 3)
self.rock_r = 0
make_entity(self)
self.arc = airborne_new{
origin_x = self.origin_x, origin_y = self.origin_y,
target_x = self.target_x, target_y = self.target_y,
flight_duration = self.flight_duration,
gravity = args.gravity or 2,
apex_scale = 3,
}
end
function polygonal:update(dt)
airborne_update(self.arc, dt)
self.rock_r = self.rock_r + self.rock_spin*dt
if self.arc.landed_now then
self:land()
entity_kill(self)
end
end
-- Landing: damage area + shatter + particles + shake + sound. Called
-- once, the frame the arc reaches the target.
function polygonal:land()
self.landed = true
local tx, ty = self.target_x, self.target_y
local pc = edition_base_color(self.edition) or self.color
-- 1. Damage — a polygon area carrying the rock's own jagged verts.
-- Instant area: single damage pass + blink-out. Visible (the
-- impact crater flash); v1 hid this.
spawn_area(tx, ty, {
shape = 'polygon',
polygon_verts = self.jagged_verts,
polygon_r = self.rock_r,
radius = self.area_radius,
damage = self.damage,
color = self.color,
edition = self.edition,
})
-- 2. Shatter into 3-4 polygon fragments.
self:shatter()
-- 3. Rocky particles between the shards (v1 line 11671).
for i = 1, random_int(8, 14) do
local pr = random_float(0, 2*math.pi)
local dst = random_float(2, self.area_radius*0.5)
spawn_particle(tx + dst*math.cos(pr), ty + dst*math.sin(pr), {
angle = pr + random_float(-0.4, 0.4),
velocity = random_float(40, 150),
duration = random_float(0.2, 0.5),
w = random_float(3, 6),
h = random_float(2, 4),
}):change_color(0.5, pc)
end
-- 4. Camera shake — directional push along the throw + a shake.
local throw_r = math.angle_to_point(self.origin_x, self.origin_y, tx, ty)
shake_push(main_camera.shake, throw_r, 8)
shake_shake(main_camera.shake, 5, 0.2)
-- 5. Landing sound stack.
sfx(sounds.fissure_cast_2, volumes.fissure_cast_2)
if sounds.explosion_flesh_variants then
sfx(sounds.explosion_flesh_variants[random_int(1, #sounds.explosion_flesh_variants)],
volumes.explosion_flesh)
end
end
-- v1's bespoke wedge-partition shatter (lines 11554-11669). The rock's
-- outline is split into 3-4 pie-slice fragments: a shared (jittered)
-- center, jagged radial cut lines between fragments so neighbours
-- share boundaries, and a run of outer vertices each. Each fragment
-- becomes a rock_fragment with its own physics collider.
function polygonal:shatter()
local verts = self.jagged_verts
local base_r = self.area_radius
local nv = #verts
-- Shared offset center for every wedge (a slightly off-centre hub).
local center_ox = random_float(-base_r*0.2, base_r*0.2)
local center_oy = random_float(-base_r*0.2, base_r*0.2)
local target_pieces = random_int(3, 4)
local big_jag_budget = 1 -- at most one dramatic jag across the rock
-- Pre-generate jagged radial cut lines from the center to each outer
-- vertex. 0-3 jag points per radial, nudged perpendicular to the
-- line so adjacent fragments share the exact same boundary.
local radial_jags = {}
for i = 1, nv do
local v = verts[i]
local vx = base_r*v.d*math.cos(v.a + self.rock_r)
local vy = base_r*v.d*math.sin(v.a + self.rock_r)
local jags = {}
local num_jags = random_bool(30) and 0 or random_int(1, 3)
for j = 1, num_jags do
local jt = j/(num_jags + 1) + random_float(-0.1, 0.1)
jt = math.clamp(jt, 0.15, 0.85)
local mx = center_ox + (vx - center_ox)*jt
local my = center_oy + (vy - center_oy)*jt
local dx, dy = vx - center_ox, vy - center_oy
local len = math.length(dx, dy)
if len > 0.01 then
local nx, ny = -dy/len, dx/len
local jag_amount
if big_jag_budget > 0 and random_bool(20) then
jag_amount = base_r*random_float(-0.35, 0.35)
big_jag_budget = big_jag_budget - 1
elseif random_bool(30) then
jag_amount = base_r*random_float(-0.18, 0.18)
else
jag_amount = base_r*random_float(-0.06, 0.06)
end
mx = mx + nx*jag_amount
my = my + ny*jag_amount
end
jags[#jags + 1] = {x = mx, y = my}
end
radial_jags[i] = {vx = vx, vy = vy, jags = jags}
end
-- Decide how many outer edges each fragment claims (they sum to nv).
local piece_edges = {}
local edges_left = nv
for p = 1, target_pieces do
if p == target_pieces then
piece_edges[#piece_edges + 1] = edges_left
else
local avg = math.floor(edges_left/(target_pieces - p + 1))
local e = math.max(1, avg + random_int(-1, 1))
e = math.min(e, edges_left - (target_pieces - p))
piece_edges[#piece_edges + 1] = e
edges_left = edges_left - e
end
end
-- Build each fragment polygon: center → jagged line out to the first
-- vertex → run of outer vertices → jagged line back to center.
local tx, ty = self.target_x, self.target_y
local throw_r = math.angle_to_point(self.origin_x, self.origin_y, tx, ty)
local throw_speed = math.distance(self.origin_x, self.origin_y, tx, ty)/self.flight_duration
local edges_used = 0
for piece_i = 1, #piece_edges do
local num_edges = piece_edges[piece_i]
local world_pts = {}
world_pts[#world_pts + 1] = {x = center_ox, y = center_oy}
local first_vi = (edges_used % nv) + 1
for _, jp in ipairs(radial_jags[first_vi].jags) do
world_pts[#world_pts + 1] = {x = jp.x, y = jp.y}
end
local cx_sum, cy_sum = 0, 0
for j = 0, num_edges do
local vi = ((edges_used + j) % nv) + 1
local rad = radial_jags[vi]
world_pts[#world_pts + 1] = {x = rad.vx, y = rad.vy}
cx_sum = cx_sum + rad.vx
cy_sum = cy_sum + rad.vy
end
local last_vi = ((edges_used + num_edges) % nv) + 1
local last_jags = radial_jags[last_vi].jags
for j = #last_jags, 1, -1 do
world_pts[#world_pts + 1] = {x = last_jags[j].x, y = last_jags[j].y}
end
-- Centroid (outer verts only, v1 line 11644) — fragment local origin.
local n_outer = num_edges + 1
local centroid_x = cx_sum/n_outer
local centroid_y = cy_sum/n_outer
local rel_pts = {}
for _, pt in ipairs(world_pts) do
rel_pts[#rel_pts + 1] = {x = pt.x - centroid_x, y = pt.y - centroid_y}
end
-- Launch outward in the centroid's direction; nudge the spawn
-- slightly out so fragments don't start interpenetrating.
local launch_r = math.atan(centroid_y, centroid_x)
rock_fragments[#rock_fragments + 1] = rock_fragment(
tx + centroid_x + 2*math.cos(launch_r),
ty + centroid_y + 2*math.sin(launch_r), {
points = rel_pts,
launch_r = launch_r,
launch_speed = random_float(80, 200),
throw_r = throw_r,
throw_speed = throw_speed,
duration = random_float(0.5, 1.0),
color = self.color,
edition = self.edition,
})
edges_used = edges_used + num_edges
end
end
function polygonal:destroy() end
function polygonal:draw(layer)
if self.landed then return end
set_edition(layer, self.edition)
-- X-marker at the target — blinks, accelerating as the rock nears
-- (v1 blink_rate lerps 0.3s → 0.04s over the flight).
local blink_rate = math.lerp(self.arc.t, 0.3, 0.04)
if math.floor(self.arc.elapsed/blink_rate) % 2 == 0 then
local s = 6
local xc = self.color()
layer_line(layer, self.target_x - s, self.target_y - s,
self.target_x + s, self.target_y + s, 2, xc)
layer_line(layer, self.target_x + s, self.target_y - s,
self.target_x - s, self.target_y + s, 2, xc)
end
-- The rock — jagged outline, spinning, scaled up toward the apex.
local base_r = self.area_radius*self.arc.scale
local c = self.color()
local v = self.jagged_verts
for i = 1, #v do
local v1 = v[i]
local v2 = v[i % #v + 1]
local x1 = self.arc.x + base_r*v1.d*math.cos(v1.a + self.rock_r)
local y1 = self.arc.y + base_r*v1.d*math.sin(v1.a + self.rock_r)
local x2 = self.arc.x + base_r*v2.d*math.cos(v2.a + self.rock_r)
local y2 = self.arc.y + base_r*v2.d*math.sin(v2.a + self.rock_r)
layer_line(layer, x1, y1, x2, y2, 2, c)
end
set_edition(layer, 0)
end
--[[
spawn_polygonal(caster, opts)
Lobs a rock from the caster toward a target point (default: the
cursor). Applies the caster-side throw juice (directional shake,
recoil spring, white flash, throw sound) — the mirror of
spawn_projectile. Returns the polygonal instance.
]]
function spawn_polygonal(caster, opts)
opts = opts or {}
local tx = opts.target_x or main_camera.mouse.x
local ty = opts.target_y or main_camera.mouse.y
local pg = polygonal(caster.x, caster.y, {
target_x = tx,
target_y = ty,
flight_duration = opts.flight_duration,
area_radius = opts.area_radius,
damage = opts.damage,
gravity = opts.gravity,
color = opts.color,
edition = opts.edition or caster.edition,
})
polygonals[#polygonals + 1] = pg
-- Caster-side throw juice. Nil-guarded so an AI caster without a
-- spring / timer still throws (it just gets no body juice).
local throw_r = math.angle_to_point(caster.x, caster.y, tx, ty)
shake_push(main_camera.shake, throw_r, 3)
if caster.spring then spring_pull(caster.spring, 'shoot', 0.25) end
if caster.timer then
caster.flashing = true
timer_after(caster.timer, 0.15, 'shoot_flash',
function() caster.flashing = false end)
end
sfx(sounds.fissure_cast_1, volumes.fissure_cast_1)
return pg
end
Continuing with the main.lua edits — let me show those next.
AskUserQuestion
User has answered your questions: "The two new files (airborne.lua, polygonal.lua) above look right? Next I'll show the main.lua wiring edits, then write all files + copy the 5 sound assets."="[No preference]". You can now continue with the user's answers in mind.
The "no preference" answer isn't explicit approval, so I'll hold off writing until you've also seen the main.lua wiring and given a clear go-ahead. Here are the 9 main.lua edits:
Edit 1 — require block (after require('flicker')):
lua
require('flicker')
require('airborne')
require('polygonal')
Edit 2 — volumes table (3 entries before the closing }):
lua
sword_slash = 0.6, -- v1 line 38
fissure_cast_1 = 0.5, -- v1 line 16
fissure_cast_2 = 0.55, -- v1 line 17
explosion_flesh = 0.52, -- v1 line 41
}
Edit 3 — sound loading (after the sword_slash_variants table):
lua
-- Big-rock (polygonal carrier) sound stack (v1 big_rock def, lines
-- 939-940 + 11685-11686): fissure_cast_1 on throw, fissure_cast_2 +
-- a random explosion_flesh variant on landing.
sounds.fissure_cast_1 = sound_load('assets/Spell_Earth_02.ogg')
sounds.fissure_cast_2 = sound_load('assets/Spell_Earth_04.ogg')
sounds.explosion_flesh_variants = {
sound_load('assets/Explosion Flesh 1.ogg'),
sound_load('assets/Explosion Flesh 3.ogg'),
sound_load('assets/Explosion Flesh 4.ogg'),
}
Edit 4 — physics (a tag + 2 collision pairs):
lua
physics_register_tag('projectile')
physics_register_tag('rock_fragment')
lua
physics_enable_sensor('projectile', 'enemy')
-- Rock-shatter fragments bounce off each other and walls only; they
-- pass through entities (v1 big_rock registers just these two pairs).
physics_enable_collision('rock_fragment', 'rock_fragment')
physics_enable_collision('rock_fragment', 'wall')
Edit 5 — keybind (after bind('beam_step_size_up', 'key:6')):
lua
bind('fire_polygonal', 'key:r')
Edit 6 — input handler (after the fire_flicker block):
lua
-- Polygonal: lob a jagged rock in a parabola to the cursor. On
-- landing it spawns a polygon damage area and shatters into shards.
if is_pressed('fire_polygonal') and not p._dead then
spawn_polygonal(p, {
damage = 12,
area_radius = 30,
})
end
Edit 7 — update loops (after the flickers loop):
lua
for i = #polygonals, 1, -1 do
local pg = polygonals[i]
if pg._dead then table.remove(polygonals, i)
else pg:update(sdt) end
end
for i = #rock_fragments, 1, -1 do
local rf = rock_fragments[i]
if rf._dead then table.remove(rock_fragments, i)
else rf:update(sdt) end
end
Edit 8 — game_layer draw (after the flickers draw line):
lua
for _, f in ipairs(flickers) do f:draw(game_layer) end
for _, pg in ipairs(polygonals) do pg:draw(game_layer) end
for _, rf in ipairs(rock_fragments) do rf:draw(game_layer) end
Edit 9 — F2 collider overlay (after the projectiles line):
lua
for _, pr in ipairs(projectiles) do draw_collider_shapes(pr) end
for _, rf in ipairs(rock_fragments) do draw_collider_shapes(rf) end
That's the full change set: 2 new files, 9 main.lua edits, 5 sound assets copied into assets/. I'll also update .claude/CLAUDE.md (carrier list, keybinding table, file map, port-order checkmark) afterward.
Should I write all of this now?
First let's change CLAUDE.md such that you don't need to show me code, but a high-level explanation of your approach for the various features, how they interface with existing features when necessary, then function names, what the function does, what other functions are called inside of it (not exhaustive, choose ones that are relevant for building a conceptual map of the codebase, not full detail), the general structure of the function when necessary. Then I'll usually reply back with questions and improvements until we get to a conclusion. Then let's try this approach for the code changes you just proposed.
The user specified the change in prose, so I'll update the attack-template CLAUDE.md directly. Two edits — the top rule block and the Working-style bullet that references it.
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
CLAUDE.md updated — the workflow is now design-brief-first. Let me re-present the polygonal carrier work in that format.
Design Brief — Polygonal carrier (big_rock)
Approach
Port v1's big_rock_effect (+ rock_fragment) as the 5th attack carrier: a jagged rock lobbed in a parabolic arc to a target point, which on landing deals polygon-shaped AoE damage and physically shatters into 3-4 collidable shards. Per the scoping decisions: build a reusable airborne abstraction now; reuse spawn_area for landing damage; drop all mods (homing/magnetic/cast_on_apex); trigger on R.
Two new files: airborne.lua (shared arc-flight helper) and polygonal.lua (the carrier + the shard entity).
airborne.lua — parabolic arc-flight helper
Approach. A procedural state object in the Anchor 2 idiom — a plain table advanced by free functions, exactly like timer/spring. It owns only the arc math, nothing visual: horizontal position is a straight lerp origin→target; "height" follows a real-physics parabola h(τ) = v0·τ − ½gτ² with v0 chosen so height returns to 0 exactly at flight_duration (peak at the midpoint). A scale field is derived from height so a thrown thing reads bigger near apex. It also exposes one-frame edge flags (apex_now, landed_now) so a consumer can react to those moments exactly once.
Functions:
airborne_new(opts)— builds and returns the state table fromorigin_x/y,target_x/y,flight_duration,gravity,apex_scale. Precomputesv0andmax_height. Calls nothing notable.airborne_update(a, dt)— advanceselapsed/t, recomputesx/y(viamath.lerp),height,scale, and setsapex_passed/landedplus the one-frameapex_now/landed_nowflags.target_x/yare plain fields, so a consumer could mutate them mid-flight for homing — homing logic itself stays out of here.
polygonal.lua — the carrier + the shard
Two classes plus a spawn helper. Globals polygonals = {} and rock_fragments = {} (separate arrays — shards outlive the rock that spawned them).
polygonal class — the lobbed rock
Approach. Not a physics object (like area/beam). It generates an 8-12 vertex jagged outline once (stored as {a,d} — the exact format area.lua's polygon mode consumes), holds an airborne arc, and spins. While airborne it draws the rock (scaled by arc.scale) plus a blinking X-marker at the target. The frame arc.landed_now fires, it runs land() then kills itself.
Interfaces: consumes airborne_new/airborne_update; on landing calls spawn_area (reused for damage), spawn_particle, shake_push/shake_shake, sfx; reads global sounds/volumes.
Functions:
polygonal:new(x,y,args)— generatesjagged_verts, callsmake_entity,airborne_new.polygonal:update(dt)—airborne_update, advancesrock_r; onarc.landed_now→self:land()+entity_kill.polygonal:land()— the landing sequence:spawn_area(polygon shape, the rock's own verts, instant damage pass + blink-out);self:shatter(); 8-14spawn_particlerocky bursts; camera shake; landing sounds.polygonal:shatter()— v1's bespoke wedge-partition algorithm, ported faithfully. Splits the outline into 3-4 pie-slice fragments around a jittered shared center, with jagged radial cut-lines so neighbouring fragments share boundaries; spawns arock_fragmentper wedge with relative-to-centroid points and a launch direction. (v1'smany_piecesbranches — only reachable with mod-driven piece counts >4 — are dropped sincetarget_piecesis always 3-4 now; the algorithm itself is unchanged.)polygonal:draw(layer)— X-marker (blink rate lerps 0.3s→0.04s over the flight) + the jagged rock outline; skipped once landed.spawn_polygonal(caster, opts)— the fire helper, mirror ofspawn_projectile: builds thepolygonal, applies caster-side throw juice (directional shake, recoil spring, white flash, all nil-guarded), plays the throw sound.
rock_fragment class — one shard
Approach. A polygon shard with a real Box2D dynamic collider. Built from points relative to its own centroid, launched outward via an impulse (centroid direction + a fraction of the rock's inherited throw velocity), flashes white briefly, then blinks out and dies on a timer.
Interfaces: creates a collider tagged rock_fragment (new physics tag — collides with other fragments + walls only, passes through entities, matching v1).
Functions:
rock_fragment:new(x,y,args)— simplifies points to ≤8 for the collider (drops near-coincident points; the full point list is still used for drawing), builds the collider, applies the launch impulse + spin, schedules flash-off and the blink-out timer chain.rock_fragment:update(dt)—timer_update,collider:sync(), reads angle from the collider.rock_fragment:draw(layer)— strokes the (full-detail) polygon outline, rotated by the collider angle.rock_fragment:destroy()— destroys the collider.
main.lua wiring
require both new files (airborne before polygonal); add 3 volumes entries + load 5 sound assets (Spell_Earth_02/04.ogg, Explosion Flesh 1/3/4.ogg copied into assets/); register the rock_fragment physics tag + 2 collision pairs; bind R → fire_polygonal; add the input handler, the two update loops, the two game_layer draw loops, and the two F2 debug-collider loops.
Two judgment calls to flag
- The landing
areawill be visible. v1 spawns ithidden(the shards are the visual). attack-template'sarea.luahas nohiddenflag — so the polygon area shows as a brief jagged flash that blinks out. I think it reads well as an impact crater under the debris, but if you'd rather it be invisible that's a smallarea.luachange. - No shockwave. v1's landing adds a
shockwave(a screen-distortion ring driven by a shader system attack-template never ported). Skipped — the landing still has the area flash + shards + particles + shake.
Questions or changes before I write it?
Change the global CLAUDE.md too to reflect this change when working with code.
For airborne, we actually want the x, y movement of the object to remain as it normally is, and for the airborne system to only handle the z movement and modifications to drawing, as well as an "airborne" tag which removes collision with normal objects. We want to potentially have seekers, projectiles, and any other objects be airborne, and visually they can be airborne by being drawn scaled up like the rock. For now, we can assume that "airborne" is just a single function that throws the object up and then down like the rock, for the future we might want other types of airborne behavior.
For polygonal, we also want this class to serve as a general "prop" class, think breakable vases/boxes in an ARPG. For now we're using it for the rock only, but we want the debris to work similarly as breakable objects in general. So for now it's fine for it to be visually a rock that then breaks into rock-like debris, but later we might want something more general. Then we want a separate portion of it that actually deals damage according to how the rock should deal damage, and you should figure out where to appropriately place that.
Tell me what you think and how you'll approach each suggestion.
This is a meaty redesign. Let me check one thing about the engine's physics API before I answer the collision question concretely.
Bash (cd E:/a327ex/attack-template && grep -nE "enable|filter|sensor|set_active|set_aw...)
23: for _, ev in ipairs(sensor_entities_begin('bullet', 'enemy')) do
199: sensor_entities_begin(tag_a, tag_b)
200: Returns a table of sensor-begin events with normalized ordering so `a`
201: corresponds to the first tag argument (typically the sensor) and `b`
204:function sensor_entities_begin(tag_a, tag_b)
205: local events = physics_get_sensor_begin(tag_a, tag_b)
209: local body_a, body_b = ev.sensor_body, ev.visitor_body
210: -- Normalize: if sensor_tag is actually the second query tag, swap
211: if ev.sensor_tag == tag_b and ev.visitor_tag == tag_a then
224: sensor_entities_end(tag_a, tag_b)
226:function sensor_entities_end(tag_a, tag_b)
227: local events = physics_get_sensor_end(tag_a, tag_b)
231: local body_a, body_b = ev.sensor_body, ev.visitor_body
232: if ev.sensor_tag == tag_b and ev.visitor_tag == tag_a then
Bash (grep -nE "physics_(enable|disable|set_body|body_set)|set_sensor|set_enabled|filt...)
93:set_filter_mode(mode)
94:get_filter_mode() -> string
120:physics_set_enabled(enabled)
122:physics_enable_collision(tag_a, tag_b)
123:physics_disable_collision(tag_a, tag_b)
124:physics_enable_sensor(tag_a, tag_b)
125:physics_enable_hit(tag_a, tag_b)
175:physics_shape_set_filter_group(shape, group) -- shapes with same non-zero group don't collide
Bash (grep -nE "physics_shape_set_sensor|physics_set_shape|physics_shape_set|shape_set...)
E:/a327ex/Anchor2/docs/ENGINE_API_QUICK.md:167:physics_shape_set_friction(shape, friction)
E:/a327ex/Anchor2/docs/ENGINE_API_QUICK.md:169:physics_shape_set_restitution(shape, restitution)
E:/a327ex/Anchor2/docs/ENGINE_API_QUICK.md:173:physics_shape_set_density(shape, density)
E:/a327ex/Anchor2/docs/ENGINE_API_QUICK.md:175:physics_shape_set_filter_group(shape, group) -- shapes with same non-zero group don't collide
E:/a327ex/Anchor2/docs/ENGINE_API.md:1252:### physics_shape_set_friction
E:/a327ex/Anchor2/docs/ENGINE_API.md:1254:`physics_shape_set_friction(shape, friction)`
E:/a327ex/Anchor2/docs/ENGINE_API.md:1259:physics_shape_set_friction(ground_shape, 0.8)
E:/a327ex/Anchor2/docs/ENGINE_API.md:1272:### physics_shape_set_restitution
E:/a327ex/Anchor2/docs/ENGINE_API.md:1274:`physics_shape_set_restitution(shape, restitution)`
E:/a327ex/Anchor2/docs/ENGINE_API.md:1279:physics_shape_set_restitution(ball_shape, 0.9)
E:/a327ex/Anchor2/docs/ENGINE_API.md:1314:### physics_shape_set_density
E:/a327ex/Anchor2/docs/ENGINE_API.md:1316:`physics_shape_set_density(shape, density)`
E:/a327ex/Anchor2/docs/ENGINE_API.md:1321:physics_shape_set_density(shape, 2.0)
E:/a327ex/Anchor2/docs/ENGINE_API.md:2637:### physics_shape_set_filter_group
E:/a327ex/Anchor2/docs/ENGINE_API.md:2639:`physics_shape_set_filter_group(shape, group)`
E:/a327ex/Anchor2/docs/ENGINE_API.md:2645:physics_shape_set_filter_group(shape, player_id)
Read (E:/a327ex/.claude/CLAUDE.md)
38
39 - Work incrementally — complete one piece, let them test, get feedback
40 - After completing a task, give the user a turn before starting the next
41 - Don't chain tasks or build large systems autonomously
42
43 ### Communication
44
45 - Present options, not conclusions
46 - Surface tradeoffs explicitly
47 - Don't assume specs are final — they're starting points for conversation
48
49 ### Code Changes
50
51 - **⛔ ALWAYS show code before writing it.** Before calling `Write` or
52 `Edit` on any code file, post the full code (or exact old → new diff
53 for `Edit`) in chat. This applies to every file operation — new
54 files, existing files, even one-line edits.
55
56 **Non-trivial changes**: ask "does this look right?" / "should I
57 commit this?" and wait for the user's go-ahead before writing.
58
59 **Trivial changes**: show the diff and apply it in the same turn —
60 no ask. Trivial means:
61 - Single-line value tweaks (number, string, flag, font size, color hex)
62 - Variable renames within one file
63 - Single-line typo fixes
64 - User-specified imperatives ("change X to Y", "rename foo to bar")
65
66 If unsure whether something counts as trivial, treat it as
67 non-trivial and ask.
... [22 more lines]
Edit (E:/a327ex/.claude/CLAUDE.md)
The file E:/a327ex/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Global CLAUDE.md updated — mirrored the design-brief workflow into the "Code Changes" section, kept the website exemption and trivial carve-out intact.
Now my thoughts on the two redesigns.
Airborne
Your reframe is better than mine, and I think it's correct. My version conflated two things — "move from A to B" and "be in the air" — into one helper. Splitting them means airborne composes with any movement instead of replacing it. A seeker keeps chasing while airborne; a projectile keeps flying straight; the rock does its own thing. Airborne just answers "how high am I and what does that do to me."
What airborne owns:
- A z arc — a normalized 0→1→0 height over a
duration. I'd drop thegravity/v0physics math entirely: since nothing renders a true z (top-down game), z is abstract and only feeds the visual. A plain normalized parabolaz = 4t(1-t)gives the identical up-then-down curve with no tuning constants. Cleaner. - A draw scale derived from z (
1 → apex_scale → 1). The entity opts in by multiplyingself.airborne.scaleinto its own draw — exactly how the rock will read as "big at apex." - Collision suppression while aloft.
- A landed event.
It does not touch x/y. Structured so airborne_throw(entity, opts) is the one behavior today, with room for other behaviors (airborne_float, etc.) later.
The collision-suppression part needs an honest flag. I checked the engine API. There's no clean runtime "collide with nothing" toggle — only physics_shape_set_filter_group (which is "same group never collide," can't express "collide with no one") and a global physics_set_enabled. The collider class exposes no set_sensor/body-disable. So your "airborne tag that removes collision" can't be done well today without a small engine addition — the cleanest being a one-function physics_shape_set_sensor(shape, bool) in anchor.c (airborne flips the entity's shape to a sensor on takeoff, back on landing).
Here's the thing though: the rock has no collider at all — it's pure visual, like area/beam. So the collision piece is dead weight for the only thing using airborne right now. My recommendation: ship airborne's z + scale now, and design the collision hook as a named seam (airborne calls a suppression step on takeoff/landing) that's currently a no-op. Add the engine function + wire the seam when the first collider-bearing entity (a knocked-up seeker) actually goes airborne. That avoids building a collision mechanism we can't test and don't yet need. Your call — I can also add the engine function now if you'd rather airborne be complete.
Polygonal / prop
Agreed, and your "separate the damage" instinct is the key one. Here's the layering I'd propose:
Generic breakable — a
shatter(x, y, verts, base_r, r, opts)free function (the wedge-partition algorithm) + adebrisclass (the shard entity, wasrock_fragment; physics tagdebris). This is the reusable core: a vase, a box, a rock all just callshatter. Visuals stay rock-styled for now, as you said — that's a later knob, not a structural one.The thrown-rock carrier — the entity that composes: a jagged polygon shape + an x/y lerp origin→target (its "normal movement") + an airborne for z/scale +
shatteron landing. This is the attack.The damage — a separate named function (e.g.
rock_land_damage(...)) that does thespawn_areapolygon AoE. It does not live in the breakable layer. A breakable vase shatters and deals no damage; only the attack deals damage. So damage belongs with the carrier — concretely, the carrier fires anon_landhook and the rock supplies a handler that spawns the area. The generic prop/debris stays damage-agnostic. That's the "appropriate place": damage is the attack layer, cleanly above the breakable layer.
One push-back: you said the class should "serve as a general prop class" but also "for now we're using it for the rock only." I'd not build a static prop entity yet — building an unused class is a half-finished implementation. Instead I'll make layer 1 (shatter + debris) genuinely generic and standalone, so adding a static vase/prop entity later is a tiny, obvious addition. The generalization lives in the seams, not in speculative classes. If you'd rather I stub the prop class now anyway, tell me.
Naming question: with this split, "polygonal" fits the thrown rock awkwardly. Options: keep the file polygonal.lua (matches the port-order doc) containing all three layers; or rename to prop.lua. And the carrier entity — polygonal, thrown_prop, or just rock? I lean: file polygonal.lua, generic pieces shatter/debris, carrier entity polygonal. Happy to go another way.
Want me to fold all this into a revised design brief?
There's no need to change the math for the z curve from the original, keep that math the exact same (in terms of z) as the original v1 for the big rock attack. The draw scale should also be functionally the same, but generalized in the way you mentioned with apex_scale and so on.
As for collision suppression, most objects are actually sensors and so we can keep the sensors on, and just add the if self.airborne flag as a guard at the top of any collision functions. If it's airborne then an airborne object shouldn't collide with an object that is not airborne. If both are airborne then that's a different story that will be handled in more specific ways depending on the game, not to be handled here where we're generalizing things.
And yes, the rock has no collider, that's fine too. Just make sure that in general you do it with what I said in mind.
As for polygonal/prop, let's name it prop, prop.lua, etc. It's the more general form of the thing. The rock just happens to be a prop that deals damage. In theory it should be a projectile that happens to be a polygon. Maybe you want to do it like that instead? I think either way is fine, as long as, behaviorally and visually, it behaves just like the Orblike v1 big_rock attack, exactly like it. The way you divide things internally and how they should be generalized here is your choice on this point.
Good — that resolves everything. Z-math stays v1-exact, collision is a flag-guard not a physics change, and prop is the general form. Here's the revised brief.
Revised Design Brief — airborne.lua + prop.lua
airborne.lua
Approach. A z-only arc component attached to any entity (plain table + free functions, timer/spring idiom). It does not move x/y — the entity moves itself. It owns height (z), a draw scale, and a landed flag. Collision suppression is not a physics change: it's just the presence of entity.airborne, which collision handlers guard on.
Z-math is copied verbatim from v1 big_rock_effect: gravity (default 2), v0 = 0.5*gravity*duration, height(τ) = v0*τ − 0.5*gravity*τ² clamped at duration, max_height = v0²/(2*gravity). Scale is the only generalization: scale = 1 + (apex_scale − 1)*(height/max_height) — with apex_scale = 3 that's identically v1's 1 + 2*(h/h_max).
Functions:
airborne_new(opts)— optsduration,gravity(2),apex_scale(3); precomputesv0/max_height; returns the state table. The single behavior for now (up-then-down), shaped to allow others later.airborne_update(a, dt)— advanceselapsed, recomputesheight/scale, setslanded+ a one-framelanded_nowatelapsed ≥ duration.airborne_no_collide(a, b)— collision-guard helper: true when exactly one of the pair is aloft (e.airborne and not e.airborne.landed). Both-grounded or both-airborne → false (both-airborne is deliberately left to game-specific code).
Interface — the collision convention. Every collision handler skips a pair when airborne_no_collide(a, b). In attack-template that's main.lua's three loops (player↔enemy, enemy↔wall, projectile↔enemy). It's inert today — the rock has no collider, nothing else goes airborne — but it establishes the rule, exactly as you described, for future airborne seekers/projectiles.
prop.lua
Approach. prop is a generic breakable polygon entity — the general form; the big rock is one configured instance. A prop has a jagged polygon outline and can shatter() into debris. Two modes: static (sits until shattered externally — a vase) and thrown (given target_x/y + flight_duration, it lerps its own x/y origin→target and attaches an airborne for z+scale; landing → shatter() + an on_land hook). Damage is never in prop — it's the on_land payload the carrier supplies.
I'm going prop-centric, not folding the rock into projectile.lua: the rock shares nothing mechanically with a projectile (no sensor collider, no contact-hit, no straight line — its damage is an AoE area on landing). prop + airborne + on_land composes cleanly; bending projectile to do this would bloat it. The thrown-rock path is an exact behavioral/visual port of v1 big_rock_effect; the split below is purely internal.
prop class (generic):
prop:new(x, y, opts)— generates the 8-12 vert jagged outline (v1 algo); iftarget_x/ygiven → thrown mode (stores origin/target/flight_duration, callsairborne_new); storeson_land;make_entity.prop:update(dt)— thrown: lerp x/y overflight_duration,airborne_update, onairborne.landed_now→self:shatter()thenself.on_land(self).prop:shatter()— v1's wedge-partition algorithm (3-4 wedges, jittered shared center, jagged radial cuts); spawns onedebrisper wedge;entity_kill(self). Generic — debris only, no FX, no damage.prop:draw(layer)— jagged outline scaled byairborne.scale(1 when grounded), spinning; while thrown+aloft also draws the blinking X-marker at the target (v1 blink-rate lerp 0.3→0.04).
debris class (generic shard, rock-styled visuals for now) — v1 rock_fragment verbatim: simplify points to ≤8 for the collider, debris-tagged Box2D dynamic polygon collider, launch impulse + spin, flash + blink-out. update/draw/destroy.
Carrier + damage (the rock-attack layer, polygonal_ prefix marks it distinct from the generic prop/debris):
spawn_polygonal(caster, opts)— the big-rock attack helper; builds a thrown rock-shapedprop, setson_land = polygonal_on_land, applies caster throw juice +fissure_cast_1.polygonal_on_land(prop)— the rock's landing handler: callspolygonal_land_damage, then rocky particles + camera shake + landing sounds (fissure_cast_2+ randomexplosion_flesh).polygonal_land_damage(prop)— the separated damage portion:spawn_areawithshape='polygon'and the prop's own verts → instant polygon AoE. The generic prop/debris never deals damage.
Interfaces: spawn_area (reused, polygon mode), airborne_*, spawn_particle, shake_*, sfx, globals sounds/volumes. New physics tag debris. Globals props = {}, debris_pieces = {}.
main.lua wiring
require airborne + prop; 3 volumes entries + 5 sound loads; register debris tag + debris↔debris/debris↔wall collision; bind R → fire_polygonal; input handler; update + draw loops for props and debris_pieces; F2 collider draw for debris; and the airborne_no_collide guard in the 3 collision loops.
Two unchanged judgment calls
The landing area is visible (a brief jagged flash — the crater; v1 hid it, attack-template's area.lua has no hidden flag). No shockwave (v1's needs a shader system never ported). Flag either if you want it different.
Good to build from this?
Go ahead.
Approved — building now. Quick check on one math helper, then writing the files.
Bash (cd E:/a327ex/attack-template && grep -n "math.distance" anchor/math.lua | head -...)
295: math.distance(0, 0, 3, 4) -> 5
296: math.distance(self.x, self.y, target.x, target.y)
304:function math.distance(x1, y1, x2, y2)
exit:0
math.distance exists. Writing the two new files.
Write (E:/a327ex/attack-template/airborne.lua)
File created successfully at: E:/a327ex/attack-template/airborne.lua (file state is current in your context — no need to Read it back)
Write (E:/a327ex/attack-template/prop.lua)
File created successfully at: E:/a327ex/attack-template/prop.lua (file state is current in your context — no need to Read it back)
Now the main.lua wiring — 10 edits.
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/main.lua)
The file E:/a327ex/attack-template/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Now copying the 5 sound assets and syntax-checking the new files.
Bash (cd E:/a327ex && cp "Invoker/v1/assets/Spell_Earth_02.ogg" "Invoker/v1/assets/Spe...)
Explosion Flesh 1.ogg
Explosion Flesh 3.ogg
Explosion Flesh 4.ogg
Spell_Earth_02.ogg
Spell_Earth_04.ogg
Bash (cd E:/a327ex/attack-template && LUA=E:/a327ex/Anchor/reference/love-compare/tool...)
syntax OK
Files compile. Now syncing the attack-template CLAUDE.md docs.
Bash (cd E:/a327ex/attack-template && grep -nE "^### Carriers|^- **`flicker|^### So...)
97:5. **`anchor/collider.lua`** — has the steering toolkit (`steering_seek`,
149:### Carriers
178:- **`flicker.lua`** — flicker-strike carrier (v1 `flicker_strike`,
187:### Sounds
212:### Physics
214:physics_register_tag('player' | 'wall' | 'enemy' | 'projectile')
308:### Keybindings (current)
422:5. **Element-flavored variants** (wind / fire / earth / etc.) are
428:6. **Wall avoidance steering exists in `collider.lua` but is unused.**
465:4. ✅ **`flicker.lua`** — DONE (session 3). v1 `flicker_strike` port:
469:5. **`polygonal.lua`** — `big_rock_effect` + `rock_fragment` shatter:
473:6. **`railgun.lua`, `psylance.lua`, `construct.lua`** — bespoke
520:├── flicker.lua # carrier — flicker-strike bezier-hop chain (session 3)
521:├── sound_tuning.lua # per-sound DSP tuning table (path → {bits, sr_div})
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
178 - **`flicker.lua`** — flicker-strike carrier (v1 `flicker_strike`,
179 line 4450). An autonomous moving entity: finds the enemy nearest the
180 click position, bezier-hops between enemies leaving a fading dashed
181 trail, strikes each with the full v1 hit FX (fx_energy_122 burst,
182 hit_circle, 4 zag particles, slash sounds, shake, body flash/spring),
183 and ends on a final burst (hit_stop, slash_final, AoE knockback, big
184 FX). `hits` opt = number of enemies struck. Player is uninvolved —
185 caster supplies only spawn pos + click target + edition.
186
187 ### Sounds
188 Loaded as originals (NOT `_lightmed`). Runtime DSP via `sound_tuner`
189 applies bitcrush + SR-reduction per sound (see Runtime DSP section
190 below). Volumes match Invoker's normalized values.
191 ```lua
192 sounds.player_hit = 'Body Fall 2.ogg'
193 sounds.enemy_hit = 'Body Head (Headshot) 1.ogg'
194 sounds.enemy_die_variants = {Bloody punches 3/7/10.ogg}
195 sounds.arrow_hit_wood = 'ARROW_Hit_Wood_Shield_stereo.ogg' -- projectile fire
196 sounds.arrow_shoot = 'RPG3_GenericArrow_Shoot01.ogg' -- projectile fire
197 sounds.dot_tick = 'Click sound 37 + Body Fall 18.ogg' -- persistent-area tick
198 sounds.slash_hit = 'PM_GENERIC_Slash02.ogg' -- flicker per-hit
199 sounds.slash_final = 'PM_GENERIC_Slash04_P2.ogg' -- flicker final hit
200 sounds.sword_slash_variants= {Sword_Slash_1_v1..v4.ogg} -- flicker per-hit (random)
201 ```
202 The arrow stack is the standard "physical projectile shot" — v1
203 layers these two on every arrow-type ability; default audio for
204 `spawn_projectile`'s `fire_sounds` opt. Each carrier brings its own
205 sounds as it's ported (per-carrier, originals not `_lightmed`). The
206 beam carries no sound — v1's beam_cast was ported then removed at the
207 user's request; persistent/instant beams are silent.
... [22 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
240 physics_enable_collision(debris↔debris, debris↔wall) -- shatter shards
241 physics_enable_sensor(projectile↔enemy) -- sensor mask is SEPARATE from collision mask
242 ```
243 - Player has a rotatable box collider (set_fixed_rotation(false) +
244 `set_angle(self.r)` each frame to lock to mouse-aim only — juice
245 rotation `visual_r` stays draw-only).
246 - Seeker has a rotatable box collider (same pattern, locked to its
247 velocity-derived `self.r`).
248 - Wall has a static chain collider (closed polygon outline).
249 - Projectile has a sensor box collider (passes through everything
250 visually; events fire via `sensor_entities_begin('projectile','enemy')`).
251 Walls are caught by the projectile's per-frame forward raycast.
252 - Areas / beams / flickers are NOT physics objects — they query
253 existing colliders by tag at damage time (circle / box / polygon
254 queries; beams also raycast walls per path segment).
255 - `set_angular_velocity(0)` is paired with each `set_angle` to cancel
256 contact-imparted spin — without it, dynamic bodies drift between
257 physics steps.
258
259 ### Hit-stop / time scale
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
336 S (save). Auto-replays the selected sound every 0.5s for live
337 feedback. Press F3 to enter/exit; game pauses while tuning.
338
339 Replaces the v1 `_lightmed` baked-asset pipeline. Originals load
340 clean; tuning dials in lo-fi character per sound. New projects copy
341 this same flow (sound_tuning.lua + sound_tuner.lua + the sfx wrapper).
342
343 ### Keybindings (current)
344 | Key | Action |
345 |-----|--------|
346 | W / A / S / D / arrows | Move player |
347 | Mouse position | Player aim direction |
348 | Mouse 1 (left) | Fire projectile at cursor |
349 | Mouse 2 (right) | Fire area at cursor (shape × jagged × brackets × persistent) |
350 | Mouse 3 (middle) | Fire beam — instant click, or hold to channel (persistent mode) |
351 | F | Fire flicker toward the cursor |
352 | 0 / F1 | Cycle player edition forward / backward |
353 | 9 / 8 | Cycle wall edition forward / backward |
354 | T | Cycle area shape (circle → rect → polygon) |
355 | G | Toggle area `jagged` flag |
356 | B | Toggle area `corner_brackets` flag |
357 | N | Toggle area instant ↔ persistent (DoT) |
358 | O | Toggle beam instant ↔ persistent (channeled) |
359 | J | Toggle beam homing on / off |
360 | 1 / 2 | Beam homing turn-rate down / up |
361 | 3 / 4 | Beam homing proximity down / up |
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
497 path, homing (`build_homing_path`), per-segment wall clip, AABB +
498 perp damage, railgun_flash retract (instant) / per-tick flash lines
499 (persistent). Deferred: returning-arc path, chain extension, sweep,
500 parallel, leading-edge white-flash dash style.
501 4. ✅ **`flicker.lua`** — DONE (session 3). v1 `flicker_strike` port:
502 bezier-hop chain, fading trail, full hit FX + sounds, final burst
503 (hit_stop, AoE knockback). Deferred: the other `dash_clone` modes
504 (`dash_strike`, `asal`) — separate carriers if ever wanted.
505 5. **`polygonal.lua`** — `big_rock_effect` + `rock_fragment` shatter:
506 parabolic-flight rock-throw that shatters into Box2D fragments on
507 landing. NEXT — also wants a generalizable "airborne" system (the
508 parabola/arc-flight abstraction). See `findings.md` section 6.
509 6. **`railgun.lua`, `psylance.lua`, `construct.lua`** — bespoke
510 single-purpose carriers. Last because they're less foundational.
511
512 For each carrier, the workflow is roughly:
513 1. Read the corresponding `findings.md` section.
514 2. Read the original v1 source (line refs in findings.md).
515 3. AskUserQuestion for scope (minimal first cut vs wider first pass;
516 demo trigger key; sound bundling) before writing — mirrors how
517 projectile and area were scoped in sessions 1-2.
518 4. Write the carrier class in attack-template, adapting `object:extend()`
519 / `self:add(collider)` / `self:action(draw)` v1 patterns to Anchor 2's
520 `class()` / plain-field / explicit-method style.
521 5. Drop sound assets (originals, not `_lightmed`) into `assets/`,
522 register them in `main.lua`'s `sounds`/`volumes` tables. Tune via
523 F3 after launch.
524 6. Wire a debug spawn key + (if needed) cycle key into `main.lua`.
525
526 ---
... [36 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
555 ├── stats.lua # procedural stats / buff machinery
556 ├── projectile.lua # carrier — straight-line sensor projectile (session 2)
557 ├── area.lua # carrier — area-of-effect, instant + persistent/DoT (sessions 2-3)
558 ├── beam.lua # carrier — line, instant + persistent, homing (session 3)
559 ├── flicker.lua # carrier — flicker-strike bezier-hop chain (session 3)
560 ├── sound_tuning.lua # per-sound DSP tuning table (path → {bits, sr_div})
561 ├── sound_tuner.lua # F3 tuning UI module (session 2)
562 ├── edition.lua # from edition-template
563 ├── palette.lua # from edition-template
564 ├── gradient.lua # from edition-template
565 ├── geometry.lua # from edition-template
566 ├── symbols.lua # from edition-template
567 ├── assets/
568 │ ├── draw_shader.frag # from edition-template (single unified shader)
569 │ ├── monogram.ttf # tuning UI font (session 2)
570 │ ├── hit1.png # fx_hit_effect spritesheet (5×96×48)
571 │ ├── fx_energy_122.png # fx_effect spritesheet (7×96×96) — flicker hit burst (session 3)
572 │ ├── Body Fall 2.ogg # player_hit
573 │ ├── Body Head (Headshot) 1.ogg # enemy_hit
574 │ ├── Bloody punches 3/7/10.ogg # enemy_die_variants
575 │ ├── ARROW_Hit_Wood_Shield_stereo.ogg # arrow_hit_wood — projectile fire (session 2)
576 │ ├── RPG3_GenericArrow_Shoot01.ogg # arrow_shoot — projectile fire (session 2)
577 │ ├── Click sound 37 + Body Fall 18.ogg # dot_tick — persistent-area tick (session 3)
578 │ ├── PM_GENERIC_Slash02.ogg # slash_hit — flicker per-hit (session 3)
579 │ ├── PM_GENERIC_Slash04_P2.ogg # slash_final — flicker final hit (session 3)
580 │ └── Sword_Slash_1_v1..v4.ogg # sword_slash — flicker per-hit random (session 3)
581 ├── anchor/ # Anchor 2 framework copy
582 │ ├── collider.lua # has +steering_wall_avoidance vs upstream
583 │ └── physics.lua # has +query_entities_polygon vs upstream (session 2)
584 ├── anchor.exe # built from Anchor2/engine/src/anchor.c — includes
... [5 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Done. All files compile, sounds copied, docs synced.
What landed
airborne.lua (new) — a z-arc component attached as entity.airborne. Owns only height + draw scale over v1's exact big-rock parabola; never touches x/y. airborne_no_collide(a, b) is the collision-guard helper.
prop.lua (new) — two layers:
- Generic:
prop(breakable polygon — static or thrown) +debris(one collidered shard;prop:shatter()runs v1's wedge-partition algorithm). Neither deals damage. - Attack:
spawn_polygonal→polygonal_on_land→polygonal_land_damage(the separated damage portion — a polygonarea).
main.lua — 13 edits: requires, sounds/volumes, debris physics tag + collisions, R keybind, input handler, update/draw/F2 loops, and the airborne_no_collide guard added to all 3 collision handlers (inert today, establishes the rule).
Assets — 5 sounds copied (Spell_Earth_02/04.ogg, Explosion Flesh 1/3/4.ogg). CLAUDE.md carrier list, sounds, physics, keybindings, port order (item 5 ✅), and file map updated. Both global and attack-template CLAUDE.md now use the design-brief workflow.
How to test
Run ./run.bat, then press R to throw a rock at the cursor. Check:
- The rock flies player→cursor in an arc, scaling up toward the apex and back down; a colour X-marker blinks at the target, accelerating as it nears.
- On landing: a jagged polygon damage flash, the rock shatters into 3-4 debris shards that fly outward and bounce off walls/each other, rocky particles, camera shake. Enemies inside the polygon take 12 damage.
- Sounds:
Spell_Earth_02on throw,Spell_Earth_04+ a randomExplosion Fleshon landing. - F2 — debris shards show lime collider outlines. F3 — the three new sounds are tunable.
Two things to eyeball specifically, since they're judgment calls I made: the landing damage area is visible (a brief jagged flash — tell me if it reads as clutter vs. crater), and there's no shockwave (v1 had one; needs a shader system attack-template never ported).
Currently the shape of the rock as it hits the ground is also showing and flashing out when it shouldn't, it should just disappear once the debris show up because the debris take over visually.
The flashing shape on the ground is the landing area — that's the judgment call I flagged. Fixing it to match v1 (which spawned that area hidden). Let me check area.lua's exact draw structure.
Read (E:/a327ex/attack-template/area.lua)
150 self.hit_cooldowns = {} -- entity_id → cooldown expire time
151
152 self.visual_radius = 0
153 self.fill_color = color_clone(self.color)
154 self.fill_color.a = 20
155 self.flashing = false
156 self.hidden = false
157 self.arc_r = 0
158 if self.duration then
159 -- Persistent: slow constant base, bumped by tick spikes (v1 line 8530).
160 -- We add an exponential decay back toward this baseline in :update so
161 -- the arc speed doesn't stay at peak forever after a few hits.
162 self.arc_sign = random_choice({-1, 1})
163 self.arc_baseline = self.arc_sign*random_float(0.5, 1)*math.pi
Read (E:/a327ex/attack-template/area.lua)
358 function area:destroy() end
359
360
361 function area:draw(layer)
362 if self.hidden then return end
363 set_edition(layer, self.edition)
364
365 local s = self.spring.main.x
366 -- Persistent non-smooth-circle shapes breathe their scale gently. Smooth
367 -- circle's life is the orbiting arcs, so it skips breathing.
The flashing ground shape is the instant landing area doing its damage-flash. v1 spawned that area hidden — I'll add that flag back. Three small edits:
Edit (E:/a327ex/attack-template/area.lua)
The file E:/a327ex/attack-template/area.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/area.lua)
The file E:/a327ex/attack-template/area.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/prop.lua)
The file E:/a327ex/attack-template/prop.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Now fixing the two doc notes that said the area was visible (a behavior they described, now changed).
Edit (E:/a327ex/attack-template/prop.lua)
The file E:/a327ex/attack-template/prop.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Bash (cd E:/a327ex/attack-template && "E:/a327ex/Anchor/reference/love-compare/tools/l...)
syntax OK
Fixed. The landing area is now spawned hidden — area.lua got an invisible flag (kept distinct from the existing blink-toggle hidden field) that makes area:draw a no-op while the area still runs its full damage lifecycle. polygonal_land_damage passes hidden = true.
So on landing the rock shape vanishes entirely and only the shatter debris (+ particles) are visible — matching v1. Both doc notes that previously said "the area stays visible" are corrected.
Press R again to confirm: the rock should disappear cleanly the instant it lands, with no flashing polygon on the ground.
Perfect. It seems like it's actually good immediately. We're actually done with the generalization, other carriers like constructs and so on will be handled individually on a per-game basis instead of on the generalization portion of it. What we want to do now is do a review of everything in terms of, is this actually easy for a new instance to use? We want to prepare the codebase such that when I ask it to do a new attack, it can easily use the existing pieces easily and with the least amount of code necessary on the new main.lua file. This means the interface for using these systems needs to be clean. First, we should organize all this into a folder, let's call this snkrx/ given that the style is based on SNKRX, and then all non-main.lua files go in there and we require the folder to get this working. We shouldn't try to modularize this and hide the internals from main.lua, however, we should just have a default main.lua that the new instance will have to look at and contend with fully, but as much of the API that can be hidden to create new attacks should, but structural things, like the lists holds various objects and so on, shouldn't, those should be explicit. Does this make sense?
Yes, this makes sense, and I think it's the right move now that the carrier set is closed. Let me play back my understanding and then pin down a few decisions.
What I understand you want
Folder move. Everything that isn't main.lua goes into snkrx/. A snkrx/init.lua aggregator means a new project does require('snkrx') and the whole toolkit is live. The folder is not an opaque module — it's fully readable, a new instance can and should look inside it. The aggregation is about organization and not-having-to-rewrite-plumbing, not encapsulation.
The hide/explicit split. The distinction you're drawing:
- Hidden (= "a new attack author never writes this, it just happens via
require('snkrx')"): module loading,edition_setup+ theset_editionshim + field-scale defaults,sfx,hit_stop, the carriers' own sound loading + spritesheet registration, the sound-tuner wiring. Plumbing. - Explicit (= "visible and owned in main.lua, because it's structural and the dev must understand it"): the carrier object lists (
projectiles,areas,beams,flickers,props,debris_pieces), the layer stack, the camera, physics setup, theupdate/drawskeleton with its entity loops and collision handlers, keybinds, entity spawns.
So update() and draw() stay in main.lua as real, legible code. The default main.lua is a complete reference scene the dev reads top-to-bottom and edits — not a 3-line stub. The win is that adding an attack built from existing carriers becomes just a keybind + a spawn_* call, because every carrier's list and update/draw loop is already wired in the default main.lua.
The actual attack API — spawn_projectile, spawn_area, spawn_beam, spawn_flicker, spawn_polygonal — is already fairly clean. Most of the friction today isn't the API, it's (a) the flat file dump and (b) main.lua being 660 lines half-buried in demo tuning scaffolding (area-shape cycling, the beam-homing tuning playground + HUD) that obscures the actual pattern.
A few decisions before I write the brief:
AskUserQuestion
User has answered your questions: "Does the anchor/ engine framework directory move into snkrx/ too, or stay at the project root?"="Keep anchor/ at root (Recommended)", "The default main.lua currently carries demo tuning scaffolding (area-shape cycle T/G/B/N, beam-homing tuning keys 1-6 + HUD, F2 collider overlay, F3 sound tuner). How much should the clean default main.lua keep?"="Preserve the old file as main_old.lua, write a new one that is a clean scene. The clean scene file should have documentation/comments on how to use the various calls, but not actually use them, except for a moving player. F2/F3 should also remain. The current settings for all sounds should also remain.", "player.lua / seeker.lua / wall.lua are the test entities the carriers fire at/against. Where do they belong?"="Keep those in snkrx/ and instruct instances to either edit them directly and treat them as the actual files for those features, or create new ones at the same level as main.lua instead of using the ones in snkrx/", "main.lua's update/draw must iterate all 6 carrier lists (update+prune, draw). How explicit should that be?"="Fully explicit per-list loops". You can now continue with the user's answers in mind.
All four answers noted. Here's the refactor brief.
Design Brief — snkrx/ reorganization + clean default main.lua
File layout
Root keeps only what the engine and the game dev directly own:
attack-template/
├── main.lua # NEW — clean reference scene (the dev's file)
├── main_old.lua # the current main.lua, preserved untouched
├── anchor/ # engine framework — stays at root
├── anchor.exe
├── run.bat
├── .claude/CLAUDE.md
└── snkrx/ # the SNKRX attack toolkit — require('snkrx') pulls it all in
├── init.lua # NEW — aggregator + cross-cutting setup
├── findings.md
├── edition.lua palette.lua gradient.lua geometry.lua symbols.lua
├── stats.lua fx.lua
├── player.lua seeker.lua wall.lua # example entities
├── projectile.lua area.lua beam.lua flicker.lua airborne.lua prop.lua
├── sound_tuner.lua sound_tuning.lua
└── assets/ # all current assets move here
snkrx/ is flat (no sub-subfolders) — files are require('snkrx.projectile') etc. from init.lua. The 18 .lua files move unchanged except asset-path strings. findings.md moves too (it's reference, not main).
snkrx/init.lua — the aggregator
require('snkrx') resolves to snkrx/init.lua, which:
- Requires every sub-module in dependency order (palette → edition → geometry/symbols/gradient → stats → fx → entities → carriers → sound_tuner).
- Absorbs the plumbing currently hand-written in
main.lua— the stuff a new-attack author should never have to write:edition_setup{ draw_shader = 'snkrx/assets/draw_shader.frag' }default_field_scale/default_dither+ theset_edition(layer, mode)shimsfx(handle, vol, pitch)andhit_stop(duration)- the
sounds/volumestables + every carriersound_load(...)+ thespritesheet_registercalls (hit1,fx_energy_122) - boot RNG seeding (
math.randomseed/random_seed)
Must be required after require('anchor')(config) — it calls engine functions (sound_load, spritesheet_register, edition_setup) that need the engine up. main.lua's ordering guarantees this.
One new function: hit_stop_update(dt) — the per-frame hit-stop countdown, extracted from main.lua's inline block so update() calls one explicit line instead of carrying 6 lines of plumbing. hit_stop(duration) itself is unchanged, just relocated.
physics_init() + tag registration + the collision matrix do not move — they stay explicit in main.lua (structural; the dev must see the full collision setup), with comments marking which tags/pairs the carriers require (projectile, debris).
The new main.lua — clean reference scene
A complete, readable scene the dev reads top-to-bottom. Structural things explicit; plumbing gone. Sections:
require('anchor'){ width, height, title, scale, filter }— window config.require('snkrx')— the whole toolkit live.- Physics —
physics_init(), tag registration, collision matrix. Explicit, commented. - Input binds — movement keys, F2, F3. A documented placeholder showing where to bind attack keys.
- Layers — the 5-layer stack (
floor/wall/game/effects/debug). Explicit. - Camera —
main_camera. Explicit. - Scene content — spawn
p = player(...)only. Seeker/wall spawns are shown as commented examples, not active. update(dt)— explicit:sync_engine_globals,engine_get_typed_text,hit_stop_update(dt),sdt = dt*time_scale,sound_tuner_update(F3),camera_update,p:update, then fully explicit per-list update+prune loops for all 8 lists (seekers,fxs,projectiles,areas,beams,flickers,props,debris_pieces) — present even though most start empty, so adding an attack needs zero loop wiring — then the 3 collision handlers (withairborne_no_collideguards), thenprocess_destroy_queue.draw()— explicit 5-layer render.
Documentation. A comment block in update() (and short notes per structural section) shows how to fire each carrier with a real example + key opts, e.g. spawn_projectile(p, { damage=1, max_range=220, fire_sounds={...} }), pointing to snkrx/<carrier>.lua for the full opts list. F2 (collider overlay) and F3 (sound tuner) stay wired. No attacks are actually fired — the scene is just a moving player plus the documented, ready-to-use skeleton.
Net effect: when you ask for a new attack, the new instance adds a bind(...) + an is_pressed(...) → spawn_*(...) block and (if it needs targets) a seeker spawn — nothing else.
Other items
main_old.lua— the currentmain.luarenamed, preserved verbatim. It won't run post-move (its flatrequires point at the old layout); it's a frozen reference of the full demo (beam-homing playground, area-shape cycling, etc.).- Asset paths — every
assets/...string →snkrx/assets/...(ininit.lua's sound loads,sound_tuner.lua's font, the shader path). Carrier files referencesounds.*globals, not paths, so they're untouched. sound_tuning.lua— unchanged; current per-sound DSP settings preserved as you asked..claude/CLAUDE.md— substantial update: file map, read order, a new "snkrx/ structure + how to add an attack" section, and the player/seeker/wall instruction below. The../findings.mdlink →../snkrx/findings.md.- player/seeker/wall — live in
snkrx/as the default example entities. CLAUDE.md will instruct a new instance: either editsnkrx/player.luaetc. directly (treat them as the real files for those features), or create fresh ones at root level besidemain.luaand require those instead.
Good to build from this?
Go ahead. Make sure to also change global CLAUDE.md.
Approved. Starting the refactor. First, surveying internal require and asset-path usage so I know what to fix.
Grep (require(|assets/)
attack-template\anchor\spritesheet.lua:5: spritesheets.hit = spritesheet_register('hit', 'assets/hit1.png', 96, 48)
attack-template\anchor\image.lua:5: images.player = image_load('player', 'assets/player.png')
attack-template\edition.lua:6: shader (assets/draw_shader.frag). When u_edition is non-zero, every
attack-template\edition.lua:150: uniform writes. Call ONCE at boot, after require('anchor')({...}).
attack-template\edition.lua:153: (default 'assets/draw_shader.frag').
attack-template\edition.lua:161: local path = opts.draw_shader or 'assets/draw_shader.frag'
attack-template\edition.lua:219:-- EDITION NAMING + CYCLE — keep these in sync with assets/draw_shader.frag.
attack-template\edition.lua:417:-- the apply_dither() switch in assets/draw_shader.frag. Keep in lockstep.
attack-template\anchor\layer.lua:28: After require('anchor.layer'), direct engine-style calls like
attack-template\anchor\font.lua:5: fonts.main = font_register('main', 'assets/monogram.ttf', 11)
attack-template\anchor\init.lua:10: require('anchor')({
attack-template\anchor\init.lua:57:require('anchor.class')
attack-template\anchor\init.lua:58:require('anchor.math')
attack-template\anchor\init.lua:59:require('anchor.array')
attack-template\anchor\init.lua:60:require('anchor.color')
attack-template\anchor\init.lua:61:require('anchor.object')
attack-template\anchor\init.lua:62:require('anchor.helpers')
attack-template\anchor\init.lua:63:require('anchor.input')
attack-template\anchor\init.lua:64:require('anchor.timer')
attack-template\anchor\init.lua:65:require('anchor.spring')
attack-template\anchor\init.lua:66:require('anchor.animation')
attack-template\anchor\init.lua:67:require('anchor.font')
attack-template\anchor\init.lua:68:require('anchor.image')
attack-template\anchor\init.lua:69:require('anchor.spritesheet')
attack-template\anchor\init.lua:70:require('anchor.layer')
attack-template\anchor\init.lua:71:require('anchor.shake')
attack-template\anchor\init.lua:72:require('anchor.camera')
attack-template\anchor\init.lua:73:require('anchor.collider')
attack-template\anchor\init.lua:74:require('anchor.physics')
attack-template\anchor\init.lua:75:require('anchor.memory')
... [61 more lines]
No internal cross-requires among the toolkit files — clean. Let me check sound_tuner.lua's save path before moving.
Grep (sound_tuning|.lua|open|write)
2: sound_tuner.lua — F3 tuning overlay for per-sound DSP values.
16: sound_tuning (data table from sound_tuning.lua)
17: sound_list, sound_play_handle, sound_handle_set_dsp, file_write_string
26: s save sound_tuning.lua
60: for path, e in pairs(sound_tuning) do
65: local e = sound_tuning[path]
99: local entry = sound_tuning[cur.path]
112: sound_tuning[cur.path] = entry
129: local ok = file_write_string('sound_tuning.lua', serialize())
130: print(ok and 'saved sound_tuning.lua' or 'FAILED to save sound_tuning.lua')
141: local entry = sound_tuning[cur.path]
159: layer_text(tune_layer, 's: save sound_tuning.lua', fonts.tune, x, y, text())
Bash (cd E:/a327ex/attack-template && mkdir -p snkrx && mv airborne.lua area.lua beam....)
--- root ---
anchor/
anchor.exe*
main_old.lua
run.bat
snkrx/
--- snkrx ---
airborne.lua
area.lua
assets/
beam.lua
edition.lua
findings.md
flicker.lua
fx.lua
geometry.lua
gradient.lua
palette.lua
player.lua
projectile.lua
prop.lua
seeker.lua
sound_tuner.lua
sound_tuning.lua
stats.lua
symbols.lua
wall.lua
Now fixing asset paths in the moved files.
Read (E:/a327ex/attack-template/snkrx/sound_tuner.lua)
38
39 if not fonts then fonts = {} end
40 fonts.tune = font_register('monogram', 'assets/monogram.ttf', 12)
41
Read (E:/a327ex/attack-template/snkrx/sound_tuner.lua)
127
128 if is_pressed('tune_save') then
129 local ok = file_write_string('sound_tuning.lua', serialize())
130 print(ok and 'saved sound_tuning.lua' or 'FAILED to save sound_tuning.lua')
131 end
Edit (E:/a327ex/attack-template/snkrx/sound_tuner.lua)
The file E:/a327ex/attack-template/snkrx/sound_tuner.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/sound_tuner.lua)
The file E:/a327ex/attack-template/snkrx/sound_tuner.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/sound_tuning.lua)
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
Read (E:/a327ex/attack-template/snkrx/sound_tuning.lua)
1 --[[
2 Per-sound DSP tuning. Auto-generated by F3 tuning UI; safe to edit by hand.
3 Keys are file paths; entries are {bits, sr_div}. Default bits=16, sr_div=1.
4 ]]
5 return {
6 ["assets/ARROW_Hit_Wood_Shield_stereo.ogg"] = {bits = 8, sr_div = 4},
7 ["assets/Bloody punches 10.ogg"] = {bits = 8, sr_div = 3},
8 ["assets/Bloody punches 3.ogg"] = {bits = 8, sr_div = 3},
9 ["assets/Bloody punches 7.ogg"] = {bits = 8, sr_div = 3},
10 ["assets/Body Fall 2.ogg"] = {bits = 8, sr_div = 4},
11 ["assets/Body Head (Headshot) 1.ogg"] = {bits = 6, sr_div = 4},
12 ["assets/Click sound 37 + Body Fall 18.ogg"] = {bits = 8, sr_div = 3},
13 ["assets/Explosion Flesh 1.ogg"] = {bits = 8, sr_div = 4},
14 ["assets/Explosion Flesh 3.ogg"] = {bits = 8, sr_div = 4},
15 ["assets/Explosion Flesh 4.ogg"] = {bits = 8, sr_div = 4},
16 ["assets/PM_GENERIC_Slash02.ogg"] = {bits = 8, sr_div = 3},
17 ["assets/PM_GENERIC_Slash04_P2.ogg"] = {bits = 8, sr_div = 3},
18 ["assets/RPG3_GenericArrow_Shoot01.ogg"] = {bits = 8, sr_div = 3},
19 ["assets/Spell_Earth_02.ogg"] = {bits = 6, sr_div = 3},
20 ["assets/Spell_Earth_04.ogg"] = {bits = 6, sr_div = 2},
21 ["assets/Sword_Slash_1_v1.ogg"] = {bits = 8, sr_div = 2},
22 ["assets/Sword_Slash_1_v2.ogg"] = {bits = 7, sr_div = 2},
23 ["assets/Sword_Slash_1_v3.ogg"] = {bits = 6, sr_div = 3},
24 ["assets/Sword_Slash_1_v4.ogg"] = {bits = 8, sr_div = 3},
25 }
26
Read (E:/a327ex/attack-template/snkrx/edition.lua)
158 ]]
159 function edition_setup(opts)
160 opts = opts or {}
161 local path = opts.draw_shader or 'assets/draw_shader.frag'
162 set_draw_shader(path)
163 edition_draw_shader = get_draw_shader()
Edit (E:/a327ex/attack-template/snkrx/sound_tuning.lua)
The file E:/a327ex/attack-template/snkrx/sound_tuning.lua has been updated. All occurrences were successfully replaced. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/edition.lua)
The file E:/a327ex/attack-template/snkrx/edition.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Both files moved and repathed. Now writing snkrx/init.lua and the new main.lua.
Write (E:/a327ex/attack-template/snkrx/init.lua)
File created successfully at: E:/a327ex/attack-template/snkrx/init.lua (file state is current in your context — no need to Read it back)
Write (E:/a327ex/attack-template/main.lua)
File created successfully at: E:/a327ex/attack-template/main.lua (file state is current in your context — no need to Read it back)
Bash (cd E:/a327ex/attack-template && LUA="E:/a327ex/Anchor/reference/love-compare/too...)
ok: main.lua
ok: snkrx/init.lua
ok: snkrx/sound_tuner.lua
ok: snkrx/sound_tuning.lua
ok: snkrx/edition.lua
ok: snkrx/prop.lua
ok: snkrx/area.lua
ok: snkrx/airborne.lua
All syntax-clean. Now updating the docs. Let me read the current CLAUDE.md sections that need changes.
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
56 line-editing.
57
58 ---
59
60 ## What this is
61
62 A starter Anchor 2 project for building **attacks** (projectiles, areas,
63 beams, polygonal projectiles, movement abilities, etc.) in the SNKRX /
64 Orblike / Invoker visual style. This is a sibling of `edition-template/`
65 — it bundles the same edition + gradient + palette + draw-shader stack,
66 plus the entities needed to test attacks against (player, seeker enemies,
67 wall obstacles), all glued together as a single self-contained directory
68 that a new project can fork.
69
70 The eventual goal is the same shape as edition-template: copy this
71 directory, write `main.lua`, and start prototyping. By the end of the
72 work it should ship every attack carrier from Orblike v1 as a focused
73 reusable module (projectile, area, beam, dash, polygonal, railgun,
74 psylance, construct, etc.), each with the SNKRX-style juice (springs,
75 hit flashes, edition-tinted particles) and shape-correct hitboxes.
76
77 **Read [findings.md](../findings.md) first.** It catalogs every attack
78 behavior in Orblike v1 with file/line references and is the canonical
79 inventory of what we plan to port.
80
81 ---
82
83 ## Read order
84
85 1. **[findings.md](../findings.md)** — the v1 attack inventory: every
... [60 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
288 - Projectile has a sensor box collider (passes through everything
289 visually; events fire via `sensor_entities_begin('projectile','enemy')`).
290 Walls are caught by the projectile's per-frame forward raycast.
291 - Areas / beams / flickers / props are NOT physics objects — they
292 query existing colliders by tag at damage time (circle / box /
293 polygon queries; beams also raycast walls per path segment). The
294 thrown rock prop has no collider; only the `debris` shards do.
295 - Debris has a dynamic polygon collider (one per shatter shard;
296 bounces off other debris + walls, passes through entities).
297 - `set_angular_velocity(0)` is paired with each `set_angle` to cancel
298 contact-imparted spin — without it, dynamic bodies drift between
299 physics steps.
300 - **Airborne collision rule:** an entity carrying a live
301 `self.airborne` is "in the air." Every collision handler in main.lua
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
309 `hit_stop(duration)` (in `main.lua`) zeroes the engine time scale for
310 `duration` seconds. The countdown in `update()` runs on **unscaled**
311 dt; every game entity is updated with **scaled** dt (`sdt = dt *
312 time_scale`), so a time_scale of 0 freezes the world while input + the
313 sound tuner keep running. The flicker's final burst calls it (0.08s).
314 Any carrier can use it.
315
316 ### Collisions handled (in main.lua's `update`)
317 - **Player ↔ enemy contact**: push the seeker away (force 6, default
318 0.5s), forward `pl:hit(damage, x, y, enemy)` so the player flashes,
319 HP drops, and the contact-point spawns edition-mixed particles.
320 - **Enemy ↔ wall contact**: `e:wall_bump(r)` — see below.
321 - **Projectile ↔ enemy sensor**: forward `pr:on_hit(e)` so the
322 projectile damages the enemy + spawns hit FX + dies. Uses
323 `sensor_entities_begin` (NOT `collision_entities_begin`) because
324 projectile fixtures are sensors.
325
326 ### `seeker:wall_bump` — streak-based wall response
327 Each contact within `WALL_BUMP_WINDOW` (0.5s) of the previous one
328 increments `self.wall_hit_count`; both push force and duration scale
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
344 `set_edition` for its own silhouette; particle bursts use
345 `edition_base_color(edition) or fallback_color` for tinting.
346 - **Particles inherit editions**:
347 - **Seeker spawn / die particles** target `edition_base_color(self.edition) or red` — a ruby seeker spawns + dies in red-ish particles, an emerald one in green-ish.
348 - **Player-hit particles** (`player:hit` with a `source` argument) target `color_mix(edition_base_color(player.edition), edition_base_color(source.edition), 0.5)` — a warm/cool mix. Edition `nil`/`0`/`23`/`24` fall back gracefully via `or text` chain.
349 - **White-flash fractions**: death particles flash at `0.6` of duration (long, dramatic), hit particles at `0.3` (briefer but visible). Clear "small hit vs. fatal hit" hierarchy.
350
351 ### Debug
352 - **F2** toggles a collider debug overlay. Iterates `p` / `seekers[]` /
353 `walls[]` / `projectiles[]`, calls `collider:get_shapes_geometry()`,
354 draws each shape outline on a dedicated `debug_layer` (no edition
355 shader). Solid shapes in lime; sensor shapes (projectiles) in yellow.
356 - **F3** enters the per-sound DSP tuning overlay (see Runtime DSP below).
357 - **H** deals 2 damage to a random live seeker (reveals HP bar +
358 triggers white-flash + `enemy_hit` sfx).
359 - **K** kills a random live seeker (full death FX + variant
360 death sound).
361
362 ### Runtime DSP + sound tuning
363 The engine (`Anchor2/engine/src/anchor.c`) has a per-voice DSP wrapper
364 that applies bitcrush + sample-rate reduction inline. Two engine
365 globals matter: `sound_handle_set_dsp(handle, bits, sr_div)` retunes
366 a playing voice live; `sound_get_path(sound)` returns the asset path
367 used as the tuning key.
368
369 - **`sound_tuning.lua`** is the project-owned table mapping file
370 paths to `{bits, sr_div}`. Sounds without an entry play clean
371 (bits=16, sr_div=1).
372 - **`sfx()` in main.lua** consults the table on every play.
373 - **`sound_tuner.lua`** is a self-contained F3 overlay module: T
... [2 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
376 - **`sound_tuner.lua`** is a self-contained F3 overlay module: T
377 conceptually unrelated (used for area shape cycle), the tuner uses
378 Up/Down (switch sound), [/] (bits), -/= (sr_div), Space (replay),
379 S (save). Auto-replays the selected sound every 0.5s for live
380 feedback. Press F3 to enter/exit; game pauses while tuning.
381
382 Replaces the v1 `_lightmed` baked-asset pipeline. Originals load
383 clean; tuning dials in lo-fi character per sound. New projects copy
384 this same flow (sound_tuning.lua + sound_tuner.lua + the sfx wrapper).
385
386 ### Keybindings (current)
387 | Key | Action |
388 |-----|--------|
389 | W / A / S / D / arrows | Move player |
390 | Mouse position | Player aim direction |
391 | Mouse 1 (left) | Fire projectile at cursor |
392 | Mouse 2 (right) | Fire area at cursor (shape × jagged × brackets × persistent) |
393 | Mouse 3 (middle) | Fire beam — instant click, or hold to channel (persistent mode) |
394 | F | Fire flicker toward the cursor |
395 | R | Throw a polygonal rock at the cursor (parabola → shatter) |
396 | 0 / F1 | Cycle player edition forward / backward |
397 | 9 / 8 | Cycle wall edition forward / backward |
398 | T | Cycle area shape (circle → rect → polygon) |
399 | G | Toggle area `jagged` flag |
400 | B | Toggle area `corner_brackets` flag |
401 | N | Toggle area instant ↔ persistent (DoT) |
402 | O | Toggle beam instant ↔ persistent (channeled) |
403 | J | Toggle beam homing on / off |
404 | 1 / 2 | Beam homing turn-rate down / up |
405 | 3 / 4 | Beam homing proximity down / up |
... [18 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
395 `main_old.lua` keeps the full pre-refactor demo keymap (mouse 1/2/3 +
396 F + R for the five carriers, plus the area-shape / beam-homing tuning
397 keys and H/K debug-damage) — frozen reference, not runnable as-is.
398
399 ---
400
401 ## Critical conventions (read these or it'll bite you)
402
403 ### Edition shader integration
404 Every entity that draws has a `.edition` field and brackets its draws
405 with `set_edition(layer, self.edition)` / `set_edition(layer, 0)`. The
406 shim in main.lua forwards to `edition_set(layer, mode, default_field_scale, default_dither)`.
407
408 **Particles / FX get edition through their BASE COLOR, not through the shader bracket** — they're drawn as part of the `fxs` loop without per-entity edition state. To make a particle reflect an entity's edition, use `edition_base_color(ed) or fallback_color` as the late-stage color in `:change_color`.
409
410 ### Camera mouse coords
411 `main_camera.mouse.x, .y` are **only refreshed by `camera_update(c, dt)`**. You must call `camera_update(main_camera, dt)` in your update loop before any code reads those fields — the player's mouse-aim depends on it. We had a bug here.
412
413 ### Rotatable collider bodies
414 `fixed_rotation = false` lets the collider geometry rotate with the entity's `self.r`, but you MUST call BOTH:
415 ```lua
416 self.collider:set_angle(self.r)
417 self.collider:set_angular_velocity(0)
418 ```
419 each frame. The zero-out cancels contact-imparted spin that Box2D otherwise preserves between physics steps. Skip it and you get jitter at high contact density.
420
421 ### Player rotation: r vs visual_r
422 - `self.r` — the "physical" facing, locked to mouse-aim. Used for the collider angle.
423 - `self.visual_r` — juice rotation from left/right key presses. **Draw-only**, never feeds into physics. Draw uses `self.r + self.visual_r`.
424
... [32 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
456 - Cross-entity references use IDs (`self.target_id = other.id` → `entities[self.target_id]`), not direct pointers.
457 - `entity:kill()` queues, `process_destroy_queue()` finalizes at frame end.
458
459 ---
460
461 ## Foundational design decisions
462
463 These resolve the "open questions" from `findings.md` section 13.
464 **Read these before adding new code.**
465
466 1. **Visual generalization only — NO modifier system.** Carriers should
467 NOT have a `mods` system, no `pierce+1` modifier vocabulary, no
468 `homing` / `cascade` / `area+` knobs threading through every carrier.
469 That's a gameplay system for the host game to add on top. The
470 template's job is to ship the *visuals* of each carrier (movement,
471 shape, hit/wall/death effects, edition tinting, sound) generically.
472
473 2. **Light logic generalization is OK** — projectiles all share
474 movement + collider + max-range + wall-raycast; areas all share
475 tween + damage pass + blink-out; beams all share path-walk + dashed
476 draw + tip circle. Port that boilerplate into each carrier class
477 with all knobs as plain fields on `self` the host can override. No
478 event bus, no plugin system. Just procedural classes with overridable
479 methods.
480
481 3. **Sounds added per-carrier as we port.** v1's full bank is ~30 MB.
482 Each new carrier brings its own sound asset(s) into `assets/` and
483 adds `sounds.X` + `volumes.X` entries. Load **originals**, not
484 `_lightmed` — runtime DSP via `sound_tuner` (F3) applies bitcrush
485 + SR-reduce per sound, configured in `sound_tuning.lua`.
... [80 more lines]
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
525 breathing.
526 3. ✅ **`beam.lua`** — instant + persistent. Polyline path, homing
527 (`build_homing_path`), per-segment wall clip, AABB + perp damage,
528 railgun_flash retract / per-tick flash lines.
529 4. ✅ **`flicker.lua`** — v1 `flicker_strike`: bezier-hop chain, fading
530 trail, full hit FX + sounds, final burst (hit_stop, AoE knockback).
531 5. ✅ **`prop.lua`** — v1 `big_rock_effect`. A breakable `prop` (static
532 or thrown) + `debris` shards; the rock-attack damage is layered on
533 as separate `polygonal_*` functions. The arc is the shared
534 `airborne.lua` component (z + draw scale only).
535
536 Deferred / per-game: a static `prop` instance (vase/box) — the generic
537 shatter + `debris` pieces are ready for it; airborne behaviors beyond
538 throw-up-and-down; element-flavored carrier variants (decision 5 below).
539
540 ---
541
542 ## Common gotchas
543
544 - **`triangulate` returns 6-number arrays in attack-template** (`{t1[1..6], t2[1..6], ...}`), NOT the v1 index-triple format (`{tri[1], tri[2], tri[3]}`). Adjust draw loops when porting v1 entity code that uses the v1 format.
545 - **`physics_raycast(x1, y1, x2, y2, tags)`** takes a tags TABLE (list of tag strings). Single-element list is `{'wall'}`, not `'wall'`.
546 - **`set_edition(layer, 0)` after every bracketed draw** — the shader uniform persists across subsequent draws on the same layer if you don't reset it.
547 - **Sensor fixtures need `physics_enable_sensor`, not `physics_enable_collision`.** The engine has separate masks for blocking collisions vs sensor events. Projectile (sensor) ↔ enemy uses the sensor enable; player/seeker (solid) ↔ wall uses collision enable. Cost us a debug cycle when projectiles wouldn't fire enemy events.
548 - **Mouse buttons follow LÖVE2D convention** — `mouse:2` = right, `mouse:3` = middle. The engine swaps SDL's native 2↔3 at the event boundary (`sdl_to_anchor_mouse_button` in anchor.c). All downstream code sees the Anchor convention. No mental remap needed.
549 - **`engine_get_typed_text()` must be drained** each frame even if nothing consumes typed input — otherwise SDL's text-input queue fills up after ~32 keypresses and warns per-press forever. main.lua calls it at the top of `update()`.
550 - **Sounds load as originals** (NOT `_lightmed`). Bitcrush + SR-reduce applied at runtime via `sound_tuner` (F3) per the `sound_tuning.lua` table.
551 - **Don't run `./run.bat` yourself** — the user runs and tests. After framework changes, `lua54.exe -e "loadfile('main.lua')"` is the syntax-check (path: `E:/a327ex/Anchor/reference/love-compare/tools/lua54.exe`).
552
553 ---
554
... [45 more lines]
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
599 │ └── physics.lua # has +query_entities_polygon vs upstream (session 2)
600 ├── anchor.exe # built from Anchor2/engine/src/anchor.c — includes
601 │ # DSP wrapper + mouse-button swap (session 2)
602 ├── run.bat
603 └── .claude/CLAUDE.md
604 ```
605
606 ---
607
608 ## Working style
609
610 Same conventions as Anchor 2 / edition-template / Invoker:
611
612 - **⛔ DESIGN BRIEF before code.** See the workflow rule at the top of
613 this file. No `Write` / `Edit` on project code without first posting
614 a conceptual design brief (approach + interfaces + per-function
615 notes), iterating it with the user, and reaching a conclusion.
616 Trivial changes skip the brief.
617 - **One change at a time.** The user tests between each step.
618 - **Don't run `./run.bat`** — user runs.
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Now the global CLAUDE.md — adding attack-template to the Projects list.
Read (E:/a327ex/.claude/CLAUDE.md)
119 ### Building and Running
120
121 **Game projects (emoji-ball-battles, Invoker, etc.):** Don't build or run after making code changes — the user will compile and test themselves.
122
123 **Engine/framework (Anchor2/):** Build after making code changes to verify compilation. **Never run the executable** — the user will run and test themselves. Don't execute `run-web.bat`, `run.bat`, or similar. (`Anchor/` is the v1 directory, kept for historical reference — active development is on Anchor 2 in `Anchor2/`.)
124
125 ---
126
127 ## Projects
128
129 - **Anchor2/** — C/Lua game engine (active; Anchor 2 framework rewrite). `Anchor/` is the v1 predecessor, kept for historical reference. The plan is to eventually move `Anchor2/` contents into `Anchor/`.
130 - **emoji-ball-battles/** — Game built with Anchor (still on v1 framework).
131 - **Invoker/** — Chord-cast spell roguelike (Invoker/Magicka-inspired) with a Minesweeper meta-dungeon, SNKRX visual style. Built on Anchor 2. Working name — was "Orblike" before the redesign.
132 - **anchor.blot.im/** — Blog/documentation site
133
134 ---
Edit (E:/a327ex/.claude/CLAUDE.md)
The file E:/a327ex/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Let me do a final syntax sweep across the whole toolkit.
Bash (cd E:/a327ex/attack-template && LUA="E:/a327ex/Anchor/reference/love-compare/too...)
all 19 snkrx files + main.lua: syntax OK
Refactor complete — all 19 snkrx/ files + main.lua pass syntax.
What changed
File layout — root is now just the dev-facing + engine files:
attack-template/
├── main.lua ← NEW clean reference scene
├── main_old.lua ← old demo, frozen (not runnable as-is)
├── anchor/ anchor.exe run.bat .claude/
└── snkrx/ ← the toolkit (require('snkrx') loads it)
├── init.lua ← NEW aggregator + all plumbing
├── findings.md, the 6 carriers, fx, 3 entities, edition stack, …
└── assets/
snkrx/init.lua — require('snkrx') loads every toolkit file and absorbs the plumbing a new attack never writes: edition_setup, the set_edition shim + defaults, sfx + sounds + volumes + all sound/spritesheet loading, hit_stop (+ new hit_stop_update(dt) extracted from main.lua's inline countdown).
New main.lua — a clean reference scene: require('anchor') → require('snkrx') → explicit PHYSICS / INPUT / LAYERS / camera / scene sections, then update/draw with all 8 carrier-list loops fully explicit (per your Q4 choice) and the 3 collision handlers. It spawns only a moving player; an ATTACKS comment block documents every spawn_* call with examples but fires nothing. F2/F3 kept.
Asset paths — all assets/ → snkrx/assets/ (sound loads, the sound_tuning.lua keys, the tuner font, the shader). DSP settings in sound_tuning.lua preserved (only the path keys repathed).
Docs — attack-template .claude/CLAUDE.md reworked (layout, read order, a new "snkrx/ — structure & adding an attack" section, file map, keybindings, carrier set marked closed). Global .claude/CLAUDE.md gained an attack-template/ entry in the Projects list.
How to test
Run ./run.bat. Expect: a window with a player you move (WASD/arrows) that aims at the mouse, no attacks. F2 toggles the collider overlay (player box in lime); F3 opens the sound tuner. The real validation is that it launches at all — if require('snkrx') or any snkrx/assets/ path were wrong it'd error on boot.
One honest caveat: I syntax-checked everything but can't run it, so require resolution (require('snkrx') → snkrx/init.lua, require('snkrx.projectile') → snkrx/projectile.lua) is unverified until you launch. It follows standard Lua path rules that the engine already uses, so I expect it to resolve — but that's the most likely first-run failure point. To sanity-check an attack still works, drop a bind('fire','mouse:1') + spawn_projectile(p,{max_range=220}) into the documented spots.
Ah, assets should be at the same level as main.lua...
Right — assets/ belongs at root, that's the engine convention. Moving it back and reverting the paths.
Bash (cd E:/a327ex/attack-template && mv snkrx/assets assets && echo "--- root ---" &&...)
--- root ---
anchor/
anchor.exe*
assets/
main.lua
main_old.lua
run.bat
snkrx/
--- assets ---
ARROW_Hit_Wood_Shield_stereo.ogg
Bloody punches 10.ogg
Bloody punches 3.ogg
Bloody punches 7.ogg
Body Fall 2.ogg
Edit (E:/a327ex/attack-template/snkrx/init.lua)
The file E:/a327ex/attack-template/snkrx/init.lua has been updated. All occurrences were successfully replaced. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/sound_tuning.lua)
The file E:/a327ex/attack-template/snkrx/sound_tuning.lua has been updated. All occurrences were successfully replaced. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/sound_tuner.lua)
The file E:/a327ex/attack-template/snkrx/sound_tuner.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/snkrx/edition.lua)
The file E:/a327ex/attack-template/snkrx/edition.lua has been updated successfully. (file state is current in your context — no need to Read it back)
Grep (snkrx/assets|assets/draw_shader)
108: `assets/draw_shader.frag`) — same files as edition-template.
146:- `snkrx/assets/draw_shader.frag` replaces the engine's default
Read (E:/a327ex/attack-template/.claude/CLAUDE.md)
104 `_wander`, `_separate`, `_align`, `_cohesion`, `_pursuit`, `_evade`,
105 `_arrive`, `_follow_path`, `_flow_field`, `_wall_avoidance`).
106 7. Anything else under `snkrx/` (`edition.lua`, `palette.lua`,
107 `gradient.lua`, `geometry.lua`, `symbols.lua`,
108 `assets/draw_shader.frag`) — same files as edition-template.
109
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Edit (E:/a327ex/attack-template/.claude/CLAUDE.md)
The file E:/a327ex/attack-template/.claude/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
Bash (cd E:/a327ex/attack-template && grep -rn "snkrx/assets" . --include=*.lua --incl...)
exit:1
Bash (cd E:/a327ex/attack-template && LUA="E:/a327ex/Anchor/reference/love-compare/too...)
ok: snkrx/init.lua
ok: snkrx/sound_tuning.lua
ok: snkrx/sound_tuner.lua
ok: snkrx/edition.lua
Done. assets/ is back at the project root (engine convention), and every path reverted:
snkrx/init.lua— sound loads + spritesheet + shader paths →assets/...snkrx/sound_tuning.lua— DSP table keys →assets/...(so they match whatsound_loadregisters)snkrx/sound_tuner.lua— font →assets/monogram.ttfsnkrx/edition.lua— default shader path →assets/draw_shader.frag.claude/CLAUDE.md— file map now showsassets/at root level, read-order and "what's wired" references corrected
Final layout: main.lua, main_old.lua, anchor/, anchor.exe, assets/, run.bat, .claude/, snkrx/ at root — with snkrx/ holding only the toolkit .lua files. No snkrx/assets references remain anywhere; all touched files pass syntax.
Perfect, you may end this session and call it Attack System Generalization 4
Skill
Launching skill: end-session
Base directory for this skill: E:\a327ex.claude\skills\end-session
End Session Workflow
This skill is invoked from the same conversation where the work was done. You already have the full session context in memory — you do NOT need to read the transcript.
Step 1: Get Session Info
Ask the user for the session title (max 30 characters). Examples: "Anchor Phase 10 Part 5", "Physics Arena Setup", "Timer System Fix", "Thalien Lune Design".
Determine the project yourself from your session context — you know which repo(s) were worked on, which files were created/modified, and where they live. No need to ask. See Step 5 for the list of known project roots; if the session touched something outside the list, infer the root from the paths you actually edited.
Step 2: Write Summary
Write the summary from your conversation memory. You have the full session context — no need to read any files.
The summary should be thorough and detailed. Each major topic deserves its own section with multiple specific bullet points. Don't compress — expand.
Purpose: These summaries serve as searchable records. Future Claude instances will grep through past logs to find how specific topics were handled. The more detail you include, the more useful the summary becomes for finding relevant context later.
Format (this is just an example structure — adapt sections to match what actually happened):
# [Title] ## Summary [1-2 sentence overview of the session's main focus] **[Topic 1 - e.g., "Spring Module Implementation"]:** - First specific detail about what was done - Second detail - include file names, function names - User correction or feedback (quote if notable) - Technical decisions and why **[Topic 2 - e.g., "Camera Research"]:** - What was researched - Key findings - How it influenced implementation **[Topic 3 - e.g., "Errors and Fixes"]:** - Specific error message encountered - Root cause identified - How it was fixed [Continue for each major topic...] --- [Rest of transcript follows]Rules: - Be thorough — If in doubt, include more detail, not less. Each topic should be as detailed as possible while still being a summary. - Think searchability — Future instances will search these logs. Include keywords, function names, error messages that someone might grep for. - One section per major topic — Don't combine unrelated work into one section - Chronological order — Sections should match conversation flow - Specific details — Error messages, file names, function names, parameter values - Include user quotes — When user gave notable feedback, quote it (e.g., "k/d variables are not intuitive at all") - Weight planning equally — Research, proposals, alternatives considered, user feedback on approach are as important as implementation - Weight problems solved — Errors, root causes, fixes, user corrections all matter - Technical specifics — Include formulas, API signatures, parameter changes when relevant
Step 3: Proceed Without Approval
Do NOT show the summary to the user for approval. Write it directly. The user can review the committed log after the fact and request a follow-up edit if anything is off.
Step 4: Convert Transcript and Write the Log File
# Find recent sessions (Claude + Cursor + Codex). Same script lives in Anchor2: python E:/a327ex/Anchor2/scripts/find-recent-session.py --limit 5 # or: python E:/a327ex/Anchor/scripts/find-recent-session.py --limit 5The script shows sessions sorted by when they ended. The first result is the current conversation (since end-session was invoked here). Use it.
Use a lowercase hyphenated slug derived from the title (e.g., "anchor-primitives-hitstop-animation").
Get the end timestamp for the Date frontmatter — this is the wall-clock time when end-session was invoked, NOT the time the JSONL started. Sessions often span multiple days, and the log should be filed under the day the work was wrapped up:
date "+%Y-%m-%d %H:%M:%S"Use this output verbatim. Do not substitute the JSONL start timestamp; the log appears in the sidebar sorted by Date, and a multi-day session with a Date pinned to day 1 will sort below sessions that ended later but started later, hiding the most recent work.
Convert the transcript to markdown:
python E:/a327ex/Anchor2/scripts/jsonl-to-markdown.py [SESSION_PATH] /tmp/session-log.md # or: python E:/a327ex/Anchor/scripts/jsonl-to-markdown.py ...The same script auto-detects Claude Code JSONL vs Cursor/Composer agent JSONL (
~/.cursor/projects/.../agent-transcripts/...) vs Codex rollouts (~/.codex/sessions/...). For Composer sessions, usefind-recent-session.py(it merges all sources) and pick the[cursor]line for the current chat.Replace the default header (
# Session YYYY-MM-DD...) at the top of/tmp/session-log.mdwith the approved title and summary, AND prepend frontmatter. The final file shape:Title: [Title] Date: YYYY-MM-DD HH:MM:SS # [Title] ## Summary [approved summary text from step 2] --- [transcript content from jsonl-to-markdown script]Frontmatter is non-negotiable. Every log file MUST start with
Title:andDate:lines. Without them, the site's sidebar shows the slug as the title and 0 (epoch) as the sort date. The backfill script ina327ex-site/deploy/backfill_metadata.pyis a safety net, not a substitute — write it correctly the first time.Then copy the final file to the log destination:
cp /tmp/session-log.md E:/a327ex/a327ex-site/logs/[slug].mdStep 5: Commit Project Repo
Identify the project repo(s) worked on this session from your own context — you already know which repos were touched and which files changed. For the common projects:
Project Root Stage command Anchor E:/a327ex/Anchorgit add docs/ framework/ engine/ scripts/ reference/Anchor2 E:/a327ex/Anchor2git add framework/ engine/ arena/ reference/ scripts/ docs/ .claude/emoji-ball-battles E:/a327ex/emoji-ball-battlesgit add -Ainvoker E:/a327ex/Invokergit add -Athalien-lune E:/a327ex/thalien-lunegit add -Aa327ex-site E:/a327ex/a327ex-sitegit add -A(this IS one of the web repos — the log file added in Step 4 is staged here automatically)For a project not listed, infer the root from the files you actually created or modified this session and stage those. If multiple candidate roots look valid, ask the user which files to stage.
cdinto the project root, stage, rungit statusto verify before committing.IMPORTANT — FULL SUMMARY IN COMMIT: The commit message MUST include the FULL summary from the log file. Read the summary back from the log file to ensure nothing is missing.
IMPORTANT — COMMIT METHOD: The summary contains backticks, special characters, and markdown that WILL break heredocs and
git commit -m. ALWAYS use the file-based method below. NEVER try a heredoc first — it will fail and produce a malformed commit that needs amending.# Skip until we hit the line "## Summary", then take everything after the next # blank line until the --- separator that precedes the transcript. awk '/^## Summary$/{found=1; next} found && NR>1 && /^---$/{exit} found' \ E:/a327ex/a327ex-site/logs/[slug].md > /tmp/commit_msg.txt # Prepend the title (plain text, no #) and append attribution sed -i "1i [Title]\n" /tmp/commit_msg.txt printf "\nGenerated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n" >> /tmp/commit_msg.txt git commit -F /tmp/commit_msg.txtStep 6: Push the Repos
Two pushes — project (to GitHub) and a327ex-site (to the VPS):
# Project repo to GitHub. Skip this push if the project IS a327ex-site # (handled by the second push below — don't duplicate). git push origin main # a327ex-site to the VPS (post-receive hook restarts the Lua server). cd E:/a327ex/a327ex-site git add -A git commit -m "[Title]" git push prod main 2>&1 | tail -3Failure handling: if either push fails, the other still happens. Local commits stay intact, so the user can re-push manually once they've fixed whatever blocked it. Don't roll back; the committed state on disk is the source of truth.
Step 7: Confirm
Tell the user: - Project commit hash + GitHub push result - a327ex-site commit hash + VPS push result
Format:
Logged — "[Title]". project (Anchor2): abc1234 pushed to GitHub a327ex-site: ghi9012 pushed to a327ex.com (post-receive deployed)If a push failed, surface that explicitly with the error.
Notes
- Frontmatter is non-negotiable. Every log file MUST have
Title:andDate:at the top. Enforced at write time in step 4. The backfill script is a safety net, not a substitute.- If the project IS
a327ex-site(e.g., you spent the session writing the renderer or fixing the theme), there's no separate "project repo to GitHub" push — the a327ex-site push at the end IS the project push. Don't duplicate.- Legacy note: this skill used to also dual-write to
anchor.blot.im/logs/andanchor.blot.im/context/. Post-cutover (May 2026),anchor.blot.imis a JS redirect toa327ex.comand no longer receives content. Drop those steps if you find any older docs that still reference them.ARGUMENTS: Attack System Generalization 4
The title is provided: "Attack System Generalization 4". Project is attack-template (root E:/a327ex/attack-template). Let me proceed.
Bash (python E:/a327ex/Anchor2/scripts/find-recent-session.py --limit 5 && echo "===DA...)
2026-05-21T01:40:09.252Z cfd6fec4-b6d6-4322-8ce2-075547aa4a2c [claude] <-- MOST RECENT
C:\Users\adn\.claude\projects\E--a327ex\cfd6fec4-b6d6-4322-8ce2-075547aa4a2c.jsonl
2026-05-21T00:16:34.726Z 1606f701-db00-4cd0-8ed2-21eb96355c4d [claude]
C:\Users\adn\.claude\projects\E--a327ex\1606f701-db00-4cd0-8ed2-21eb96355c4d.jsonl
2026-05-20T20:39:50.264Z dd3ecde1-3cb1-4313-83d4-c4a70e472cbe [claude]
C:\Users\adn\.claude\projects\E--a327ex\dd3ecde1-3cb1-4313-83d4-c4a70e472cbe.jsonl
2026-05-20T16:31:14.672Z 928a6664-ad51-4697-a25f-b60224a1210a [claude]
C:\Users\adn\.claude\projects\E--a327ex\928a6664-ad51-4697-a25f-b60224a1210a.jsonl
2026-05-20T15:09:08.721Z 13e244ef-3ffd-4276-ad90-5f800435a664 [claude]
C:\Users\adn\.claude\projects\E--a327ex\13e244ef-3ffd-4276-ad90-5f800435a664.jsonl
===DATE===
2026-05-20 22:40:46