a327ex.com

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

Workflow change — design-brief before code:

Airborne redesign (airborne.lua rewritten z-only):

Prop redesign (prop.lua — generalized from the rock):

Landing-area visibility fix:

snkrx/ reorganization:

assets/ relocation:

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:

  1. airborne.lua — a procedural arc-flight helper (Anchor 2 style: plain table + free functions, like timer/spring). Owns only the parabola math: horizontal lerp + real-physics vertical parabola + apex/landing edge events. The shared abstraction for any lobbed attack.

  2. polygonal.lua — the big_rock carrier (polygonal class) + rock_fragment shard class. Jagged rock flies an airborne arc with a blinking X-marker at the target; on landing it spawns a polygon area (reusing spawn_area), runs v1's wedge-partition shatter into 3-4 Box2D fragments, kicks out rocky particles, shakes the camera, plays sounds.

Scope notes:

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:

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:

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:

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 Rfire_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

  1. The landing area will be visible. v1 spawns it hidden (the shards are the visual). attack-template's area.lua has no hidden flag — 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 small area.lua change.
  2. 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:

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:

  1. Generic breakable — a shatter(x, y, verts, base_r, r, opts) free function (the wedge-partition algorithm) + a debris class (the shard entity, was rock_fragment; physics tag debris). This is the reusable core: a vase, a box, a rock all just call shatter. Visuals stay rock-styled for now, as you said — that's a later knob, not a structural one.

  2. 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 + shatter on landing. This is the attack.

  3. The damage — a separate named function (e.g. rock_land_damage(...)) that does the spawn_area polygon 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 an on_land hook 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:

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

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

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

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:

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 hiddenarea.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:

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

  1. Requires every sub-module in dependency order (palette → edition → geometry/symbols/gradient → stats → fx → entities → carriers → sound_tuner).
  2. 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 + the set_edition(layer, mode) shim
    • sfx(handle, vol, pitch) and hit_stop(duration)
    • the sounds / volumes tables + every carrier sound_load(...) + the spritesheet_register calls (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:

  1. require('anchor'){ width, height, title, scale, filter } — window config.
  2. require('snkrx') — the whole toolkit live.
  3. Physicsphysics_init(), tag registration, collision matrix. Explicit, commented.
  4. Input binds — movement keys, F2, F3. A documented placeholder showing where to bind attack keys.
  5. Layers — the 5-layer stack (floor/wall/game/effects/debug). Explicit.
  6. Cameramain_camera. Explicit.
  7. Scene content — spawn p = player(...) only. Seeker/wall spawns are shown as commented examples, not active.
  8. 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 (with airborne_no_collide guards), then process_destroy_queue.
  9. 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

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.luarequire('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:

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 5

The 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, use find-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.md with 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: and Date: lines. Without them, the site's sidebar shows the slug as the title and 0 (epoch) as the sort date. The backfill script in a327ex-site/deploy/backfill_metadata.py is 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].md

Step 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/Anchor git add docs/ framework/ engine/ scripts/ reference/
Anchor2 E:/a327ex/Anchor2 git add framework/ engine/ arena/ reference/ scripts/ docs/ .claude/
emoji-ball-battles E:/a327ex/emoji-ball-battles git add -A
invoker E:/a327ex/Invoker git add -A
thalien-lune E:/a327ex/thalien-lune git add -A
a327ex-site E:/a327ex/a327ex-site git 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.

cd into the project root, stage, run git status to 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.txt

Step 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 -3

Failure 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: and Date: 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/ and anchor.blot.im/context/. Post-cutover (May 2026), anchor.blot.im is a JS redirect to a327ex.com and 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