Loading…
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:

  • Mini Looper: an on-rails SNKRX-style train/snake runs a fixed loop defending a central base; enemies march in from all edges, train units auto-attack and the body rams. Beat-based director (wave.lua) scales pressure over 18 beats. Game lives in root *.lua (main, loop, train, units, enemy, building, wave, grid, builds, info_text); .claude/CLAUDE.md is still the snkrx-template's; Mini Looper is not in the global CLAUDE.md project list.
  • Framed the session as the "End" fun-gate (hardcode the endpoint, test fun, don't build systems). Reframed Mini Looper as an autonomous auto-battler — the test is "is watching a maxed build execute a screen-clear satisfying," not "does aiming feel good."
  • Flagged the likely perf bottleneck: enemy:update calls steering_separate(16, enemies, …) (O(n) per enemy = O(n²) total) and unit_pack_target is also O(n²); the Lua-side O(n²) bites before Box2D at high enemy counts.
  • Surveyed SNKRX's real roster (from E:/a327ex/SNKRX/main.lua character_names/descriptions/class_strings/class_descriptions) — key finding: SNKRX has NO true move-into-contact damage; its "melee" = self-centered AoE bursts (Swordsman cleave, Highlander, Barbarian) + knockback (Juggernaut, Forcer class). Catalogued contact-damage abilities across Isaac/RoR2/PoE/DoTA2/LoL by mechanic family (move-into-contact, speed/distance scaling, knockback, damage aura, thorns, cleave, on-kill chain, trail, count-scaling, glass-cannon). Surfaced the "Fling vs Grind" insight: knockback splits the ramming build into two sub-flavors that want opposite knockback values; chose Grind first (low knockback).

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

  • New inert block unit (unit_defs['block'], spec only, no attack); two guards so "def with no attack = inert" (unit_attack_update early-returns on not def.attack; unit_init_combat sets cooldown 0). Blocks initially steel grey, later changed to white.
  • Variable-size blocks (size 1/2/3 → a 10/20/30px-long segment, one collider each): train_unit:new(x,y,character,size) reads size → self.w = size*train_unit_base. Followers pin to a new cumulative trail_offset (sum of preceding segments' lengths + gaps) instead of (index-1)*spacing, so long blocks push followers back correctly; reduces to the old formula when uniform. train_build computes offsets; train_total_length used for trail trim.
  • setup_ramming_build() interleaves the attacker units + 8 sized blocks (shuffled via array.shuffle) behind a fixed head; replaced the scene's plain train_build.
  • Count-scaling: ramming_apply_scaling() sets train_max_speed/train_contact_damage from base + per-block. Per-block (not per-size) scaling chosen ("itself is only a single unit"). User-set values: +10 speed/block, +2 damage/block.
  • Added b debug key: burst ~20 enemies at the edges (minimal test harness; high-beat beat-jump harness deferred).
  • Follow-up: made each modifier independently toggleable in the F1 panel (ram_mod_order/ram_mod_enabled, ramming_apply_scaling reads the toggles, recompute on toggle); dropped ATTACK/COOLDOWNS panel sections (settled); SPEED/CONTACT became readouts. Blocks recolored white (color='solid', color_a='white').

Increment 2 — Juggernaut (front carver):

  • Per-unit contact_damage (train units can override the shared train_contact_damage); ram handler uses u.contact_damage or train_contact_damage. Juggernaut's = 999 ("huge", not literal instakill, so a future high-HP elite survives — user chose this over instakill).
  • "Deletes, doesn't fling": ram push now guarded to living enemies only (not e._dead and not e._dying) — verified entity_killdefault_kill sets _dying synchronously (anchor/object.lua:64).
  • Always the head in setup_ramming_build; it's a unit (not a block), so not counted by ram_block_count.
  • Unlock "Charge": on contact, train_speed_boost set to +100 (refresh-to-max, decays via train_speed_boost_decay=80/s in train_update); train_current_speed() = base + boost, used by head movement + the turn-coupling. Plowing pins the train at its 360 cap; clear field settles to ~260. Trigger model chosen: contact-refresh (over periodic or per-kill-stacking). Gold spec (snkrx_yellow↔yellow). Hook is unit_on_ram(u, e) in units.lua, called from main.lua's train↔enemy ram handler.

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

  • Floating effect-text (spell name / "Fizzle" / "No Mana" in Invoker). Ported: ui_text→raw layer_text (top-left, manual centering), draws bracketed flat (effect_set({color='none'})) through the effect shader, ui_font_body_ark. Wired info_text_update(sdt) / info_text_draw(effects_layer) (camera-attached) / info_text_reset() on restart.
  • Triggered on a FRESH Charge only: was_active = train_speed_boost > 0 before applying; spawns "Charge" (yellow) at the Juggernaut's live position only if not already charging (a refresh while charging does not re-pop).

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:

  • Build fun-verified; juice deliberately deferred ("need to work on the juice of it all a little but that's for another session").
  • Psylooper dropped — user: "too complicated and doesn't even fit properly here." It was always the off-archetype outlier (homing-grab that pins enemies to the loop, Psyker/Forcer-flavored).
  • Each unit's special unlock is a hardcoded global flag (jugg_charge_unlocked, sword_cleave_unlocked, earth_loop_unlocked, detonate_all_unlocked, poison_more_unlocked), on by default.
  • Final train: Juggernaut (head) + archer, cannoneer, swordsman, earthshaker, detonator, poisoner + 8 variable-size white blocks.
  • Still open for next session: the 3 "spice" modifiers (enter/exit aura, proximity-all-damage, crush-on-kill, never implemented — only the 4 count-scaling modifiers exist); the high-beat test harness (only the b burst key, no beat-jump/uncap).
  • Saved a project memory project_mini_looper.md + MEMORY.md pointer (Mini Looper isn't in global CLAUDE.md). Workflow that worked well: port real mechanics from SNKRX/Invoker source, one unit per increment, design-brief-then-code each time.

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.

Opus 4.8

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

[REDACTED: private project content]

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:

  • loop.lua — fixed irregular ellipse, 18 waypoints. Train head walks it kinematically; followers pin to a breadcrumb trail (classic SNKRX snake, no physics chain).
  • train.lua / units.lua — 5 auto-attacker types (archer = arrow, magician = rect AoE, cannoneer = projectile→explosion, outlaw = 3-pellet fan, geomancer = lobbed rock). Each ticks unit_attack_update independently, desynced.
  • enemy.lua — off-screen spawn → seek base center via steering_seek + wander + separate. Train ram applies train_contact_damage (3) + speed-scaled knockback per unit-pass.
  • building.lua — the objective base with HP; enemies pile on and chip it.
  • wave.lua — fractal difficulty D(b) = (1+(b-1)·0.5)·(1+v3(b)), ambient omnidirectional trickle + telegraphed flank surges on beats 9/18.

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:

  • Fastest to on-screen (the gate rewards this). The train already rams with speed-scaled knockback. Ramming ≈ a long train of mostly-inert blocks + a contact-damage formula that scales with block count + high speed. Very little new code → fun-testable this session.
  • Cleanest perf scenario. A long snake sweeping a dense field is the legible max-entity case: long train (follower pinning + trail walk) × screen of enemies (the O(n²) steering) × per-unit-per-enemy contact events. It stresses exactly the paths I'm worried about.
  • It also lets us build the harness once and reuse it for the other three.

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)

  • Block unit — a new inert character = 'block': no unit_def.attack, a heavy solid look (neutral/steel color='solid', no breathe, maybe bigger w,h) so the snake visibly reads as a battering train. Its whole job is body + contact damage.
  • Block-count scaling (the archetype's identity), as hardcoded globals:
    • train_contact_damage = base + per_block · num_blocks → 30 blocks = a number that one-shots.
    • Velocity bonus: more blocks → higher train_max_speed (and ram force is already speed-coupled, so heavier = faster = harder hits — a clean feedback loop).
    • Knockback bonus: each block adds to train_ram_force, so the maxed train flings enemies off the loop.
  • Ramming "modifiers" (unit-independent, hardcoded): contact_damage_mult, a "crush" radius (ram also damages enemies near the unit, not just touching), "every block is a damage instance" (already true — long train = many hits per sweep).
  • The feel to judge: a long fast steel snake plowing the loop, gibbing and flinging the whole field in a sweep, screen clearing as it passes. The question the test answers: is the sweep satisfying to watch, or does pure on-rails melee read as too passive even for an auto-battler? That's the real risk and exactly what the third-sitting read is for.

Two decisions before I write a brief:

AskUserQuestion (Which archetype do we build + fun-test first this session? (One only — we test i...)

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 ({"title": "Ramming build design", "summary": "Designing the hardcoded ramming endgame build for fun + perf testing"})

[
  {
    "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)

  • train_build(n, roster) — reused as-is; I just pass a roster of 'block'. It already strings units back along the heading.
  • The main.lua train↔enemy ram handlerunchanged. It already reads train_contact_damage and computes knockback from train_ram_force + train_max_speed. The build sets those; the handler does the rest.
  • unit_defs registry (units.lua) — gains a 'block' entry (spec only). This also motivates a one-line cleanup: unit_attack_update should early-return when a def has no attack, so "a def with no attack = inert" becomes a clean rule.
  • The three globals it drives already live in the codebase (train_contact_damage, train_ram_force in enemy.lua; train_max_speed in train.lua). The build reads/writes them; nothing else changes ownership.
  • New file builds.lua — home for the hardcoded per-build setups (ramming now, the other three later). Explicitly a parking lot for fake builds, not a build-framework. required in main.lua after the game files.

Functions

  • unit_defs['block'] (data, units.lua) — { spec = block_spec }, no attack/range/cooldown. Plus a block_spec (flat steel, e.g. color='solid', color_a='snkrx_neutral').
  • unit_attack_update(u, dt) (changed, units.lua) — add an early if not def.attack then return end after the def lookup. Makes blocks inert with zero special-casing.
  • ramming_apply_scaling() (new, builds.lua) — counts 'block' units in train_units, sets the three globals to base + per_block·count (speed clamped). Called from setup; can move to per-frame later if blocks are gained mid-run.
  • setup_ramming_build() (new, builds.lua) — sets ramming_build_active = true + the ram_* constants, calls train_build(N, 'block'), then ramming_apply_scaling(). Calls: train_build, ramming_apply_scaling.
  • main.lua SCENE (changed, one line) — replace train_build(6, {...}) with setup_ramming_build().

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

  • Swordsman [Warrior] — "deals damage in an area, +0.15× damage per unit hit" (Cleave). The scales-with-enemies-hit unit — a dense sweep is its best case. Closest existing analogue to "ram a pack."
  • Highlander [Warrior] — "deals 5× AoE damage." The single-target-into-a-crowd nuke; melee Warrior.
  • Blade [Warrior, Nuker] — "throws multiple blades, AoE damage." Orbiting/thrown melee (Isaac orbital flavor).
  • Barbarian [Curser, Warrior] — "deals AoE damage and stuns hit enemies 4s." On-self AoE + crowd control.

The knockback / push unit (the literal rammer)

  • Juggernaut [Forcer, Warrior] — "deals AoE damage and pushes enemies away with a strong force." This is the contact-and-fling unit, and it shares its name with bruiser archetypes in PoE/DoTA.

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

  • Cryomancer [Mage, Voider] — "nearby enemies take damage per second."
  • Pyromancer [Mage, Nuker, Voider] — same, fire-flavored.
  • Plague Doctor [Nuker, Voider] — "creates an area that deals damage per second."
  • These are the Radiance/Sunfire aura family — and Mini Looper already has the persistent-area carrier that does exactly this. A "ramming aura" is the cheapest modifier to wire.

Self-damage / risk-melee flavor

  • Flagellant [Psyker, Enchanter] — "deals damage to self, grants +4% damage to allies per cast." The glass-cannon-sacrifice 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)

  • LoL — Rammus, Powerball (literally "ram"): roll into enemies → AoE damage + knockback, speed-ramped.
  • LoL — Sion ult, Unstoppable Onslaught: charge, collision deals damage + knockup, scales with distance charged.
  • LoL — Hecarim: movespeed → bonus damage; Devastating Charge knockback scales with distance.
  • DoTA — Spirit Breaker, Charge + Greater Bash: charges across the map, bash damage + knockback scales with movespeed.
  • RoR2 — Loader: charged-fist melee with massive knockback.
  • PoE — Cyclone: channel and move while spinning, hitting every enemy you pass through.
  • Isaac — Spirit Sword / Mom's Knife: held melee — you damage by moving into enemies.

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

  • Sion (distance→damage), Rammus/Hecarim/Spirit Breaker (movespeed→damage+knockback). Validates "faster, heavier train hits harder."

3. Knockback / fling on contact

  • SNKRX Juggernaut + Forcer class, DoTA Magnus (Skewer/Empower cleave), LoL Malphite (charge knockup), LoL Sion (knockup), Rammus. The fling is both damage-setup and defensive (clears the approach).

4. Damage aura around the body (passive proximity)

  • DoTA — Radiance: burn aura, constant damage to all nearby.
  • LoL — Sunfire Aegis: immolate aura.
  • PoE — Death's Oath: chaos-degen aura around you (+ on-kill cloud).
  • RoR2 — Focus Crystal: +damage to enemies near you (proximity bonus).
  • SNKRX — Cryomancer/Pyromancer/Plague Doctor (in-engine already).

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

  • LoL — Thornmail, RoR2 — Razorwire (on-hit, damage all nearby), DoTA — Centaur Return / Bristleback quills, PoE — Reckoning/Vengeance, Isaac — orbital "body" familiars.

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

  • DoTA — Battle Fury cleave / Sven Great Cleave / Tidehunter Anchor Smash, RoR2 — Brilliant Behemoth (every hit gains an AoE explosion), SNKRX — Swordsman Cleave. This is your proposed "crush radius."

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

  • RoR2 — Gasoline (kill → ignite + AoE, chains) & Will-o'-the-wisp (kill → delayed AoE).
  • PoE — Herald of Ice (kill → shatter AoE).
  • SNKRX — Jester (cursed enemies explode into knives on death).
  • The best screen-clear spectacle modifier — a ram that kills a row sets off a chain.

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

  • LoL — Singed Poison Trail (leave damage as you move).
  • Isaac/RoR — mines on path (bridges to the construct archetype).
  • A "scorched loop" trail behind the snake is thematically perfect for an on-rails game.

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

  • PoE — Boneshatter (trauma stacks: more self-damage = more damage), Isaac — Cube of Meat / Ball of Bandages (orbitals level up), RoR2 — bruiser stacking, RoR2 — Crowbar (bonus vs full-HP enemies — rewards the one-shot first contact). Validates "more blocks = more X."

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

  • PoE — Abyssus (+melee damage, +damage taken) & Double Edge / Boneshatter, SNKRX — Flagellant.

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

  • Map cleanly (cheap, in-engine): speed→damage/knockback scaling (#2, already done) · body damage aura (#4 — reuse the persistent-area carrier; the strongest cheap add) · crush/cleave radius (#6 — the "ram hits nearby" I proposed) · on-kill chain explosion (#7 — the spectacle modifier) · count→power (#9 — your block scaling) · scorched trail (#8 — fits the loop beautifully).
  • Need a design change: thorns/retaliate (#5) and glass-cannon (#10) only work if the train can take damage — right now it's invincible. Worth deciding whether the ramming train should be damageable (it'd make Warrior-style tankiness and thorns meaningful), or stay invincible (pure offense test).
  • Don't map: charge-distance scaling (Sion/Hecarim) — the loop runs at constant speed, no wind-up. Speed-scaling substitutes for it.

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

  • Juggernaut — the only true rammer, and "front of the train" introduces positional strategy (order matters: the carver leads, blocks clean up), which is genuinely good emergent depth for a snake. One decision though: instakill erases the "powerful enemies" texture we're trying to test — the whole point of an endgame test is feeling enemies be tanky and your build overpower them; literal instakill makes HP and every +contact modifier irrelevant for the front unit. I'd make it huge contact damage instead of instakill (scales with your modifiers, still one-shots normal enemies, but a future elite/boss survives a graze). Charge (+decaying speed) is great and feeds the speed→force loop — just define its trigger (on-kill feedback loop? periodic pulse?).

  • Swordsman — fine as-is; gives the build an active damage layer distinct from the ram, and Cleave (+dmg/unit hit) rewards density, which this build is swimming in. No notes.

  • Earthshaker — strong for a defense game (stun stops the advance, then the ram cleans up sitting ducks). Dependency: enemies have no stun state today — that's one new enemy-side mechanic to add (a timer that suspends steering). Cheap, but it's net-new. Earthshatter's density-scaling damage is thematically consistent (this build loves crowds).

  • Detonatorthe standout. It's the one idea that gives inert blocks an active payload, so stacking blocks isn't just passive stats — they're ammunition. "All inert blocks detonate" is a perfect endgame spectacle beat (the whole body erupts). One fork: does detonation consume the block? If yes, the snake shrinks — which fights your "more blocks = more power" core and you don't want that in an endgame test. I'd make it non-destructive (block flashes + emits AoE, stays; re-arms on cooldown). Keep "consume for burst" as a deeper-economy idea for the real game.

  • Poisoner — here's a Mini-Looper-exclusive advantage worth seeing clearly: because the loop is fixed and lapped, a poison trail overlaps itself every circuit and saturates the whole loop into a permanent kill-ring. Singed's trail is transient because he moves freely; yours compounds because the path repeats. That's the build's best emergent property. Perf flag: a continuous trail = lots of overlapping DoT zones — your single biggest perf cost here, which also makes it a great engine stress test. Needs pooling/merging or a cap.

  • Psylooper — the odd one out. The concept is cool (grab an enemy and force-feed it to the ram path — that's ramming-supportive), but mechanically it's a homing beam + grab + reposition + persistent pin: the most expensive of the six, and it's flavored Psyker/Forcer, not melee. This reads like a mage/psyker build's signature that wandered into ramming. I'd either reserve it for the mage archetype, or simplify it for ramming (grab + yank toward the loop / into the body, no permanent pin). It's also the one whose "permanent" is self-contradicting — a pinned enemy in the ram path dies in one lap.

Modifiers — quick take

Solid, and two are smarter than they look:

  • The enter/exit aura is well-suited to a moving emitter. A stationary enemy the train sweeps past: enters (hit) → exits (hit) = two hits per pass, no per-tick cost. Better than a flat DoT for something that moves. And it double-dips with knockback (enter → flung out → exit → second hit) — that's a designed combo.
  • "+damage to enemies near the train (all damage)" is the keystone multiplier — it amplifies contact, Swordsman, poison, detonation, everything, gated by the proximity this whole build already lives in.

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:

  • FLING build — high knockback + enter/exit aura + crush-on-kill + Juggernaut. Enemies pinball off the train, get hit entering and exiting, die and crush into neighbors. Spectacle = chaos pinball. Strongly defensive (everything gets flung away from the base).
  • GRIND build — poison trail + Earthshaker stun + Swordsman cleave + density damage + proximity-all-damage, with knockback dialed DOWN (you want enemies to stay in the soup and melt). Spectacle = crowds dissolving in a kill-zone.

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:

  • If FLING: blocks + count-scaling + Juggernaut + enter/exit aura + crush-on-kill. (No new enemy states; reuses ram knockback.)
  • If GRIND: blocks + count-scaling + Poisoner trail + proximity-all-damage, knockback low. (Tests the loop-compounding trail + the perf-heavy path at once.)

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

  • train_build(n, roster) — reused unchanged, passed 'block'.
  • main.lua ram handler — unchanged; reads train_contact_damage / train_ram_force / train_max_speed, which the build sets.
  • units.lua — gains a 'block' registry entry (spec only) and two small guards so a def with no attack is safely inert.
  • New builds.lua — home for hardcoded build setups (ramming now, the others later). required in main.lua after the game files; its setup_ramming_build() replaces the current train_build(6, {…}) line in SCENE.

Functions

  • block_spec + unit_defs['block'] = { spec = block_spec } (data, units.lua) — flat steel (color='solid', e.g. color_a='snkrx_neutral'), no attack/range/cooldown.
  • unit_attack_update (changed, units.lua) — early if not def.attack then return end, so blocks never fire.
  • unit_init_combat (changed, units.lua) — handle a def with no attack (set attack_cooldown = 0, return) so seeding a block doesn't hit the nil-cooldown path.
  • ramming_apply_scaling() (new, builds.lua) — count 'block' units in train_units; set train_contact_damage = ram_base_contact + ram_contact_per_block·n, train_max_speed = clamp(ram_base_speed + ram_speed_per_block·n, …, ram_speed_cap). Run once at setup (block count is fixed until a growth driver exists; note to move per-frame later).
  • setup_ramming_build() (new, builds.lua) — kill any existing train units, set ramming_build_active = true + the ram_* config globals + a tight train_spacing, train_build(RAMMING_BLOCKS, 'block'), then ramming_apply_scaling(). Calls: train_build, ramming_apply_scaling.
  • main.lua SCENE (changed, one line) — setup_ramming_build() in place of the 6-unit train_build.

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)

  • RAMMING_BLOCKS = 24 (a clearly-long half-loop snake; can push toward ~56 for a full rotating ring as a stress test later)
  • train_spacing = 11 (blocks ~touching → a near-continuous wall)
  • speed: base 180, +4/block, cap 320 (24 blocks → ~276)
  • contact: base 4, +1.5/block (24 blocks → ~40 — one-shots beat-18-ish enemies at ~28 HP, leaves headroom)
  • train_ram_force = 4 (modest; grind flavor)

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

  • 8 blocks, not 24. Spacing back to 12. Speed +10/block, contact damage +2/block.
  • Blocks are now variable-length, and they interleave with the existing 6 attacker units instead of sitting in their own run.

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)

  • units.luablock_spec + unit_defs['block'] (spec only) + the two inert-def guards (unit_attack_update, unit_init_combat).
  • train.luatrain_unit gains block_size / length / trail_offset; train_build handles string-or-table entries + computes cumulative offsets; follower pinning + trail-trim use the offsets.
  • builds.lua (new) — setup_ramming_build() (interleaved sized roster + ram_* globals) + ramming_apply_scaling().
  • main.lua — require builds; replace the train_build(6,…) line with setup_ramming_build(); add a b = burst ~20 enemies debug key so it's testable against a crowd.

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:

  • builds.lua (new) — setup_ramming_build() strings a 14-segment snake: your 6 attackers + 8 inert blocks (each a random size 1/2/3), shuffled so they mix irregularly. ramming_apply_scaling() counts the blocks and sets train_max_speed = 180 + 10·blocks (→ 260) and train_contact_damage = 4 + 2·blocks (→ 20). train_ram_force pinned low (4) for the grind flavor.
  • units.luablock_spec (flat steel grey), unit_defs['block'] (color only, no attack), and two guards so "a def with no attack = inert" (blocks never fire, and seeding their cooldown doesn't choke).
  • train.lua — segments are now variable-length: a size-N block is an N×10-long body (one unit, one collider — a wider rammer and a longer wall). Followers pin to a cumulative trail_offset so a long block pushes everything behind it back the right amount (reduces exactly to the old (index-1)·spacing when uniform).
  • main.lua — requires builds, the scene now calls setup_ramming_build(), and b dumps a burst of ~20 enemies so you can watch the ram against a crowd.

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

  • Knockback at speed 260 is ~13 effective (the force formula auto-scales force with speed). I set the base low (4), but if it still flings enemies out of the sweep more than a grind build wants, the real fix is decoupling force from speed — easy follow-up.
  • The F1 SPACING slider is now inert for this build — trail offsets are baked at build time (they have to be, for variable sizes). Spacing is fixed at 12. If you want it live again I can recompute offsets per-frame (also future-proofs detonation/growth), but that's a small add I left out to keep this increment minimal.

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

  • Blocks are now whiteblock_spec uses the white token (a 200-grey that still flashes brighter to near-white on a ram, so the hit-flash stays visible).
  • Each modifier is independently toggleable. Restructured the scaling so speed and contact are built from intrinsic bases + four switchable terms:
    • +SPEED (flat +60) · +SPD/BLK (+10·blocks) · +CONTACT (flat +4) · +DMG/BLK (+2·blocks)
    • All on @ 8 blocks → speed 260, contact 22. All off → the snake's bare intrinsic 120 / 2, so you can feel exactly what each adds. Toggling recomputes instantly.
    • The set lives in ram_mod_order / ram_mod_enabled (builds.lua), so when I add the spice modifiers (aura / proximity / crush) they'll append as toggles automatically.
  • F1 panel rebuilt — dropped ATTACK and COOLDOWNS (settled). Now:
    • LEFT: TRAIN readouts — SPEED / CONTACT (derived) / BLOCKS count — then the live SEEK knob and the auto EASE/CORNER readouts.
    • RIGHT: MODIFIERS — a checkbox per modifier.

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.

  • Huge contact via a per-unit contact-damage override. Today every train unit rams for the shared train_contact_damage; I'll let a unit carry its own contact_damage, and the ram handler uses u.contact_damage or train_contact_damage. Juggernaut's is huge (999 — one-shots normals, but it's a finite number so a future high-HP elite survives, which is exactly why you picked "huge" over literal instakill). Also: a ram that one-shots now skips the knockback (push only living enemies), so the Juggernaut deletes rather than flings — the blocks behind still push the survivors.
  • Front placement: setup_ramming_build() puts 'juggernaut' as the head, then shuffles the attackers + blocks behind it. It's a unit, not a block, so it doesn't count toward the block scaling.

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

  • units.luajuggernaut_spec (a gold breathe, distinct from white blocks / red enemies), unit_defs['juggernaut'] (spec + contact_damage, no attack → inert in the attack scaffold, like a block but with the huge ram), the jugg_* constants, and unit_on_ram(u, e) (per-character contact hook — applies Charge now, extensible for later units).
  • train.luatrain_unit:new reads def.contact_damage; train_speed_boost global + its decay in train_update; train_current_speed() used by the head speed + coupling.
  • builds.lua — Juggernaut pinned to the head in the roster.
  • main.lua — ram handler uses the per-unit damage, calls unit_on_ram, guards the push to living enemies; F1 SPEED readout shows the live (boosted) speed so you can watch Charge work.

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)

  • Per-unit contact damage. Train units can now carry their own contact_damage; the ram handler uses u.contact_damage or train_contact_damage. Juggernaut's is 999 — it one-shots normal enemies, but it's a finite number so a future high-HP elite would survive (the reason you chose "huge" over literal instakill).
  • Deletes, doesn't fling. A ram that kills now skips the knockback (push only living enemies), so the Juggernaut carves a clean hole at the front while the white blocks behind still shove whatever survived their 22-damage hit.
  • Always at the head. setup_ramming_build() pins 'juggernaut' to the front; attackers + blocks shuffle behind it. It's a unit, not a block, so it doesn't change the block-count scaling.
  • Charge (the unlock). On contact, the Juggernaut sets a train_speed_boost to +100 (their "+10 speed") that decays back to 0 over ~1.25s. Effective speed (train_max_speed + boost) drives the head's movement and the turn-coupling. So plowing the horde pins the train at its 360 cap; breaking into open field settles it back to ~260. On by default (jugg_charge_unlocked).
  • Gold Juggernaut. A gold organic breathe — distinct from the flat-white blocks and red/orange enemies.

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:

  • ui_textlayer_text. Invoker's ui_text(lyr, text, x, y, {align='center'}) doesn't exist here (mini-looper's ui_text is the immediate-mode UI widget). I'll use raw layer_text(lyr, text, font, x, y, color), which is top-left anchored, so I center manually (x - text_width/2).
  • Bracket the draws in effect_set(lyr, {color='none'}) / effect_clear so the bg/text colors pass straight through the effect shader (same as loop_draw / the building HP bar do).
  • Font ui_font_body_ark; tokens bg/red/light_blue/yellow/text + color_clone all exist here. The info_text_spawn / _update / _draw / _reset API is unchanged, so future uses (damage numbers, "No Mana", etc.) work as in Invoker.

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)

  • require('info_text') with the game files.
  • info_text_update(sdt) in update (scaled dt, so hit-stop freezes it).
  • info_text_draw(effects_layer) inside the camera-attached effects-layer block (world space, above the fxs).
  • info_text_reset() on restart, alongside building_reset() / director_reset().

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

  • info_text.lua — the Invoker floating-text object, ported. Same behavior (anchored at a world pos, floats up 22px over 0.9s, holds then fades, dark bg rect + colored text). Adaptations for this codebase: raw layer_text with manual centering (mini-looper's ui_text is a different widget), and the draws bracketed flat (color='none') so they pass through the effect shader. The info_text_spawn/update/draw/reset API is generic — future "No Mana"/damage-number uses drop in unchanged.
  • Trigger on a fresh Charge (units.lua unit_on_ram): checks train_speed_boost > 0 before applying the charge. If the boost was at 0 it's a new charge → spawns 'Charge' (yellow) at the Juggernaut's live position. If the boost was already active (mid-plow refresh) → nothing. So one popup per plowing burst, none during it.
  • Wired into main.lua: required, info_text_update(sdt) (scaled, so hit-stop freezes it), info_text_draw(effects_layer) in the camera-attached effects pass, and info_text_reset() on restart.

How to test

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

  • When it first plows into the crowd from open space → a "Charge" popup floats up from it.
  • While it keeps grinding through (boost staying topped up) → no repeat popups.
  • Let it run clear of enemies for ~1.5s (boost decays to 0), then hit a fresh group → a new "Charge" pops. The F1 SPEED readout dropping back to ~260 tells you the boost has expired and the next contact will re-pop.

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

  • units.luaswordsman_spec (a pink breathe, distinct from the gold Juggernaut / white blocks / blue magician), swordsman_cooldown, the sword_* constants, unit_defs['swordsman'], and the enemies_in_radius helper.
  • builds.lua — add 'swordsman' to RAMMING_UNITS so it appears in the train to test.

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:

  • Trigger: Circle(self.x, self.y, 48) — a small sensor; every 3s, if an enemy is inside it, attack. (the "low trigger range")
  • Attack shape: Rectangle(self.x, self.y, 1.5·w, 1.5·w, self.r) — a square rotated to the unit's facing, side 1.5·96 = 144 — much bigger than the 48 trigger (that's the "ranged" part: a small trigger sets off a big rotated-rectangle swing).
  • Cleave damage (computed inside the Area from its own hit count): dmg + dmg·0.15·#enemies — each enemy takes more the more enemies the rectangle catches.

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:

  • Low trigger range (20px): fires only when an enemy is close — a small sensor, much smaller than the swing.
  • Ranged rotated square (60px side): on trigger, spawns a rect area centered on the swordsman and oriented to its travel facing, reaching past the trigger range (the "ranged" part). Not a circle, not trigger-sized.
  • Cleave damage from the square's own hit count: I count the enemies the rotated square will catch with the same query_entities_box the area uses (so N is exactly the hit-set), then each enemy takes base + per_hit·N. Unlocked: 3 + 1.5·N — a pack of 8 → 15 each, a pack of 15 → ~25 each. The Cleave unlock (+1 per hit) more than doubles the per-hit term vs locked (0.5).
  • Cooldown 1.2s, on by default (sword_cleave_unlocked), pink segment.

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:

  • As the train plows through a pack, the swordsman should slash a rotated square (pink-tinted) into the nearby enemies — and only when something's right next to it (the low trigger).
  • The cleave is density-rewarding: the square hitting a dense clump should chunk each enemy far harder than hitting one or two. Easiest way to feel it is to let a big pile build (spam b) and watch packs near the swordsman melt.
  • To A/B the unlock, flip sword_cleave_unlocked = false in units.lua and compare — locked it's a weak 3 + 0.5·N.

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)

  • enemy:stun(duration) — sets self.stunned, zeroes velocity, arms a timer to clear it. While stunned, the update skips steering and stays frozen (the existing pushed branch still takes precedence, so a ram still knocks a stunned enemy). It keeps its collider, so it's still rammable and damageable — that's the whole synergy (freeze them, then the train/Swordsman/shatter clean up the frozen pile).
  • Visual: a flat icy-blue stun_spec while stunned (drawn instead of its red breathe), so a frozen enemy reads clearly against the moving field.

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

  • enemy.luaenemy:stun, the stunned init + update branch, stun_spec, draw swap.
  • units.luaearthshaker_spec, earthshaker_cooldown, the earth_*/EARTH_* constants, unit_defs['earthshaker'] + the earthshaker_pulse / earthshaker_shatter helpers (both take the captured x/y/spec).
  • builds.lua — add 'earthshaker' to RAMMING_UNITS to test it.

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

  • base_r = angle from the unit to the nearest enemy (the auto-cast stand-in for Invoker's manual aim).
  • Walk count areas forward from the unit: first at radius·0.6, each next +radius·rand(0.85,1.0) further along base_r, with a per-area ±8° jitter (so the crack wavers). Positions are captured at cast time (the crack is a fixed ground scar — it doesn't follow the moving unit), and each area spawns at i·delay via the unit's timer.
  • Each is spawn_area(ax, ay, {shape='rect', jagged=true, r=slight_r, radius, damage=fissure_damage, caster=u, shake=0}), with fissure_cast_1 played per area and one rumble shake_shake on cast.

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

  • units.luaearthshaker_spec, earthshaker_cooldown, the fissure_* constants, unit_defs['earthshaker'] (aims at nearest enemy → spawn_fissure, counter picks normal vs big), and the spawn_fissure helper.
  • builds.lua — add 'earthshaker' to RAMMING_UNITS.

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:

  • On cooldown (1.0s), when an enemy is within range (80), the Earthshaker cracks a forward-marching chain of 4 jagged-rect areas from itself toward the nearest enemy — each area opens 0.08s after the last, marching ~radius further along the aim with a slight ±8° waver, 3 damage each. Positions are captured at cast (the crack is a fixed ground scar, doesn't follow the moving unit). Plays fissure_cast_1 per crack + one rumble shake.
  • Unlock (every 3rd cast): a longer + bigger fissure — 8 areas (your number) instead of 4, each radius 22 instead of 14. So casts 3, 6, 9… erupt as a long, fat crack reaching ~3× as far. On by default (earth_fissure_unlocked).
  • Earthy gold→lime segment. Added to the ramming roster.

How to test

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

  • When enemies get within ~80px, it should fire a line of jagged rectangles racing outward toward them, cracking open in sequence (not all at once).
  • Count casts: every 3rd fissure should be visibly longer and chunkier (8 big cracks reaching much further) — that's the unlock. Easiest to catch by watching it fire a few times against a steady crowd.
  • Faithful to Invoker: jagged rects, the staggered crack, the earth cast sound.

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:

  • Finding the unit: look for the blue segment — easy now.
  • Seeing the cleave: spam b so the crowd is dense enough that enemies still reach the swordsman, and watch for the rotated blue square. If it's still too rare to evaluate, say the word and I'll temporarily widen its trigger range (or drop its cooldown) just so you can judge the swing — without changing the SNKRX-faithful values permanently.

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:

  • Find the Earthshaker's nearest loop point (nearest_loop_index), then walk loop_points backwards (decreasing index, wrapping) for the full #loop_points steps — back to one segment shy of the start.
  • At each loop point, spawn a jagged-rect fissure area (radius = fissure_area_radius, damage = fissure_damage), oriented along the local backward path direction, with a propagation delay of k · loop_fissure_delay — so it reads as a single crack traveling around the loop over ~1.4s, not all at once. (The loop has 18 points ≈ 35px apart, so radius-14 cracks form a near-continuous racing wave.) Earth sound every few cracks + one shake.
  • Spawns the "Looping Fissure" info_text at the unit (in lime), exactly like Charge.

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

  • Removed the old "every-3rd-cast bigger fissure" unlock (constants + branch). The base Earthshaker just casts the normal fissure at the nearest enemy on its cooldown.
  • New unlock — Looping Fissure (earth_loop_unlocked, on by default): every 10s, a fissure spawns from the Earthshaker and races backwards around the whole loop (opposite the snake's direction) — one crack per loop point, propagating as a ~1.4s traveling wave back to where it started. It damages enemies as the shockwave sweeps over them crossing the ring. Fires the "Looping Fissure" info_text (lime) at the unit, exactly like Charge.
  • Armed via timer_every(u.timer, 10, …) in unit_init_combat for earthshaker units (same inline-check pattern as the Juggernaut's ram hook).

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:

  • Every ~10 seconds, a "Looping Fissure" popup appears at it and a chain of cracks races around the entire loop backwards (against the train's travel), circling back over ~1.4s.
  • Burst enemies (b) so you can see the wave chew through whatever it sweeps over as it laps the ring — a nice defensive sweep.
  • The normal fissure (cracking toward the nearest enemy) still fires independently on its own cooldown, so you'll see both.

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)

  • Bigger cracks: radius 17 (was 14).
  • Tighter: I switched from one crack per loop point (~35px, irregular) to an arc-length walk at a fixed loop_fissure_step = 24px — denser and more continuous, and easy to tighten further (lower the step).
  • Camera shake: stronger on start (6) and on end (6, fired when the wave completes), plus a small 1.5 rumble per crack as the wave travels.
  • Longer: the wave now fills 2.5s (the per-crack delays scale to the loop perimeter, so it's exactly 2.5s regardless of step/size).
  • Cooldown from the end: it's now a self-re-arming chain — each cast schedules the next at duration + cooldown after itself, so the 10s cooldown starts counting when the effect ends, not from the cast.

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:

  • "Looping Fissure" pops, a stronger jolt kicks it off, the denser/bigger crack wave rolls backward around the whole loop over ~2.5s with a continuous ground rumble, and a stronger jolt lands as it closes the loop.
  • Then a full 10s of quiet before the next one (that's the cooldown-from-end change).

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

  • detonate_block(b, source)spawn_area(b.x, b.y, {shape='circle', radius, damage, caster=source}) for the boom + damage, plus b.flashing (brief) + spring_pull(b.spring) + a hit_circle pulse at the block. No removal (non-destructive).
  • unit_defs['detonator'].attack:
    • Base: collect train_units where character=='block', detonate_block one at random (array.random).
    • Unlock (on by default): detonate_block every block. One camera shake per cast (bigger when all detonate), not per block.
  • The Detonator is itself an attacker segment (has def.attack), so it's not counted as an inert block and never detonates itself — it only sets off the white blocks.
  • Fires when an enemy is within detonator_range of the unit (the snake's usually in the thick of it, so it triggers during combat; the random block may still pop in open space — that's the nature of "random").

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

  • units.luadetonator_spec, detonator_cooldown, the detonate_* constants + detonate_all_unlocked, detonate_block, unit_defs['detonator'].
  • builds.lua — add 'detonator' to RAMMING_UNITS.

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

  • A solid-pink segment that detonates inert blocks — every 5s (when an enemy is within 100px), each block gets a rectangular AoE centered on it (aligned to the block, radius 30, damage 6), flashes white + spring-pops + bursts, and stays in the train (non-destructive, re-arms next cast).
  • Unlock — Cascade (on by default): all 8 blocks erupt at once, so the whole snake detonates in a chain of rectangular blasts with a big shake. (Base, with the unlock off: one random block per cast + a smaller shake.)
  • The Detonator is an attacker segment, so it's not counted as a block and never detonates itself — only the white inert blocks.
  • Added to the roster.

How to test

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

  • Roughly every 5 seconds, while it's near enemies, the entire snake should erupt — a row of rectangular blasts popping along every white block at once, blocks flashing, a strong jolt. Enemies hugging the snake body get chunked.
  • The blocks don't disappear — confirm they're all still there after the blast (non-destructive).
  • This is also a deliberate perf data point — 8 rect areas + 8 bursts in one frame; watch the framerate when it goes off in a dense crowd.
  • To see the base (one random block), flip detonate_all_unlocked = false in units.lua.

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:

  • Cascade → 5 random blocks (not all), each blast 1.5× radius (30 → 45).
  • Explosion shake → trauma-based, higher + longer (shake_trauma, amount 0.9 ≈ ~19px peak vs the old 7, over 0.6s vs 0.3s).

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

  • Cascade → 5 random blocks (of the 8) instead of all, via array.random(blocks, 5).
  • Each Cascade blast is 1.5× radius (30 → 45). I read "area 1.5xed" as the rectangle's size ×1.5 (linear) — so noticeably bigger blasts. If you actually meant the footprint area ×1.5 (which is only ~1.22× linear), it's a one-number change.
  • Explosion shake is now trauma-based (shake_trauma): higher (amount 0.9 → ~19px peak vs the old 7) and longer (0.6s vs 0.3s). The intensity scales as trauma²·amplitude, so it's the rumbly, weighty explosion feel rather than a quick jolt. (Base single-block detonation gets a half-strength version.)

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

  • spawn_poison(u)spawn_area(u.x, u.y, {shape='circle', radius=poison_radius, damage=poison_dmg, duration=poison_duration, tick_rate=poison_tick_rate, caster=u, shake=0}). The carrier handles the ticking, per-enemy cooldown, grow/linger/blink visual, and spec tint — exactly what you expected it to support.
  • Trail timer: timer_every(u.timer, poison_drop_interval, …) armed in unit_init_combat for poisoners (same passive pattern as the Earthshaker's loop). The Poisoner has no def.attack (inert in the attack scaffold; the trail is everything).
  • The saturation property (the reason this fits a looping game): lap time ≈ 2.4s and patches last ~2s, so the trail forms a near-continuous poison ring around the loop — a passive defensive field enemies must cross. It's bounded (~14 patches active; old ones expire), so it won't run away — and it's your DoT-trail perf data point. Overlapping patches stack (each area is independent), so the ring bites harder where the trail doubles back.

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

  • units.luapoisoner_spec, the poison_* constants, spawn_poison, unit_defs['poisoner'] (no attack), and the trail timer in unit_init_combat.
  • builds.lua — add 'poisoner'.

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

  • A lime-green segment that, as it rides the loop, drops a trail of persistent poison DoT circles (radius 16) every 0.15s — each at a slightly scattered position (±8px off the exact trail) so the field reads organic, not a perfect line.
  • Each patch lingers ~2s, ticking damage every 0.5s (4/tick with Virulence on, 2/tick off) via the area carrier's built-in DoT. Patches stack where they overlap.
  • Because lap time (~2.4s) ≈ patch lifetime, the trail forms a near-continuous poison ring around the loop — a passive defensive field, and bounded (~14 patches live at once), so it's a clean DoT-trail perf data point.
  • No attack — the trail is its whole kit (armed as a passive timer in unit_init_combat, same pattern as the Earthshaker loop).

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 ([memory: sealed target])

[sealed content withheld: memory]

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

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

Edit ([memory: sealed target])

[sealed content withheld: memory]

Memory saved. Now ending the session with your title.

Skill (end-session Mini Looper Ramming Endgame Build)

Launching skill: end-session

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

```markdown
# [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

```bash
# 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:

```bash
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:

```bash
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:

```markdown
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:

```bash
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.

```bash
# 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 <[email protected]>\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):

```bash
# 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:

Bash (cd E:/a327ex && python E:/a327ex/Anchor2/scripts/jsonl-to-markdown.py "C:/Users/...)

Written to C:/Users/adn/AppData/Local/Temp/session-log.md

=== head ===
# Session 2026-06-02 21:54



---



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

=== lines ===
3577 /tmp/session-log.md

Transcript converted (3577 lines). Now writing the summary header, then assembling the final log.

Write (C:/Users/adn/AppData/Local/Temp/summary-header.md)

File created successfully at: C:/Users/adn/AppData/Local/Temp/summary-header.md (file state is current in your context — no need to Read it back)

Bash (cd E:/a327ex && tail -n +4 /tmp/session-log.md > /tmp/transcript-body.md && cat ...)

=== head of final log ===
Title: Mini Looper Ramming Endgame Build
Date: 2026-06-02 23:15:01

# 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."
=== total lines ===
3626 E:/a327ex/a327ex-site/logs/mini-looper-ramming-endgame-build.md

Log assembled with proper frontmatter. Now decrementing the lock (8→7) and staging the Mini Looper repo.

Bash (printf '{"remaining": 7}' > E:/a327ex/a327ex-site/.lock.json && echo "lock -> $(...)

lock -> {"remaining": 7}
=== mini-looper git ===
warning: in the working copy of 'ideas.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'main.lua', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'train.lua', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'units.lua', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'builds.lua', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'info_text.lua', LF will be replaced by CRLF the next time Git touches it
A  builds.lua
M  ideas.md
A  info_text.lua
M  main.lua
M  train.lua
M  units.lua
=== remote ===
origin  [email protected]:a327ex/mini-looper.git (fetch)
origin  [email protected]:a327ex/mini-looper.git (push)

Staged (new: builds.lua, info_text.lua; modified: main.lua, train.lua, units.lua, ideas.md), and origin is a327ex/mini-looper on GitHub. Building the commit message and committing.

Bash (cd E:/a327ex/mini-looper && { echo "Mini Looper Ramming Endgame Build"; echo; aw...)

=== commit msg head ===
Mini Looper Ramming Endgame Build


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

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
=== committing ===
[main 9fd2ce9] Mini Looper Ramming Endgame Build
 6 files changed, 588 insertions(+), 50 deletions(-)
 create mode 100644 builds.lua
 create mode 100644 info_text.lua

Committed 9fd2ce9. Pushing Mini Looper to GitHub, then committing + pushing a327ex-site to the VPS.

Bash (cd E:/a327ex/mini-looper && echo "=== push mini-looper ===" && git push origin m...)

=== push mini-looper ===
To github.com:a327ex/mini-looper.git
   4b56988..9fd2ce9  main -> main

Bash (cd E:/a327ex/a327ex-site && git add -A && echo "=== staged ===" && git status --...)

warning: in the working copy of 'logs/mini-looper-ramming-endgame-build.md', CRLF will be replaced by LF the next time Git touches it
=== staged ===
M  .lock.json
A  logs/mini-looper-ramming-endgame-build.md
=== commit ===
[main fcf3e17] Mini Looper Ramming Endgame Build
 2 files changed, 3627 insertions(+), 1 deletion(-)
 create mode 100644 logs/mini-looper-ramming-endgame-build.md
=== push prod ===
remote: Already on 'main'        
remote: [post-receive] restarting a327ex.service        
remote: [post-receive] deployed at 2026-06-03T02:19:19Z        
To ssh://a327ex/srv/a327ex.git
   82b0437..fcf3e17  main -> main

Logged — "Mini Looper Ramming Endgame Build".

project (mini-looper):  9fd2ce9  pushed to GitHub (a327ex/mini-looper)
a327ex-site:            fcf3e17  pushed to a327ex.com (post-receive deployed)

Also done:

  • Posting lock decremented 8 → 7 (staged in the a327ex-site commit).
  • Project memory project_mini_looper.md saved + MEMORY.md pointer added, so future sessions have a pointer (Mini Looper still isn't in the global CLAUDE.md).

That wraps the session. The ramming endgame build is fun-verified — the real win, since that's the gate this session existed to clear. When you pick Mini Looper back up, the open threads are the three spice modifiers (aura / proximity / crush), the high-beat test harness, and — whenever the grey-box build graduates — the juice pass you deferred. Nice work shipping a fun-proven prototype.

To ideas.md: orb build, orbs loop around the train itself and deal contact damage.

Adding it to ideas.md — this is just appending to the unfiltered idea list, so I'll match the existing style. Let me read the current file first.

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

Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.

The file is unchanged. Appending the orb build to the end:

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

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

Added to ideas.md under a new Orb build (orbital contact) section — orbs orbiting the train and dealing contact damage, framed as a contact-archetype cousin of the ramming build.

It's an uncommitted local change (the session's already committed/pushed), so it'll ride along with the next session's commit — or say the word and I'll commit it now.