a327ex.com

Mini Looper Ramming Endgame Build

Summary

Built and fun-verified the ramming endgame build for Mini Looper (SNKRX × tower-defense hybrid on Anchor 2, forked from snkrx-template), as the build-heavy pipeline's "End" fun-gate: a hardcoded "fake" maxed build to test whether the screen-clearing power fantasy is fun before building the unit/upgrade systems. Implemented 6 of 7 planned ramming units one increment at a time (design-brief-then-code each), porting real mechanics from SNKRX source and the old Invoker. User confirmed mid-session: "I have already verified that the game is very fun like this, doesn't seem like fun will be an issue."

Alignment & strategic framing:

Increment 1 — blocks + count-scaling (foundation), in builds.lua / units.lua / train.lua / main.lua:

Increment 2 — Juggernaut (front carver):

info_text port (from Invoker-old/info_text.lua):

Increment 3 — Swordsman (cleave): initially mis-designed as a self-centered circle; user corrected: "it's not self-centered and it's not a circle, it's a ranged rectangle area with a low trigger range." Re-read SNKRX source (player.lua Player:attack + Area:init): attack_sensor = Circle(48) (low trigger) → self:attack(96)Area is Rectangle(self.x, self.y, 1.5*w, 1.5*w, self.r) (rotated square centered on the unit, side 1.5×96=144), cleave damage dmg + dmg*0.15*#enemies computed inside the Area from its own hit count. Ported faithfully scaled down: low trigger range (sword_trigger_range=20) → rotated square (sword_area_size=60) oriented to travel; cleave count via the SAME query_entities_box the area uses; dmg = base + per_hit·N, unlock raises per_hit. Recolored from pink to BLUE (blue↔light_blue) because pink↔violet read too close to the cannoneer's purple↔violet (user: "I'm not sure I'm seeing swordsman. What color is he?"). Noted the low 20px trigger makes the cleave sporadic since the head clears enemies first.

Roster trims: removed duplicate archer + outlaw (→ 4 old), then magician + geomancer (→ 2 old: archer, cannoneer), keeping the ranged projectile units that complement the short-range ramming core (Juggernaut contact, Swordsman cleave, Earthshaker/Detonator/Poisoner AoE).

Increment 4 — Earthshaker / Fissure: ported Invoker's Fissure (from Invoker-old/v1/main.lua, melee carrier): a forward-marching chain of jagged-rect areas (fissure_count, fissure_area_radius, fissure_delay, ±~8° waver, positions captured at cast since the train moves). spawn_fissure(u, base_r, count, radius) aims at nearest enemy. Initial unlock was "every 3rd cast = longer+bigger" (count 8, via u.fissure_n % 3), then user replaced it with Looping Fissure: a fissure that races BACKWARDS around the whole loop (opposite the snake) from the Earthshaker, back to start. Rewritten as an arc-length walk of the backward loop polyline (nearest_loop_index start, loop_fissure_step spacing, loop_fissure_radius), per-crack delay scaled to a fixed loop_fissure_duration so it fills the time regardless of step/size. Camera shake stronger on start/end (loop_shake_start/loop_shake_end=6) + small per-crack rumble (loop_shake_each=1.5). Cooldown counts from the effect's END (self-re-arming earthshaker_loop_tick chain, timer_after(duration+cooldown), not timer_every). Spawns "Looping Fissure" info_text (lime). Tuning over several passes: aimed fissure spacing 0.9→2.5→2 (fissure_spacing); loop duration 1.4→2.5→2.1; loop step 24→30; loop radius 17.

Increment 5 — Detonator ("Cascade"): detonates inert blocks (non-destructive — block flashes/spring-pops/bursts but stays in the train). detonate_block(b, source, radius) spawns a rectangular AoE centered on the block (aligned to b.r) + a hit_circle pulse. Base = one random block (array.random(blocks)); unlock = several. User tuning: 5s cooldown; rectangular (not circle) areas centered on each block; Cascade = 5 random blocks (not all 8) with each blast 1.5× radius (cascade_block_count=5, cascade_radius_mult=1.5, detonate_radius=30→45); explosion shake switched to trauma-based (shake_trauma(main_camera.shake, 0.9, 0.6) — "higher, longer, and trauma-based", intensity = amount²·amplitude). Solid pink spec (magenta is the one free hue; solid avoids the enemy-red / cannoneer-violet breathe clash; explosions carry the visual). Detonator is an attacker segment, so it is not a block and never detonates itself.

Increment 6 — Poisoner ("Virulence"): leaves a trail of persistent circle DoT as it rides the loop (a passive timer armed in unit_init_combat, no attack — the trail is its whole kit). Confirmed the area carrier supports DoT: spawn_area with duration+tick_ratearea:apply_tick does per-enemy-cooldown'd e:hit(self.damage, 0) (silent, light particles). spawn_poison(u) drops a DoT circle (poison_radius=16) at slightly-scattered positions (poison_scatter=8) every poison_drop_interval=0.15s, lasting poison_duration=2s, poison_tick_rate=0.5. Because lap time (~2.4s) ≈ patch lifetime, the trail forms a near-continuous, bounded poison ring (~14 patches live); overlapping patches stack (each area independent). Unlock multiplies per-tick damage (poison_unlock_mult=2 → 4/tick). Acid lime-green (lime↔green).

Outcome & decisions:


Let's continue on Mini Looper. This session we'll try endgame builds. On top of the builds, we also want to make sure the game is performant and runs well with lots of entities on screen, so it doubles as an endgame build test but also an engine performance one. Currently the game has 18 beats where difficulty scales, we'll likely want to test the endgame builds from beats higher than 18 because we really want to make sure the screen is filled with lots of powerful enemies.

As for the builds, I have a few archetypes already in mind. Ramming archetype: focuses on increasing snake size via the use of inert unit blocks, on damage/velocity increases based on inert block amount, contact damage increases, and so on. Anything that makes melee/contact damage higher goes into this archetype. Projectile archetype: a simple projectile focused build, piercing, homing, warping (crosses screen edges), ricocheting, etc, just fills the screen with projectiles and kills enemies like this. Construct/summon archetype: creates constructs/summons that deal damage, some of these may be SNKRX-like swarmers, others may be mines that get placed on the snake's path, and various other autonomous entities that we might want to implement. And finally mage archetype: focuses on long ranged spells of various kinds as well as weird effects and/or interactions, generally AoE-heavy. We want only 1 build per archetype, capturing the archetypes ideas well. We also want the ability to have modifiers that are unrelated to the units (because the game will eventually have those), although we will just hardcode them for now.

Get aligned and then let's discuss where to start as well as ideas for units on the build we start with.

I'll get acquainted with Mini Looper first — it's not in my CLAUDE.md project list, so let me find it and read its current state.

Glob (/miniloop*/)

mini-looper-old\assets\More 15.ttf
mini-looper-old\anchor\class.lua
mini-looper-old\anchor\object.lua
mini-looper-old\anchor\timer.lua
mini-looper-old\anchor\math.lua
mini-looper-old\anchor\spring.lua
mini-looper-old\anchor\color.lua
mini-looper-old\anchor\camera.lua
mini-looper-old\anchor\shake.lua
mini-looper-old\anchor\image.lua
mini-looper-old\anchor\helpers.lua
mini-looper-old\anchor\spritesheet.lua
mini-looper-old\anchor\input.lua
mini-looper-old\anchor\animation.lua
mini-looper-old\anchor\init.lua
mini-looper-old\anchor\layer.lua
mini-looper-old\anchor\memory.lua
mini-looper-old\anchor\array.lua
mini-looper-old\anchor\font.lua
mini-looper-old\snkrx\gradient.lua
mini-looper-old\snkrx\geometry.lua
mini-looper-old\snkrx\symbols.lua
mini-looper-old\snkrx\stats.lua
mini-looper-old\snkrx\player.lua
mini-looper-old\anchor\collider.lua
mini-looper-old\assets\hit1.png
mini-looper-old\assets\monogram.ttf
mini-looper-old\assets\ARROW_Hit_Wood_Shield_stereo.ogg
mini-looper-old\assets\RPG3_GenericArrow_Shoot01.ogg
mini-looper-old\assets\Bloody punches 3.ogg
... [71 more lines]

There's mini-looper-old. Let me find the current Mini Looper directory.

Glob (mini-looper*/)

No files found

Bash (ls -d E:/a327ex/loop E:/a327ex/Loop 2>/dev/null; echo "---"; ls -d E:/a327ex...)

E:/a327ex/mini-looper/
E:/a327ex/mini-looper-old/
---
E:/a327ex/Anchor-YueScript//
E:/a327ex/Anchor-lua//
E:/a327ex/Anchor//
E:/a327ex/Anchor2//
E:/a327ex/BYTEPATH//
E:/a327ex/Danpenzero//
E:/a327ex/Invoker-old//
E:/a327ex/Invoker//
E:/a327ex/Null//
E:/a327ex/Psyonia//
E:/a327ex/RETICLE//
E:/a327ex/RTFX//
E:/a327ex/Ricochet//
E:/a327ex/SNKRX//
E:/a327ex/a327ex-site//
E:/a327ex/a327ex.com-blog//
E:/a327ex/anchor.blot.im//
E:/a327ex/data//
E:/a327ex/edition-template//
E:/a327ex/emoji-aimer//
E:/a327ex/emoji-ball-battles-generated//
E:/a327ex/emoji-ball-battles//
E:/a327ex/emoji-ball-bounce//
E:/a327ex/generation-loss//
E:/a327ex/it-follows//
E:/a327ex/lane-battle//
E:/a327ex/ld54-remake//
... [13 more lines]

There's mini-looper/ (current). Let me read its docs and structure.

Bash (cd E:/a327ex/mini-looper && echo "=== ROOT FILES ===" && ls -la --time-style=+%Y...)

=== ROOT FILES ===
-rw-r--r-- 1 adn 197121  5214 2026-05-31 README.md
-rw-r--r-- 1 adn 197121  5618 2026-06-02 building.lua
-rw-r--r-- 1 adn 197121  9494 2026-06-01 enemy.lua
-rw-r--r-- 1 adn 197121  2457 2026-06-01 grid.lua
-rw-r--r-- 1 adn 197121  2377 2026-06-02 ideas.md
-rw-r--r-- 1 adn 197121  3048 2026-06-01 loop.lua
-rw-r--r-- 1 adn 197121 24429 2026-06-02 main.lua
-rw-r--r-- 1 adn 197121 28688 2026-05-31 main_old.lua
-rw-r--r-- 1 adn 197121 18873 2026-05-31 main_scaffold.lua
-rw-r--r-- 1 adn 197121  8901 2026-06-01 train.lua
-rw-r--r-- 1 adn 197121 10676 2026-06-01 units.lua
-rw-r--r-- 1 adn 197121  6426 2026-06-01 wave.lua

=== ALL .md FILES ===
./.claude/CLAUDE.md
./ideas.md
./README.md
./snkrx/findings.md

=== TOP DIRS ===
anchor//
assets//
snkrx//

=== .claude ===
total 64
drwxr-xr-x 1 adn 197121     0 May 31 23:00 ./
drwxr-xr-x 1 adn 197121     0 Jun  2 00:39 ../
-rw-r--r-- 1 adn 197121 60653 May 31 23:00 CLAUDE.md

Let me read the core docs and game files in parallel.

Read (E:/a327ex/mini-looper/README.md)

1    # snkrx-template
2   
3   A starter [Anchor 2](https://github.com/a327ex) project for building **attacks in
4   the SNKRX / Orblike / Invoker visual style** — punchy 2D action with a living,
5   breathing palette. It ships three reusable pieces:
6   
7   - five **attack carriers** (projectile · area · beam · flicker · prop),
8   - a four-axis **effect system** (pattern × color × dither × shape), and
9   - a SNKRX-style **UI toolkit** (panels, slots, bars, cards, HUD widgets, …).
10  
11  Fork the folder, edit `main.lua`, build your game.
12  
13  ## Quickstart
14  
15  ```sh
16  ./run.bat        # runs main.lua — run.bat resolves its own folder name
17  ```
18  
19  `anchor.exe` (the Anchor 2 engine) is committed, so the template runs out of the
20  box. `main.lua` is the clean player-only scene; for the full demo (every carrier
21  + the dev tools) copy **`main_scaffold.lua`** over `main.lua`.
22  
23  ## Fork & use
24  
25  1. **Copy the whole folder** and rename it (`cp -r snkrx-template my-game`).
26     `run.bat` derives its own folder name, so the copy runs immediately — no edits.
27  2. **Boot** is three lines at the top of `main.lua`:
28     ```lua
29     require('anchor')({ width = 480, height = 270, title = 'my-game', scale = 3, filter = 'rough' })
30     require('snkrx')             -- attack carriers + effect system + UI toolkit, in one require
... [72 more lines]

The .claude/CLAUDE.md and README are still the template's — the actual Mini Looper game lives in the new .lua files. Let me read those.

Read (E:/a327ex/mini-looper/ideas.md)

1    # Mini Looper — Ideas
2   
3   Running list of design ideas. Unfiltered; not commitments.
4   
5   ## Solid blocks (non-combat units)
6   
7   Units that are just **solid blocks** — they don't attack and have no HP. Their only purpose is to **increase the train's length**, so the train physically blocks more enemies from reaching the center.
8   
9   - Enables builds that gain **more bonuses the more blocks** you have.
10  - Aids a **ramming archetype**: focus on making the train go fast and deal **contact damage** to enemies as it sweeps, instead of relying on units attacking.
11  
12  ## F3 sound tuner — surface played sounds first (tooling / QoL)
13  
14  When opening the F3 sound tuner, the sounds shown first should be the ones that have **actually played during this run of the program**, ordered **most recently played first**. Makes it fast to find and tune a sound you just heard instead of hunting through the full loaded list.
15  
16  - Played sounds get a **visual indicator** that they played — maybe even the **total number of times** played this run.
17  - Sounds that haven't played get **no indicator**.
18  
19  (Dev-tooling detail, not gameplay — deferred so it doesn't interrupt fun-testing.)
20  
21  ## RTS direction — loop economy, speed, tech tree
22  
23  The game plays like an **RTS**. You start with the looping train and the central **core**. From the core you build **resource-extraction buildings**, each placed **near a resource on the map** — and **inside the loop**, because anything outside the loop is unprotected and gets hit by enemies.
24  
25  **Everything runs on a loop basis.** Resource buildings pay out **per completed loop** — every time the train finishes a full circuit is one logical unit of action. This ties the strategy directly to the action, and makes **speed the most valuable resource**: faster loops = more income, better defense coverage, and it's also what looks cool and plays well (so the optimal play is also the satisfying one).
26  
27  - **Units are bought by constructing new building types** (a building unlocks / produces a unit type or capability).
28  - Ideally **hundreds of units**, but a single run only has the resources to explore **one "line"** of the tech tree if you want to succeed.
29  - **Tech tree works like a standard RTS**: gated by resources and the new buildings you unlock, with upgrades, etc.
30  - Logically simple from a systems perspective — the depth lives in the tuning.
... [1 more lines]

Now the actual game code. Let me read the core files.

Read (E:/a327ex/mini-looper/main.lua)

1    --[[
2     snkrx-template / main.lua — clean reference scene.
3   
4     This is your file. It is a complete, minimal scene: a player you can
5     move (WASD / arrows), the five-layer render stack, a camera, physics,
6     the update / draw skeleton, AND the UI render pipeline — all wired
7     and ready, but firing no attacks and drawing no UI widgets. Read it
8     top to bottom; it is the contract you build on.
9   
10    TWO TOOLKITS, ONE REQUIRE.  `require('snkrx')` pulls in BOTH:
11  
12      snkrx/<carrier>.lua     attack toolkit — projectile / area / beam
13                              / flicker / prop, the effect draw-shader
14                              stack, fx, sounds, example entities.
15      snkrx/ui/<module>.lua   UI toolkit — panel / button / slot / bar /
16                              tabs / checkbox / radio / slider /
17                              list_row / card / text_input / modal /
18                              bracket_tag / stat_chip / pips / ...
19  
20    Both are plain, readable Lua, not opaque modules — open the files.
21  
22    ADDING AN ATTACK.  Every carrier already has its object list and its
23    update / draw loop wired below. To add an attack:
24      1. bind a key in the INPUT section,
25      2. call the carrier's spawn_* helper on that press inside update()
26         — see the ATTACKS comment block there.
27    Nothing else: no new list, no new loop.
28  
29    DRAWING UI.  The UI toolkit is immediate-mode: call widget functions
30    in update() and they queue draws onto `ui_layer`. The render pipeline
... [482 more lines]

Now the game-logic files. Let me read them all in parallel.

Read (E:/a327ex/mini-looper/units.lua)

1    --[[
2     units.lua — the shared unit auto-attack scaffold + the per-character
3     attack registry (mini-looper).
4   
5     Every train unit is an auto-attacker: on a cooldown it finds the nearest
6     enemy in range and fires a carrier at it. The shared machinery (target
7     acquisition, cooldown tick, fire) lives here; each character is ONE entry
8     in unit_defs. Adding a unit = add a registry entry — no new plumbing,
9     since the carrier update / draw / damage loops already live in main.lua.
10  
11    train_unit (train.lua) owns self.character + self.attack_cooldown, takes
12    its color (self.spec) from its def, and calls unit_attack_update(self, dt)
13    each frame.
14  
15    Desync: cooldowns are seeded with a random phase AND jittered on every
16    reset (cooldown_jitter), and an idle unit (no target in range) re-arms
17    after a small random delay — so a cluster of units never fires on the
18    same frame. Without this they collapse to "ready" while idle and volley
19    in lockstep the instant enemies arrive.
20  
21    unit_defs[name] = {
22      cooldown,         -- seconds between attacks (number OR function -> number,
23                        --   so it can read a live tuning knob)
24      cooldown_jitter,  -- optional ± fraction applied on each reset (default 0.15)
25      range,            -- target-acquisition radius (px)
26      spec,             -- effect spec = the unit's (and its projectiles') color
27      attack,           -- function(u, target): fire the unit's carrier at target
28    }
29  ]]
30  
... [246 more lines]

Read (E:/a327ex/mini-looper/train.lua)

1    --[[
2     train.lua — the on-rails SNKRX train (mini-looper).
3   
4     The HEAD (unit 1) is a dynamic body driven kinematically: each frame it
5     eases a HEADING (self.move_r) toward the next waypoint and sets its
6     velocity at that heading (scaled down by train_corner_slow in sharp
7     turns). It walks the loop and lays a breadcrumb trail.
8   
9     FOLLOWERS (units 2..n) are pinned each frame to the head's trail,
10    (index-1)*spacing back, so they trace the head's exact path (classic SNKRX
11    snake). No physics chain.
12  
13    Turning auto-scales with speed (train_apply_speed_coupling): as
14    train_max_speed rises, train_turn_t (heading-ease time) shrinks and
15    train_corner_slow grows, so a faster train still tracks the loop and brakes
16    harder into corners. Anchors: 100 -> (t 0.30, corner 0.40); 200 -> (t 0.15,
17    corner 0.75); linear + clamped.
18  
19    Live knobs (tuning UI in main.lua): train_max_speed, train_seek_distance,
20    train_spacing. train_turn_t / train_corner_slow are derived from speed;
21    train_turn_p is a fixed companion to the ease.
22  
23    Each unit also auto-attacks: it carries a self.character and calls
24    unit_attack_update (units.lua) every frame to fire its carrier at the
25    nearest enemy in range. Movement and combat are independent.
26  ]]
27  
28  train_units = {}
29  train_trail = {}            -- rolling breadcrumb of head positions (newest first)
30  train_head_x, train_head_y = nil, nil  -- head's live position, the trail's distance-0 anchor
... [189 more lines]

Read (E:/a327ex/mini-looper/enemy.lua)

1    --[[
2     enemy.lua — mini-looper enemy: spawns off-screen, marches to the loop
3     center, reacts to train-unit contact.
4   
5     Structurally a retarget of snkrx/seeker.lua. Same dynamic box collider,
6     same push / hit / die / spawn FX / floating HP bar (it reuses the global
7     should_show_health_ui / draw_health_ui defined in seeker.lua). The ONE
8     behavioral change is steering: it seeks (loop_cx, loop_cy) — the loop
9     center — instead of the player, which mini-looper doesn't have. There is
10    no arrival handling yet: an enemy that reaches the center just clumps
11    there (separation keeps the blob from fully overlapping). The base /
12    lose-condition is a later step.
13  
14    The seeker's `wall_bump` streak machinery is dropped — mini-looper has no
15    wall colliders.
16  
17    Manual spawning (this step): main.lua binds a key and calls
18    spawn_enemy_at_edge() on the press. spawn_enemy(x, y, args) builds the
19    entity and registers it in enemies[] so main.lua's prune loop updates +
20    draws it and steering_separate has a list to walk.
21  
22    Train ram: the train↔enemy contact (handled in main.lua) calls :hit +
23    :push; the train itself is driven kinematically and plows through
24    unaffected.
25  ]]
26  
27  
28  enemies = {}
29  
30  enemy_base_hp        = 10   -- hp pool of a spawned enemy
... [229 more lines]

Read (E:/a327ex/mini-looper/wave.lua)

1    --[[
2     wave.lua — continuous pressure director (mini-looper).
3   
4     Replaces discrete waves: enemies trickle in continuously from ALL around the
5     perimeter so the train is always sweeping into something and the whole loop
6     earns its keep. Pacing is authored, not random.
7   
8     BEATS carry the fractal difficulty. Time is chopped into beats; beat b has
9     difficulty D(b) = (1 + (b-1)*0.5)*(1 + v3(b)) — the 1-2-3 fractal (two calm
10    beats, then a spike; bigger spikes every 9th/18th). Survive past
11    beat_count_max -> WIN (game_won).
12  
13    AMBIENT trickle: a timer fires every ambient_interval(D) seconds (shrinks as
14    D rises), dropping a small group at a uniform-random angle around the center,
15    off-screen, marching in. So the small fractal spikes (v3==1) just crank this
16    omnidirectional rate on their own.
17  
18    SUPER-SPIKE SURGE (v3>=2: beats 9, 18): a telegraphed flank assault. At the
19    beat start a warning bar pulses on ONE SIDE — left or right only, never
20    top/bottom — then after telegraph_time a dense swarm pours from that side
21    while ambient dampens, so the threat reads as coming from there. The
22    "defend the flank" moment (and where you'll reinforce with buildings later).
23  
24    main.lua: director_update(dt) in the sim, director_draw_telegraph(game_layer)
25    in draw, director_reset() on restart; game_won folds into the freeze gate.
26  ]]
27  
28  
29  -- Tunables (the balance surface — flow rate vs the train's kill rate).
30  beat_count_max   = 18
... [150 more lines]

Read (E:/a327ex/mini-looper/building.lua)

1    --[[
2     building.lua — generic grid-placed building (mini-looper).
3   
4     A square static obstacle on a grid cell, with HP. Enemies (which seek the
5     center) pile against it; the building<->enemy collision in main.lua knocks
6     each enemy back and calls building:hit, so a swarm chips it down. The
7     CENTRAL building (objective = true) is the base you defend: when it dies the
8     run is lost (building_destroyed -> main.lua freezes the field + shows the
9     "BASE DESTROYED" overlay; R restarts via building_reset).
10  
11    Draws a breathing square + an HP bar below it (fill + numeric hp/max).
12  
13      building_place(col, row, opts) -> building   opts: objective, hp, spec, size
14      building_reset()                              restore the objective, clear enemies
15  
16    Generic (non-objective) buildings just die + free their cell on 0 HP — the
17    hook for the future placement layer; only the central one exists today.
18  ]]
19  
20  
21  buildings          = {}
22  central_building   = nil
23  building_destroyed = false
24  
25  building_size       = 20    -- square side (px); cell is grid_cell_size (24)
26  building_max_hp     = 100
27  building_push_force = 8      -- knockback applied to an enemy that hits a building
28  
29  -- A polychrome linear-gradient (cyan -> violet -> pink) — the only ramp/gradient
30  -- object in the game, so the base reads as special, distinct from the solid-hue
... [129 more lines]

Read (E:/a327ex/mini-looper/loop.lua)

1    --[[
2     loop.lua — the waypoint loop the train follows (mini-looper, step 1).
3   
4     `loop_points` is just an ordered list of {x, y} points. There is no
5     baked path geometry and no arc-length table — the train walks this
6     list with the `steering_follow_path` behavior and wraps back to point
7     1 at the end, so in practice it loops forever. `loop_cx, loop_cy` hold
8     the loop center (used for the inward test-push).
9   
10      build_loop_ring(cx, cy, rx, ry, n)  -- n points on a plain ellipse
11      build_loop_track(cx, cy)            -- a fixed IRREGULAR loop (twists,
12                                          -- sharp turns, concave notches)
13      nearest_loop_index(x, y)            -- index of the closest loop point
14      loop_draw(layer)                    -- debug: dots + connecting lines
15  ]]
16  
17  loop_points = {}
18  loop_cx, loop_cy = 0, 0
19  
20  -- Place n points evenly around an ellipse centered at (cx, cy) with
21  -- radii rx / ry. Replaces loop_points wholesale.
22  function build_loop_ring(cx, cy, rx, ry, n)
23    loop_cx, loop_cy = cx, cy
24    loop_points = {}
25    for i = 1, n do
26      local a = (i - 1)/n*2*math.pi
27      loop_points[i] = { x = cx + rx*math.cos(a), y = cy + ry*math.sin(a) }
28    end
29    return loop_points
30  end
... [55 more lines]

Read (E:/a327ex/mini-looper/grid.lua)

1    --[[
2     grid.lua — the logical placement grid (mini-looper).
3   
4     A grid of square cells covering the whole screen. Purely logical: it owns
5     the cell math (world <-> cell) and an occupancy map, so buildings snap to
6     cells and a future placement layer can query what's filled. Buildings draw
7     themselves; the grid only draws faint lines under the F2 debug overlay.
8   
9       grid_cell_center(col, row) -> x, y      (cell center, world space)
10      grid_cell_at(x, y)         -> col, row  (clamped into the grid)
11      grid_get / grid_set(col, row[, b])      (occupancy)
12      grid_draw(layer)                        (debug lines)
13  
14    Cells are 1-indexed: col in 1..grid_cols, row in 1..grid_rows.
15  ]]
16  
17  
18  grid_cell_size = 24
19  -- Odd cell counts so a single cell sits dead-center on the screen, plus a
20  -- centering origin offset so the whole grid is centered (leftover margin from
21  -- non-exact division is split evenly on both sides).
22  grid_cols = math.floor(width/grid_cell_size)
23  grid_rows = math.floor(height/grid_cell_size)
24  if grid_cols % 2 == 0 then grid_cols = grid_cols - 1 end
25  if grid_rows % 2 == 0 then grid_rows = grid_rows - 1 end
26  grid_origin_x = (width  - grid_cols*grid_cell_size)/2
27  grid_origin_y = (height - grid_rows*grid_cell_size)/2
28  
29  grid_cells = {}   -- occupancy: grid_cells[(row-1)*grid_cols + col] = building
30  
... [37 more lines]

Let me check the project's git history to understand the trajectory of past sessions.

Bash (cd E:/a327ex/mini-looper && git log --oneline -25 2>/dev/null && echo "---STATUS...)

4b56988 Mini Looper Attacks and Enemies
a6077cd Mini Looper 1
b837ebc Initial commit: fork from snkrx-template
---STATUS---
 M ideas.md

Let me read the projectile carrier to know exactly what knobs exist — it matters for the projectile-build discussion.

Read (E:/a327ex/mini-looper/snkrx/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                   spec_color(self.spec).
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        spec,              -- four-axis effect spec; default caster.spec
25        max_range,         -- default nil = unlimited
26        damage,            -- default 1
27        speed,             -- default 190
28        w, h,              -- default 10, 4
29        on_death,          -- optional fn(x, y) called at the impact/death point
30                           --   (NOT on off-screen cull) — a host-composition
... [273 more lines]

I've now read the whole Mini Looper codebase. Here's where I've landed.

Where Mini Looper is

It's a SNKRX × tower-defense hybrid: an on-rails snake (the "train") runs a fixed loop defending a central base. Enemies march in from all edges toward the base; the train's units auto-attack the nearest/densest enemies and the train body rams anything it sweeps through. A beat-based director (wave.lua) scales pressure over 18 beats, win at beat 18, lose if the base dies.

The pieces, concretely:

The five carriers (projectile / area / beam / flicker / prop) exist with effect-system visuals. Crucially: the projectile carrier is straight-line only — no pierce, homing, warp, or ricochet. Beam has homing; nothing has the rest.

The strategic read (and one honest flag)

This session is the "End" fun-gate from your build-heavy pipeline — exactly the right move, testing whether the screen-clearing power fantasy is fun before building the systems that grant it. So the discipline that applies: every build is a hardcoded fake, toggled on, not reached through a unit-shop/modifier-roll/upgrade tree. You already said "hardcode them for now" — good. I'll hold that line: no acquisition system, no modifier-roll system, just a build table that swaps the roster and sets global knobs.

Scope flag, said plainly: 4 full archetypes is too much for one session. We do one, get it fun-tested and perf-tested, then stop and you decide the next. Chaining all four would be the relapse.

A reframe worth stating: Mini Looper is an autonomous combat game — the train's on rails, units auto-fire, the player's agency lives between runs (building placement, build composition), à la SNKRX/TD. So the endgame test isn't "does aiming feel good" — it's "is watching a maxed build execute a screen-clear satisfying to watch, and does it run well doing it." That's the SNKRX bet, and it's the right thing to validate here.

Perf bottleneck I can already see: enemy:update calls steering_separate(16, enemies, …) — O(n) per enemy, O(n²) total — and unit_pack_target is also O(n²). At high beats with no concurrent-enemy cap, the Lua-side O(n²) will bite before Box2D does. That's the real thing the perf test will surface, and it's load-driven by the director, not by which build we pick — so whichever archetype we test, letting enemies accumulate at a high beat IS the engine stress test. One harness serves both goals.

The test harness we need either way

Small and finite (named, time-boxed — not a system):

  1. Build-select — a hardcoded endgame_build that sets the train roster + global modifier knobs (the "modifiers unrelated to units," hardcoded as globals like contact_damage_mult, projectile_pierce, etc.).
  2. Reach high beats — the director wins at beat 18, so we need to either jump the beat and uncap the win, and/or dump a heavy swarm instantly.
  3. That's it — no UI, just debug keys.

My recommendation: start with Ramming

Reasoning, honestly weighed:

The alternative is Projectile — it's the more spectacular endgame (the "fill the screen with projectiles" image you led with) and the best stress test for entity count + sensor events. But it needs the most genuinely-new carrier work (pierce/homing/warp/ricochet), so it's the slowest to first-fun. I'd rank effort: Ramming (almost free) < Projectile (carrier knobs) < Construct (new entity class) < Mage (open-ended "weird interactions" — balloon risk).

Ramming build — concrete unit/modifier ideas (if we start here)

Two decisions before I write a brief:

AskUserQuestion

Your questions have been answered: "Which archetype do we build + fun-test first this session? (One only — we test it, then you decide the next.)"="Ramming", "How should we reach the above-beat-18 endgame load for testing?"="We'll decide later.". You can now continue with these answers in mind.

Locked in: Ramming first, harness decision deferred (I'll surface it again when the build's in and we actually need a packed screen to stress it). I won't build any harness yet.

Per the workflow rule, here's the design brief for the Ramming endgame build — conceptual, not code. Let's iterate it, then I'll write it.

mcpccd_sessionmark_chapter

[
  {
    "type": "text",
    "text": "Chapter marked: \"Ramming build design\". Continue your current work."
  }
]

Design brief — Ramming endgame build

Approach

A hardcoded, maxed ramming build: the train becomes a long snake of inert steel blocks that plows the loop, and its contact damage / speed / knockback scale with the block count. No attackers needed — the body is the weapon. This is a fake endgame (the discipline: hardcode the endpoint, don't build the system that grants it), toggled on at scene init in place of the current mixed 6-unit train_build.

Three moving parts:

  1. The block unit — a new inert character = 'block'. It has a registry entry for its color but no attack, so it never fires. Visually it's flat steel (color='solid', no breathe) so the snake reads as armored and distinct from the breathing colored attackers and the red/orange enemies.

  2. Count-scaling modifiers — the archetype's identity. A handful of hardcoded globals (ram_*_per_block) and one function that derives train_contact_damage / train_max_speed / train_ram_force from the live block count. More blocks → harder, faster hits → (force is already speed-coupled in the existing ram handler) → bigger flings. The existing main.lua ram math already keys off exactly these three globals, so the build needs only to set them — no change to the collision handler.

  3. Build setup — one function that clears the train, strings together the long block snake, flips ramming_build_active, and applies the scaling.

Interfaces (what it touches / reuses)

Functions

Open choices — let's decide these before I write

  1. Snake length for the test — I'm proposing ~25 blocks (1 head + 24). Long enough that a sweep lands many contact hits and the perf paths bite, short enough to read on a 480×270 loop. Your call on the number.
  2. Block look / size — flat steel, and I lean uniform 10×10 (same as attackers) for a clean uniform snake; alternative is slightly chunkier 12×12 blocks that read as armor plating and tunnel less between sparse enemies. Minor, but it's a feel choice.
  3. Scaling vs flat numbers — I'm proposing the cheap derive-from-count formula (≈10 lines, makes "add blocks → visibly stronger" true and more fun to test). The stricter-discipline alternative is to just hardcode flat maxed numbers and skip even the formula. I think the formula is worth it here because the count→power relationship is literally what the archetype is testing — but flag if you'd rather hardcode flat.
  4. Pure blocks vs a few attacker anchors — I'm proposing a pure-block snake so the archetype reads cleanly (ramming = body damage; attackers are other builds' identity). Mixing in a couple of archers muddies what we're fun-testing.
  5. Crush radius (optional) — ram could also damage enemies near each block, not just touching, so a fast snake doesn't tunnel through gaps. I'd defer this unless tunneling looks bad in testing — it's an easy add later.
  6. Invincible train — the train currently takes no damage (no enemy→train path exists). For an offense/spectacle test that's fine; just confirming we're not testing train survival this round.

One honest note on what this test will reveal: ramming is the most passive archetype — on-rails body + no aiming means you're purely watching. The thing to judge on the third sitting is whether the sweep-and-fling spectacle carries it, or whether melee-on-rails reads as too hands-off even for an auto-battler. That's the real signal, and it's exactly what we want this gate to surface cheaply.

How do you want to call the six choices?

We need to define units that will make the build as well as the modifiers in more detail. Look at SNKRX units that would fit here, I think melee damage ones probably make most sense. List them all to me. List abilities from other games (Isaac, RoR, PoE, etc) that have to do with contact damage too.

[Request interrupted by user]

(Isaac, RoR, PoE, DoTA, LoL, etc)*

This is reference research to feed the build design — let me pull the real SNKRX roster from the source in the repo rather than work from memory, so the unit list is accurate.

Bash (cd E:/a327ex/SNKRX && echo "=== files ===" && ls *.lua 2>/dev/null && echo "" &&...)

=== files ===
arena.lua
buy_screen.lua
conf.lua
enemies.lua
main.lua
mainmenu.lua
media.lua
objects.lua
player.lua
shared.lua

=== likely data files ===
./arena.lua
./buy_screen.lua
./conf.lua
./enemies.lua
./engine/init.lua
./engine/sound.lua
./engine/system.lua
./main.lua
./mainmenu.lua
./media.lua
./objects.lua
./player.lua
./shared.lua

Grep (character_names|^\scharacters\s=|character_classes|class_color|'juggernaut'|\"juggernaut\")

Found 3 files
buy_screen.lua
main.lua
player.lua

Grep (character_names\s=|character_names[|^\scharacters\s*=)

275:  character_names = {

Read (E:/a327ex/SNKRX/main.lua)

275    character_names = {
276     ['vagrant'] = 'Vagrant',
277     ['swordsman'] = 'Swordsman',
278     ['wizard'] = 'Wizard',
279     ['magician'] = 'Magician',
280     ['archer'] = 'Archer',
281     ['scout'] = 'Scout',
282     ['cleric'] = 'Cleric',
283     ['outlaw'] = 'Outlaw',
284     ['blade'] = 'Blade',
285     ['elementor'] = 'Elementor',
286     ['saboteur'] = 'Saboteur',
287     ['bomber'] = 'Bomber',
288     ['stormweaver'] = 'Stormweaver',
289     ['sage'] = 'Sage',
290     ['squire'] = 'Squire',
291     ['cannoneer'] = 'Cannoneer',
292     ['dual_gunner'] = 'Dual Gunner',
293     ['hunter'] = 'Hunter',
294     ['sentry'] = 'Sentry',
295     ['chronomancer'] = 'Chronomancer',
296     ['spellblade'] = 'Spellblade',
297     ['psykeeper'] = 'Psykeeper',
298     ['engineer'] = 'Engineer',
299     ['plague_doctor'] = 'Plague Doctor',
300     ['barbarian'] = 'Barbarian',
301     ['juggernaut'] = 'Juggernaut',
302     ['lich'] = 'Lich',
303     ['cryomancer'] = 'Cryomancer',
304     ['pyromancer'] = 'Pyromancer',
... [90 more lines]

Now the ability descriptions and class tags for the melee/contact-relevant ones.

Grep (character_descriptions\s=|character_class_strings\s=|class_names\s=|^\sclasses\s*=)

515:  character_class_strings = {
590:  character_descriptions = {

Read (E:/a327ex/SNKRX/main.lua)

515    character_class_strings = {
516     ['vagrant'] = '[fg]Explorer, Psyker',
517     ['swordsman'] = '[yellow]Warrior',
518     ['wizard'] = '[blue]Mage, [red]Nuker',
519     ['magician'] = '[blue]Mage',
520     ['archer'] = '[green]Ranger',
521     ['scout'] = '[red]Rogue',
522     ['cleric'] = '[green]Healer',
523     ['outlaw'] = '[yellow]Warrior, [red]Rogue',
524     ['blade'] = '[yellow]Warrior, [red]Nuker',
525     ['elementor'] = '[blue]Mage, [red]Nuker',
526     -- ['saboteur'] = '[red]Rogue, [orange]Conjurer, [red]Nuker',
527     ['bomber'] = '[red]Nuker, [orange]Builder',
528     ['stormweaver'] = '[blue]Enchanter',
529     ['sage'] = '[red]Nuker, [yellow]Forcer',
530     ['squire'] = '[yellow]Warrior, [blue]Enchanter',
531     ['cannoneer'] = '[green]Ranger, [red]Nuker',
532     ['dual_gunner'] = '[green]Ranger, [red]Rogue',
533     -- ['hunter'] = '[green]Ranger, [orange]Conjurer, [yellow]Forcer',
534     ['sentry'] = '[green]Ranger, [orange]Builder',
535     ['chronomancer'] = '[blue]Mage, Enchanter',
536     ['spellblade'] = '[blue]Mage, [red]Rogue',
537     ['psykeeper'] = '[green]Healer, [fg]Psyker',
538     ['engineer'] = '[orange]Builder',
539     ['plague_doctor'] = '[red]Nuker, [purple]Voider',
540     ['barbarian'] = '[purple]Curser, [yellow]Warrior',
541     ['juggernaut'] = '[yellow]Forcer, Warrior',
542     ['lich'] = '[blue]Mage',
543     ['cryomancer'] = '[blue]Mage, [purple]Voider',
544     ['pyromancer'] = '[blue]Mage, [red]Nuker, [purple]Voider',
... [45 more lines]

Read (E:/a327ex/SNKRX/main.lua)

590    character_descriptions = {
591     ['vagrant'] = function(lvl) return '[fg]shoots a projectile that deals [yellow]' .. get_character_stat('vagrant', lvl, 'dmg') .. '[fg] damage' end,
592     ['swordsman'] = function(lvl) return '[fg]deals [yellow]' .. get_character_stat('swordsman', lvl, 'dmg') .. '[fg] damage in an area, deals extra [yellow]' ..
593       math.round(get_character_stat('swordsman', lvl, 'dmg')*0.15, 2) .. '[fg] damage per unit hit' end,
594     ['wizard'] = function(lvl) return '[fg]shoots a projectile that deals [yellow]' .. get_character_stat('wizard', lvl, 'dmg') .. ' AoE[fg] damage' end,
595     ['magician'] = function(lvl) return '[fg]creates a small area that deals [yellow]' .. get_character_stat('magician', lvl, 'dmg') .. ' AoE[fg] damage' end,
596     ['archer'] = function(lvl) return '[fg]shoots an arrow that deals [yellow]' .. get_character_stat('archer', lvl, 'dmg') .. '[fg] damage and pierces' end,
597     ['scout'] = function(lvl) return '[fg]throws a knife that deals [yellow]' .. get_character_stat('scout', lvl, 'dmg') .. '[fg] damage and chains [yellow]3[fg] times' end,
598     ['cleric'] = function(lvl) return '[fg]creates [yellow]1[fg] healing orb every [yellow]8[fg] seconds' end,
599     ['outlaw'] = function(lvl) return '[fg]throws a fan of [yellow]5[fg] knives, each dealing [yellow]' .. get_character_stat('outlaw', lvl, 'dmg') .. '[fg] damage' end,
600     ['blade'] = function(lvl) return '[fg]throws multiple blades that deal [yellow]' .. get_character_stat('blade', lvl, 'dmg') .. ' AoE[fg] damage' end,
601     ['elementor'] = function(lvl) return '[fg]deals [yellow]' .. get_character_stat('elementor', lvl, 'dmg') .. ' AoE[fg] damage in a large area centered on a random target' end,
602     ['saboteur'] = function(lvl) return '[fg]calls [yellow]2[fg] saboteurs to seek targets and deal [yellow]' .. get_character_stat('saboteur', lvl, 'dmg') .. ' AoE[fg] damage' end,
603     ['bomber'] = function(lvl) return '[fg]plants a bomb, when it explodes it deals [yellow]' .. 2*get_character_stat('bomber', lvl, 'dmg') .. ' AoE[fg] damage' end,
604     ['stormweaver'] = function(lvl) return '[fg]infuses projectiles with chain lightning that deals [yellow]20%[fg] damage to [yellow]2[fg] enemies' end,
605     ['sage'] = function(lvl) return '[fg]shoots a slow projectile that draws enemies in' end,
606     ['squire'] = function(lvl) return '[yellow]+20%[fg] damage and defense to all allies' end, 
607     ['cannoneer'] = function(lvl) return '[fg]shoots a projectile that deals [yellow]' .. 2*get_character_stat('cannoneer', lvl, 'dmg') .. ' AoE[fg] damage' end,
608     ['dual_gunner'] = function(lvl) return '[fg]shoots two parallel projectiles, each dealing [yellow]' .. get_character_stat('dual_gunner', lvl, 'dmg') .. '[fg] damage' end,
609     ['hunter'] = function(lvl) return '[fg]shoots an arrow that deals [yellow]' .. get_character_stat('hunter', lvl, 'dmg') .. '[fg] damage and has a [yellow]20%[fg] chance to summon a pet' end,
610     ['sentry'] = function(lvl) return '[fg]spawns a rotating turret that shoots [yellow]4[fg] projectiles, each dealing [yellow]' .. get_character_stat('sentry', lvl, 'dmg') .. '[fg] damage' end,
611     ['chronomancer'] = function(lvl) return '[yellow]+20%[fg] attack speed to all allies' end,
612     ['spellblade'] = function(lvl) return '[fg]throws knives that deal [yellow]' .. get_character_stat('spellblade', lvl, 'dmg') .. '[fg] damage, pierce and spiral outwards' end,
613     ['psykeeper'] = function(lvl) return '[fg]creates [yellow]3[fg] healing orbs every time the psykeeper takes [yellow]25%[fg] of its max HP in damage' end,
614     ['engineer'] = function(lvl) return '[fg]drops turrets that shoot bursts of projectiles, each dealing [yellow]' .. get_character_stat('engineer', lvl, 'dmg') .. '[fg] damage' end,
615     ['plague_doctor'] = function(lvl) return '[fg]creates an area that deals [yellow]' .. get_character_stat('plague_doctor', lvl, 'dmg') .. '[fg] damage per second' end,
616     ['barbarian'] = function(lvl) return '[fg]deals [yellow]' .. get_character_stat('barbarian', lvl, 'dmg') .. '[fg] AoE damage and stuns enemies hit for [yellow]4[fg] seconds' end,
617     ['juggernaut'] = function(lvl) return '[fg]deals [yellow]' .. get_character_stat('juggernaut', lvl, 'dmg') .. '[fg] AoE damage and pushes enemies away with a strong force' end,
618     ['lich'] = function(lvl) return '[fg]launches a slow projectile that jumps [yellow]7[fg] times, dealing [yellow]' ..  2*get_character_stat('lich', lvl, 'dmg') .. '[fg] damage per hit' end,
619     ['cryomancer'] = function(lvl) return '[fg]nearby enemies take [yellow]' .. get_character_stat('cryomancer', lvl, 'dmg') .. '[fg] damage per second' end,
... [40 more lines]

Let me grab the SNKRX class set-bonuses (Warrior, Forcer, Nuker, etc.) since those define how melee/contact synergies stack.

Grep (class_descriptions|set_descriptions|class_set|['warrior']|['forcer']|['nuker'])

238:    ['warrior'] = yellow[0],
243:    ['nuker'] = red[0],
248:    ['forcer'] = yellow[0],
257:    ['warrior'] = 'yellow',
262:    ['nuker'] = 'red',
267:    ['forcer'] = 'yellow',
954:    ['warrior'] = {hp = 1.4, dmg = 1.1, aspd = 0.9, area_dmg = 1, area_size = 1, def = 1.25, mvspd = 0.9},
959:    ['nuker'] = {hp = 0.9, dmg = 1, aspd = 0.75, area_dmg = 1.5, area_size = 1.5, def = 1, mvspd = 1},
963:    ['forcer'] = {hp = 1.25, dmg = 1.1, aspd = 0.9, area_dmg = 0.75, area_size = 0.75, def = 1.2, mvspd = 1},
990:  class_descriptions = {
992:    ['warrior'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']+25[light_bg]/[' .. ylb2(lvl) .. ']+50 [fg]defense to allied warriors' end,
997:    ['nuker'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']+15%[light_bg]/[' .. ylb2(lvl) .. ']+25% [fg]area damage and size to allied nukers' end,
1001:    ['forcer'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+25%[light_bg]/[' .. ylb2(lvl) .. ']+50% [fg]knockback force to all allies' end,
1178:  class_set_numbers = {
1180:    ['warrior'] = function(units) return 3, 6, nil, get_number_of_units_per_class(units).warrior end,
1182:    ['nuker'] = function(units) return 3, 6, nil, get_number_of_units_per_class(units).nuker end,
1189:    ['forcer'] = function(units) return 2, 4, nil, get_number_of_units_per_class(units).forcer end,

Read (E:/a327ex/SNKRX/main.lua)

990    class_descriptions = {
991     ['ranger'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']8%[light_bg]/[' .. ylb2(lvl) .. ']16% [fg]chance to release a barrage on attack to allied rangers' end,
992     ['warrior'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']+25[light_bg]/[' .. ylb2(lvl) .. ']+50 [fg]defense to allied warriors' end,
993     ['mage'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']-15[light_bg]/[' .. ylb2(lvl) .. ']-30 [fg]enemy defense' end,
994     ['rogue'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']15%[light_bg]/[' .. ylb2(lvl) .. ']30% [fg]chance to crit to allied rogues, dealing [yellow]4x[] damage' end,
995     ['healer'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+15%[light_bg]/[' .. ylb2(lvl) .. ']+30% [fg] chance to create [yellow]+1[fg] healing orb on healing orb creation' end,
996     ['enchanter'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+15%[light_bg]/[' .. ylb2(lvl) .. ']+25% [fg]damage to all allies' end,
997     ['nuker'] = function(lvl) return '[' .. ylb1(lvl) .. ']3[light_bg]/[' .. ylb2(lvl) .. ']6 [fg]- [' .. ylb1(lvl) .. ']+15%[light_bg]/[' .. ylb2(lvl) .. ']+25% [fg]area damage and size to allied nukers' end,
998     ['conjurer'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+25%[light_bg]/[' .. ylb2(lvl) .. ']+50% [fg]construct damage and duration' end,
999     ['psyker'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+2[light_bg]/[' .. ylb2(lvl) .. ']+4 [fg]total psyker orbs and [yellow]+1[fg] orb for each psyker' end,
1000        ['curser'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+1[light_bg]/[' .. ylb2(lvl) .. ']+3 [fg]max curse targets to allied cursers' end,
1001        ['forcer'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+25%[light_bg]/[' .. ylb2(lvl) .. ']+50% [fg]knockback force to all allies' end,
1002        ['swarmer'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+1[light_bg]/[' .. ylb2(lvl) .. ']+3 [fg]hits to critters' end,
1003        ['voider'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+20%[light_bg]/[' .. ylb2(lvl) .. ']+40% [fg]damage over time to allied voiders' end,
1004        ['sorcerer'] = function(lvl) 
1005          return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4[light_bg]/[' .. ylb3(lvl) .. ']6 [fg]- sorcerers repeat their attacks once every [' .. 
1006            ylb1(lvl) .. ']4[light_bg]/[' .. ylb2(lvl) .. ']3[light_bg]/[' .. ylb3(lvl) .. ']2[fg] attacks'
1007        end,
1008        ['mercenary'] = function(lvl) return '[' .. ylb1(lvl) .. ']2[light_bg]/[' .. ylb2(lvl) .. ']4 [fg]- [' .. ylb1(lvl) .. ']+8%[light_bg]/[' .. ylb2(lvl) .. ']+16% [fg]chance for enemies to drop gold on death' end,
1009        ['explorer'] = function(lvl) return '[yellow]+15%[fg] attack speed and damage per active class to allied explorers' end,
1010      }
1011    
1012      tier_to_characters = {
1013        [1] = {'vagrant', 'swordsman', 'magician', 'archer', 'scout', 'cleric', 'arcanist', 'merchant'},
1014        [2] = {'wizard', 'bomber', 'sage', 'squire', 'dual_gunner', 'sentry', 'chronomancer', 'barbarian', 'cryomancer', 'beastmaster', 'jester', 'carver', 'psychic', 'witch', 'silencer', 'outlaw', 'miner'},
1015        [3] = {'elementor', 'stormweaver', 'spellblade', 'psykeeper', 'engineer', 'juggernaut', 'pyromancer', 'host', 'assassin', 'bane', 'barrager', 'infestor', 'flagellant', 'artificer', 'usurer', 'gambler'},
1016        [4] = {'priest', 'highlander', 'psykino', 'fairy', 'blade', 'plague_doctor', 'cannoneer', 'vulcanist', 'warden', 'corruptor', 'thief'},
1017      }
1018    
1019      non_attacking_characters = {'cleric', 'stormweaver', 'squire', 'chronomancer', 'sage', 'psykeeper', 'bane', 'carver', 'fairy', 'priest', 'flagellant', 'merchant', 'miner'}

Good call — pulling from the actual SNKRX source so the roster is exact, not from memory. One framing insight up front that changes how you read all of this:

SNKRX has no true "move-into-enemy contact damage" mechanic. In SNKRX, enemies damage you on contact; your snake never deals damage just by touching. What SNKRX calls "melee" is really self-centered AoE bursts + knockback. So Mini Looper's ram (the body deals damage by sweeping) is genuinely novel relative to SNKRX — the SNKRX units below are flavor + knockback/AoE/aura analogues, and the truer contact-damage references come from the other games.


SNKRX units that fit the ramming/melee archetype

Pulled from SNKRX/main.lua. Grouped by how they'd feed a ramming build (class tags are SNKRX's real ones):

Core melee / on-self AoE

The knockback / push unit (the literal rammer)

Body damage auras (already an in-engine pattern!)

Self-damage / risk-melee flavor

The classes that ARE the ramming build (set-bonuses): | Class | Bonus | Relevance | |---|---|---| | Warrior (3/6) | +25/+50 defense to warriors | tankiness — the melee survivability core | | Forcer (2/4) | +25%/+50% knockback force to ALL allies | the knockback amplifier — directly scales the ram fling | | Nuker (3/6) | +15%/+25% area damage & size | turns each contact-AoE bigger (crush-radius amplifier) | | Rogue (3/6) | 15%/30% crit for 4× damage | spike damage on the sweep | | Curser (2/4) | +curse targets | stun/debuff stacking (Barbarian) |

So "the SNKRX ramming team" = Warrior + Forcer core (Swordsman / Blade / Barbarian / Juggernaut / Highlander) with Nuker widening the bursts and an aura unit (Cryo/Pyro) layered on.


Contact-damage abilities across games — by mechanic family

I've organized by mechanic family rather than by game, because each family is a candidate modifier for the ramming build. Sources span Isaac / RoR2 / PoE / DoTA2 / LoL.

1. Move-into-contact body damage (the core fantasy)

2. Speed / distance → damage or knockback scaling (you already do speed→force)

3. Knockback / fling on contact

4. Damage aura around the body (passive proximity)

5. Thorns / retaliate when touched (requires a damageable train — see below)

6. Cleave / splash (one hit spreads to neighbors)

7. On-kill chain explosion (sweep kills a line → it cascades)

8. Trail left behind (ties to the loop!)

9. Stacks / count → power (your block-count scaling)

10. Glass-cannon risk-melee (requires a damageable train)


How these map onto Mini Looper's on-rails train


So the ramming build's modifier menu, distilled from all of the above, is roughly: block-count → {contact damage, speed, knockback}, plus optional layers of {body aura, crush/cleave radius, on-kill chain, scorched trail}, with an open question on thorns/damageable-train.

Which of these families do you want the first hardcoded ramming build to actually include? I'd keep v1 tight — count-scaling + one or two spectacle layers — so we're testing a clean idea, not a kitchen sink. Once you pick, I'll fold them into the design brief. (And once we've settled the build, I can drop this whole reference into mini-looper/reference/ramming.md so it's not just in chat.)

Units, I'm describing them with their "unlocks", which is similar to SNKRX's Lv.3 upgrades, I'm not sure if this game will have those yet, but I think adding them here costs nothing and will make the build more interesting.

  • Juggernaut - no damage dealt normally (no area, no projectile, etc), but instant kill when damaging enemies via contact, special unlock: Charge - +10 decaying speed to the train (+1 speed = +10 speed in terms of internal values) - this unit is good for placing at the front of the train
  • Swordsman - like SNKRX, special unlock: Cleave - +1 damage per unit hit
  • Earthshaker - stuns units close to the train, special unlock: Earthshatter - a second attack after the first, stunned units take damage according to how many are close
  • Detonator - a random inert block detonates, dealing area damage around it, special unlock: [to be named] - all inert blocks detonate
  • Poisoner - leave a trail of poison behind that deals damage over time, special unlock: [to be named] - the poison deals more damage
  • Psylooper - manifests a hand of force (homing beam) that grabs an enemy and places it permanently in a position on the loop, special unlock: [to be named] - manifests multiple hands at the same time

Modifiers:

  • +speed
  • +speed per inert block
  • +contact damage
  • +contact damage per inert block
  • Envelop the train in an aura around it that deals damage once enemies enter or exit it
  • +damage to enemies near the train (point blank but for all damage)
  • Enemies killed on contact damage by the train are crushed, dealing area damage around them

What do you think? Anything important missing?

This is a strong set — but it quietly redefines the build, and naming that makes everything else click into place:

The inert blocks + the modifiers ARE the ramming build. The six named units are active toys layered on top. Only Juggernaut is true contact damage; Swordsman/Earthshaker/Detonator are periodic AoE, Poisoner is trail-DoT, Psylooper is a grab. So the archetype's core identity (contact damage, count-scaling) lives entirely in the blocks + modifiers, and the units are force-multipliers and spice. That's a clean structure — few named units up front, MANY inert blocks behind (which is also what makes "+per inert block" actually scale). Good. Let me go through them.

Per-unit takes

Modifiers — quick take

Solid, and two are smarter than they look:

The count-scaling four (+speed, +speed/block, +contact, +contact/block) are exactly the archetype core. Crush-on-kill (ram-kills only) correctly stays tied to the ramming identity.

What's missing — the important part

1. Knockback is the central knob you haven't named — and it splits the build in two. Lean on the synergies and they pull apart into two coherent ramming sub-flavors:

These want opposite knockback values — Fling wants it high, Grind wants it near zero (knockback flings enemies off your poison and out of your density bonuses). Right now knockback is a baked-in constant (speed-scaled, always on). It needs to become a signed, build-defining tunable, not just a "+knockback" modifier. And for a clean v1 fun-test, pick ONE sub-flavor — mixing them muddies both readings.

2. The body-as-wall defensive identity — your own ideas.md core — is absent here. Every modifier you listed is offensive. But "more inert blocks = a longer physical wall that blocks more enemies from the base" is pure ramming, costs nothing (it's already how collision works), and it's the defensive half of "more blocks = more power." That's arguably the most ramming-pure scaling of all, and it's missing from the list.

3. No block-growth driver. Nothing in the roster adds blocks mid-run, so block count is fixed at build time. The "snake grows → gets stronger" fantasy needs an engine (a unit or building that appends blocks over time). Defer for the hardcoded test (we just spawn N blocks), but it's the archetype's missing economy — flagging it because it connects to the RTS/building direction in ideas.md.

4. The damageable-train / risk question is still open. None of this makes the train vulnerable, so the build has zero personal risk — the only stakes are "clear fast enough or the base dies." That's a legitimate model (throughput vs base HP, not train survival), but it means thorns/glass-cannon stay off the table and the power fantasy has no downside. Fine for the fun-test; just naming it so it's a choice, not an accident.

Scope — don't build all six before the first fun-test

Six units + unlocks + a stun state + a trail system + detonation + a grab is a production-sized chunk, and building it all before playing it once is the exact "finish the system before testing the fun" pattern the gate exists to stop. I'd build a v1 ramming core — inert blocks + the count-scaling modifiers + Juggernaut (front carver) + one spectacle layer — get it on screen, fun-test the sweep, then tier in the rest.

My proposed v1, depending on which sub-flavor you want to validate first:

So, four forks before I write the brief:

  1. Fling or Grind for the first fun-test? (I lean Fling — no new enemy states, reuses what's built, fastest to on-screen; Grind is the better perf test but needs the trail system first.)
  2. Juggernaut: huge contact damage (modifiers stay meaningful) or literal instakill?
  3. Detonation non-destructive (block stays) or consumes the block?
  4. Psylooper: reserve for the mage build, or simplify-and-keep for ramming?

Let's test the exact unit and modifiers I mentioned, if we still have more context when we're done we'll try another flavor of a contact build that's based on knockback. Juggernaut with huge contact damage, sure, detonation is non-destructive, and let's try to implement the Psylooper without simplifying, but let's leave him for last. Let's implement one unit at a time.

Locked: exact units + modifiers as listed (GRIND flavor), Juggernaut = huge contact damage, detonation non-destructive, Psylooper full-fat but last, one unit at a time. Knockback flavor is a separate later test (which is also why none of these modifiers touch knockback — this flavor wants it low).

One ordering note before the units: the inert blocks + the count-scaling modifiers have to come first, because they're the foundation every unit sits on — Juggernaut goes "at the front" of a block train, and "+X per inert block" needs blocks to count. So I'd sequence it:

Increment 1 — blocks + scaling core (foundation) → 2 — Juggernaut3 — Swordsman4 — Earthshaker5 — Detonator6 — Poisoner7 — Psylooper. The three spice modifiers (enter/exit aura, proximity-all-damage, crush-on-kill) slot in as their own small increments alongside the units they pair with.

Here's the brief for Increment 1.

Design brief — Increment 1: inert blocks + ramming scaffold + count-scaling modifiers

Approach

A new inert block unit becomes the train body: no attack, flat steel look (so it reads as dead weight / armor, distinct from the breathing colored attackers). The train is rebuilt as a long snake of blocks. A small set of ramming config globals (the modifiers) plus one ramming_apply_scaling() function set the train's contact_damage / max_speed from base + per_block · block_count — so a longer snake hits harder and sweeps faster. The existing main.lua ram handler already reads exactly those globals, so the sweep mechanic itself needs no change — the build just sets the numbers.

Knockback stays modest this flavor (grind wants enemies to stay in the kill-zone, not get flung) — I'll leave train_ram_force at a low baseline and not scale it per-block; the speed-coupling already gives a gentle shove as a side effect, which we'll watch and tune.

Interfaces

Functions

Minimal test harness (needed to actually see it)

The E key spawns one enemy and the director only trickles — neither shows a snake mowing a crowd. I'll add one debug key (b) that bursts ~20 enemies at the edges, so increment 1 is testable against a pack. This is the minimal harness; the faithful beat-jump/uncap version we can add when we test the real difficulty curve.

Defaults I'll use (all trivially tunable — flag any you want different)

If those defaults are fine, say go and I'll write Increment 1. Otherwise tell me what to change first.

Do like 8 ramming blocks, make blocks have size 1, 2 or 3. A size 3 block means it's a rectangle the length of the 3 units, but itself is only a single unit. Interleave (not perfectly) blocks with the current units the snake has, don't do blocks first then units, or anything like that. Keep spacing at the previous value (I think it was 12). Speed = +10 per block. Damage = +2 per block.

Got it — these change the block from a uniform body into a variable-length segment, which adds one real structural change to the snake. Here's the updated brief for Increment 1.

Updated brief — Increment 1: variable-size blocks + interleaved roster + scaling

What changed from the prior brief

Variable-size blocks (the one structural change)

A block is one unit / one collider / one train segment whose body is a rectangle size × 10 long (along travel) by 10 wide — so size-1 = 10×10, size-2 = 20×10, size-3 = 30×10. The collider matches and rotates to the travel heading, so a longer block is a wider rammer (it contacts more enemies per sweep) and a longer wall — size buys physical coverage, not stats.

That breaks one current assumption: followers are pinned at (index-1)·spacing back, which assumes every segment is the same length. With long blocks, a size-3 has to push everything behind it ~3× further back. So each unit gains a cumulative trail_offset = the summed lengths (+gaps) of every segment ahead of it; followers pin to trail_point_at(trail_offset) instead of (index-1)·spacing. For uniform size-1 units this reduces to exactly the old (index-1)·spacing, so nothing else about the snake changes — it's a strict generalization. train_build computes these offsets once (used for both initial stringing and live pinning); the trail-trim length uses the snake's true total length.

Interleaved roster

setup_ramming_build() builds a 14-segment roster = the 6 existing attackers (archer/magician/cannoneer/outlaw/geomancer/archer) + 8 blocks, each block assigned a random size in {1,2,3}, then shuffled so blocks and attackers mix irregularly (not a clean ABAB, not all-blocks-then-units). Roster entries become either a string (attacker, size 1) or {character='block', size=N}; train_build handles both. (Juggernaut will claim the front slot in Increment 2; for now the head is whatever the shuffle lands.)

Scaling

ramming_apply_scaling() counts blocks only (not the attackers) and sets train_max_speed = base + 10·blocks, train_contact_damage = base + 2·blocks. With 8 blocks that's +80 speed / +16 damage over the bases (proposing base speed 180 → 260, base contact 4 → 20). train_ram_force stays modest (4) — this flavor wants low knockback.

One fork: you said "+10/+2 per block" and stressed a size-3 is "only a single unit," so I'll default to per block entity (8 blocks = +80/+16, block size affects only physical coverage). If you instead want size to weight power (sum of sizes, so a size-3 counts as 3), it's a one-line change — say which.

Files touched (delta)

I'll build it exactly this way. Only thing I need from you is the per-block-vs-per-size fork above (default = per block) — give me a go and I'll write it.

Go ahead.

Implementing Increment 1 with per-block scaling. Let me first verify two framework details (array.shuffle and a steel palette token), then write the changes.

Read (E:/a327ex/mini-looper/anchor/array.lua)

1    --[[
2     Array utilities for the list/array part of Lua tables.
3   
4     All operations that modify the array do so in-place.
5     If you need to preserve the original, copy it first.
6   
7     Array functions:
8       all                       - check if all elements pass predicate
9       any                       - check if any element passes predicate
10      average                   - compute average of values
11      count                     - count elements or occurrences
12      delete                    - remove all instances of value
13      flatten                   - flatten nested arrays
14      get                       - get element(s) by index with negative support
15      get_circular_buffer_index - wrap index for circular buffer
16      has                       - check if element exists
17      index                     - find first index of element
18      join                      - concatenate to string
19      max                       - find maximum value
20      print                     - debug print array
21      random                    - get random element(s)
22      remove                    - remove element at index
23      remove_random             - remove random element(s)
24      reverse                   - reverse array order
25      rotate                    - shift elements circularly
26      shuffle                   - randomize order
27      sum                       - compute sum of values
28  
29    Table utilities:
30      table.copy     - deep copy a table
... [615 more lines]

array.shuffle exists. Now the palette tokens to pick a steel color for blocks.

Grep (snkrx_neutral|steel|iron|grey|gray|silver|^\spalette\s=|palette_token_names|\bsnkrx_\w+)

8:       project's locked palette (9 chrome / grayscale ladder + 9 accents
13:       `palette_token_names` list, the `palette_token_index` (0-based)
39:      palette_token_names below. If you ADD a token, append it to the
51:-- Chrome / grayscale ladder, dark → light. Use these for backgrounds,
61:grey          = color(74, 77, 80)
86:-- the canonical accents above (snkrx_blue is a bright teal, NOT the canonical
88:-- snkrx_blue. Use the canonical accents for new work where a clear hue exists.
90:snkrx_blue    = color( 82, 179, 203)
91:snkrx_purple  = color(178, 108, 161)
92:snkrx_red     = color(216,  70,  84)
93:snkrx_green   = color(121, 177,  89)
94:snkrx_orange  = color(244, 144,  62)
95:snkrx_yellow  = color(255, 184,  51)
96:snkrx_blue2   = color( 71, 120, 186)
97:snkrx_neutral = color(176, 168, 159)
102:-- palette_token_names ORDER == the shader's u_palette[] index order. The
107:palette_token_names = {
109:  'bg', 'slot_fill', 'panel_fill', 'charcoal', 'panel_border', 'grey',
115:  'snkrx_blue', 'snkrx_purple', 'snkrx_red', 'snkrx_green', 'snkrx_orange',
116:  'snkrx_yellow', 'snkrx_blue2', 'snkrx_neutral',
121:palette = {
123:  panel_border = panel_border, grey = grey, text_muted = text_muted,
127:  snkrx_blue = snkrx_blue, snkrx_purple = snkrx_purple, snkrx_red = snkrx_red,
128:  snkrx_green = snkrx_green, snkrx_orange = snkrx_orange,
129:  snkrx_yellow = snkrx_yellow, snkrx_blue2 = snkrx_blue2,
130:  snkrx_neutral = snkrx_neutral,
136:for i, n in ipairs(palette_token_names) do
140:palette_token_count = #palette_token_names
158:-- grayscale ladder (a lightness shimmer); accents partner with the next hue on
166:  charcoal = 'panel_border', panel_border = 'grey', grey = 'text_muted',
... [4 more lines]

Read (E:/a327ex/mini-looper/snkrx/palette.lua)

51   -- Chrome / grayscale ladder, dark → light. Use these for backgrounds,
52  -- panels, borders, and text. The 9-step ladder gives enough resolution to
53  -- distinguish UI strata without going cluttered. It also doubles as a
54  -- LIGHTNESS ramp: mixing a token with its neighbor in this list is a
55  -- lightness breathe (used by the UI's optional shimmer — see ui/paint.lua).
56  bg            = color(34, 40, 46)
57  slot_fill     = color(27, 31, 34)
58  panel_fill    = color(31, 33, 38)
59  charcoal      = color(45, 48, 51)
60  panel_border  = color(56, 60, 65)
61  grey          = color(74, 77, 80)
62  text_muted    = color(140, 140, 145)
63  white         = color(200, 200, 200)
64  text          = color(240, 240, 235)
65  
66  -- Accents, roughly by hue (a hue wheel: red → … → pink → back to red).
67  -- Mixing an accent with its neighbor here is a warm/cool hue breathe — the
68  -- SNKRX "polychrome" feel — which is how the entity specs reproduce the old
69  -- edition shimmer.
70  red           = color(210,  49,  56)
71  orange        = color(233, 108,  27)
72  yellow        = color(234, 171,  28)
73  lime          = color(148, 176,  41)
74  green         = color(106, 166,  52)
75  light_blue    = color( 35, 143, 194)
76  blue          = color( 39,  88, 180)
77  violet        = color(110,  63, 178)
78  pink          = color(208,  66, 120)
79  
80  -- Aliases that some Anchor 2 helpers expect (memory tracker etc.). Cheap.
... [5 more lines]

array.shuffle (Fisher-Yates) is available, and text_muted (cool grey) is the steel token. Writing Increment 1 now.

First the new builds.lua:

Write (E:/a327ex/mini-looper/builds.lua)

File created successfully at: E:/a327ex/mini-looper/builds.lua (file state is current in your context — no need to Read it back)

Now the units.lua edits — block_spec, the registry entry, and the two inert-def guards:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now train.lua — globals, the sized-segment constructor, cumulative-offset build, follower pinning, and trail trim:

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now main.lua — require builds, swap the scene's train build, and add the burst-spawn debug key:

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

All edits are in. Syntax-checking the changed Lua files with loadfile (compiles without running the engine):

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   builds.lua
OK   units.lua
OK   train.lua
OK   main.lua

All four compile cleanly. Let me set the knockback low for this grind flavor (the brief called for a modest train_ram_force, and the speed-coupling amplifies it at high speed):

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK builds.lua

Increment 1 is in and compiles. Here's what changed and how to test it.

What I built

The ramming foundation — inert variable-length blocks, interleaved with the attacker units, with count-scaling:

How to test

Run it (./run.bat), then:

  1. b — burst-spawn ~20 enemies. Watch the snake sweep through them. Check: do the variable-size blocks read (the steel segments of different lengths)? Does the snake track the loop at the higher speed (260) without the long blocks cutting corners badly? Do enemies die to contact as it passes?
  2. Hold/spam b to pile on a real crowd — this is the perf look. Watch the framerate as enemy count climbs (the O(n²) enemy steering is the thing I expect to bite first, not the train).
  3. F1 — the tuning panel still shows SPEED/contact; note the train is now at 260 speed / 20 contact.
  4. e single enemy, r restart, F2 collider overlay (good for seeing the long block hitboxes).

Two things to flag

Give it a run and tell me how the sweep feels — then I'll do Increment 2: Juggernaut (front carver, huge contact damage, Charge speed-burst).

Give me knobs to toggle each modifier that's applying to the train on and off. Inert blocks should be white.

[Request interrupted by user]

You can remove ATTACK and COOLDOWNS from the tuning panel as those have already been settled.

Good notes — toggling modifiers in isolation is exactly how you find which ones carry the feel. Three changes: restructure the modifiers so each is independently toggleable, make blocks white, and rebuild the F1 panel (drop ATTACK/COOLDOWNS, add the modifier toggles).

First builds.lua — modifiers become toggleable terms:

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now blocks white in units.lua:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now the F1 panel rewrite in main.lua — header/size, then the column content:

Edit (E:/a327ex/mini-looper/main.lua)

<tool_use_error>String to replace not found in file.
String:   -- TUNING UI — sliders to dial the train's feel live. Each maps a 0..1 slider
  -- onto a real range and writes the global the train reads. Each maps a 0..1
  -- slider onto a real range and writes the global the train reads.
  -- ui_slider runs its hit-test here in update() (mouse edge events are
  -- update-only). EASE P / EASE T are the velocity-ease (path smoothing):
  -- higher T (and lower P) = the travel direction turns slower = rounder,
  -- looser path.
  if tuning_ui_visible then
    local panel = rect_new(6, 6, 232, 196)
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>

Let me re-read the exact panel text before editing.

Read (E:/a327ex/mini-looper/main.lua)

192  
193   -- Refresh main_camera.mouse.{x,y} before anything reads it — the
194   -- player's mouse-aim does.
195   camera_update(main_camera, sdt)
196 
197   -- TUNING UI — sliders to dial the train's feel live. Each maps a 0..1
198   -- slider onto a real range and writes the global the train reads.
199   -- ui_slider runs its hit-test here in update() (mouse edge events are
200   -- update-only). EASE P / EASE T are the velocity-ease (path smoothing):
201   -- higher T (and lower P) = the travel direction turns slower = rounder,
202   -- looser path.
203   if tuning_ui_visible then
204     local panel = rect_new(6, 6, 232, 196)
205     ui_panel({ rect = panel })
206     local body  = rect_contract(panel, 7)
207     local colw  = math.floor((body.w - 8)/2)
208     local left  = rect_new(body.x, body.y, colw, body.h)
209     local right = rect_new(body.x + colw + 8, body.y, body.w - colw - 8, body.h)
210     -- A labelled slider that maps a 0..1 track onto [lo, hi] and writes back.
211     local function tuner(col, key, label, val, lo, hi, fmt)
212       local lr = rect_cut_top(col, 11)
213       local sr = rect_cut_top(col, 12)
214       rect_cut_top(col, 3)
215       ui_text({ rect = lr, text = string.format('%s  '..(fmt or '%.0f'), label, val),
216                 font = ui_font_body_ark })
217       local res = ui_slider({ rect = sr, id = key, value = (val - lo)/(hi - lo) })
218       return lo + res.value*(hi - lo)
219     end
220 
221     -- LEFT column: train movement + per-unit attack toggles.
... [25 more lines]

Now I have the exact text. Two edits:

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now syntax-check the three changed files:

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   builds.lua
OK   units.lua
OK   main.lua

All compile. Done.

What changed

How to test

Run it, press F1, then b to burst a crowd. Toggle each modifier checkbox on/off and watch the SPEED/CONTACT readouts and the snake's behavior change — e.g. turn everything off to see the bare snake barely chip enemies, then flip +DMG/BLK alone to feel the per-block contact ramp. Blocks should now read as white segments in the snake.

One thing to know: the SEEK slider is still live, but SPEED is no longer a slider (it's modifier-driven) — so to change raw speed now you toggle modifiers or edit the ram_speed_* values in builds.lua.

Tell me how isolating the modifiers feels, then I'll move to Increment 2: Juggernaut.

It works and already feels pretty fun to watch, need to work on the juice of it all a little but that's for another session. Go ahead.

That's the gate read I wanted to hear — fun to watch on a real sitting, and you're correctly parking juice. Onto Increment 2: Juggernaut. It's a new mechanic (a triggered train-speed buff) so here's a tight brief first — mainly so you can veto the Charge model, since I had to pick how it triggers.

Design brief — Increment 2: Juggernaut

Approach

A front-of-train carver: deals huge contact damage (no projectile/area — its whole offense is the ram), and its unlock Charge gives the train a decaying speed boost while it's plowing.

Charge (the unlock) — the one decision to confirm

"+10 decaying speed (+1 = +10 internal)" → +100 internal px/s, decaying. I'm triggering it on contact (the Juggernaut's only action) as a refresh-to-max decaying boost: each enemy it touches sets a train_speed_boost to +100; the boost decays back to 0 over ~1.25s. So plowing the horde = train pinned fast; break into open field = settles back down. Effective speed = train_max_speed + boost, used by the head's movement and the turn-coupling (so it corners harder while charging). With all modifiers on, base 260 + 100 = 360, which is exactly the existing speed cap the train's already tuned to handle — it tops out cleanly.

I picked contact-refresh over the two alternatives — periodic (charges on a timer even when idle — less connected to the action) and per-kill stacking (can spike past the loop's trackable speed). If you'd rather have one of those, say so.

Charge is on by default (jugg_charge_unlocked = true, a global). I'm not adding a panel toggle for it (it's a unit unlock, not one of the 7 train modifiers) — but I can if you want to A/B it.

Files touched

Watch item

+100 while plowing pushes the train to its 360 cap — if it overshoots corners at that speed, we dial the boost or decay down. Easy tune.

Give me a go (or redirect the Charge model) and I'll write it.

Go ahead.

Implementing Increment 2. Starting with units.lua — the Juggernaut spec, def + constants, and the ram hook:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now train.lua — boost globals, the effective-speed helper, the per-unit damage read, and wiring the boost into movement:

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/train.lua)

The file E:/a327ex/mini-looper/train.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now builds.lua — pin Juggernaut to the head:

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now main.lua — the ram handler (per-unit damage, ram hook, push only living) and the SPEED readout:

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Syntax-check all four changed files:

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   builds.lua
OK   units.lua
OK   train.lua
OK   main.lua

All compile. Let me verify one assumption the push-guard depends on — that entity_kill marks the enemy _dying/_dead synchronously, so a one-shot Juggernaut hit is recognized as dead in the same frame:

Grep (function entity_kill|dying\s*=|dead\s*=|function default_kill)

anchor\object.lua:62:local function default_kill(self)
anchor\object.lua:64:  self._dying = true
anchor\object.lua:87:function entity_kill(e)
anchor\object.lua:105:      e._dead = true

Read (E:/a327ex/mini-looper/anchor/object.lua)

60     that takes precedence.
61  ]]
62  local function default_kill(self)
63    if self._dying then return end
64    self._dying = true
65    destroy_queue[#destroy_queue + 1] = self
66  end
67  
68  --[[
69    make_entity(e)
70    Assigns e.id and registers e in the global entities table.
71    Also installs a default kill method if none exists.
72    Returns e for chaining.
73  ]]
74  function make_entity(e)
75    e.id = next_id
76    next_id = next_id + 1
77    entities[e.id] = e
78    if not e.kill then e.kill = default_kill end
79    return e
80  end
81  
82  --[[
83    entity_kill(e)
84    Free function equivalent to e:kill(). Useful if you have a plain
85    entity table and don't want to use method syntax.
86  ]]
87  function entity_kill(e)
88    default_kill(e)
89  end
... [20 more lines]

Confirmed — entity_kill sets _dying synchronously (object.lua:64), so the push-guard correctly recognizes a one-shot Juggernaut kill in the same frame and skips the fling. Increment 2 is in and verified.

What I built — Juggernaut (front carver)

How to test

Run it, then:

  1. b to burst a crowd. Watch the gold head at the front: enemies it touches should vanish (one-shot, no fling), while the white body blocks fling/grind the rest.
  2. F1 → watch the SPEED readout: it should jump toward 360 while the head is in the crowd (Charge) and fall back to ~260 when it breaks into open space. That's Charge working.
  3. The watch item: at 360 while charging, does the head overshoot/cut the loop corners badly? If so we dial jugg_charge_amount or train_speed_boost_decay down — easy tune.

Tell me how the carve + the Charge momentum feel, and whether the Juggernaut reads clearly at the front. Then we go to Increment 3: Swordsman (cleave AoE on attack, +damage per unit hit).

Great, let's port over the object that's spawned when in Invoker we cast a spell, the one that says either spell name, no mana or fizzle, and let's have it activate every time a new charge happens. If a charge is currently happening and it just gets refreshed, the object doesn't spawn. Spawn it from the juggernaut unit's position at the time it happens.

This is a port from the old Invoker — let me find the cast-text object that shows spell name / "no mana" / "fizzle".

Grep (fizzle|no mana|no_mana|cast_text|cast_label|spell_text|cast_log)

Found 9 files
Invoker-old\main.lua
Invoker-old\cast.lua
Invoker-old\spells.lua
Invoker-old\plan.md
Invoker-old\info_text.lua
Invoker-old\reference\archive\design_orbs_v1.md
Invoker-old\reference\archive\plan_beach.md
Invoker-old\reference\poe_gem_mechanics.md
Invoker-old\v1\main.lua

Read (E:/a327ex/Invoker-old/info_text.lua)

1    --[[
2     info_text.lua — floating effect-text (damage-number style).
3   
4     Spawned by gameplay events to announce what happened in-world.
5     Currently used by cast_attempt to show spell name on success and
6     "Fizzle" on undefined chords; future uses include damage numbers,
7     status applications ("Ignited"), pickups, etc.
8   
9     Behavior:
10      - Spawns at a world position. Anchored to spawn location — the
11        text doesn't follow whatever spawned it (so it behaves like a
12        damage number floating away from a hit, not a tooltip stuck to
13        the player).
14      - Floats upward over INFO_TEXT_DURATION, rising INFO_TEXT_RISE
15        pixels by end of life.
16      - Alpha holds at 1.0 for the first FADE_HOLD fraction of the
17        lifetime, then fades linearly to 0 over the remainder.
18      - Renders a dark, semi-transparent rectangle behind the text;
19        text is drawn in the effect color (e.g. red for Fire spells).
20      - Drawn on the world-space (camera-attached) game layer.
21  
22    State is a module-level `info_texts` array. Update advances time
23    for each entry and removes expired ones; draw renders all active.
24  ]]
25  
26  info_texts = {}
27  
28  local INFO_TEXT_DURATION = 0.9   -- total lifespan, seconds
29  local INFO_TEXT_RISE     = 22    -- pixels of upward travel by end
30  local FADE_HOLD          = 0.5   -- fraction of lifetime at full alpha
... [94 more lines]

Let me see how it's spawned (the name / "no mana" / "fizzle" cases and their colors) and check the API differences vs Anchor 2.

Grep (info_text_spawn)

Invoker-old\cast.lua-137-    -- Fizzle: pure-formation chord OR undefined combination.
Invoker-old\cast.lua:138:    info_text_spawn(spawn_x, spawn_y, 'Fizzle', red)
E:\a327ex\Invoker-old\cast.lua-139-    return false
--
E:\a327ex\Invoker-old\cast.lua-149-  if p and p.mp < cost then
Invoker-old\cast.lua:150:    info_text_spawn(spawn_x, spawn_y, 'No Mana', light_blue)
E:\a327ex\Invoker-old\cast.lua-151-    return false
--
E:\a327ex\Invoker-old\cast.lua-160-    local c = (elem_def and elem_def.color) or text
Invoker-old\cast.lua:161:    info_text_spawn(spawn_x, spawn_y, entry.name, c)
E:\a327ex\Invoker-old\cast.lua-162-  end
--
E:\a327ex\Invoker-old\info_text.lua-35---[[
Invoker-old\info_text.lua:36:  info_text_spawn(x, y, text, color)
E:\a327ex\Invoker-old\info_text.lua-37-
--
E:\a327ex\Invoker-old\info_text.lua-41-]]
Invoker-old\info_text.lua:42:function info_text_spawn(x, y, txt, c)
E:\a327ex\Invoker-old\info_text.lua-43-  info_texts[#info_texts + 1] = {

Found it — info_text.lua. It shows the spell name (element color), 'Fizzle' (red), 'No Mana' (light_blue). I'll port it to mini-looper and trigger it on a new Charge. Two API differences to confirm first: the font-height accessor and that ui_text here is a positional helper (mini-looper's ui_text is a different immediate-mode widget — I'll use raw layer_text).

Grep (text_width|text_height|.height|font_height|:height)

8:  A font is a plain table with .name, .size, .height plus query methods.
23:  self.height = font_get_height(name)
26:function font:text_width(text)
27:  return font_get_text_width(self.name, text)

Found everything I need. info_text.lua is a self-contained floating-text object (float up + fade, dark bg rect, colored text), used in Invoker for spell name / 'Fizzle' (red) / 'No Mana' (light_blue). The font API matches (f.height, f:text_width), so the port is mostly swapping the draw calls.

Here's the brief.

Design brief — port info_text + trigger on new Charge

Port (info_text.lua → mini-looper)

Copy the object verbatim in behavior (spawn at a world pos, float up INFO_TEXT_RISE over INFO_TEXT_DURATION, hold alpha then fade, dark bg rect behind colored text). Two adaptations for this codebase:

Trigger on a new Charge

In unit_on_ram (where the Juggernaut applies Charge), detect new-vs-refresh by the boost's prior value: local was_active = train_speed_boost > 0 train_speed_boost = math.min(train_speed_boost + jugg_charge_amount, jugg_charge_max) if not was_active then info_text_spawn(u.x, u.y, 'Charge', yellow) end So it spawns only when the boost was at 0 (a fresh charge), from the Juggernaut's live position. While the train is plowing (boost > 0), re-contacts just refresh it → no popup. You get one "Charge" at the start of each plowing burst, none during it. (Multiple simultaneous contacts in one frame also yield a single popup, since the first sets the boost.)

Integration (main.lua)

Choices to confirm

Text 'Charge', color yellow (matching the gold Juggernaut), font ark, drawn on effects_layer. Say go (or change the text/color) and I'll write it.

Go ahead.

Implementing the port. First the new info_text.lua:

Write (E:/a327ex/mini-looper/info_text.lua)

File created successfully at: E:/a327ex/mini-looper/info_text.lua (file state is current in your context — no need to Read it back)

Now the trigger in units.lua — spawn only on a fresh charge:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Now the four main.lua integration points — require, update, draw, reset:

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/main.lua)

The file E:/a327ex/mini-looper/main.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Syntax-check the changed files:

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   info_text.lua
OK   units.lua
OK   main.lua

All compile. Done.

What I added

How to test

Run it, then b to burst a crowd and watch the gold Juggernaut head:

Tell me if the popup reads well (timing/position/color), then we're set to move on to Increment 3: Swordsman.

Perfect, you can move to the next unit.

Moving to Increment 3: Swordsman. Let me confirm the area carrier's spawn_area opts so the brief is accurate, then write it.

Grep (function spawn_area|damage|shape|radius|hidden|enemies)

4:  Three base shapes with an orthogonal `jagged` flag:
12:  Damage queries match base shape (not jagged):
15:    polygon → query_entities_polygon  (jagged actually changes hit area, since verts are the shape)
19:    Instant (default, duration = nil): tween radius 0 → target over 0.05s
20:      → single damage pass + spring pop + 0.15s flash → 0.2s hold → blink × 7 → kill.
22:    Persistent (duration set): tween radius 0 → target over 0.25s. Damage
31:      shape,           -- 'circle' (default) | 'rect' | 'polygon'
33:      radius,          -- default 24 (matches v1 fireball area_radius=25)
34:      rect_h,          -- rect only; default = radius (square)
37:                       -- fraction of radius). Auto-generates 6 evenly-spaced
40:      damage,          -- default 1
43:      tick_rate,       -- persistent only; seconds between damage ticks
64:-- 0.85-1.1 radius jitter. v1 jagged_circle pattern (~line 9119).
65:local function build_jagged_circle_verts(radius)
71:    verts[#verts + 1] = {wx = radius*d*math.cos(a), wy = radius*d*math.sin(a)}
95:-- is folded into each angle. If jagged, apply per-vert angle + radius
97:local function build_polygon_verts(input, radius, poly_r, jagged)
115:    verts[#verts + 1] = {wx = radius*d*math.cos(a), wy = radius*d*math.sin(a)}
140:  self.radius  = args.radius or 24
142:  self.damage  = args.damage or 1
144:  self.shape   = args.shape or 'circle'
152:  self.visual_radius = 0
159:  self.hidden   = false              -- blink-out toggle (set by the blink timer)
160:  self.invisible = args.hidden or false  -- never drawn at all (damage-only area)
169:    -- Slow scale breathing for non-smooth-circle persistent shapes —
183:  if self.shape == 'polygon' then
184:    self.verts = build_polygon_verts(args.polygon_verts, self.radius, args.polygon_r or 0, self.jagged)
185:  elseif self.shape == 'circle' and self.jagged then
186:    self.verts = build_jagged_circle_verts(self.radius)
187:  elseif self.shape == 'rect' and self.jagged then
... [12 more lines]

spawn_area's opts are confirmed (shape/radius/damage/caster/shake; instant damages all in radius ~0.05s after spawn, anchored at the spawn point — so a caller-side count and the area's hit-set are effectively the same). Here's the brief.

Design brief — Increment 3: Swordsman (cleave)

Approach

An auto-attacker that, on cooldown, does a self-centered circle cleave: it hits every enemy around it, and each enemy takes more damage the denser the crowd (the SNKRX cleave). Per-enemy damage = base + per_hit × N, where N = enemies in the cleave. The unlock Cleave raises the per_hit term (your "+1 per unit hit"), so the upgrade massively rewards plowing the train into a pack.

Mechanic

In the swordsman's attack(u, target):

  1. Count enemies within SWORD_CLEAVE_RADIUS of the swordsman (enemies_in_radius(x,y,r) — a small O(n) helper alongside the existing targeting helpers).
  2. per_hit = sword_per_hit_base + (sword_cleave_unlocked and sword_cleave_bonus or 0); dmg = sword_base_damage + per_hit·N.
  3. spawn_area(u.x, u.y, { shape='circle', radius=SWORD_CLEAVE_RADIUS, damage=dmg, caster=u, shake=2 }) — reuses the area carrier for the slash visual + the damage pass (it hits the same radius ~0.05s later at the spawn point; enemies move ~1px in that window, so the count and the hit-set match).

It fires when an enemy is within the cleave radius (def.range = SWORD_CLEAVE_RADIUS), centered on the swordsman (the target just gates "something's in swing range").

Numbers (tunable)

base 3, per_hit_base 0.5 (the SNKRX-ish baseline), cleave_bonus +1 (the unlock), unlocked on by default (sword_cleave_unlocked, same pattern as jugg_charge_unlocked — a global flag, no panel toggle). Radius 28, cooldown 1.2s. So unlocked vs a pack of 8 → 3 + 1.5·8 = 15/enemy; locked → 7. Against 15 enemies → ~25 each. No cap (dense crowd = devastating, which is the point for the endgame test).

Files

Confirm

Mainly the cleave-damage model + numbers above, and the pink color (juice deferred, so it's just a placeholder for distinctness). Go and I'll write it.

Look at how SNKRX's swordsman works, it's not self-centered and it's not a circle, it's a ranged rectangle area with a low trigger range.

You're right — let me look at the actual SNKRX swordsman attack code rather than guess.

Grep (swordsman)

26:  elseif self.character == 'swordsman' then
27-    self.attack_sensor = Circle(self.x, self.y, 48)
28-    self.t:cooldown(3, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
29-      self:attack(96)
--
1192:  if self.character == 'swordsman' and self.level == 3 then
1193:    self.swordsman_dmg_m = 2
1194-  end
1195-
1196-  if self.character == 'outlaw' and self.level == 3 then
--
1337:  self.buff_dmg_m = (self.squire_dmg_m or 1)*(self.vagrant_dmg_m or 1)*(self.enchanter_dmg_m or 1)*(self.swordsman_dmg_m or 1)*(self.flagellant_dmg_m or 1)*(self.psyker_dmg_m or 1)*(self.ballista_dmg_m or 1)*(self.awakening_dmg_m or 1)*(self.reinforce_dmg_m or 1)*(self.payback_dmg_m or 1)*(self.immolation_dmg_m or 1)*(self.damage_4_dmg_m or 1)*(self.offensive_stance_dmg_m or 1)*(self.last_stand_dmg_m or 1)*(self.dividends_dmg_m or 1)*(self.explorer_dmg_m or 1)
1338-  self.buff_def_m = (self.squire_def_m or 1)*(self.ouroboros_def_m or 1)*(self.unwavering_stance_def_m or 1)*(self.reinforce_def_m or 1)*(self.defensive_stance_def_m or 1)*(self.last_stand_def_m or 1)*(self.unrelenting_stance_def_m or 1)*(self.hardening_def_m or 1)
1339-  self.buff_area_size_m = (self.nuker_area_size_m or 1)*(self.magnify_area_size_m or 1)*(self.unleash_area_size_m or 1)*(self.last_stand_area_size_m or 1)
1340-  self.buff_area_dmg_m = (self.nuker_area_dmg_m or 1)*(self.amplify_area_dmg_m or 1)*(self.unleash_area_dmg_m or 1)*(self.last_stand_area_dmg_m or 1)
--
1925:  if self.character == 'swordsman' or self.character == 'barbarian' or self.character == 'juggernaut' or self.character == 'highlander' then
1926:    _G[random:table{'swordsman1', 'swordsman2'}]:play{pitch = random:float(0.9, 1.1), volume = 0.75}
1927-  elseif self.character == 'elementor' then
1928-    elementor1:play{pitch = random:float(0.9, 1.1), volume = 0.5}
1929-  elseif self.character == 'psychic' then
--
2451:    elseif self.character == 'swordsman' then
2452-      if self.parent.resonance then resonance_dmg = (self.dmg + self.dmg*0.15*#enemies)*resonance_m*#enemies end
2453-      enemy:hit(self.dmg + self.dmg*0.15*#enemies + resonance_dmg, self)
2454-    elseif self.character == 'blade' and self.level == 3 then
--
2474:    elseif self.character == 'swordsman' or self.character == 'barbarian' or self.character == 'juggernaut' or self.character == 'highlander' then
2475-      hit2:play{pitch = random:float(0.95, 1.05), volume = 0.35}
2476-    elseif self.character == 'blade' then
... [1 more lines]

Let me see what self:attack(96) actually spawns — the shape and placement.

Grep (function .*:attack\b|attack = function|:attack(|Rectangle(|attack_sensor|self.attack\b)

18:    self.attack_sensor = Circle(self.x, self.y, 96)
19:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
20:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
27:    self.attack_sensor = Circle(self.x, self.y, 48)
28:    self.t:cooldown(3, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
29:      self:attack(96)
33:    self.attack_sensor = Circle(self.x, self.y, 128)
34:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
35:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
42:    self.attack_sensor = Circle(self.x, self.y, 96)
43:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
45:      local enemy = self:get_random_object_in_shape(self.attack_sensor, main.current.enemies)
47:        self:attack(32, {x = enemy.x, y = enemy.y})
113:    self.attack_sensor = Circle(self.x, self.y, 160)
114:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
115:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
122:    self.attack_sensor = Circle(self.x, self.y, 64)
123:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
124:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
131:    self.attack_sensor = Circle(self.x, self.y, 64)
132:    self.t:cooldown(2, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
133:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
190:    self.attack_sensor = Circle(self.x, self.y, 128)
191:    self.t:cooldown(4, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
192:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
210:    self.attack_sensor = Circle(self.x, self.y, 96)
236:    self.attack_sensor = Circle(self.x, self.y, 96)
237:    self.t:cooldown(3, function() local enemies = self:get_objects_in_shape(self.attack_sensor, main.current.enemies); return enemies and #enemies > 0 end, function()
238:      local closest_enemy = self:get_closest_object_in_shape(self.attack_sensor, main.current.enemies)
245:    self.attack_sensor = Circle(self.x, self.y, 64)
... [12 more lines]

Now the attack() method itself — to see the rectangle shape, size, and how it's placed without a target position:

Grep (^function Player:attack|Player:attack =|^ attack = |:attack = function|function.*attack(area)

SNKRX\player.lua:1917:function Player:attack(area, mods)
SNKRX\player.lua:1941:function Player:dot_attack(area, mods)

Read (E:/a327ex/SNKRX/player.lua)

1917 function Player:attack(area, mods)
1918      mods = mods or {}
1919      camera:shake(2, 0.5)
1920      self.hfx:use('shoot', 0.25)
1921      local t = {group = main.current.effects, x = mods.x or self.x, y = mods.y or self.y, r = self.r, w = self.area_size_m*(area or 64), color = self.color, dmg = self.area_dmg_m*self.dmg,
1922        character = self.character, level = self.level, parent = self}
1923      Area(table.merge(t, mods))
1924    
1925      if self.character == 'swordsman' or self.character == 'barbarian' or self.character == 'juggernaut' or self.character == 'highlander' then
1926        _G[random:table{'swordsman1', 'swordsman2'}]:play{pitch = random:float(0.9, 1.1), volume = 0.75}
1927      elseif self.character == 'elementor' then
1928        elementor1:play{pitch = random:float(0.9, 1.1), volume = 0.5}
1929      elseif self.character == 'psychic' then
1930        psychic1:play{pitch = random:float(0.9, 1.1), volume = 0.4}
1931      elseif self.character == 'launcher' then
1932        buff1:play{pitch == random:float(0.9, 1.1), volume = 0.5}
1933      end
1934    
1935      if self.character == 'juggernaut' then
1936        elementor1:play{pitch = random:float(0.9, 1.1), volume = 0.5}
1937      end
1938    end
1939    
1940    

The swordsman spawns an Area at the unit's position, rotated to the unit's facing r, with w = 96. Let me check the Area class to see the actual rectangle geometry (how w and r form the ranged rectangle).

Grep (Area = Object|^Area\b|function Area:|shape = |Rectangle(|self.shape|self.w\b|self.h\b)

46:  self.w = 3
65:  graphics.polyline(self.color, self.w, unpack(self.points))
139:  graphics.rectangle(self.x, self.y, self.shape.w, self.shape.h, 2, 2, self.hfx.hit.f and fg[0] or self.color)
150:  self.shape = Rectangle(self.x, self.y, 10, 4)
167:  graphics.rectangle(self.x, self.y, self.shape.w, self.shape.h, 2, 2, self.hfx.hit.f and fg[0] or self.color)

Grep (Area)

No matches found

Grep (^Area |Area =|Area\s=\sclass|function Area|Area:init|w/2.h|self.shape\s=\s*Rectangle)

SNKRX\shared.lua:76:    bg_gradient:draw(gw/2, gh/2, 480, 270)
SNKRX\shared.lua:82:    if flashing then graphics.rectangle(gw/2, gh/2, gw, gh, nil, nil, flash_color) end
SNKRX\shared.lua:466:        self.x, self.y = gw/2, gh/2
SNKRX\shared.lua:489:  if self.text then self.text:draw(gw/2, gh/2, 0, self.text_sx, self.text_sy) end
SNKRX\shared.lua:545:      graphics.line(c.x - c.w/2, c.y + c.h/2 + c.h/10, c.x + c.w/2, c.y + c.h/2 + c.h/10)
SNKRX\shared.lua:700:  graphics.rectangle2(self.x - self.w/2, self.y - self.oy, self.w, self.h, nil, nil, fg[0])
SNKRX\enemies.lua:353:    graphics.line(self.x - self.shape.w/2, self.y + 8, self.x + self.shape.w/2, self.y + 8, bg[-3], 2)
SNKRX\enemies.lua:354:    graphics.line(self.x - self.shape.w/2, self.y + 8, self.x - self.shape.w/2 + self.shape.w*n, self.y + 8, self.color, 2)
SNKRX\buy_screen.lua:48:  camera.x, camera.y = gw/2, gh/2
SNKRX\buy_screen.lua:99:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 34, y = gh/2 - 30, character = 'swordsman', level = 1})
SNKRX\buy_screen.lua:100:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 54, y = gh/2 - 30, character = 'swordsman', level = 1})
SNKRX\buy_screen.lua:101:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 74, y = gh/2 - 30, character = 'swordsman', level = 1})
SNKRX\buy_screen.lua:102:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 34, y = gh/2 - 10, character = 'swordsman', level = 2})
SNKRX\buy_screen.lua:103:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 54, y = gh/2 - 10, character = 'swordsman', level = 2})
SNKRX\buy_screen.lua:104:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 74, y = gh/2 - 10, character = 'swordsman', level = 2})
SNKRX\buy_screen.lua:105:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 114, y = gh/2 - 30, character = 'swordsman', level = 2})
SNKRX\buy_screen.lua:106:    table.insert(self.tutorial_cards, TutorialCharacterPart{group = self.tutorial, x = gw/2 + 114, y = gh/2 - 10, character = 'swordsman', level = 3})
SNKRX\buy_screen.lua:107:    table.insert(self.tutorial_cards, TutorialClassIcon{group = self.tutorial, x = gw/2 + 114, y = gh/2 + 18, class = 'warrior', units = {}})
SNKRX\buy_screen.lua:108:    table.insert(self.tutorial_cards, TutorialClassIcon{group = self.tutorial, x = gw/2 + 134, y = gh/2 + 18, class = 'warrior', units = {{character = 'swordsman'}, {character = 'barbarian'}, {character = 'juggernaut'}}})
SNKRX\buy_screen.lua:109:    table.insert(self.tutorial_cards, TutorialClassIcon{group = self.tutorial, x = gw/2 + 154, y = gh/2 + 18, class = 'warrior', units = {{character = 'swordsman'}, {character = 'barbarian'}, {character = 'juggernaut'},

[Showing results with pagination = limit: 20]

Grep (Area\s=\sObject|Area:implement|function Area|Area.init|shape\s=\s(Rectangle|Polygon)|self:set_as_rectangle|w/2, .*h/2)

Found 19 files
SNKRX\arena.lua
SNKRX\buy_screen.lua
SNKRX\enemies.lua
SNKRX\engine\game\physics.lua
SNKRX\engine\graphics\camera.lua
SNKRX\engine\graphics\canvas.lua
SNKRX\engine\graphics\graphics.lua
SNKRX\engine\graphics\text.lua
SNKRX\engine\init.lua
SNKRX\engine\map\tilemap.lua
SNKRX\engine\math\math.lua
SNKRX\engine\math\rectangle.lua
SNKRX\engine\math\vector.lua
SNKRX\main.lua
SNKRX\mainmenu.lua
SNKRX\media.lua
SNKRX\objects.lua
SNKRX\player.lua
SNKRX\shared.lua

Grep (^Area\b|Area = |class.Area|Area\s:|set_as_rectangle|attack_sensor.*Rectangle|w, self.w)

No matches found

Bash (cd E:/a327ex/SNKRX && grep -rn "Area = |Area=|^Area|Area = Object|Area:init...)

player.lua:2436:Area = Object:extend()
player.lua:2437:Area:implement(GameObject)
player.lua:2438:function Area:init(args)
player.lua:2546:function Area:update(dt)
player.lua:2551:function Area:draw()
player.lua:2570:DotArea = Object:extend()
player.lua:2573:function DotArea:init(args)
player.lua:2735:ForceArea = Object:extend()
player.lua:2738:function ForceArea:init(args)

Read (E:/a327ex/SNKRX/player.lua)

2438 function Area:init(args)
2439      self:init_game_object(args)
2440      self.shape = Rectangle(self.x, self.y, 1.5*self.w, 1.5*self.w, self.r)
2441      local enemies = main.current.main:get_objects_in_shape(self.shape, main.current.enemies)
2442      for _, enemy in ipairs(enemies) do
2443        local resonance_dmg = 0
2444        local resonance_m = (self.parent.resonance == 1 and 0.03) or (self.parent.resonance == 2 and 0.05) or (self.parent.resonance == 3 and 0.07) or 0
2445        if self.character == 'elementor' then
2446          if self.parent.resonance then resonance_dmg = 2*self.dmg*resonance_m*#enemies end
2447          enemy:hit(2*self.dmg + resonance_dmg, self)
2448          if self.level == 3 then
2449            enemy:slow(0.4, 6)
2450          end
2451        elseif self.character == 'swordsman' then
2452          if self.parent.resonance then resonance_dmg = (self.dmg + self.dmg*0.15*#enemies)*resonance_m*#enemies end
2453          enemy:hit(self.dmg + self.dmg*0.15*#enemies + resonance_dmg, self)
2454        elseif self.character == 'blade' and self.level == 3 then
2455          if self.parent.resonance then resonance_dmg = (self.dmg + self.dmg*0.33*#enemies)*resonance_m*#enemies end
2456          enemy:hit(self.dmg + self.dmg*0.33*#enemies + resonance_dmg, self)
2457        elseif self.character == 'highlander' then
2458          if self.parent.resonance then resonance_dmg = 6*self.dmg*resonance_m*#enemies end
2459          enemy:hit(6*self.dmg + resonance_dmg, self)
2460        elseif self.character == 'launcher' then
2461          if self.parent.resonance then resonance_dmg = (self.level == 3 and 6*self.dmg*0.05*#enemies or 2*self.dmg*0.05*#enemies) end
2462          enemy:curse('launcher', 4*(self.hex_duration_m or 1), (self.level == 3 and 6*self.dmg or 2*self.dmg) + resonance_dmg, self.parent)
2463        elseif self.character == 'freezing_field' then
2464          enemy:slow(0.5, 2)
2465        else
2466          if self.parent.resonance then resonance_dmg = self.dmg*resonance_m*#enemies end
2467          enemy:hit(self.dmg + resonance_dmg, self)

Now I understand the real SNKRX swordsman. Key facts from the source:

So my brief was wrong on two counts (circle → rotated rectangle; trigger range = damage range → small trigger, big rectangle). Let me check area.lua's rect geometry + damage pass so I can port the cleave count correctly.

Read (E:/a327ex/mini-looper/snkrx/area.lua)

136  function area:new(x, y, args)
137   args = args or {}
138   self.x = x
139   self.y = y
140   self.radius  = args.radius or 24
141   self.rect_h  = args.rect_h
142   self.damage  = args.damage or 1
143   self.spec    = args.spec or (args.caster and args.caster.spec) or attack_spec
144   self.shape   = args.shape or 'circle'
145   self.jagged  = args.jagged or false
146   self.corner_brackets = args.corner_brackets or false
147   self.r       = args.r or 0
148   self.duration  = args.duration               -- nil → instant; set → persistent
149   self.tick_rate = args.tick_rate or 0.5
150   self.hit_cooldowns = {}                      -- entity_id → cooldown expire time
151 
152   self.visual_radius = 0
153   -- Translucent fill = the spec's primary color at low alpha. Drawn under the
154   -- same effect bracket as the outline, so the recipe recolors it; the shader
155   -- preserves the base draw alpha (20), keeping the fill translucent.
156   self.fill_color = color_clone(spec_color(self.spec))
157   self.fill_color.a = 20
158   self.flashing = false
159   self.hidden   = false              -- blink-out toggle (set by the blink timer)
160   self.invisible = args.hidden or false  -- never drawn at all (damage-only area)
161   self.arc_r    = 0
162   if self.duration then
163     -- Persistent: slow constant base, bumped by tick spikes (v1 line 8530).
164     -- We add an exponential decay back toward this baseline in :update so
165     -- the arc speed doesn't stay at peak forever after a few hits.
... [10 more lines]

Grep (function area:apply_damage|query_entities|rect_h|self.radius|self.r\b|cleave|:hit()

13:    circle  → query_entities_circle   (jagged is visual-only)
14:    rect    → query_entities_box      (jagged is visual-only)
15:    polygon → query_entities_polygon  (jagged actually changes hit area, since verts are the shape)
34:      rect_h,          -- rect only; default = radius (square)
140:  self.radius  = args.radius or 24
141:  self.rect_h  = args.rect_h
147:  self.r       = args.r or 0
184:    self.verts = build_polygon_verts(args.polygon_verts, self.radius, args.polygon_r or 0, self.jagged)
186:    self.verts = build_jagged_circle_verts(self.radius)
188:    self.verts = build_jagged_rect_verts(self.radius, self.rect_h or self.radius)
247:    timer_tween(self.timer, 0.25, self, {visual_radius = self.radius}, math.cubic_in_out)
256:    timer_tween(self.timer, 0.05, self, {visual_radius = self.radius}, math.cubic_in_out, function()
275:    local box_h = self.rect_h and self.rect_h*2 or self.radius*2
276:    return query_entities_box(self.x, self.y, self.radius*2, box_h, self.r, {'enemy'})
278:    return query_entities_polygon(self.x, self.y, verts_to_flat(self.verts), {'enemy'})
280:    return query_entities_circle(self.x, self.y, self.radius, {'enemy'})
285:function area:apply_damage()
290:      e:hit(self.damage)
318:        e:hit(self.damage, 0)
411:    local h = (self.rect_h or self.visual_radius)*2*s
415:    layer_push(layer, self.x, self.y, self.r, 1, 1)
432:    local scale = r/math.max(self.radius, 1)
433:    local push_r = (self.shape == 'rect') and self.r or 0

Read (E:/a327ex/mini-looper/snkrx/area.lua)

270  end
271 
272 
273 function area:query_enemies()
274   if self.shape == 'rect' then
275     local box_h = self.rect_h and self.rect_h*2 or self.radius*2
276     return query_entities_box(self.x, self.y, self.radius*2, box_h, self.r, {'enemy'})
277   elseif self.shape == 'polygon' then
278     return query_entities_polygon(self.x, self.y, verts_to_flat(self.verts), {'enemy'})
279   else  -- 'circle' (smooth or jagged)
280     return query_entities_circle(self.x, self.y, self.radius, {'enemy'})
281   end
282 end
283 
284 
285 function area:apply_damage()
286   local enemies = self:query_enemies()
287   local pc = spec_color(self.spec)
288   for _, e in ipairs(enemies or {}) do
289     if e.hit and not e._dead then
290       e:hit(self.damage)
291       spawn_hit_effect(e.x, e.y)
292       for i = 1, random_int(2, 3) do
293         spawn_particle(e.x, e.y, {
294           velocity = random_float(50, 150),
295         }):change_color(0.5, pc)
296       end
297     end
298   end
299 end
... [5 more lines]

[Request interrupted by user]

Use the existing structures and your previous decisions regarding everything else (like damage), just make the attack shape + trigger range match SNKRX, nothing else needs to change.

I have the real mechanic now: small trigger circle (radius 48) → big rotated square (side 1.5·96 = 144) centered on the unit, oriented to facing, with cleave damage base + 0.15·base·N computed from the square's own hit count. Per your instruction, I'll keep all my prior decisions (cleave damage model, numbers, cooldown, pink color) and only swap the shape to a rotated square + split out a low trigger range — scaled to mini-looper's sizes, preserving SNKRX's ratio (trigger ≈ ⅓ of the square's side).

The one nice consequence: since the damage shape is now a rect, I count the cleave with the same query_entities_box the area uses — so N is exactly the hit-set, no separate circle helper.

Writing it:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Let me confirm query_entities_box exists as a global and syntax-check:

Bash (cd E:/a327ex/mini-looper && echo "=== query_entities_box def ===" && grep -rn "f...)

=== query_entities_box def ===
anchor/physics.lua:52:function query_entities_box(x, y, w, h, angle, tags)
=== syntax ===
OK   units.lua
OK   builds.lua

query_entities_box(x, y, w, h, angle, tags) matches my call exactly, and both files compile. Increment 3 done.

What I built — Swordsman (Cleave)

Faithful to SNKRX's actual mechanic, scaled to mini-looper:

Added 'swordsman' to the ramming roster so it's in the train to test.

How to test

Run it, b to burst a crowd, and find the pink segment in the snake:

Let me know how the swing reads (size/trigger/timing), then we go to Increment 4: Earthshaker (stun-pulse around the train + the Earthshatter follow-up that scales with crowd density).

Remove two previous attacking units from the train. So we should have 4 old, Juggernaut + Swordsman.

Trivial config change — I'll trim RAMMING_UNITS from 6 old entries to 4, removing the duplicate archer and outlaw (outlaw's projectile fan is the most archer-redundant, so this keeps the 4 distinct: projectile / rect-AoE / projectile-explosion / lobbed-AoE).

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Done. The train is now Juggernaut (head) + 4 old (archer, magician, cannoneer, geomancer) + Swordsman + 8 blocks = 14 segments, blocks/units shuffled behind the Juggernaut. Dropped the duplicate archer and the outlaw.

It's the same build with a less cluttered roster, so the new ramming units (Juggernaut gold, Swordsman pink) read more clearly against fewer old projectiles. Run it and you'll see four old attackers instead of six. Ready for Increment 4: Earthshaker whenever you are.

Go ahead.

Onto Increment 4: Earthshaker. It needs the one new enemy-side mechanic I flagged way back — a stun state — plus a two-stage attack, so here's the brief.

Design brief — Increment 4: Earthshaker (stun + Earthshatter)

New: enemy stun state (enemy.lua)

Earthshaker unit (units.lua)

An auto-attacker. On cooldown, when an enemy is within the stun radius, it does a stun pulse around its own position (this is my read of "close to the train" — an AoE centered on the Earthshaker segment): query_entities_circle:stun() every enemy in radius, plus a ground-slam ring (hit_circle) + a rumble shake.

Earthshatter (the unlock)

0.4s after the stun, a second pulse at the captured pulse position (not the unit's current spot — the train moves ~100px in 0.4s, but the stunned enemies stay frozen where they were, so the shatter has to land back on the pile). It damages only the still-stunned enemies in radius, scaling with how many are caught: dmg = base + per_close·N. So a big stunned crowd shatters for a lot each — your "damage according to how many are close."

Numbers (tunable)

cooldown 2.5s, stun_radius 44, stun_duration 1.5s, shatter_delay 0.4s, shatter_base 4, shatter_per_close 1.5 (8 frozen → 16 each; 15 → ~26). Earthshatter on by default (earth_shatter_unlocked, the usual global flag). Color: an earthy stone breathing to icy-blue (snkrx_neutral ↔ light_blue) — nods to both earth and the stun, distinct from the warm-stone geomancer.

Files

Confirm

The "close to the train" = AoE-around-the-Earthshaker-unit reading, the stun freezing them in place (which is what makes the captured-position shatter land), and the numbers/color. Go and I'll write it.

Let's actually change Earthshaker to just use the Invoker ability, Fissure. Just copy that as close as you can. Then the unlock makes it spawn a longer and bigger fissure (each area bigger) every 3 fissures.

Let me find the Fissure ability in the old Invoker to copy it faithfully.

Grep (fissure|Fissure)

Found 11 files
Invoker-old\reference\poe_skill_examples.md
Invoker-old\reference\poe_gem_mechanics.md
Invoker-old\v1\main.lua
Invoker-old\reference\todo.md
Invoker-old\reference\ai_coding_paradigms.md
Invoker-old\reference\interactions.md
Invoker-old\reference\tags.md
Invoker-old\reference\ability_editions.md
Invoker-old\reference\ability_modifier_map.md
Invoker-old\reference\ability_design_reference.md
Invoker-old\reference\ability_reference.md

Grep (fissure)

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'},
731:  fissure = {
739:    cast_sounds = {'fissure_cast_1'},
801:    cast_sound = 'fissure_cast_1',
802:    area_sound = 'fissure_cast_2',
804:    aftershock_sound = 'fissure_cast_1',
852:    pool = {'fireball', 'null_arrow', 'fissure', 'frost_nova', 'windfield', 'arc', 'earthquake'},
939:    fire_sound = 'fissure_cast_1',
940:    area_sound = 'fissure_cast_2',
1088:  'fireball', 'null_arrow', 'fissure', 'windfield', 'arc', 'frost_nova',
1363:      -- Spawn a fissure area + play random cast sound
1365:      local function spawn_fissure_area(pos)
1398:          spawn_fissure_area(pos)
1400:          self.timer:after(d, function() spawn_fissure_area(pos) end)
1412:            self.timer:after(d, function() spawn_fissure_area(pos) end)

Read (E:/a327ex/Invoker-old/v1/main.lua)

731    fissure = {
732     activation = 'manual',
733     tags = {'area', 'earth', 'melee'},
734     cooldown = 0.8,
735     carrier = 'melee',
736     color = yellow,
737     damage = 3,
738     element = 'earth',
739     cast_sounds = {'fissure_cast_1'},
740     hit_sound = 'fireball_hit',
741     area_radius = 14,
742     area_shape = 'jagged_rect',
743     area_count = 4,
744     area_delay = 0.08,
745   },
746   windfield = {
747     activation = 'manual',
748     tags = {'area', 'wind'},
749     cooldown = 2,
750     carrier = 'aimed_area',
751     color = fg_color,
752     damage = 4,
753     element = 'wind',
754     area_radius = 30,
755     area_shape = 'rect',
756     cast_sound = 'windfield_cast',
757     area_sound = 'windmagic_epic_impact',
758     max_range = 150,
759     slow_amount = 0.5,
760     slow_duration = 2,
... [45 more lines]

Now the actual fissure spawn logic (the melee carrier):

Read (E:/a327ex/Invoker-old/v1/main.lua)

1355       local area_mods = {}
1356          for k, v in pairs(mods) do area_mods[k] = v end
1357          area_mods.area_echo = nil
1358    
1359          -- Build directions from area_ modifiers
1360          local directions = build_area_angles(base_r, mods)
1361          local parallel_n = mods.area_parallel or mods.parallel or 1
1362    
1363          -- Spawn a fissure area + play random cast sound
1364          local melee_log = (override_pos and override_pos.cast_log) or damage_cast_log.new(ability_name, def.element)
1365          local function spawn_fissure_area(pos)
1366            an:add(area(pos.x, pos.y, {ability = ability_name, color = def.color, mods = area_mods, shape = def.area_shape, r = pos.r, edition = orb_ed, cast_log = melee_log}))
1367            if def.cast_sounds then
1368              local snd = an.random:choice(def.cast_sounds)
1369              sfx(snd, volumes[snd])
1370            end
1371          end
1372    
1373          -- Spawn wave for each direction (and parallel offset)
1374          local all_positions = {}
1375          for _, dir_r in ipairs(directions) do
1376            for p = 1, parallel_n do
1377              local offset = 0
1378              if parallel_n >= 2 then
1379                offset = size*(p - 1 - (parallel_n - 1)/2)
1380              end
1381              local perp_x = offset*math.cos(dir_r + math.pi/2)
1382              local perp_y = offset*math.sin(dir_r + math.pi/2)
1383              local positions = {}
1384              local cumulative_dist = size*0.6
... [45 more lines]

This is the Invoker Fissure: a melee carrier that spawns a forward-marching chain of jagged-rect areasarea_count=4 areas, area_radius=14, area_delay=0.08 between each, damage=3, each placed ~size further along the aim with a small ±8° jitter, big shake on cast. Let me confirm mini-looper's spawn_area opts + the fissure sound globals before briefing.

Bash (cd E:/a327ex/mini-looper && echo "=== spawn_area signature ===" && grep -n "func...)

=== spawn_area signature ===
482:function spawn_area(x, y, opts)
=== jagged/shake in area opts ===
47:                       -- caster-side juice: camera shake (directional toward
51:      shake,           -- caster shake amount on cast (default 4); 0 disables
145:  self.jagged  = args.jagged or false
202:    -- Aimed cast (area not on caster) → directional shake + cone particles
206:    local sh = args.shake or 4
=== fissure sounds/volumes ===
snkrx/init.lua:93:  fissure_cast_1  = 0.5,   -- v1 line 16
snkrx/init.lua:94:  fissure_cast_2  = 0.55,  -- v1 line 17
snkrx/init.lua:123:-- Big-rock (polygonal carrier) stack: fissure_cast_1 on throw,
snkrx/init.lua:124:-- fissure_cast_2 + a random explosion_flesh on landing.
snkrx/init.lua:125:sounds.fissure_cast_1 = sound_load('assets/Spell_Earth_02.ogg')
snkrx/init.lua:126:sounds.fissure_cast_2 = sound_load('assets/Spell_Earth_04.ogg')

Confirmed: spawn_area takes jagged/shake (0 disables), and sounds.fissure_cast_1 + volumes.fissure_cast_1 exist. The stun design is scrapped (it was only a brief — no code written), so this is a clean swap. Here's the brief.

Design brief — Increment 4 (revised): Earthshaker casts Fissure

Approach

The Earthshaker unit now casts Fissure — a port of Invoker's ability: a forward-marching chain of jagged-rect areas that races across the ground from the unit toward the nearest enemy, each cracking open a beat after the last. Faithful to Invoker's shape and numbers.

Mechanic (spawn_fissure(u, base_r, count, radius))

The unlock — bigger fissure every 3rd cast

A per-unit counter u.fissure_n; each cast increments it. When unlocked and fissure_n % 3 == 0, cast a longer + bigger fissure — more areas and each area's radius up — otherwise the normal one. (Same per-area damage; the big one just catches far more by reaching further with fatter cracks.)

Numbers (tunable, ~Invoker)

Normal: radius 14, count 4, delay 0.08, damage 3. Big: count 7, radius 22. cooldown 1.0 (Invoker's 0.8, eased slightly for an auto-caster), trigger range 80. Unlock on by default (earth_fissure_unlocked). Color: an earthy gold→lime breathe (snkrx_yellow ↔ lime), distinct from the warm-stone geomancer and gold Juggernaut.

Files

Confirm

Aim at nearest enemy, auto-cast cooldown ~1.0, the big-fissure scale (7×, radius 22), and the gold-lime color. Go and I'll write it.

Big count = 8, go ahead.

Implementing with big count = 8.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   units.lua
OK   builds.lua

Both compile. Increment 4 done.

What I built — Earthshaker casts Fissure

A faithful port of Invoker's Fissure:

How to test

Run it, b a crowd, and find the gold-lime segment:

Let me know how the crack reads (length/spacing/timing/the big one), then we move to Increment 5: Detonator (a random inert block detonates for AoE; unlock = all blocks detonate).

Remove another 2 old units.

Trimming to 2 old units. I'll remove magician and geomancer — both are short-range AoE that now overlap with the new ramming units (swordsman's rect cleave, earthshaker's fissure), and keep archer + cannoneer, the two ranged projectile units that complement the melee/short-range ramming core instead of duplicating it.

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Done. The train is now Juggernaut (head) + 2 old (archer, cannoneer) + Swordsman + Earthshaker + 8 blocks = 13 segments. Removed magician and geomancer; kept the two ranged projectile units for coverage the short-range ramming units don't provide. Ready for Increment 5: Detonator whenever you want it.

Fissure areas need a lot more spacing between them.

Quick tuning — the inter-crack step was only ~0.9·radius (so the 28px boxes heavily overlapped). I'll add a fissure_spacing multiplier and bump it so the cracks are clearly separated as the fissure marches out.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK units.lua

Done. The cracks now step ~2.5·radius apart instead of ~0.9·radius, so the fissure reads as distinct cracks marching outward with real gaps between them (normal fissure reaches ~100px now; the big one strides much further). fissure_spacing is a single knob if 2.5 is too much or too little — tell me how it looks and I'll nudge it, then we go to Increment 5: Detonator.

2 instead. Also, I'm not sure I'm seeing swordsman. What color is he?

Two things:

1. Spacing → 2. Done below.

2. The swordsman is pink — specifically pink ↔ violet. And that's almost certainly why you can't pick it out: the cannoneer is purple ↔ violet, so both segments breathe toward the same violet and read as "the purplish one." I'll recolor the swordsman to a clearly distinct blue so it stands apart from the green archer, purple cannoneer, and gold earthshaker/juggernaut.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK units.lua

Done — fissure spacing is now 2, and the swordsman is a clear blue (blue ↔ light_blue), distinct from the purple cannoneer.

One more reason it may have been hard to see in action: the swordsman's cleave only fires when an enemy is within 20px of it (the faithful SNKRX low trigger), and since the Juggernaut head + front blocks clear enemies before they reach the swordsman deeper in the body, its cleave can be sporadic. So:

Ready for Increment 5: Detonator when you are.

Special unlock for Earthshaker is Looping Fissure - a fissure that spawns from the Earthshaker and loops around the path backwards (opposite direction of snake) until it reaches the initial position again. This happens every 10 seconds, when it happens "Looping Fissure" spawns as text, like with Charge.

This replaces the old "every 3rd cast = bigger fissure" unlock with a new one. New mechanic + a recurring timer, so let me check the timer_every signature (for the every-10s trigger), then brief.

Grep (function timer_every|function timer_after|function timer_every_immediate)

80:function timer_after(t, delay, name_or_callback, callback_function)
81-  local name, callback
82-  if type(name_or_callback) == 'string' then
83-    name, callback = name_or_callback, callback_function
84-  else
85-    name, callback = uid(t), name_or_callback
86-  end
87-  insert_entry(t, {name = name, mode = 'after', time = 0, delay = delay, callback = callback})
88-end
--
95:function timer_every(t, delay, name_or_callback, callback_or_times, times_or_after, after_function)
96-  local name, callback, times, after
97-  if type(name_or_callback) == 'string' then
98-    name, callback, times, after = name_or_callback, callback_or_times, times_or_after, after_function
99-  else
100-    name, callback, times, after = uid(t), name_or_callback, callback_or_times, times_or_after
101-  end
102-  insert_entry(t, {name = name, mode = 'every', time = 0, delay = delay, callback = callback, times = times, after = after, count = 0})
103-end
--
191:function timer_every_step(t, start_delay, end_delay, times, name_or_callback, callback_or_step, step_or_after, after_function)
192-  local name, callback, step_method, after
193-  if type(name_or_callback) == 'string' then
194-    name, callback, step_method, after = name_or_callback, callback_or_step, step_or_after, after_function
195-  else
196-    name, callback, step_method, after = uid(t), name_or_callback, callback_or_step, step_or_after
197-  end
198-  step_method = step_method or math.linear
199-  local delays = {}

timer_every(u.timer, 10, fn) gives an infinite 10s recurrence (no times → forever), and nearest_loop_index(x,y) already exists in loop.lua. Here's the brief — this replaces the old "every-3rd-cast bigger fissure" unlock.

Design brief — Earthshaker unlock: Looping Fissure

Replace the old unlock

Drop earth_fissure_unlocked + the fissure_count_big/fissure_radius_big branch — the base attack just casts the normal fissure at the nearest enemy. The new unlock is a separate passive.

Looping Fissure (spawn_looping_fissure(u))

A fissure that races around the loop backwards (opposite the snake's travel) from the Earthshaker, all the way around to where it started:

Each crack is instant (pops + fades ~0.5s), so it's a shockwave sweeping the loop — it damages enemies as it passes over them crossing the ring (a nice defensive sweep), rather than a persistent wall.

Trigger — every 10s

timer_every(u.timer, 10, …) armed once in unit_init_combat for earthshaker units when earth_loop_unlocked (on by default). First fire at t=10s, then every 10s. (One inline character-check there, same pattern as unit_on_ram.)

Numbers / files

loop_fissure_interval = 10, loop_fissure_delay = 0.08 (~1.4s to circle), reuses radius 14 / damage 3. units.lua only — remove the big-fissure bits, add earth_loop_unlocked + the two loop constants + spawn_looping_fissure + the timer_every in unit_init_combat. No roster change.

Confirm

The "one crack per loop point, traveling backwards as a ~1.4s wave around the whole loop" reading, and that this fully replaces the bigger-fissure unlock. Go and I'll write it.

Go ahead.

Implementing. Three edits to units.lua: swap the constants, replace spawn_fissure's helper-block + def (add spawn_looping_fissure, simplify the attack), and arm the 10s timer in unit_init_combat.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK units.lua

Compiles. Done — the Looping Fissure unlock is in.

What changed

How to test

It's a passive on a 10s timer, so you don't need to trigger it — just run and watch the gold-lime Earthshaker segment:

Tell me how the looping wave reads (direction, speed, density), then we're set for Increment 5: Detonator.

Looping Fissure areas should be slightly bigger, slightly tighter, camera shake applied a bit stronger on start and end, with small versions on each area spawned, and the whole effect should last a bit longer also, maybe 2.5s instead of 1.4s. Cooldown starts counting again when the effect ends, not from previous cast.

Got it — these are refinements to the agreed feature, so I'll implement directly. The "tighter" one needs a real change: instead of one crack per loop point (~35px, irregular), I'll walk the loop path by arc-length at a fixed step, which lets me control density (tighter) and scale the propagation to exactly fill the 2.5s. And the cooldown becomes a self-re-arming chain that starts counting at the effect's end.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK units.lua

Compiles. All your tweaks are in.

What changed (Looping Fissure)

How to test

Cadence is now first at ~10s, then every ~12.5s (2.5s effect + 10s gap-after-end). Watch the gold-lime Earthshaker:

If the 12.5s wait is tedious while you evaluate the feel, drop loop_fissure_cooldown to ~3 temporarily — one knob in units.lua. Tell me how it reads, then on to Increment 5: Detonator.

2.1s, slightly less tight on the distance between areas.

Two single-value tweaks: duration 2.5 → 2.1, and step 24 → 30 (a bit more gap between cracks).

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK

Done — wave duration is now 2.1s and cracks step 30px apart (a bit more gap than the 24 before). Ready for Increment 5: Detonator when you are.

Great, let's do the Detonator now.

Onto Increment 5: Detonator. Here's the brief.

Design brief — Increment 5: Detonator

Approach

An auto-attacker that detonates inert blocks — pure body-payload, the idea that makes stacking blocks more than passive stats. On cooldown (when an enemy is near), it sets off a non-destructive explosion at a block: an AoE area at the block's position, the block flashes white + spring-pops + emits a burst, but stays in the train (re-arms next cooldown). The unlock detonates all blocks at once — the whole snake erupts.

Mechanic

Numbers (tunable)

cooldown 2.5, range 100, detonate_radius 30, detonate_damage 6. With the unlock, all 8 blocks erupt every 2.5s for overlapping booms along the body — the spectacle (and a real perf data point: 8 areas + 8 bursts per cast). Unlock on by default (detonate_all_unlocked). Color: solid pink — magenta is the one clearly-free hue, and I'm making it solid because pink's only breathe-neighbors are enemy-red and the cannoneer's violet (the exact clash we just fixed on the swordsman); the explosions carry the visual anyway.

Files

Confirm

The non-destructive detonation, base-random vs unlock-all, the numbers, solid pink — and the unlock's name (you left it "[to be named]"). I'd call it "Cascade"; say the word if you want something else. Go and I'll write it.

5s cooldown, rectangular areas, centered on each block. Go ahead.

Implementing: 5s cooldown, rectangular blasts centered on each block.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   units.lua
OK   builds.lua

Both compile. Increment 5 done.

What I built — Detonator ("Cascade")

How to test

Run it, b a crowd, and watch the solid-pink segment:

Tell me how the eruption reads (size/damage/cadence), then it's on to the last one, Increment 6: Poisoner — though we may want to check context budget before the trail system.

Higher, longer, and trauma-based camera shake for the explosions. Cascade is 5 inert blocks instead of all, but the area for each rectangle is 1.5xed.

Two changes — let me first find the trauma-based shake API (vs the shake_shake I've been using).

Bash (cd E:/a327ex/mini-looper && echo "=== shake functions ===" && grep -n "^function...)

=== shake functions ===
4:  Lives as a sub-structure of a camera. Tracks trauma (Perlin noise shake),
13:    shake_trauma(camera.shake, amount, duration)
23:function shake_new()
26:    trauma_instances = {},
27:    trauma_amplitude = {x = 24, y = 24, rotation = 0.2, zoom = 0.2},
28:    trauma_time = 0,
61:  shake_trauma(s, amount, [duration], [amplitude])
63:  amount is the trauma value (intensity is amount^2 * amplitude * noise).
64:  amplitude (optional) overrides the global trauma_amplitude for this instance.
66:function shake_trauma(s, amount, duration, amplitude)
68:  s.trauma_instances[#s.trauma_instances + 1] = {
76:  shake_set_trauma_parameters(s, amplitude)
77:  Sets the global trauma amplitude (x, y, rotation, zoom).
79:function shake_set_trauma_parameters(s, amplitude)
80:  if amplitude.x then s.trauma_amplitude.x = amplitude.x end
81:  if amplitude.y then s.trauma_amplitude.y = amplitude.y end
82:  if amplitude.rotation then s.trauma_amplitude.rotation = amplitude.rotation end
83:  if amplitude.zoom then s.trauma_amplitude.zoom = amplitude.zoom end
91:function shake_shake(s, amplitude, duration, frequency)
109:function shake_push(s, angle, amount, frequency, bounce)
125:function shake_sine(s, angle, amplitude, frequency, duration)
139:function shake_square(s, angle, amplitude, frequency, duration)
153:function shake_handcam(s, enabled, amplitude, frequency)
169:function shake_get_effects(s)
182:  for i = 1, #s.trauma_instances do
183:    local inst = s.trauma_instances[i]
184:    local amp = inst.amplitude or s.trauma_amplitude
186:    ox = ox + intensity*amp.x*noise(s.trauma_time*10, 0)
187:    oy = oy + intensity*amp.y*noise(0, s.trauma_time*10)
... [9 more lines]

shake_trauma(s, amount, duration) is the trauma-based one (intensity = amount²·amplitude). Applying both changes:

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && E:/a327ex/Anchor/reference/love-compare/tools/lua54....)

OK

Compiles. Done.

What changed

How to test

b a crowd, watch the solid-pink segment: every 5s it sets off 5 of the blocks (random), each a bigger rectangular blast, with a stronger, longer screen rumble — fewer but heavier explosions than the all-block version. Blocks still stay (non-destructive). detonate_trauma / detonate_trauma_dur and cascade_block_count / cascade_radius_mult are the knobs if any of it needs nudging.

That's all five of the easier ramming units in. The last one, Poisoner (the trail-DoT system), is the heaviest remaining piece — and you flagged Psylooper for last. Given how much we've built this session, want to check the context budget before starting the trail system, or push on?

Let's do the poisoner and then end it. I have already verified that the game is very fun like this, doesn't seem like fun will be an issue. But let's do the poisoner so we get some simple AoE DoTs going. Look at how SNKRX does such areas, they're usually circles. I'm not sure if the current area carrier supports damage over time, but it should.

The game's fun-verified — that's the gate passed, which is the whole point of this session's work. Let me confirm the area carrier's DoT support, then brief the Poisoner.

Read (E:/a327ex/mini-looper/snkrx/area.lua)

302  -- Persistent tick. Per-enemy cooldown (`hit_cooldowns[id] = time + tick_rate`)
303 -- prevents the same enemy from being double-tapped if multiple ticks land in
304 -- one frame somehow. Per-enemy hit sound is silenced (hit_vol=0) so the
305 -- single `dot_tick` plays once for the whole tick — matches v1 line 8685
306 -- where non-cyclone non-hit_sounds DoT areas pass hit_vol=0 to take_damage.
307 -- Lighter particles (1-2, lower velocity, narrower, shorter) keep DoT ticks
308 -- from feeling as heavy as instant hits — v1 line 8691-8698.
309 function area:apply_tick()
310   local enemies = self:query_enemies()
311   local pc = spec_color(self.spec)
312   local hit_any = false
313   for _, e in ipairs(enemies or {}) do
314     if e.hit and not e._dead then
315       local cd = self.hit_cooldowns[e.id]
316       if not cd or time >= cd then
317         self.hit_cooldowns[e.id] = time + self.tick_rate
318         e:hit(self.damage, 0)
319         hit_any = true
320         for i = 1, random_int(1, 2) do
321           spawn_particle(e.x, e.y, {
322             velocity = random_float(30, 80),
323             duration = random_float(0.1, 0.3),
324             w        = 4,
325           }):change_color(0.5, pc)
326         end

Confirmed — the area carrier has full DoT support: pass duration + tick_rate and it ticks damage per enemy (with per-enemy cooldown, silent, light particles). So the poison patch is just a persistent circle area. Here's the brief.

Design brief — Increment 6: Poisoner (trail DoT)

Approach

A unit that leaves a trail of poison as it rides the loop — its whole kit is the trail (no targeted attack). On a recurring timer it drops a persistent circle DoT at its current position; the patches linger and tick damage to anything standing in them.

Mechanic

Unlock ("Virulence")

The poison deals more damage — a × poison_unlock_mult on the per-tick damage. On by default (poison_more_unlocked).

Numbers (tunable)

radius 22, duration 2.0, drop_interval 0.15, tick_rate 0.5, damage 2/tick (× 2 unlocked → 4/tick). Color: an acid lime-green (toxic; the trail field is the dominant visual so the segment color matters little).

Files

Go (or rename "Virulence") and I'll write it — then we're at a clean stopping point to end the session.

Radius 16, drop it at slightly randomized locations from the poisoner's position, so the areas aren't perfectly on the trail always.

Implementing — radius 16, and a poison_scatter offset so patches land slightly off the exact trail.

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/units.lua)

The file E:/a327ex/mini-looper/units.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Edit (E:/a327ex/mini-looper/builds.lua)

The file E:/a327ex/mini-looper/builds.lua has been updated successfully. (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex/mini-looper && LUA=E:/a327ex/Anchor/reference/love-compare/tools/lu...)

OK   units.lua
OK   builds.lua

Both compile. Poisoner is in — that's 6 of the 7 ramming units done this session.

What I built — Poisoner ("Virulence")

Test: b a crowd, find the green segment, watch it lay a scattered green trail that builds into a ring; enemies crossing it should bleed DoT, worst where patches overlap.


Session wrap

The train is now Juggernaut (head) + archer · cannoneer · swordsman · earthshaker · detonator · poisoner + 8 blocks, and you've fun-verified it on a real sitting — which means this session cleared the build-heavy pipeline's hardest gate (the end-game power fantasy is fun). What we built: the ramming foundation (variable-size blocks + count-scaling modifiers + the F1 toggle panel), the info_text port, and Juggernaut / Swordsman / Earthshaker (+ Looping Fissure) / Detonator (Cascade) / Poisoner (Virulence).

Still open (next session): Psylooper (the one you flagged for last — the homing-grab), the three unimplemented spice modifiers (aura / proximity / crush), and the high-beat test harness (we only added the b burst key).

To close out, run /end-session — it'll write the session log, commit, and push. Want me to do anything else before you do?

OK, this works. We'll not implement Psylooper, too complicated and doesn't even fit properly here I don't think. We have successfully verified the game works well at this level and is interesting to watch/play. Call this session Mini Looper Ramming Endgame Build

Agreed — Psylooper was always the off-archetype, expensive outlier; dropping it is the right call. And the key outcome stands: the ramming endgame build is fun-verified — that's the gate this session existed to clear.

Let me jot a project memory (Mini Looper isn't in the global CLAUDE.md yet, so future sessions should have a pointer), then end the session with your title.

Write (C:\Users\adn.claude\projects\E--a327ex\memory\project_mini_looper.md)

File created successfully at: C:\Users\adn\.claude\projects\E--a327ex\memory\project_mini_looper.md (file state is current in your context — no need to Read it back)

Read (C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md)

Read (C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md)

Edit (C:\Users\adn.claude\projects\E--a327ex\memory\MEMORY.md)

The file C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

Memory saved. Now ending the session with your title.

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 4.5: Decrement the lock (if active)

Read E:/a327ex/a327ex-site/.lock.json if it exists. If it contains {"remaining": N} with N > 0:

  • Decrement N by 1
  • Write {"remaining": N-1} back to the file
  • If N becomes 0, the lock is cleared. You may leave the file at {"remaining": 0} or delete it; both work.

The lock file lives in the a327ex-site repo, so the change is staged automatically by git add -A in Step 6.

If no lock file exists or remaining is already 0, do nothing. (See the /lock skill for the lock's full design.)

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: Mini Looper Ramming Endgame Build

I'll run the end-session workflow. The title exceeds the 30-char guideline but you chose it explicitly, so I'll use it verbatim. First, gathering session info.

Bash (cd E:/a327ex && python E:/a327ex/Anchor2/scripts/find-recent-session.py --limit ...)

2026-06-03T02:14:21.950Z f6d87419-3048-4f13-9948-dad03424ef5a [claude] <-- MOST RECENT
   C:\Users\adn\.claude\projects\E--a327ex\f6d87419-3048-4f13-9948-dad03424ef5a.jsonl

2026-06-02T22:21:40.628Z 19a749d1-fc04-409e-9dc3-8da75161cb61 [claude]
   C:\Users\adn\.claude\projects\E--a327ex\19a749d1-fc04-409e-9dc3-8da75161cb61.jsonl

2026-06-02T16:23:36.402Z 3ec7c86a-d4e5-4f02-8a7e-6deb6961ef74 [claude]
   C:\Users\adn\.claude\projects\E--a327ex\3ec7c86a-d4e5-4f02-8a7e-6deb6961ef74.jsonl

2026-06-02T03:39:44.751Z 7ce53a10-5168-4388-84bc-e614b523ec68 [claude]
   C:\Users\adn\.claude\projects\E--a327ex\7ce53a10-5168-4388-84bc-e614b523ec68.jsonl

2026-06-02T03:15:29.043Z 0751ea9b-b519-4976-ba6f-32bffe9fed2f [claude]
   C:\Users\adn\.claude\projects\E--a327ex\0751ea9b-b519-4976-ba6f-32bffe9fed2f.jsonl

=== DATE ===
2026-06-02 23:15:01
=== LOCK ===
{"remaining": 8}

Current session identified. Local end time: 2026-06-02 23:15:01. Lock is at remaining: 8 (→ decrement to 7). Converting the transcript: