# PORTING.md — the port record

Source: Java Swing bullet-hell ("EXALT", RotMG-style), ~6,650 lines across 79 classes.
Target: `web/index.html` + `web/src` ES modules (grouped `core/ entities/ content/ presentation/`) + `web/assets`. No frameworks, no build step, Canvas 2D only.
The Java source referenced throughout this file now lives under `legacy/java/`; its class names are unchanged.

> **Status (July 2026): the web port is the canonical implementation.** The Java
> original is frozen as a reference specimen — it receives no fixes or features.
> Deliberate behavior changes (bug fixes) land in the JS only and are recorded in
> this file by striking through the corresponding quirk entry (see #29 for the
> pattern).

**What this file is, now that only one side is maintained.** Mostly a historical
record — but the quirks ledger below is not history, it is a **live safety
rail**, and it is why this file stays. The port deliberately reproduces a long
list of the original's odd behaviors, and without the ledger the only signal
that a line is odd *on purpose* is that it looks wrong. Read it before "fixing"
anything that looks like a bug.

The class map, the timing model and the divergences list are reference: correct,
useful for reading entity code beside its Java twin, but essentially append-only
now. Two of them have already been partly overtaken — the Greek re-theme and the
tier ladders broke the enemy and item classes' line-for-line correspondence with
the Java, both recorded under Intentional divergences.

**Design documentation for the game as it stands lives in
[`ARCHITECTURE.md`](ARCHITECTURE.md)** — the arena and camera, the responsive
canvas, dungeons and tier ladders, the enemy AI layer, the remaster
presentation layer, and the performance rules. None of that was ported from
anything, so none of it belongs here. A new subsystem documents itself there.

## Original bugs & quirks — the ledger

Numbering is stable (code comments and issues reference it). This list used to be "preserved, not fixed" in its entirety; it is now a status ledger. Struck-through entries are retired: most were fixed in the web port under GitHub issues #3–#8 and #10–#15, two were retired earlier (7 reclassified as intended RotMG behavior, 29 an owner-approved typo fix that pre-dates the freeze and landed in both codebases), the remaster retired 26 plus the visual halves of 19 and 24, the circular-arena update retired 21, and 14 was retired on its own (one bullet, one enemy). Per the source-of-truth policy, the frozen Java reference keeps **every** original behavior, including the retired ones — a struck entry documents where the port now deliberately diverges from it. The remaining preserved entries (9, 16, the despawn half of 17, the logic half of 19, 20, 22, 25, 27, 28) stay preserved in the port on purpose; do not "fix" them. One entry is *narrowed* rather than struck: the enemy AI layer adds a lead offset on top of quirk 22's aim anchor without moving the anchor, so 22 stays in the do-not-fix list — see its entry. (The layers named as retiring entries — the remaster, the circular arena, the enemy AI — are documented in ARCHITECTURE.md.)

