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.mdis 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:updatecallssteering_separate(16, enemies, …)(O(n) per enemy = O(n²) total) andunit_pack_targetis 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.luacharacter_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
blockunit (unit_defs['block'], spec only, no attack); two guards so "def with no attack = inert" (unit_attack_updateearly-returns onnot def.attack;unit_init_combatsets 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 cumulativetrail_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_buildcomputes offsets;train_total_lengthused for trail trim. setup_ramming_build()interleaves the attacker units + 8 sized blocks (shuffled viaarray.shuffle) behind a fixed head; replaced the scene's plaintrain_build.- Count-scaling:
ramming_apply_scaling()setstrain_max_speed/train_contact_damagefrom 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
bdebug 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_scalingreads 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 sharedtrain_contact_damage); ram handler usesu.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) — verifiedentity_kill→default_killsets_dyingsynchronously (anchor/object.lua:64). - Always the head in
setup_ramming_build; it's a unit (not a block), so not counted byram_block_count. - Unlock "Charge": on contact,
train_speed_boostset to +100 (refresh-to-max, decays viatrain_speed_boost_decay=80/s intrain_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 isunit_on_ram(u, e)inunits.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→rawlayer_text(top-left, manual centering), draws bracketed flat (effect_set({color='none'})) through the effect shader,ui_font_body_ark. Wiredinfo_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 > 0before 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_rate → area: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
bburst 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.
I'll get acquainted with Mini Looper first — it's not in my CLAUDE.md project list, so let me find it and read its current state.
Glob (/miniloop*/)
mini-looper-old\assets\More 15.ttf
mini-looper-old\anchor\class.lua
mini-looper-old\anchor\object.lua
mini-looper-old\anchor\timer.lua
mini-looper-old\anchor\math.lua
mini-looper-old\anchor\spring.lua
mini-looper-old\anchor\color.lua
mini-looper-old\anchor\camera.lua
mini-looper-old\anchor\shake.lua
mini-looper-old\anchor\image.lua
mini-looper-old\anchor\helpers.lua
mini-looper-old\anchor\spritesheet.lua
mini-looper-old\anchor\input.lua
mini-looper-old\anchor\animation.lua
mini-looper-old\anchor\init.lua
mini-looper-old\anchor\layer.lua
mini-looper-old\anchor\memory.lua
mini-looper-old\anchor\array.lua
mini-looper-old\anchor\font.lua
mini-looper-old\snkrx\gradient.lua
mini-looper-old\snkrx\geometry.lua
mini-looper-old\snkrx\symbols.lua
mini-looper-old\snkrx\stats.lua
mini-looper-old\snkrx\player.lua
mini-looper-old\anchor\collider.lua
mini-looper-old\assets\hit1.png
mini-looper-old\assets\monogram.ttf
mini-looper-old\assets\ARROW_Hit_Wood_Shield_stereo.ogg
mini-looper-old\assets\RPG3_GenericArrow_Shoot01.ogg
mini-looper-old\assets\Bloody punches 3.ogg
... [71 more lines]
There's mini-looper-old. Let me find the current Mini Looper directory.
Glob (mini-looper*/)
No files found
Bash (ls -d E:/a327ex/loop E:/a327ex/Loop 2>/dev/null; echo "---"; ls -d E:/a327ex...)
E:/a327ex/mini-looper/
E:/a327ex/mini-looper-old/
---
E:/a327ex/Anchor-YueScript//
E:/a327ex/Anchor-lua//
E:/a327ex/Anchor//
E:/a327ex/Anchor2//
E:/a327ex/BYTEPATH//
E:/a327ex/Danpenzero//
E:/a327ex/Invoker-old//
E:/a327ex/Invoker//
E:/a327ex/Null//
E:/a327ex/Psyonia//
E:/a327ex/RETICLE//
E:/a327ex/RTFX//
E:/a327ex/Ricochet//
E:/a327ex/SNKRX//
E:/a327ex/a327ex-site//
E:/a327ex/a327ex.com-blog//
E:/a327ex/anchor.blot.im//
E:/a327ex/data//
E:/a327ex/edition-template//
E:/a327ex/emoji-aimer//
E:/a327ex/emoji-ball-battles-generated//
E:/a327ex/emoji-ball-battles//
E:/a327ex/emoji-ball-bounce//
E:/a327ex/generation-loss//
E:/a327ex/it-follows//
E:/a327ex/lane-battle//
E:/a327ex/ld54-remake//
... [13 more lines]
There's mini-looper/ (current). Let me read its docs and structure.
Bash (cd E:/a327ex/mini-looper && echo "=== ROOT FILES ===" && ls -la --time-style=+%Y...)
=== ROOT FILES ===
-rw-r--r-- 1 adn 197121 5214 2026-05-31 README.md
-rw-r--r-- 1 adn 197121 5618 2026-06-02 building.lua
-rw-r--r-- 1 adn 197121 9494 2026-06-01 enemy.lua
-rw-r--r-- 1 adn 197121 2457 2026-06-01 grid.lua
-rw-r--r-- 1 adn 197121 2377 2026-06-02 ideas.md
-rw-r--r-- 1 adn 197121 3048 2026-06-01 loop.lua
-rw-r--r-- 1 adn 197121 24429 2026-06-02 main.lua
-rw-r--r-- 1 adn 197121 28688 2026-05-31 main_old.lua
-rw-r--r-- 1 adn 197121 18873 2026-05-31 main_scaffold.lua
-rw-r--r-- 1 adn 197121 8901 2026-06-01 train.lua
-rw-r--r-- 1 adn 197121 10676 2026-06-01 units.lua
-rw-r--r-- 1 adn 197121 6426 2026-06-01 wave.lua
=== ALL .md FILES ===
./.claude/CLAUDE.md
./ideas.md
./README.md
./snkrx/findings.md
=== TOP DIRS ===
anchor//
assets//
snkrx//
=== .claude ===
total 64
drwxr-xr-x 1 adn 197121 0 May 31 23:00 ./
drwxr-xr-x 1 adn 197121 0 Jun 2 00:39 ../
-rw-r--r-- 1 adn 197121 60653 May 31 23:00 CLAUDE.md
Let me read the core docs and game files in parallel.
Read (E:/a327ex/mini-looper/README.md)
1 # snkrx-template
2
3 A starter [Anchor 2](https://github.com/a327ex) project for building **attacks in
4 the SNKRX / Orblike / Invoker visual style** — punchy 2D action with a living,
5 breathing palette. It ships three reusable pieces:
6
7 - five **attack carriers** (projectile · area · beam · flicker · prop),
8 - a four-axis **effect system** (pattern × color × dither × shape), and
9 - a SNKRX-style **UI toolkit** (panels, slots, bars, cards, HUD widgets, …).
10
11 Fork the folder, edit `main.lua`, build your game.
12
13 ## Quickstart
14
15 ```sh
16 ./run.bat # runs main.lua — run.bat resolves its own folder name
17 ```
18
19 `anchor.exe` (the Anchor 2 engine) is committed, so the template runs out of the
20 box. `main.lua` is the clean player-only scene; for the full demo (every carrier
21 + the dev tools) copy **`main_scaffold.lua`** over `main.lua`.
22
23 ## Fork & use
24
25 1. **Copy the whole folder** and rename it (`cp -r snkrx-template my-game`).
26 `run.bat` derives its own folder name, so the copy runs immediately — no edits.
27 2. **Boot** is three lines at the top of `main.lua`:
28 ```lua
29 require('anchor')({ width = 480, height = 270, title = 'my-game', scale = 3, filter = 'rough' })
30 require('snkrx') -- attack carriers + effect system + UI toolkit, in one require
... [72 more lines]
The .claude/CLAUDE.md and README are still the template's — the actual Mini Looper game lives in the new .lua files. Let me read those.
Read (E:/a327ex/mini-looper/ideas.md)
1 # Mini Looper — Ideas
2
3 Running list of design ideas. Unfiltered; not commitments.
4
5 ## Solid blocks (non-combat units)
6
7 Units that are just **solid blocks** — they don't attack and have no HP. Their only purpose is to **increase the train's length**, so the train physically blocks more enemies from reaching the center.
8
9 - Enables builds that gain **more bonuses the more blocks** you have.
10 - Aids a **ramming archetype**: focus on making the train go fast and deal **contact damage** to enemies as it sweeps, instead of relying on units attacking.
11
12 ## F3 sound tuner — surface played sounds first (tooling / QoL)
13
14 When opening the F3 sound tuner, the sounds shown first should be the ones that have **actually played during this run of the program**, ordered **most recently played first**. Makes it fast to find and tune a sound you just heard instead of hunting through the full loaded list.
15
16 - Played sounds get a **visual indicator** that they played — maybe even the **total number of times** played this run.
17 - Sounds that haven't played get **no indicator**.
18
19 (Dev-tooling detail, not gameplay — deferred so it doesn't interrupt fun-testing.)
20
21 ## RTS direction — loop economy, speed, tech tree
22
23 The game plays like an **RTS**. You start with the looping train and the central **core**. From the core you build **resource-extraction buildings**, each placed **near a resource on the map** — and **inside the loop**, because anything outside the loop is unprotected and gets hit by enemies.
24
25 **Everything runs on a loop basis.** Resource buildings pay out **per completed loop** — every time the train finishes a full circuit is one logical unit of action. This ties the strategy directly to the action, and makes **speed the most valuable resource**: faster loops = more income, better defense coverage, and it's also what looks cool and plays well (so the optimal play is also the satisfying one).
26
27 - **Units are bought by constructing new building types** (a building unlocks / produces a unit type or capability).
28 - Ideally **hundreds of units**, but a single run only has the resources to explore **one "line"** of the tech tree if you want to succeed.
29 - **Tech tree works like a standard RTS**: gated by resources and the new buildings you unlock, with upgrades, etc.
30 - Logically simple from a systems perspective — the depth lives in the tuning.
... [1 more lines]
Now the actual game code. Let me read the core files.
Read (E:/a327ex/mini-looper/main.lua)
1 --[[
2 snkrx-template / main.lua — clean reference scene.
3
4 This is your file. It is a complete, minimal scene: a player you can
5 move (WASD / arrows), the five-layer render stack, a camera, physics,
6 the update / draw skeleton, AND the UI render pipeline — all wired
7 and ready, but firing no attacks and drawing no UI widgets. Read it
8 top to bottom; it is the contract you build on.
9
10 TWO TOOLKITS, ONE REQUIRE. `require('snkrx')` pulls in BOTH:
11
12 snkrx/<carrier>.lua attack toolkit — projectile / area / beam
13 / flicker / prop, the effect draw-shader
14 stack, fx, sounds, example entities.
15 snkrx/ui/<module>.lua UI toolkit — panel / button / slot / bar /
16 tabs / checkbox / radio / slider /
17 list_row / card / text_input / modal /
18 bracket_tag / stat_chip / pips / ...
19
20 Both are plain, readable Lua, not opaque modules — open the files.
21
22 ADDING AN ATTACK. Every carrier already has its object list and its
23 update / draw loop wired below. To add an attack:
24 1. bind a key in the INPUT section,
25 2. call the carrier's spawn_* helper on that press inside update()
26 — see the ATTACKS comment block there.
27 Nothing else: no new list, no new loop.
28
29 DRAWING UI. The UI toolkit is immediate-mode: call widget functions
30 in update() and they queue draws onto `ui_layer`. The render pipeline
... [482 more lines]
Now the game-logic files. Let me read them all in parallel.
Read (E:/a327ex/mini-looper/units.lua)
1 --[[
2 units.lua — the shared unit auto-attack scaffold + the per-character
3 attack registry (mini-looper).
4
5 Every train unit is an auto-attacker: on a cooldown it finds the nearest
6 enemy in range and fires a carrier at it. The shared machinery (target
7 acquisition, cooldown tick, fire) lives here; each character is ONE entry
8 in unit_defs. Adding a unit = add a registry entry — no new plumbing,
9 since the carrier update / draw / damage loops already live in main.lua.
10
11 train_unit (train.lua) owns self.character + self.attack_cooldown, takes
12 its color (self.spec) from its def, and calls unit_attack_update(self, dt)
13 each frame.
14
15 Desync: cooldowns are seeded with a random phase AND jittered on every
16 reset (cooldown_jitter), and an idle unit (no target in range) re-arms
17 after a small random delay — so a cluster of units never fires on the
18 same frame. Without this they collapse to "ready" while idle and volley
19 in lockstep the instant enemies arrive.
20
21 unit_defs[name] = {
22 cooldown, -- seconds between attacks (number OR function -> number,
23 -- so it can read a live tuning knob)
24 cooldown_jitter, -- optional ± fraction applied on each reset (default 0.15)
25 range, -- target-acquisition radius (px)
26 spec, -- effect spec = the unit's (and its projectiles') color
27 attack, -- function(u, target): fire the unit's carrier at target
28 }
29 ]]
30
... [246 more lines]
Read (E:/a327ex/mini-looper/train.lua)
1 --[[
2 train.lua — the on-rails SNKRX train (mini-looper).
3
4 The HEAD (unit 1) is a dynamic body driven kinematically: each frame it
5 eases a HEADING (self.move_r) toward the next waypoint and sets its
6 velocity at that heading (scaled down by train_corner_slow in sharp
7 turns). It walks the loop and lays a breadcrumb trail.
8
9 FOLLOWERS (units 2..n) are pinned each frame to the head's trail,
10 (index-1)*spacing back, so they trace the head's exact path (classic SNKRX
11 snake). No physics chain.
12
13 Turning auto-scales with speed (train_apply_speed_coupling): as
14 train_max_speed rises, train_turn_t (heading-ease time) shrinks and
15 train_corner_slow grows, so a faster train still tracks the loop and brakes
16 harder into corners. Anchors: 100 -> (t 0.30, corner 0.40); 200 -> (t 0.15,
17 corner 0.75); linear + clamped.
18
19 Live knobs (tuning UI in main.lua): train_max_speed, train_seek_distance,
20 train_spacing. train_turn_t / train_corner_slow are derived from speed;
21 train_turn_p is a fixed companion to the ease.
22
23 Each unit also auto-attacks: it carries a self.character and calls
24 unit_attack_update (units.lua) every frame to fire its carrier at the
25 nearest enemy in range. Movement and combat are independent.
26 ]]
27
28 train_units = {}
29 train_trail = {} -- rolling breadcrumb of head positions (newest first)
30 train_head_x, train_head_y = nil, nil -- head's live position, the trail's distance-0 anchor
... [189 more lines]
Read (E:/a327ex/mini-looper/enemy.lua)
1 --[[
2 enemy.lua — mini-looper enemy: spawns off-screen, marches to the loop
3 center, reacts to train-unit contact.
4
5 Structurally a retarget of snkrx/seeker.lua. Same dynamic box collider,
6 same push / hit / die / spawn FX / floating HP bar (it reuses the global
7 should_show_health_ui / draw_health_ui defined in seeker.lua). The ONE
8 behavioral change is steering: it seeks (loop_cx, loop_cy) — the loop
9 center — instead of the player, which mini-looper doesn't have. There is
10 no arrival handling yet: an enemy that reaches the center just clumps
11 there (separation keeps the blob from fully overlapping). The base /
12 lose-condition is a later step.
13
14 The seeker's `wall_bump` streak machinery is dropped — mini-looper has no
15 wall colliders.
16
17 Manual spawning (this step): main.lua binds a key and calls
18 spawn_enemy_at_edge() on the press. spawn_enemy(x, y, args) builds the
19 entity and registers it in enemies[] so main.lua's prune loop updates +
20 draws it and steering_separate has a list to walk.
21
22 Train ram: the train↔enemy contact (handled in main.lua) calls :hit +
23 :push; the train itself is driven kinematically and plows through
24 unaffected.
25 ]]
26
27
28 enemies = {}
29
30 enemy_base_hp = 10 -- hp pool of a spawned enemy
... [229 more lines]
Read (E:/a327ex/mini-looper/wave.lua)
1 --[[
2 wave.lua — continuous pressure director (mini-looper).
3
4 Replaces discrete waves: enemies trickle in continuously from ALL around the
5 perimeter so the train is always sweeping into something and the whole loop
6 earns its keep. Pacing is authored, not random.
7
8 BEATS carry the fractal difficulty. Time is chopped into beats; beat b has
9 difficulty D(b) = (1 + (b-1)*0.5)*(1 + v3(b)) — the 1-2-3 fractal (two calm
10 beats, then a spike; bigger spikes every 9th/18th). Survive past
11 beat_count_max -> WIN (game_won).
12
13 AMBIENT trickle: a timer fires every ambient_interval(D) seconds (shrinks as
14 D rises), dropping a small group at a uniform-random angle around the center,
15 off-screen, marching in. So the small fractal spikes (v3==1) just crank this
16 omnidirectional rate on their own.
17
18 SUPER-SPIKE SURGE (v3>=2: beats 9, 18): a telegraphed flank assault. At the
19 beat start a warning bar pulses on ONE SIDE — left or right only, never
20 top/bottom — then after telegraph_time a dense swarm pours from that side
21 while ambient dampens, so the threat reads as coming from there. The
22 "defend the flank" moment (and where you'll reinforce with buildings later).
23
24 main.lua: director_update(dt) in the sim, director_draw_telegraph(game_layer)
25 in draw, director_reset() on restart; game_won folds into the freeze gate.
26 ]]
27
28
29 -- Tunables (the balance surface — flow rate vs the train's kill rate).
30 beat_count_max = 18
... [150 more lines]
Read (E:/a327ex/mini-looper/building.lua)
1 --[[
2 building.lua — generic grid-placed building (mini-looper).
3
4 A square static obstacle on a grid cell, with HP. Enemies (which seek the
5 center) pile against it; the building<->enemy collision in main.lua knocks
6 each enemy back and calls building:hit, so a swarm chips it down. The
7 CENTRAL building (objective = true) is the base you defend: when it dies the
8 run is lost (building_destroyed -> main.lua freezes the field + shows the
9 "BASE DESTROYED" overlay; R restarts via building_reset).
10
11 Draws a breathing square + an HP bar below it (fill + numeric hp/max).
12
13 building_place(col, row, opts) -> building opts: objective, hp, spec, size
14 building_reset() restore the objective, clear enemies
15
16 Generic (non-objective) buildings just die + free their cell on 0 HP — the
17 hook for the future placement layer; only the central one exists today.
18 ]]
19
20
21 buildings = {}
22 central_building = nil
23 building_destroyed = false
24
25 building_size = 20 -- square side (px); cell is grid_cell_size (24)
26 building_max_hp = 100
27 building_push_force = 8 -- knockback applied to an enemy that hits a building
28
29 -- A polychrome linear-gradient (cyan -> violet -> pink) — the only ramp/gradient
30 -- object in the game, so the base reads as special, distinct from the solid-hue
... [129 more lines]
Read (E:/a327ex/mini-looper/loop.lua)
1 --[[
2 loop.lua — the waypoint loop the train follows (mini-looper, step 1).
3
4 `loop_points` is just an ordered list of {x, y} points. There is no
5 baked path geometry and no arc-length table — the train walks this
6 list with the `steering_follow_path` behavior and wraps back to point
7 1 at the end, so in practice it loops forever. `loop_cx, loop_cy` hold
8 the loop center (used for the inward test-push).
9
10 build_loop_ring(cx, cy, rx, ry, n) -- n points on a plain ellipse
11 build_loop_track(cx, cy) -- a fixed IRREGULAR loop (twists,
12 -- sharp turns, concave notches)
13 nearest_loop_index(x, y) -- index of the closest loop point
14 loop_draw(layer) -- debug: dots + connecting lines
15 ]]
16
17 loop_points = {}
18 loop_cx, loop_cy = 0, 0
19
20 -- Place n points evenly around an ellipse centered at (cx, cy) with
21 -- radii rx / ry. Replaces loop_points wholesale.
22 function build_loop_ring(cx, cy, rx, ry, n)
23 loop_cx, loop_cy = cx, cy
24 loop_points = {}
25 for i = 1, n do
26 local a = (i - 1)/n*2*math.pi
27 loop_points[i] = { x = cx + rx*math.cos(a), y = cy + ry*math.sin(a) }
28 end
29 return loop_points
30 end
... [55 more lines]
Read (E:/a327ex/mini-looper/grid.lua)
1 --[[
2 grid.lua — the logical placement grid (mini-looper).
3
4 A grid of square cells covering the whole screen. Purely logical: it owns
5 the cell math (world <-> cell) and an occupancy map, so buildings snap to
6 cells and a future placement layer can query what's filled. Buildings draw
7 themselves; the grid only draws faint lines under the F2 debug overlay.
8
9 grid_cell_center(col, row) -> x, y (cell center, world space)
10 grid_cell_at(x, y) -> col, row (clamped into the grid)
11 grid_get / grid_set(col, row[, b]) (occupancy)
12 grid_draw(layer) (debug lines)
13
14 Cells are 1-indexed: col in 1..grid_cols, row in 1..grid_rows.
15 ]]
16
17
18 grid_cell_size = 24
19 -- Odd cell counts so a single cell sits dead-center on the screen, plus a
20 -- centering origin offset so the whole grid is centered (leftover margin from
21 -- non-exact division is split evenly on both sides).
22 grid_cols = math.floor(width/grid_cell_size)
23 grid_rows = math.floor(height/grid_cell_size)
24 if grid_cols % 2 == 0 then grid_cols = grid_cols - 1 end
25 if grid_rows % 2 == 0 then grid_rows = grid_rows - 1 end
26 grid_origin_x = (width - grid_cols*grid_cell_size)/2
27 grid_origin_y = (height - grid_rows*grid_cell_size)/2
28
29 grid_cells = {} -- occupancy: grid_cells[(row-1)*grid_cols + col] = building
30
... [37 more lines]
Let me check the project's git history to understand the trajectory of past sessions.
Bash (cd E:/a327ex/mini-looper && git log --oneline -25 2>/dev/null && echo "---STATUS...)
4b56988 Mini Looper Attacks and Enemies
a6077cd Mini Looper 1
b837ebc Initial commit: fork from snkrx-template
---STATUS---
M ideas.md
Let me read the projectile carrier to know exactly what knobs exist — it matters for the projectile-build discussion.
Read (E:/a327ex/mini-looper/snkrx/projectile.lua)
1 --[[
2 projectile.lua — straight-line attack carrier (minimal v1 port).
3
4 Scope of this first pass (per session 2 decisions):
5 • Default rounded-rect shape (w=10, h=4). No shape variants yet
6 (ball_lightning circle, wind_slash blades, arc crescent come later).
7 • Straight-line travel only. No homing / linear-return / arc-return.
8 • Sensor box collider — projectiles pass through enemies/walls
9 visually. Enemy hits are surfaced via sensor_entities_begin in
10 main.lua. Walls are caught by a per-frame forward raycast (sensors
11 don't physically collide).
12 • Spawn juice: spring pull on 'main' + 0.2s white flash.
13 • Wall hit: 6r/0.15s hit_circle + 2 particles, then die.
14 • Enemy hit: full damage + hit_effect sprite burst, then die. No
15 pierce/chain in this first pass — that's gameplay-side anyway.
16 • Death: 8r/0.2s hit_circle + 2 particles, particle color =
17 spec_color(self.spec).
18
19 Caller API (per session 2 decision — "everything in spawn_projectile,
20 caster passed in, everything else derived"):
21 spawn_projectile(caster, {
22 aim_r, -- optional, default: caster -> main_camera.mouse
23 spawn_offset, -- default 8 (v1's hardcoded muzzle offset)
24 spec, -- four-axis effect spec; default caster.spec
25 max_range, -- default nil = unlimited
26 damage, -- default 1
27 speed, -- default 190
28 w, h, -- default 10, 4
29 on_death, -- optional fn(x, y) called at the impact/death point
30 -- (NOT on off-screen cull) — a host-composition
... [273 more lines]
I've now read the whole Mini Looper codebase. Here's where I've landed.
Where Mini Looper is
It's a SNKRX × tower-defense hybrid: an on-rails snake (the "train") runs a fixed loop defending a central base. Enemies march in from all edges toward the base; the train's units auto-attack the nearest/densest enemies and the train body rams anything it sweeps through. A beat-based director (wave.lua) scales pressure over 18 beats, win at beat 18, lose if the base dies.
The pieces, concretely:
- 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_updateindependently, desynced. - enemy.lua — off-screen spawn → seek base center via
steering_seek + wander + separate. Train ram appliestrain_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):
- Build-select — a hardcoded
endgame_buildthat sets the train roster + global modifier knobs (the "modifiers unrelated to units," hardcoded as globals likecontact_damage_mult,projectile_pierce, etc.). - 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.
- 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': nounit_def.attack, a heavy solid look (neutral/steelcolor='solid', no breathe, maybe biggerw,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
Your questions have been answered: "Which archetype do we build + fun-test first this session? (One only — we test it, then you decide the next.)"="Ramming", "How should we reach the above-beat-18 endgame load for testing?"="We'll decide later.". You can now continue with these answers in mind.
Locked in: Ramming first, harness decision deferred (I'll surface it again when the build's in and we actually need a packed screen to stress it). I won't build any harness yet.
Per the workflow rule, here's the design brief for the Ramming endgame build — conceptual, not code. Let's iterate it, then I'll write it.
mcpccd_sessionmark_chapter
[
{
"type": "text",
"text": "Chapter marked: \"Ramming build design\". Continue your current work."
}
]
Design brief — Ramming endgame build
Approach
A hardcoded, maxed ramming build: the train becomes a long snake of inert steel blocks that plows the loop, and its contact damage / speed / knockback scale with the block count. No attackers needed — the body is the weapon. This is a fake endgame (the discipline: hardcode the endpoint, don't build the system that grants it), toggled on at scene init in place of the current mixed 6-unit train_build.
Three moving parts:
The block unit — a new inert
character = 'block'. It has a registry entry for its color but noattack, 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.Count-scaling modifiers — the archetype's identity. A handful of hardcoded globals (
ram_*_per_block) and one function that derivestrain_contact_damage / train_max_speed / train_ram_forcefrom the live block count. More blocks → harder, faster hits → (force is already speed-coupled in the existing ram handler) → bigger flings. The existingmain.luaram math already keys off exactly these three globals, so the build needs only to set them — no change to the collision handler.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.luatrain↔enemy ram handler — unchanged. It already readstrain_contact_damageand computes knockback fromtrain_ram_force+train_max_speed. The build sets those; the handler does the rest. unit_defsregistry (units.lua) — gains a'block'entry (spec only). This also motivates a one-line cleanup:unit_attack_updateshould early-return when a def has noattack, 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_forceinenemy.lua;train_max_speedintrain.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 inmain.luaafter the game files.
Functions
unit_defs['block'](data,units.lua) —{ spec = block_spec }, noattack/range/cooldown. Plus ablock_spec(flat steel, e.g.color='solid', color_a='snkrx_neutral').unit_attack_update(u, dt)(changed,units.lua) — add an earlyif not def.attack then return endafter thedeflookup. Makes blocks inert with zero special-casing.ramming_apply_scaling()(new,builds.lua) — counts'block'units intrain_units, sets the three globals tobase + 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) — setsramming_build_active = true+ theram_*constants, callstrain_build(N, 'block'), thenramming_apply_scaling(). Calls:train_build,ramming_apply_scaling.main.luaSCENE (changed, one line) — replacetrain_build(6, {...})withsetup_ramming_build().
Open choices — let's decide these before I write
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
Detonator — the 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:
- 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.)
- Juggernaut: huge contact damage (modifiers stay meaningful) or literal instakill?
- Detonation non-destructive (block stays) or consumes the block?
- 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 — Juggernaut → 3 — Swordsman → 4 — Earthshaker → 5 — Detonator → 6 — Poisoner → 7 — 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.luaram handler — unchanged; readstrain_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 inmain.luaafter the game files; itssetup_ramming_build()replaces the currenttrain_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'), noattack/range/cooldown.unit_attack_update(changed,units.lua) — earlyif not def.attack then return end, so blocks never fire.unit_init_combat(changed,units.lua) — handle a def with no attack (setattack_cooldown = 0, return) so seeding a block doesn't hit the nil-cooldown path.ramming_apply_scaling()(new,builds.lua) — count'block'units intrain_units; settrain_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, setramming_build_active = true+ theram_*config globals + a tighttrain_spacing,train_build(RAMMING_BLOCKS, 'block'), thenramming_apply_scaling(). Calls:train_build,ramming_apply_scaling.main.luaSCENE (changed, one line) —setup_ramming_build()in place of the 6-unittrain_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.lua—block_spec+unit_defs['block'](spec only) + the two inert-def guards (unit_attack_update,unit_init_combat).train.lua—train_unitgainsblock_size/length/trail_offset;train_buildhandles 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— requirebuilds; replace thetrain_build(6,…)line withsetup_ramming_build(); add ab= 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 setstrain_max_speed = 180 + 10·blocks(→ 260) andtrain_contact_damage = 4 + 2·blocks(→ 20).train_ram_forcepinned low (4) for the grind flavor.units.lua—block_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 anN×10-long body (one unit, one collider — a wider rammer and a longer wall). Followers pin to a cumulativetrail_offsetso a long block pushes everything behind it back the right amount (reduces exactly to the old(index-1)·spacingwhen uniform).main.lua— requiresbuilds, the scene now callssetup_ramming_build(), andbdumps a burst of ~20 enemies so you can watch the ram against a crowd.
How to test
Run it (./run.bat), then:
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?- Hold/spam
bto 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). F1— the tuning panel still shows SPEED/contact; note the train is now at 260 speed / 20 contact.esingle enemy,rrestart,F2collider 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 white —
block_specuses thewhitetoken (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:
TRAINreadouts — SPEED / CONTACT (derived) / BLOCKS count — then the live SEEK knob and the auto EASE/CORNER readouts. - RIGHT:
MODIFIERS— a checkbox per modifier.
- LEFT:
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 owncontact_damage, and the ram handler usesu.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.lua—juggernaut_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), thejugg_*constants, andunit_on_ram(u, e)(per-character contact hook — applies Charge now, extensible for later units).train.lua—train_unit:newreadsdef.contact_damage;train_speed_boostglobal + its decay intrain_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, callsunit_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 usesu.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_boostto +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:
bto 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.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.- The watch item: at 360 while charging, does the head overshoot/cut the loop corners badly? If so we dial
jugg_charge_amountortrain_speed_boost_decaydown — 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_text→layer_text. Invoker'sui_text(lyr, text, x, y, {align='center'})doesn't exist here (mini-looper'sui_textis the immediate-mode UI widget). I'll use rawlayer_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_clearso the bg/text colors pass straight through the effect shader (same asloop_draw/ the building HP bar do). - Font
ui_font_body_ark; tokensbg/red/light_blue/yellow/text+color_cloneall exist here. Theinfo_text_spawn/_update/_draw/_resetAPI 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, alongsidebuilding_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: rawlayer_textwith manual centering (mini-looper'sui_textis a different widget), and the draws bracketed flat (color='none') so they pass through the effect shader. Theinfo_text_spawn/update/draw/resetAPI is generic — future "No Mana"/damage-number uses drop in unchanged.- Trigger on a fresh Charge (
units.luaunit_on_ram): checkstrain_speed_boost > 0before 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, andinfo_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):
- Count enemies within
SWORD_CLEAVE_RADIUSof the swordsman (enemies_in_radius(x,y,r)— a small O(n) helper alongside the existing targeting helpers). per_hit = sword_per_hit_base + (sword_cleave_unlocked and sword_cleave_bonus or 0);dmg = sword_base_damage + per_hit·N.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.lua—swordsman_spec(a pink breathe, distinct from the gold Juggernaut / white blocks / blue magician),swordsman_cooldown, thesword_*constants,unit_defs['swordsman'], and theenemies_in_radiushelper.builds.lua— add'swordsman'toRAMMING_UNITSso 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, side1.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
rectarea 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_boxthe area uses (so N is exactly the hit-set), then each enemy takesbase + per_hit·N. Unlocked:3 + 1.5·N— a pack of 8 → 15 each, a pack of 15 → ~25 each. The Cleave unlock (+1per 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 = falseinunits.luaand compare — locked it's a weak3 + 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)— setsself.stunned, zeroes velocity, arms a timer to clear it. While stunned, the update skips steering and stays frozen (the existingpushedbranch 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_specwhile 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.lua—enemy:stun, thestunnedinit + update branch,stun_spec, draw swap.units.lua—earthshaker_spec,earthshaker_cooldown, theearth_*/EARTH_*constants,unit_defs['earthshaker']+ theearthshaker_pulse/earthshaker_shatterhelpers (both take the captured x/y/spec).builds.lua— add'earthshaker'toRAMMING_UNITSto 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 areas — area_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
countareas forward from the unit: first atradius·0.6, each next+radius·rand(0.85,1.0)further alongbase_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 ati·delayvia 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}), withfissure_cast_1played per area and one rumbleshake_shakeon 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.lua—earthshaker_spec,earthshaker_cooldown, thefissure_*constants,unit_defs['earthshaker'](aims at nearest enemy →spawn_fissure, counter picks normal vs big), and thespawn_fissurehelper.builds.lua— add'earthshaker'toRAMMING_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.08safter the last, marching~radiusfurther along the aim with a slight ±8° waver,3damage each. Positions are captured at cast (the crack is a fixed ground scar, doesn't follow the moving unit). Playsfissure_cast_1per crack + one rumble shake. - Unlock (every 3rd cast): a longer + bigger fissure —
8areas (your number) instead of 4, eachradius 22instead 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
bso 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 walkloop_pointsbackwards (decreasing index, wrapping) for the full#loop_pointssteps — 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 ofk · 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, …)inunit_init_combatfor 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 small1.5rumble 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 + cooldownafter 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, plusb.flashing(brief) +spring_pull(b.spring)+ ahit_circlepulse at the block. No removal (non-destructive).unit_defs['detonator'].attack:- Base: collect
train_unitswherecharacter=='block',detonate_blockone at random (array.random). - Unlock (on by default):
detonate_blockevery block. One camera shake per cast (bigger when all detonate), not per block.
- Base: collect
- 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_rangeof 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.lua—detonator_spec,detonator_cooldown, thedetonate_*constants +detonate_all_unlocked,detonate_block,unit_defs['detonator'].builds.lua— add'detonator'toRAMMING_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 = falseinunits.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 old7, 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 (amount0.9→ ~19px peak vs the old7) and longer (0.6svs0.3s). The intensity scales astrauma²·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 inunit_init_combatfor poisoners (same passive pattern as the Earthshaker's loop). The Poisoner has nodef.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.lua—poisoner_spec, thepoison_*constants,spawn_poison,unit_defs['poisoner'](no attack), and the trail timer inunit_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/tickwith Virulence on,2/tickoff) 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 (C:\Users\adn.claude\projects\E--a327ex\memory\project_mini_looper.md)
File created successfully at: C:\Users\adn\.claude\projects\E--a327ex\memory\project_mini_looper.md (file state is current in your context — no need to Read it back)
Read (C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md)
Read (C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md)
Edit (C:\Users\adn.claude\projects\E--a327ex\memory\MEMORY.md)
The file C:\Users\adn\.claude\projects\E--a327ex\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)
Memory saved. Now ending the session with your title.
Skill
Launching skill: end-session
Base directory for this skill: E:\a327ex.claude\skills\end-session
End Session Workflow
This skill is invoked from the same conversation where the work was done. You already have the full session context in memory — you do NOT need to read the transcript.
Step 1: Get Session Info
Ask the user for the session title (max 30 characters). Examples: "Anchor Phase 10 Part 5", "Physics Arena Setup", "Timer System Fix", "Thalien Lune Design".
Determine the project yourself from your session context — you know which repo(s) were worked on, which files were created/modified, and where they live. No need to ask. See Step 5 for the list of known project roots; if the session touched something outside the list, infer the root from the paths you actually edited.
Step 2: Write Summary
Write the summary from your conversation memory. You have the full session context — no need to read any files.
The summary should be thorough and detailed. Each major topic deserves its own section with multiple specific bullet points. Don't compress — expand.
Purpose: These summaries serve as searchable records. Future Claude instances will grep through past logs to find how specific topics were handled. The more detail you include, the more useful the summary becomes for finding relevant context later.
Format (this is just an example structure — adapt sections to match what actually happened):
# [Title] ## Summary [1-2 sentence overview of the session's main focus] **[Topic 1 - e.g., "Spring Module Implementation"]:** - First specific detail about what was done - Second detail - include file names, function names - User correction or feedback (quote if notable) - Technical decisions and why **[Topic 2 - e.g., "Camera Research"]:** - What was researched - Key findings - How it influenced implementation **[Topic 3 - e.g., "Errors and Fixes"]:** - Specific error message encountered - Root cause identified - How it was fixed [Continue for each major topic...] --- [Rest of transcript follows]Rules: - Be thorough — If in doubt, include more detail, not less. Each topic should be as detailed as possible while still being a summary. - Think searchability — Future instances will search these logs. Include keywords, function names, error messages that someone might grep for. - One section per major topic — Don't combine unrelated work into one section - Chronological order — Sections should match conversation flow - Specific details — Error messages, file names, function names, parameter values - Include user quotes — When user gave notable feedback, quote it (e.g., "k/d variables are not intuitive at all") - Weight planning equally — Research, proposals, alternatives considered, user feedback on approach are as important as implementation - Weight problems solved — Errors, root causes, fixes, user corrections all matter - Technical specifics — Include formulas, API signatures, parameter changes when relevant
Step 3: Proceed Without Approval
Do NOT show the summary to the user for approval. Write it directly. The user can review the committed log after the fact and request a follow-up edit if anything is off.
Step 4: Convert Transcript and Write the Log File
# Find recent sessions (Claude + Cursor + Codex). Same script lives in Anchor2: python E:/a327ex/Anchor2/scripts/find-recent-session.py --limit 5 # or: python E:/a327ex/Anchor/scripts/find-recent-session.py --limit 5The script shows sessions sorted by when they ended. The first result is the current conversation (since end-session was invoked here). Use it.
Use a lowercase hyphenated slug derived from the title (e.g., "anchor-primitives-hitstop-animation").
Get the end timestamp for the Date frontmatter — this is the wall-clock time when end-session was invoked, NOT the time the JSONL started. Sessions often span multiple days, and the log should be filed under the day the work was wrapped up:
date "+%Y-%m-%d %H:%M:%S"Use this output verbatim. Do not substitute the JSONL start timestamp; the log appears in the sidebar sorted by Date, and a multi-day session with a Date pinned to day 1 will sort below sessions that ended later but started later, hiding the most recent work.
Convert the transcript to markdown:
python E:/a327ex/Anchor2/scripts/jsonl-to-markdown.py [SESSION_PATH] /tmp/session-log.md # or: python E:/a327ex/Anchor/scripts/jsonl-to-markdown.py ...The same script auto-detects Claude Code JSONL vs Cursor/Composer agent JSONL (
~/.cursor/projects/.../agent-transcripts/...) vs Codex rollouts (~/.codex/sessions/...). For Composer sessions, usefind-recent-session.py(it merges all sources) and pick the[cursor]line for the current chat.Replace the default header (
# Session YYYY-MM-DD...) at the top of/tmp/session-log.mdwith the approved title and summary, AND prepend frontmatter. The final file shape:Title: [Title] Date: YYYY-MM-DD HH:MM:SS # [Title] ## Summary [approved summary text from step 2] --- [transcript content from jsonl-to-markdown script]Frontmatter is non-negotiable. Every log file MUST start with
Title:andDate:lines. Without them, the site's sidebar shows the slug as the title and 0 (epoch) as the sort date. The backfill script ina327ex-site/deploy/backfill_metadata.pyis a safety net, not a substitute — write it correctly the first time.Then copy the final file to the log destination:
cp /tmp/session-log.md E:/a327ex/a327ex-site/logs/[slug].mdStep 4.5: Decrement the lock (if active)
Read
E:/a327ex/a327ex-site/.lock.jsonif 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 -Ain Step 6.If no lock file exists or
remainingis already 0, do nothing. (See the/lockskill 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/Anchorgit add docs/ framework/ engine/ scripts/ reference/Anchor2 E:/a327ex/Anchor2git add framework/ engine/ arena/ reference/ scripts/ docs/ .claude/emoji-ball-battles E:/a327ex/emoji-ball-battlesgit add -Ainvoker E:/a327ex/Invokergit add -Athalien-lune E:/a327ex/thalien-lunegit add -Aa327ex-site E:/a327ex/a327ex-sitegit add -A(this IS one of the web repos — the log file added in Step 4 is staged here automatically)For a project not listed, infer the root from the files you actually created or modified this session and stage those. If multiple candidate roots look valid, ask the user which files to stage.
cdinto the project root, stage, rungit statusto verify before committing.IMPORTANT — FULL SUMMARY IN COMMIT: The commit message MUST include the FULL summary from the log file. Read the summary back from the log file to ensure nothing is missing.
IMPORTANT — COMMIT METHOD: The summary contains backticks, special characters, and markdown that WILL break heredocs and
git commit -m. ALWAYS use the file-based method below. NEVER try a heredoc first — it will fail and produce a malformed commit that needs amending.# Skip until we hit the line "## Summary", then take everything after the next # blank line until the --- separator that precedes the transcript. awk '/^## Summary$/{found=1; next} found && NR>1 && /^---$/{exit} found' \ E:/a327ex/a327ex-site/logs/[slug].md > /tmp/commit_msg.txt # Prepend the title (plain text, no #) and append attribution sed -i "1i [Title]\n" /tmp/commit_msg.txt printf "\nGenerated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n" >> /tmp/commit_msg.txt git commit -F /tmp/commit_msg.txtStep 6: Push the Repos
Two pushes — project (to GitHub) and a327ex-site (to the VPS):
# Project repo to GitHub. Skip this push if the project IS a327ex-site # (handled by the second push below — don't duplicate). git push origin main # a327ex-site to the VPS (post-receive hook restarts the Lua server). cd E:/a327ex/a327ex-site git add -A git commit -m "[Title]" git push prod main 2>&1 | tail -3Failure handling: if either push fails, the other still happens. Local commits stay intact, so the user can re-push manually once they've fixed whatever blocked it. Don't roll back; the committed state on disk is the source of truth.
Step 7: Confirm
Tell the user: - Project commit hash + GitHub push result - a327ex-site commit hash + VPS push result
Format:
Logged — "[Title]". project (Anchor2): abc1234 pushed to GitHub a327ex-site: ghi9012 pushed to a327ex.com (post-receive deployed)If a push failed, surface that explicitly with the error.
Notes
- Frontmatter is non-negotiable. Every log file MUST have
Title:andDate:at the top. Enforced at write time in step 4. The backfill script is a safety net, not a substitute.- If the project IS
a327ex-site(e.g., you spent the session writing the renderer or fixing the theme), there's no separate "project repo to GitHub" push — the a327ex-site push at the end IS the project push. Don't duplicate.- Legacy note: this skill used to also dual-write to
anchor.blot.im/logs/andanchor.blot.im/context/. Post-cutover (May 2026),anchor.blot.imis a JS redirect toa327ex.comand no longer receives content. Drop those steps if you find any older docs that still reference them.ARGUMENTS: 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: