Files
square-defense/docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md
2026-06-16 10:57:01 +03:00

28 KiB
Raw Permalink Blame History

TowerAn — Phase 3: Content, Meta & Retention

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Note on TDD: This project has no test infrastructure (libGDX game, procedural rendering, no existing JUnit/Spock tests). The "write failing test first" ritual is not applicable. Verification is visual / manual: run the desktop launcher (lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher), reproduce the scenario, confirm the change. Each task lists explicit visual checks in place of test assertions. Introducing a test harness is itself a Phase 3 candidate (Task 0) but is not a prerequisite.

Goal: Close the content and retention gaps identified by the three game-design agents — new enemy archetypes, boss fight depth, environment variety, audio, onboarding, achievements, daily seeds, save/resume, and full-tree build diversity.

Architecture: All enemies extend BaseCircleData in the flat ru.project.tower package. New systems (achievements, daily seed, settings) piggyback on existing Preferences-based persistence alongside PlayerStats/PlayerAbilities. Onboarding is a FirstScreen overlay (same pattern as the mid-run upgrade pick added in Phase 2). Biomes are data-driven palette swaps applied each wave.

Tech Stack: Java 8, libGDX 1.13.1, ShapeRenderer/SpriteBatch, Preferences for persistence, GlyphLayout for tooltips.

Prerequisites: Phases 1 and 2 merged. Run the game at least once to confirm Phase 2 overlays render correctly (the Phase 2 session discovered that renderUI()/renderGameObjects() are not wired — render pipeline is direct-to-render(); new renderers must be called explicitly from render()).


Task ordering

Tasks are grouped by area. Within an area they go easiest → hardest. Between areas:

  1. Enemy archetypes (pure data classes, mechanical payoff, low risk)
  2. Boss depth (enrage phase, reuses existing frameworks)
  3. Projectile variants (ties into existing BulletData flags from Phase 1)
  4. Audio pack (tiny code, requires asset decisions)
  5. Biomes (procedural palette swap)
  6. Onboarding / tooltips (touches many screens)
  7. Achievements & unlocks (new persistent store)
  8. Daily seed & run modifiers (roguelite hook)
  9. Save/resume mid-run (serialization work)
  10. Ability mastery (XP per ability, optional late-tier)

Each Task group ends with a commit. Skip any group that the user explicitly de-prioritizes.


Area A — Enemy archetypes

Two complaints from the variety agent: only 5 enemy archetypes (Basic/Strong/Fast/Spawner/Splitter), all travel in a straight line toward center (no behaviour differences beyond HP/speed/colour). Add 4 behaviourally distinct enemies. Each is a single file + a tiny spawn-chance hook in FirstScreen.spawnCircle.

Task A1: HealerCircleData — AoE healer

Files:

  • Create: core/src/main/java/ru/project/tower/HealerCircleData.java

  • Modify: core/src/main/java/ru/project/tower/FirstScreen.java (add spawn branch + constants)

  • Step 1: Create HealerCircleData

package ru.project.tower;

import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.TimeUtils;

public class HealerCircleData extends BaseCircleData {
    private static final long HEAL_INTERVAL_MS = 1500L;
    private static final float HEAL_RADIUS = 120f;
    private static final int HEAL_AMOUNT = 3;
    private long lastHealTime = TimeUtils.millis();

    public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) {
        super(circle, velocity, health, damage);
    }

    public boolean shouldPulse() {
        if (TimeUtils.millis() - lastHealTime > HEAL_INTERVAL_MS) {
            lastHealTime = TimeUtils.millis();
            return true;
        }
        return false;
    }

    public static float getHealRadius() { return HEAL_RADIUS; }
    public static int getHealAmount() { return HEAL_AMOUNT; }

    @Override
    public float[] getColor() {
        return new float[]{0.2f, 1.0f, 0.5f, 1f};
    }

    @Override
    protected int getBaseReward() { return 4; }
}
  • Step 2: Add spawn constants in FirstScreen.java near the other enemy constants (search for SPLITTER_CIRCLE_CHANCE):
private static final float HEALER_CIRCLE_CHANCE = 0.08f;
private static final int HEALER_CIRCLE_HEALTH = 6;
private static final int HEALER_START_WAVE = 8;
  • Step 3: Add spawn branch in spawnCircle() — after the isSplitterCircle branch, before isStrongCircle:
boolean isHealerCircle = !isSpawnerCircle && !isFastCircle && !isSplitterCircle
    && currentWave >= HEALER_START_WAVE
    && random < (currentFastCircleChance + currentSplitterCircleChance + HEALER_CIRCLE_CHANCE);

Then add a construction branch mirroring the other archetypes and set spawned = new HealerCircleData(...).

  • Step 4: Pulse loop in the per-frame circle iterator (same block that spawns from SpawnerCircleData):
if (circleData instanceof HealerCircleData) {
    HealerCircleData healer = (HealerCircleData) circleData;
    if (healer.shouldPulse()) {
        for (BaseCircleData other : circles) {
            if (other == healer) continue;
            if (Vector2.dst(healer.getX(), healer.getY(), other.getX(), other.getY())
                <= HealerCircleData.getHealRadius()) {
                other.health += HealerCircleData.getHealAmount();
            }
        }
        createExplosion(healer.getX(), healer.getY(),
            new Color(0.2f, 1f, 0.5f, 1f));
    }
}
  • Step 5: Visual check — play until wave 8, spot a green enemy, watch nearby enemies gain HP (visible because StrongCircleData.getColor scales alpha with health — strong enemies near a healer should stay bright blue).

  • Step 6: Commit

git add core/src/main/java/ru/project/tower/HealerCircleData.java \
        core/src/main/java/ru/project/tower/FirstScreen.java
git commit -m "feat: add HealerCircleData AoE healer enemy"

Task A2: DasherCircleData — telegraphed charge

Uses JuggernautData's charge pattern at a smaller scale for a rank-and-file enemy.

Files:

  • Create: core/src/main/java/ru/project/tower/DasherCircleData.java

  • Modify: FirstScreen.java (spawn branch, per-frame state tick, render tint for the "windup" phase)

  • Step 1: Create class — holds a long dashReadyAt and boolean dashing. At update() time: if idle and in range of player, enter windup for 800ms (velocity halved, visually pulses yellow), then lock a direction, multiply velocity ×3 for 400ms, return to normal.

package ru.project.tower;

import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.TimeUtils;

public class DasherCircleData extends BaseCircleData {
    private static final long WINDUP_MS = 800L;
    private static final long DASH_MS = 400L;
    private static final float DASH_SPEED_MULT = 3f;
    private enum State { IDLE, WINDUP, DASHING }
    private State state = State.IDLE;
    private long stateEnter = 0;
    private Vector2 baseVelocity;

    public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) {
        super(circle, velocity, health, damage);
        this.baseVelocity = new Vector2(velocity);
    }

    public boolean isWindup() { return state == State.WINDUP; }
    public boolean isDashing() { return state == State.DASHING; }

    public void tick(float squareX, float squareY) {
        long now = TimeUtils.millis();
        switch (state) {
            case IDLE:
                if (Vector2.dst(getX(), getY(), squareX, squareY) < 280f) {
                    state = State.WINDUP;
                    stateEnter = now;
                    velocity.scl(0.3f);
                }
                break;
            case WINDUP:
                if (now - stateEnter > WINDUP_MS) {
                    state = State.DASHING;
                    stateEnter = now;
                    float dx = squareX - getX(), dy = squareY - getY();
                    float len = (float)Math.sqrt(dx*dx + dy*dy);
                    if (len > 0f) {
                        velocity.set(dx/len, dy/len).scl(baseVelocity.len() * DASH_SPEED_MULT);
                    }
                }
                break;
            case DASHING:
                if (now - stateEnter > DASH_MS) {
                    state = State.IDLE;
                    velocity.set(baseVelocity);
                }
                break;
        }
    }

    @Override
    public float[] getColor() {
        switch (state) {
            case WINDUP: return new float[]{1f, 0.9f, 0.3f, 1f};
            case DASHING: return new float[]{1f, 0.4f, 0.1f, 1f};
            default: return new float[]{0.9f, 0.6f, 0.2f, 1f};
        }
    }

    @Override
    protected int getBaseReward() { return 3; }
}
  • Step 2: Spawn branch in spawnCircle with currentWave >= 6 and chance 0.10.

  • Step 3: Call dasher.tick(squareCenterX, squareCenterY) inside the per-frame circles loop (same spot that handles spawner spawning).

  • Step 4: Visual check — from wave 6 a dasher should pulse yellow for ~0.8s then streak toward the square at 3× speed. Good stress test for Dash/Shield abilities.

  • Step 5: Commit.


Task A3: SniperCircleData — ranged shooter

Stays at range, fires BulletData(isEnemyBullet=true) projectiles.

Files: as above.

  • Step 1: Class holds long lastShotAt and overrides movement — moves toward player until distance ~= 400, then stops (velocity set to 0) and fires every 2s.

  • Step 2: Spawning & firing mirror ShadowWeaverData.shouldShoot() usage on lines 59355940 of current FirstScreen.

  • Step 3: Balance — damage = 2× basic damage, HP = 0.5× basic.

  • Step 4: Visual check — sniper stops in a ring ~400px from square, red tracer shots visible. Shield/Dash should block them.

  • Step 5: Commit.


Task A4: GnatCircleData — 1-HP swarm

Fastest type, 1 HP, spawned in packs. Overrides BaseCircleData's single-spawn assumption by inserting a small "swarm" around a spawn roll.

Files: as above.

  • Step 1: Class is trivial — health always 1, speed 1.8× basic, radius 0.6× basic.

  • Step 2: Spawn as a pack — when rolled, call spawnCircle-like helper 5 times with small offsets around the roll point. Add isGnatSwarm branch before the Basic branch.

  • Step 3: Visual check — from wave 10+, occasional pack of tiny pink dots darts in. AoE abilities should shred them.

  • Step 4: Commit.


Area B — Boss depth

Task B1: Enrage phase for all 6 bosses

Add a shared BaseBossData abstract class or a BossEnrage mixin-helper invoked from each boss. Pragmatic path: helper invoked from each boss's update().

Files:

  • Modify: JuggernautData, SwarmQueenData, GlacialWardenData, IllusionMasterData, ShadowWeaverData, VolatileReactorData

  • Step 1: Add per-boss maxHealth field — captured at construction time. Boss HP is already scaled in spawnRandomBoss; snapshot right after construction.

  • Step 2: Add boolean isEnraged() per bosshealth / maxHealth < 0.33f. Cache the flag so enrage triggers once.

  • Step 3: Multiply attack cadence by 1.5× when enraged — find each shouldShoot/shouldCharge/shouldCreateFireZone cadence constant and divide by isEnraged() ? 1.5f : 1f.

  • Step 4: Visual/audio telegraph — one-time screen shake + red flash + "ENRAGE!" floating text when transitioning. Trigger from FirstScreen.updateBosses by checking boss.isEnraged() && !boss.enrageTriggered.

  • Step 5: Visual check — boss at low HP flashes red, floating text appears, projectile cadence noticeably faster.

  • Step 6: Commit.

Task B2: De-templatize the "projectile barrage + summon" pattern

Agent noted 4 of 6 bosses use the same burst + minions pattern. Low-effort differentiator:

  • Step 1: Give ShadowWeaverData a beam attack (continuous line that deals 1 dmg/tick instead of its volley).
  • Step 2: Give GlacialWardenData a wall-spawn (creates temporary immovable obstacles that bullets collide with).
  • Step 3: Commit.

(Lower priority — skip if schedule tight.)


Area C — Projectile variants exposed to the player

Phase 1 added piercesLeft / homing / aoeRadius to BulletData and Phase 2 wired pierce + AoE to keystones. homing is defined but unimplemented.

Task C1: Implement homing

Files: FirstScreen.java, BulletData.java

  • Step 1: In the bullet-update loop (where bulletData.move(delta) lives), if bullet.homing && !bullet.isEnemyBullet(): find nearest enemy within 400px, compute desired velocity direction, lerp bullet.velocity toward it by 0.06f per frame (caps turn rate so homing feels responsive but not snap-on).

  • Step 2: Add a new Keystone talent keystone_homing (tier 3) that sets bullet.homing = true on all bullets when level >= 1. Add registration in TalentTree.initializeTree, add "keystone_homing" to PlayerStats.TALENT_NODE_IDS.

  • Step 3: Visual check — with the keystone bought, bullets curve toward enemies.

  • Step 4: Commit.

Task C2: Chain lightning on hit

New per-bullet flag chainJumps (int). On collision, if chainJumps > 0, find nearest enemy within 200px, spawn a short-lived line render + deal damage, decrement.

  • Step 1: Add field to BulletData.
  • Step 2: Add collision-site chain logic in FirstScreen bullet-vs-circle resolution block (near the existing bulletData.aoeRadius > 0f check).
  • Step 3: Render the chain as a thin additive-blend line from hit-point to hit-target, alpha fading over 150ms.
  • Step 4: New keystone keystone_chain. Each level = +1 jump.
  • Step 5: Commit.

Area D — Audio pack

The project ships with 2 sounds (pop.wav, shot.wav) and 1 PNG. Need a base pack. Asset dependency — cannot be completed in a code-only session. The plan's task is to wire up the loader; the user supplies the files.

Task D1: Sound asset loader

Files:

  • Create: core/src/main/java/ru/project/tower/SoundAssets.java

  • Modify: FirstScreen.java

  • Step 1: Create SoundAssets singleton that lazy-loads and caches Sound handles:

public class SoundAssets {
    private static SoundAssets instance;
    private final Map<String, Sound> cache = new HashMap<>();

    public static SoundAssets get() {
        if (instance == null) instance = new SoundAssets();
        return instance;
    }

    public Sound load(String filename) {
        Sound s = cache.get(filename);
        if (s != null) return s;
        FileHandle f = Gdx.files.internal(filename);
        if (!f.exists() || f.length() < 200) return null;
        try { s = Gdx.audio.newSound(f); cache.put(filename, s); return s; }
        catch (Exception e) { return null; }
    }

    public void play(String filename, float volume, float pitch) {
        Sound s = load(filename);
        if (s != null) s.play(volume, pitch, 0f);
    }
}
  • Step 2: Replace direct Gdx.audio.newSound in FirstScreen.show with SoundAssets.get().load(...).

  • Step 3: Add explicit call sites for the 10 events the variety agent listed. For each event, add SoundAssets.get().play("<name>.wav", vol, pitch):

    • player_hit.wav — in the 5 damage-to-player sites added in Phase 1
    • boss_spawn.wav — in spawnRandomBoss
    • ability_freeze.wav / _explosion.wav / _shield.wav / _impulse.wav / _dash.wav / _pull.wav / _turret.wav — start of each activateX method
    • wave_start.wav — first line of startNextWave
    • wave_clear.wav — in endWave after bonus calculation
    • game_over.wav — first line of gameOver
    • ui_click.wav — in handleUpgradeButtons / handleUpgradePickTouch after a successful button hit
  • Step 4: Missing files degrade silently (loader returns null → play is no-op). No crash if user hasn't supplied assets yet.

  • Step 5: Commit.

Task D2: Music loop slots

  • Step 1: Add Music menuTrack, combatTrack, bossTrack fields; load via Gdx.audio.newMusic(Gdx.files.internal("music/...ogg")) guarded by exists().
  • Step 2: Start combat track in show(); switch to boss track when a boss is active (isAnyBossOnScreen); restore combat track when boss dies.
  • Step 3: Commit.

Area E — Biomes / palette variety

One BACKGROUND_COLOR, one particle palette. Agent: rotate every ~5 waves.

Task E1: BiomePalette data class + per-wave selection

Files:

  • Create: core/src/main/java/ru/project/tower/BiomePalette.java

  • Modify: FirstScreen.java

  • Step 1: Create class

public class BiomePalette {
    public final Color background;
    public final Color particle1;
    public final Color particle2;
    public final String label;
    public BiomePalette(Color bg, Color p1, Color p2, String label) {
        this.background = bg; this.particle1 = p1; this.particle2 = p2; this.label = label;
    }
    public static final BiomePalette[] ALL = {
        new BiomePalette(new Color(0.05f,0.05f,0.10f,1f), new Color(0.2f,0.6f,1f,1f), new Color(1f,0.2f,0.6f,1f), "Neon City"),
        new BiomePalette(new Color(0.02f,0.08f,0.05f,1f), new Color(0.2f,1f,0.5f,1f), new Color(1f,1f,0.3f,1f), "Bio Jungle"),
        new BiomePalette(new Color(0.08f,0.02f,0.02f,1f), new Color(1f,0.3f,0.1f,1f), new Color(1f,0.8f,0.2f,1f), "Magma Forge"),
        new BiomePalette(new Color(0.03f,0.03f,0.12f,1f), new Color(0.5f,0.2f,1f,1f), new Color(0.2f,0.9f,1f,1f), "Void Shift"),
    };
}
  • Step 2: Select in startNextWavecurrentBiome = BiomePalette.ALL[(currentWave / 5) % BiomePalette.ALL.length]. Replace the static BACKGROUND_COLOR references in render() with currentBiome.background.

  • Step 3: Background particle colors — in the background particle spawn logic, pick randomly between currentBiome.particle1 and particle2.

  • Step 4: Biome transition floating text — when biome changes, spawn a 3-second floating text with currentBiome.label.

  • Step 5: Visual check — at waves 5, 10, 15, 20 the background and particle colors rotate.

  • Step 6: Commit.

Task E2: Random wave modifiers

Once per run, at wave-start, roll a 25% chance of a modifier from a small list: "double gravity" (pulls enemies toward center faster), "fog" (shooting range -30%), "rush" (+25% enemy speed, +25% reward). Display as floating text at wave start. Stores as fields on FirstScreen; cleared in endWave.

  • Step 1: Add enum WaveModifier { NONE, GRAVITY, FOG, RUSH } and WaveModifier currentWaveModifier.
  • Step 2: Roll in startNextWave.
  • Step 3: Apply effects: GRAVITY → multiply currentEnemySpeedMultiplier by 1.3 and pull velocity toward center each tick; FOG → shrink SHOOTING_RANGE effective value by 0.7; RUSH → multiply speed × 1.25, reward × 1.25.
  • Step 4: Floating text at wave start with the label.
  • Step 5: Commit.

Area F — Onboarding

Task F1: Tutorial overlay for first run

Files:

  • Modify: FirstScreen.java, PlayerStats.java (add tutorialSeen preference)

  • Step 1: Add boolean tutorialSeen to PlayerStats persisted in Preferences, default false.

  • Step 2: In FirstScreen.show, if !playerStats.tutorialSeen, set showTutorialStep = 0 and skip auto-starting wave 1.

  • Step 3: Add renderTutorialOverlay() method — dims the screen and shows one tip at a time with an arrow pointing at the relevant UI region.

    • Step 0: "Автоматическая турель в центре стреляет по ближайшему врагу"
    • Step 1: "Кнопки способностей снизу — жмите когда заряжены"
    • Step 2: "Каждые 3 волны выбирайте улучшение"
    • Step 3: "Магазин и дерево талантов в главном меню"
  • Step 4: Tap advances. Last step → tutorialSeen = true; saveData(); and wave 1 starts.

  • Step 5: Call renderTutorialOverlay() at the end of render() (alongside renderUpgradePickOverlay).

  • Step 6: Commit.

Task F2: Tooltips on upgrade/talent/ability buttons

  • Step 1: Add tooltipText field on each button and track hover state (already tracked in renderNeonButton via isHovered).
  • Step 2: When hovered, render a small panel above the button with text. One pass, one method — drawTooltip(Rectangle btn, String text).
  • Step 3: Wire per button — damage/speed/cooldown upgrades get stat deltas, ability buttons get radius/damage/duration values.
  • Step 4: Commit.