1. ~~Diagonal movement is √2 faster (per-axis speed, acknowledged in README).~~ **Fixed in the web port (issue #13)** — two non-opposing axes are normalized by √2/2; straight-line speed is unchanged.
2. ~~`Player.takeDamage`: when damage pierces defense, the popup shows the **full** damage value though `damage − defense` was applied.~~ **Fixed in the web port (issue #14)** — the popup now shows the amount actually subtracted. (The 20% minimum damage on absorbed hits is *not* a bug — it's RotMG's minimum-damage rule, intended.)
3. ~~`Enemy.takeDamage` minimum-damage branch computes the popup value from the **already-reduced** health (`takeDamageInt = health − damage*0.2` after the subtraction), so popups show a nonsense number.~~ **Fixed in the web port (issue #14)** — `takeDamageInt` is computed before health is mutated, in every branch.
4. ~~The player dies when a bullet's **raw** damage ≥ current health — defense is ignored in the lethality check (`Board.checkCollisions`).~~ **Fixed in the web port (issue #6)** — the hit always goes through `player.takeDamage` (defense + 20% minimum), and death triggers only when the mitigated health reaches ≤ 0.
5. ~~Level-up conditions are swapped: `maxMana < maximumMana` grants +10 max **health**; `maxHealth < maximumHealth` grants +15 max **mana**.~~ **Fixed in the web port (issue #7)** — each stat's growth now checks its own cap (only the conditions were swapped; the +10/+15 amounts stayed put).
6. ~~`Wizard.abilityCost` is captured once at spawn (60, from T1Spell) and never updated — a T3 spell deals 140 damage but still costs 60 mana.~~ **Fixed in the web port (issue #8)** — the cost is read live from the equipped ability on each SPACE press.
7. ~~The SPACE ability spawns its 12-bullet ring **at the mouse cursor position**, not at the player.~~ **Intended, per the owner**: this is the RotMG Wizard "spell bomb", which detonates its bolt burst at the cursor. Listed here only because it looks like a bug if you don't know the reference game.
8. ~~Item constructors are inconsistent about self-equipping: T2/T3 Atk/Def/Dex/Spd/Vit/Wis rings call `equipItem()` at construction, i.e. **their stats apply the moment the loot drops**, even if never picked up — and apply *again* when actually equipped (stats-over-cap exploit noted in the README). Hp/Mana rings and all T2/T3 staffs/spells/robes don't self-equip. All reproduced exactly per class.~~ **Fixed in the web port (issue #5)** — the 12 T2/T3 stat-ring `selfEquip` flags in `items.js` are now false, so equip/unequip applies stats exactly once each way; the frozen Java still self-equips them at construction. Self-equip survives only on the starting kit, which depends on it: that was tier 1 until the 0-13 tier ladder landed and is **tier 0** now — tier 1 became a real drop, and a dropped self-equipper is this exploit. (The frozen Java still self-equips its T1 gear, which there is only ever starting gear.)
9. `fireChanger` is first scheduled while `dexterity` is still 0 (400 ms period); equipping the starting robe reschedules it (328 ms in the Java; **345 ms in the web port**, whose starting kit is tier 0 and whose period formula diverged — see Intentional divergences) with delay 0, which also means any dex-changing equip grants an instant shot. The quirk itself — the instant shot on any dex-changing equip — is preserved.
10. ~~`ZombieKing.die()` (now `Hellhound`) lacks the `if (!visible) return` guard the other enemies have → two bullets killing it in the same tick grant 50 XP twice (loot is still dropped once).~~ **Fixed in the web port (issue #11)** — `die()` now opens with the same visibility guard as the other enemies; a second same-tick kill is a no-op.
11. ~~**Bes/Geb** (now Hyperion/Cronus): leaving the 1000 px range cancels only the aimed-shot timer; the 500 ms ring attack keeps firing forever once started, and each re-entry stacks an **additional** ring-attack task onto the same timer (rings fire faster and faster).~~ **Fixed in the web port (issue #4)** — range exit cancels `timer2` alongside `timer`, and range re-entry recreates it before scheduling (the same pattern as the issue #3 Nut fix).
12. ~~**Nut** (now Oceanus): leaving range cancels both timers, but re-entering schedules onto the cancelled timer → `IllegalStateException` **every tick**, which (on the Swing EDT) aborts the rest of that game update each tick — enemies/projectiles freeze while stacking a new 300 ms aimed-fire timer per tick. This is the most severe original bug.~~ **Fixed in the web port (issue #3)** — `timer2` is recreated on range re-entry, mirroring `timer`. The machinery that made this bug reproducible is deliberately unchanged: `GameTimer` still throws on schedule-after-cancel and the per-tick update still runs in the EDT-style try/catch — nothing in the port schedules on a cancelled timer anymore.
13. ~~Nut's (now Oceanus's) aimed volley aims its center bullet at `(0, playerY)` (`point2.getX()` typo — `point2` is always (0,0)).~~ **Fixed in the web port (issue #10)** — `fire` passes its own `point` parameter's x, so the center bullet is actually aimed at the player. (`fire2`'s unused parameter remains as-is.)
14. ~~A player bullet keeps testing (and damaging) further enemies in the same tick after its first hit — overlapping enemies are all damaged by one bullet.~~ **Fixed in the web port (July 2026)** — `checkCollisions` breaks out of the candidate loop on the first hit, so one bullet damages exactly one enemy; bullets are not piercing weapons in RotMG either. The same loop now also skips `!visible` candidates, so a bullet passes through a same-tick corpse instead of being spent on it (with the break in place, being consumed by something already dead would silently eat shots).
15. ~~Enemy bullets whose range expired this tick still collide with the player this tick (that loop never checks visibility); expired player bullets don't (that one does).~~ **Fixed in the web port (issue #15)** — the enemy-projectile loop now has the same `!visible → continue` guard as the player-projectile loop.
16. Every removal loop (`updateProjectiles`, `updateEnemies`, `updateBags`, damage lists) has the classic index-skip bug — the element after a removed one skips that pass. Replicated with equivalent single-pass compaction (skip-after-drop).
17. Loot bags despawn 10 s after creation — including bags you drop items into from your inventory (by design; the timers are untouched). ~~A full bag or full inventory silently destroys the item being moved.~~ **The silent-destruction half was fixed in the web port (issue #12)** — `Bag.add` now reports success, and a full destination leaves the item where it was (in the bag on pickup, in the inventory slot on drop).
18. ~~Health/mana pot pickup checks `<= 6`, so you can hold 7.~~ **Fixed in the web port (issue #15)** — all pickup checks are `< 6`; the counters cap at 6 and a 7th pot falls through to the inventory.
19. Damage popups live ~90 ms (10 ms tick, removed at `time > 8`) rising 1 px/tick. **Logic fully preserved** (the per-entity lists, their 10 ms timers and the index-skip removal still run, and tests read them); ~~their rendering~~ **the remaster no longer draws these lists** — floating combat text from `fx.js` (longer-lived, purely cosmetic) replaced them on screen.
20. Boss loot "T3 ring" branch always adds a guaranteed T3HpRing **plus** one random T3 ring (possibly a second T3HpRing). Same for the named monsters (was Medusa).
21. ~~Spawn ranges use the full 1920 px width, so enemies can spawn under the right-hand UI panel; the player is clamped to the 1620 px game area.~~ **Both halves retired by the circular-arena update.** The world is no longer the window: it is a disc of radius 1500 centred at (1650, 1650), the canvas is a camera onto it, and the UI panel is screen furniture that occupies no world space at all — so there is nothing to spawn "under". Spawns come through four cardinal gates on the rim, and the player is contained by a circular rule instead of the 1620×1080 rectangle. The frozen Java keeps the original behavior. Two incoherences went with it: escorts used to re-roll their position on every line (so a king's guard never actually clustered around it), and the zombie and named-monster branches disagreed about where the east edge was (`bX-300` = 1620 vs `bX` = 1920).
22. Wizard bullets originate at the sprite's **top-left**; enemy bullets originate at enemy center and aim at the player's **top-left**. **Preserved, and narrowed — not retired — by the enemy AI layer.** The top-left is still the *anchor* of every enemy shot, and `lead 0` (the default, and the only value the ported realm classes use below `AI_ROUND_GATE`) still resolves to `Math.round(player.x/y)` exactly as here. What an opted-in attack adds is a velocity *offset* on top of that anchor; the off-by-half-a-sprite bias this quirk describes is unchanged, so do not "fix" it by re-anchoring on the player's centre.
23. ~~The title screen (and README) say Right-click = shoot, Left-click = drop; the code does the opposite (Left = shoot/pick-up/equip, Right = drop). The port follows the **code**.~~ **Fixed in the web port (issue #15)** — the port's title-screen labels now read "Left Click = Use item / shoot" / "Right Click = Drop item", matching the implemented controls. The frozen Java title screen (and its actual controls, which were always Left = shoot) still shows the swapped text.
24. ~~Bag-panel slot 4 (second row, first cell) is missing its null check~~ (**fixed in the web port, issue #12** — slot 4 now has the same null-check shape as the other seven slots); ~~the inventory tooltip still draws slot 4's overflow line at y=200 instead of y=150~~ — **retired by the remaster**: the tooltip is now a wrapped card, so the slot-4 overflow line no longer exists (hover zones themselves are unchanged).
25. Holding SPACE retriggers the ability at the OS key-repeat rate in Java; the port likewise does not filter browser key-repeat.
26. ~~Game over has no restart (the Java app could only be closed); the port keeps that (reload the page).~~ **Retired by the remaster**: the game-over screen returns to the title on click, and the pause menu offers restart/quit. The Java-faithful post-death behavior underneath is intact — timers keep ticking on the dead board until the player actually leaves.
27. HP regen uses Java's compound-assignment truncation: `health += vit*0.15` truncates the *sum*; mana adds `(int)(wis*0.2)` each 500 ms. Reproduced with the same rounding.
28. `T1ManaRing`, `T1SpeedRing`, and `Enemy`'s string-less constructor are dead code (never instantiated / referenced only by dead paths).
29. ~~`T1Staff` declares `getInfwo()` (typo), so its real `getInfo()` is inherited and returns `""` — the starting staff's tooltip is blank.~~ **Fixed in both Java and JS per the owner** (minor typo, tooltip now shows "Attack: 20").

## Class mapping

| Java class | JS counterpart | Notes |
|---|---|---|
| `game.Application` | `web/src/main.js` | JFrame → canvas bootstrap, screen switching (title ↔ game). |
| `game.TitleScreen` | `web/src/presentation/titlescreen.js` | JButtons → canvas-drawn buttons with hit tests. **Remaster:** fully redesigned visuals; the two button hit-rects and the control-listing strings are unchanged (tests assert both). |
| `game.Board` | `web/src/core/board.js` | Main loop body, spawn/round logic, collision, all drawing. Swing `Timer(10ms)` → fixed-timestep update (see Timing). |
| `game.MusicPlayer` | `web/src/presentation/audio.js` | `Clip.loop` → looping `<audio>`; uses the `.mp3` twins of the `.wav` files (same 5 tracks, random pick). **Remaster:** wrapped in a start/stop manager so restart/quit-to-title can swap tracks; `MUSIC.md` documents the intended replacement score. |
| `game.canFire` | `web/src/core/gametimer.js` task in `web/src/entities/player.js` | 10 ms poll: if `fire` flag set → fire at mouse, clear flag. |
| `game.fireChanger` | task in `web/src/entities/player.js` | Period `40000 / (100 + dex)` ms: sets `fire` flag. **Diverges** from the Java's `400 − dex*4` (July 2026 rebalance) — see Intentional divergences. Identical at dexterity 0 (both 400 ms); `MIN_FIRE_PERIOD_MS` (40) stays as `GameTimer`'s guard but is no longer reachable. |
| `game.regenStats` | task in `web/src/entities/player.js` | 500 ms: `regenHp()` + `regenMana()`. |
| `game.enemyCanFire` | task in `web/src/entities/enemy.js` | Fires at the player's top-left (as in Java). |
| `game.checkCollision`, `game.checkProjectiles` | — (not ported) | Dead code: thread wrappers, commented out in `Board.actionPerformed`. |
| `sprite.Sprite` | `web/src/entities/sprite.js` | x/y/w/h/visible + AABB intersect. |
| `sprite.Player` | `web/src/entities/player.js` | Movement, damage, regen, XP/level, inventory & bag click logic, pots, fire timers. |
| `sprite.Wizard` | `web/src/entities/wizard.js` | Stats, starting gear, SPACE ability (12-bullet ring **at the mouse point**), `fire()`. |
| `sprite.Projectile` | `web/src/entities/projectile.js` | Pooled. All 4 constructors collapse to (x, y, angle-or-target, sprite, range, speed, damage). |
| `sprite.Damage` | `web/src/entities/damage.js` | Pooled damage popups; per-entity list ticked at 10 ms, removed when `time > 8`. |
| `enemies.Enemy` | `web/src/entities/enemy.js` | Base: chase player top-left at `SPEED`/tick, takeDamage, die, damage list. |
| `enemies.ZombieKing` | `web/src/entities/enemies.js` `Hellhound` | Speed 1, HP 400+10·round, def 5. 1000 ms: 5-way spread (aimed, ±30°, ±60°), dmg 15+2·round. Blue/Cyan loot. Renamed and redrawn by the Greek re-theme; stats and timings unchanged. |
| `enemies.ZombieMage` | `web/src/entities/enemies.js` `Telchine` | Speed 1.2, HP 100+10·round, def 5. 500 ms aimed shot, dmg 10+2·round. 20% brown bag. |
| `enemies.ZombieArcher` | `web/src/entities/enemies.js` `Centaur` | As the Telchine but 50% brown bag, different bullet. |
| `enemies.Medusa` | `web/src/entities/enemies.js` `NAMED` / `NAMED_CLASSES` | Was one class; now a **six-entry roster** cycling one per named round (Gorgon → Chimera → Hydra → Minotaur → Manticore → Nemean Lion). The Gorgon entry is the Java stat block verbatim — speed 1.5, HP 1000, def 20, 2000 ms 3-way, dmg 25+3·round; the others lean their speed/HP/defence/pattern around it. XP (300) and the White 5% / Cyan 45% / Blue 50% loot chain are identical for all six. |
| `enemies.Bes` | `web/src/entities/enemies.js` `Hyperion` | Speed 0.75, HP 3000, def 30. 2000 ms 13-way aimed (dmg 150, spd 1); 500 ms 13-way fixed ring (dmg 20, spd 7); artifacts every 10 s. |
| `enemies.Nut` | `web/src/entities/enemies.js` `Oceanus` | Speed 1.5, HP 4000, def 20. 300 ms 9-way aimed (dmg 30); 500 ms 13-way ring (dmg 20); artifacts every 10 s. |
| `enemies.Geb` | `web/src/entities/enemies.js` `Cronus` | Speed 1.5, HP 5000, def 20. 2000 ms 3-way aimed (dmg 150, spd 8); 500 ms 13-way ring (dmg 30); artifacts every 10 s. |
| `enemies.BesArtifact` / `NutArtifact` / `GebArtifact` | `web/src/entities/enemies.js` `SunChariot` / `OceanidSpawn` / `HourShard` | Speed 2; HP 500/500/2000; fire 500/1500/300 ms; dmg 50/100/150. |
| `equipment.Item` | `web/src/entities/items.js` `Item` | equip/unequip applies stat deltas to current **and** max stats. |
| `equipment.T{1,2,3}Staff/Spell/Robe` + 24 ring classes | `web/src/entities/items.js` (table-driven; the Java's 33 classes are tiers 1-3 of two ladders — staffs/robes 0-13, spells/rings 0-6) | Exact stats preserved at tiers 1-3. Self-equip now diverges deliberately: since the issue #5 fix the JS self-equips only the starting kit — tier 0 since the ladder landed — while the frozen Java still self-equips T2/T3 stat rings (see Quirk 8). |
| `pots.Pot` + `HealthPot MpPot AtkPot DefPot SpdPot DexPot VitPot WisPot LifePot ManaPot` | `web/src/entities/pots.js` | Exact effects (+1 stat if below max; ±100 hp/mana; +5 max hp/mana if below maximum). |
| `bags.Bag` + `BrownBag BlueBag CyanBag WhiteBag` | `web/src/entities/bags.js` | 8 slots, despawn 10 s after creation, despawn when emptied. **The colors diverge (July 2026):** the JS keeps brown/blue/cyan, adds pink/purple/red, and retired `WhiteBag` — a bag's color is now the tier of the gear inside it (RotMG's convention), not the enemy that dropped it. JS-only content; see ARCHITECTURE.md. |
| `status.Status`, `status.Slow` | `web/src/entities/statuseffects.js` | **Dead code** in Java (`statusEffects` is commented out; nothing instantiates them). Ported as inert classes for completeness. |
| — | `web/src/core/gametimer.js` | Emulates `java.util.Timer.schedule(task, delay, period)` on the game clock, incl. throwing on schedule-after-cancel (Java-faithful; formerly the driver of Quirk 12's soft-lock, fixed in issue #3). |
| — | `web/src/core/pool.js`, `web/src/core/grid.js`, `web/src/presentation/assets.js`, `web/src/core/input.js`, `web/src/core/config.js`, `web/src/presentation/ui.js` | Port infrastructure: object pools, uniform collision grid, atlas/preloader, input, side-panel UI. `assets.js` and `ui.js` also carry the remaster (runtime sprite pipeline; redesigned HUD). |
| — | `web/src/presentation/fx.js`, `web/src/presentation/sfx.js` | **Remaster only, no Java counterpart.** Pooled particles / floating combat text / shake / banners, and WebAudio-synthesized sound effects. Presentation-only: gameplay code calls their emit functions, nothing reads back. |

## Timing model

Java: Swing `Timer` with 10 ms delay drives `actionPerformed` (nominally 100 Hz); ~15 `java.util.Timer` background threads fire tasks on wall time.

Port: `requestAnimationFrame` + fixed 60 Hz logic timestep with an accumulator; rendering interpolates entity positions between the last two logic states. No `Thread.sleep`/busy loops.

* All per-tick velocities are converted to real time using the nominal 10 ms tick: px/s = Java-per-tick × 100 (e.g. bullet speed 7 → 700 px/s), then integrated per 60 Hz step. Real-time balance is identical; per-step displacement is 1.67× a Java tick, so very fast bullets cross a hitbox in slightly coarser steps (largest step ≈ 13 px for the fastest bullet vs 8 px in Java — well under the smallest hitbox, no tunneling).
* Every `java.util.Timer` becomes a `GameTimer` driven by the accumulated game clock and processed at each logic step (fires multiple times per step when period < 16.7 ms, e.g. the 10 ms damage-popup and fire-poll tasks). Thread interleaving becomes deterministic (tasks run at step boundaries); Java's scheduling jitter (< a few ms) is not simulated.
* Timers are wall-clock in Java and keep running after death/game-over; the port keeps ticking them after game over too (matching, e.g., live enemies continuing to enqueue projectiles onto the frozen board).

## Intentional divergences (platform-forced; everything else is 1:1)

* **The remaster is the big one** (ARCHITECTURE.md, "Remaster presentation layer"): everything you *see or hear* diverges deliberately; everything the simulation *computes* does not.

* Mouse position: Java polls `MouseInfo` (absolute screen coords) and applies fudges (−25 px title bar in tooltip hover, frame-origin subtraction when firing). The browser has real canvas coordinates, so the port uses them directly — hover regions land exactly where clicks do, which in Java they didn't quite.
* The four walking animations are animated GIFs; Canvas 2D `drawImage` renders an animated GIF's first frame only. Walking shows the first frame of the correct directional GIF. (Decoding GIF frames by hand was out of scope; flagged, not silently redrawn.)
* Audio: browsers can't autoplay — the soundtrack starts on the Start click (a user gesture). `.mp3` versions used (`.wav` twins are 27× larger; both encode the same 5 tracks).
* Game-over screen redraws every frame (Swing relied on system repaint events; canvas has no such thing).
* Exit button calls `window.close()`, which browsers may ignore for user-opened tabs.
* Fonts: `"Serial"` doesn't exist, so Java silently fell back to the Dialog (sans-serif) font — the port uses `sans-serif` for those, real `serif` where Java used "Serif", and `Verdana`/`Helvetica` with fallbacks elsewhere, at the same sizes/styles.
* Enemy rendering is batched into passes (all sprites, then all health bars, then all popups) to satisfy the batched-state-changes performance requirement; Java interleaved them per enemy, so when two enemies overlap, one's health bar can layer differently than in Swing. Cosmetic only.
* When one player bullet overlaps several enemies in the same tick, the grid yields those enemies in cell order rather than Java's list order. The set of enemies damaged — and all totals — are identical; only the ordering of the simultaneous `takeDamage` calls differs.
* **The fire-rate period is `40000 / (100 + dex)` ms, not the Java's `400 − dex*4`** (July 2026 powerscaling rebalance, `entities/player.js: firePeriod`). The Java form is a straight line with a zero at dexterity 100: `java.util.Timer` — and `GameTimer`, which reproduces it — throws on a period ≤ 0, and the throw kills the timer, so dexterity ≥ 100 would permanently stop the player shooting. Tiers 1-3 could never reach 100 dex, but the 0-13 ladder can (a tier-13 robe and a tier-6 dex ring alone are 122), so the port originally floored the period at 40 ms (`core/config.js: MIN_FIRE_PERIOD_MS`). That floor turned a crash into a **dead stat**: every point of dexterity past 90 bought nothing, and the dex ring was worth literally zero from tier 10 up. The hyperbolic form makes *shots per second* linear in dexterity instead, so a point pays the same at 20 as at 190, and there is no zero to cross — the highest reachable dexterity (197) still yields 135 ms. It is exactly 400 ms at dexterity 0, so the ported baseline is unchanged at the bottom; the starting kit reads 345 ms where the Java's tier-1 kit read 328. `MIN_FIRE_PERIOD_MS` stays as the guard it always was, now unreachable.
* Saving: the Java game has **no persistence at all** (nothing is written to disk), so there is nothing to migrate to localStorage.

* **The Greek re-theme (July 2026): the realm bestiary moved from `src/` keys to `gen/` keys.** The zombie squad, the Medusa pair and the three Egyptian gods were redesigned as Greek myth — Hellhound / Telchine / Centaur, a rotating roster of named monsters, and the Titans Hyperion / Oceanus / Cronus — and their ported PNGs were **deleted from `web/assets/`**, exactly as the 0-13 tier ladder did to the ported item art. `legacy/java/src/` still holds every original, and `legacy/classic-web/` still reads them from there, so the Java reference and the nostalgia demo are untouched.

  The cost, stated plainly: those entity classes are **no longer line-comparable with the Java** the way the rest of the port is, because their sprite and bullet keys no longer match the Java's asset strings. That was the whole point of keeping `src/` keys verbatim, so it is a real loss and not a tidy-up. What was preserved instead is everything the simulation computes **for the renamed classes** — every speed, HP formula, defence, fire period, bullet count, spread angle, range, XP award and loot chain is unchanged for the Hellhound, Telchine, Centaur, the three Titans and their three minions — and each redrawn sprite keeps the **exact pixel dimensions** of the art it replaced, so no hitbox moved. (The named-monster roster is a deliberate exception; see the next paragraph.) `web/tests/enemy-art.test.js` pins those dimensions; the class-mapping table above still names the Java class each one came from.

  Two things genuinely changed rather than being renamed. The fifth-round monster is now a **six-entry roster** (`NAMED` in `entities/enemies.js`) instead of always Medusa, so round 5 and round 45 are different fights; the Gorgon entry keeps Medusa's stat block verbatim and the other five deliberately do not: across the roster defence spans 15-35 against Medusa's 20, speed 1.1-2.2 against 1.5, fire period 1400-2200 against 2000, and bullets per volley 1-9 against 3. What *is* invariant across all six is the bullet range (700), the XP award (300) and the loot chain, so the reward curve is untouched even though the fights are not. And a fifth dungeon theme, the **Abyss**, rolls only past round 25 and is kept by Typhon — which also means the `round >= 25` gate consumes one extra `rand()` on the four Titan rounds that can reach it, very slightly lowering the rift roll there (rounds under 25 short-circuit before the draw and are bit-identical).

  `themeForBoss` is ordered by **keeper difficulty, not by the Titan's element**: Cerberus 3000 HP < Scylla 3200 < Enceladus 3600, so round 10 still opens onto the softest keeper and round 30 the hardest, as the three fixed portals always did. Mapping by element instead reads nicer and silently inverts that curve.

## Assets

All 85 images the Java referenced, plus the 5 soundtracks, exist in the repo. The complete original set lives in the frozen `legacy/java/src/` tree (`Map.png` beside it); `web/assets/` carries **the subset `web/` actually loads**, at the same relative paths, the Java key `src/…` → `web/assets/…`. The atlas keys keep the Java `src/…` spelling as *identity strings*; they name the original's asset tree, not any directory under `web/`.

That subset stopped being the whole set in July 2026: the 0-13 tier ladder superseded the ported item art, so the 3 spells, 3 robes, 3 staffs, 24 rings and 5 player-bullet balls were **deleted from `web/assets/`** and their keys dropped from `IMAGE_KEYS`. `legacy/java/src/` still has all 38, and that is now where `legacy/classic-web/` reads its images. If you need to restore one to `web/`, copy it from there.

JS-only art added since the port lives in the `gen/` key namespace and has **no twin under `legacy/java/src/`** — do not copy it there. That is the whole bestiary (`Enemies/Greek|Named|Titans|Dungeon|Keeper/`, `Portals/`, listed by `content/pixelart.js`, baked by `web/tools/gen-enemy-sprites.js`) and the tier-ladder equipment set (91 PNGs filling `Weapons/`, `Armor/`, `Abilities/` and `Rings/`, listed by `content/itemart.js`, baked by `web/tools/gen-item-sprites.js`).

**Formerly missing**: `T1SpeedRing` loaded `src/Rings/T1SpeedRing.png`, but the file on disk is `T1SpdRing.png` (broken in the original Java too; harmless since the class is never instantiated). **Fixed in both Java and JS per the owner** — the class now points at the real `T1SpdRing.png`.

The `.wav` soundtracks under `legacy/java/src/Sounds/` are live for the Java app. The unreferenced root images (`MapOld.png`, `PurpleBlast.png`, `chars16x16dMountains1.png`, `players.png`), the committed `.class` files and the generated `doc/` javadoc were removed in the restructure; git history retains them.