Area G — Achievements & unlocks

Task G1: Achievements store

Files:

  • Create: core/src/main/java/ru/project/tower/Achievements.java

  • Modify: FirstScreen.java (hook increment calls)

  • Step 1: Define enum of 15 achievements:

    • FIRST_BLOOD (1 kill), KILL_100, KILL_1000
    • WAVE_10, WAVE_25, WAVE_50
    • BOSS_ALL_6 (defeat each boss once)
    • NO_HIT_WAVE (complete a wave with no damage taken)
    • FREEZE_100_FROZEN, SHATTER_50
    • ELITE_KILL_100
    • TALENT_TREE_COMPLETE, FULL_ABILITY_ROSTER
    • DAILY_SEED_FIRST, RUN_WITH_ALL_3_KEYSTONES
  • Step 2: Achievements singleton with Preferences-backed unlocked set and counters. increment(key), unlock(id), isUnlocked(id). Each unlock awards a currency bounty (e.g., 25 for easy, 100 for hard).

  • Step 3: Hook call sites — kill counter in the bullet-kill block, wave counter in endWave, boss-defeated in the circle.health<=0 block when isBossCircle, etc.

  • Step 4: Unlock toast — on first unlock of an achievement, floating text "ДОСТИЖЕНИЕ: (+N монет)" + shake.

  • Step 5: Add an "Achievements" button on MainScreen → new AchievementsScreen with a scrollable list (use same stage/skin style as AbilityShopScreen).

  • Step 6: Commit.

Task G2: Cosmetic unlocks

Simple: square tint unlocks (5 colours unlocked by achievement thresholds). Stored in PlayerStats.selectedTint. Applied in renderNeonSquare.

  • Step 1: Add String selectedTint / list of unlocked tints to PlayerStats.
  • Step 2: Cosmetics screen launched from MainScreen.
  • Step 3: Commit.

Area H — Daily seed & run modifiers

Task H1: Daily seed

Files:

  • Create: core/src/main/java/ru/project/tower/DailySeed.java

  • Modify: MainScreen.java, FirstScreen.java

  • Step 1: DailySeed.todaySeed() returns LocalDate.now().toString().hashCode().

  • Step 2: New "Daily Challenge" button on main menu that starts a run with MathUtils.random.setSeed(seed) injected into FirstScreen before first spawnCircle.

  • Step 3: Record best-wave-today in Preferences keyed by date. Display on main menu "Daily: best W14".

  • Step 4: At wave end in daily mode, also save to a rolling 7-day log for a "last 7 days" leaderboard view.

  • Step 5: Commit.

Task H2: Pre-run modifiers

Before starting a run, offer an optional modifier card: "+30% reward if all 3 abilities selected", "Bosses +50% HP / reward ×2", etc. Applied as GameState.runModifier flag, checked in relevant methods.

  • Deferred — pair with Area E2 work.

Area I — Save mid-run

Agent noted GameState is in-memory only. Serialize run to JSON on pause/quit so players can resume.

Files:

  • Modify: GameState.java, FirstScreen.java (serialize on pause, deserialize on show)

  • Step 1: Add toJson()/fromJson(String) on GameState using libGDX Json. Include: currentWave, squareHealth, maxSquareHealth, money, runDamageBonus, runPierceBonus, etc. (the Phase 2 run* fields).

  • Step 2: On pause (shouldPause branch), write JSON string into Preferences.

  • Step 3: On FirstScreen.show, if a saved run exists and user chose "Resume" from main menu, deserialize and jump to startNextWave-equivalent state.

  • Step 4: Main menu gets a "Resume" button that appears only when save exists.

  • Step 5: Commit.


Area J — Ability mastery

Roguelite depth — each ability gains XP as it's used; XP unlocks passive modifiers.

Files:

  • Create: core/src/main/java/ru/project/tower/AbilityMastery.java

  • Modify: FirstScreen.java (increment on each cast), AbilitySelectionScreen.java (show levels)

  • Step 1: AbilityMastery singleton tracks int masteryXp[ability] persisted in Preferences. Level = floor(sqrt(xp/10)).

  • Step 2: Increment 10 XP per cast; 50 XP per "meaningful kill" (freeze of 5+, explosion of 10+ etc.)

  • Step 3: Unlocks per level:

    • Freeze L2: +30% radius; L4: +25% duration; L6: enemies under Freeze take +25% bullet damage.
    • Explosion L2: +30% radius; L4: applies brief slow; L6: double damage vs frozen (stacks with Phase 1 synergy).
    • Shield L2: +1s duration; L4: reflects projectiles; L6: explosion on expire.
    • Impulse L2: +30% force; L4: +50% damage to pushed enemies; L6: also applies freeze 500ms.
    • Dash L2: +150ms i-frames; L4: leaves fire trail; L6: double-dash (2 charges).
    • Pull L2: +30% radius; L4: pulled enemies take +20% damage for 2s; L6: pulls bosses briefly.
    • Turret L2: +3s lifetime; L4: +25% fire rate; L6: turret shots pierce +1.
  • Step 4: Visualise — in AbilitySelectionScreen, show level number on each selected ability card.

  • Step 5: Commit.


Area K — Test harness (optional, enabler for future work)

Not required for any Phase 3 task but strongly suggested before Phase 4.

  • Step 1: Add JUnit 5 to core/build.gradle: testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0'.
  • Step 2: Headless libGDX test scaffolding via Headless Application — enough for PlayerStats, Achievements, TalentTree, DailySeed logic tests.
  • Step 3: One sample test per class to prove the pattern.
  • Step 4: Document in README the gradle test command.
  • Step 5: Commit.

Priority summary

Priority Area Rationale
P1 A1 Healer, A3 Sniper, B1 Enrage, E1 Biomes Cheap, high-impact variety; no new assets
P1 F1 Tutorial, F2 Tooltips Fixes onboarding (agent's #3 critical gap)
P1 G1 Achievements (core only) Fixes retention goals (agent's #2 critical gap)
P2 A2 Dasher, A4 Gnat, C1 Homing, C2 Chain More variety + keystone payoffs
P2 H1 Daily seed Flywheel for returning players
P2 I Save mid-run Mobile QoL
P3 D1 Audio pack Requires assets
P3 B2 Boss de-templating Large rework, lower leverage
P3 J Ability mastery Late-tier depth
P3 E2 Wave modifiers, H2 Pre-run modifiers Nice-to-have
P4 K Test harness Enabler for Phase 4

Implement top-to-bottom. P1 block (A1+A3+B1+E1+F1+F2+G1) is the minimum that addresses all three agent reports.


Self-review notes

  • Spec coverage: Every top-level gap from the three agent reports maps to at least one task. Elite modifier and 3 new abilities (Dash/Pull/Turret) from Phase 2 aren't re-planned here; homing is the only BulletData flag that was dormant.
  • Placeholder scan: Task B2 is the only explicitly-deferred item; flagged P3. All other code steps show the code.
  • Type consistency: HealerCircleData.getHealRadius() used from FirstScreen matches class definition. BiomePalette.ALL array is the public API used in startNextWave. Achievements.increment(String key) signature referenced in Task G1.step3 matches Task G1.step2.
  • Out-of-scope: Multiplayer, shader-based bloom, and new fonts are not in this plan. Boss entry cinematics, cosmetic particle trails, and lore text are intentionally skipped — re-open after data shows engagement.

Execution handoff

This plan is saved as docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md. When you're ready to execute:

  1. Subagent-Driven (recommended): Fresh subagent per task group (A, B, ... J), review between groups. Use superpowers:subagent-driven-development.
  2. Inline Execution: Step through in-session with superpowers:executing-plans, checkpoint after each area.

Tell Claude which approach and which priority tier (P1 only? P1+P2? full sweep?) to run.