tiny-army / docs /rpg-progression-plan.md
polats's picture
docs: record fun + living-world milestone
3e1a2a0
|
Raw
History Blame Contribute Delete
18.7 kB

Tiny Army — RPG Progression, Skill Forge, Character Chat & Voice

Status (2026-06-13): all 6 phases implemented & committed on branch local (tiny-army) + local (auto-battler). Phase 0 state · Phase 1 leveling · Phase 2 skill forge + detail art · Phase 3 chat · Phase 4 voice · Phase 5 emotes. Each verified in-browser (logic + mount + live sidecar round-trip). Not yet pushed.

Fun + living-world milestone (2026-06-13, also committed, not pushed): The core game loop was shallow, so a second pass made combat actually fun and wove in more load-bearing LLM:

  • Leveling scales power — level+spent points → rank/HP/damage (statBonuses); a level-up gives an immediate live buff + full heal; attribute points spendable (+HP/+DMG).
  • Escalating waves — foes ramp with time+kills; Elite (2.4× HP) and Champion boss (4.6× HP, bigger sprite) on kill milestones, worth far more XP.
  • Hit juice — hitstop + screenshake (processLog onHit/onDeath hooks); existing red flash.
  • Dodge roll — Shift / ⟲ button → fast i-frame dash (engine invulnUntil in dealDamage).
  • Combat HUD — adrenaline meter, per-skill ready-dim, top threat badge (surfaces the engine's hidden adrenaline/condition/combo depth).
  • Live combat state in chatbuildStateTurn takes a live snapshot (HP/threat) so a fighter answers aware of its current wounds, not just persisted history.
  • Death after-action + war-diary recap — on death, a report (kills/threat/levels/time)
    • an in-voice LLM recap of the run, banked to the hero's memory (feeds chat).
  • Personal-best records + shared Chronicle — a record to beat per hero; fallen heroes' legends post to a world feed shown on the after-action (the world feels populated).

Still open (good next swings): loot/rewards, the bigger living-world LLM features (social fabric/relationships, off-screen evolution, skill+soul mutation, an LLM director).

Goal: turn each fighter from a static persona into a living, evolving character that levels up, forges unique skills (code + flavor + art), and you can talk to on its character page and hear it speak. Mobile-first throughout.

This plan is grounded in the current architecture:

  • Souls persist client-side: web/personaStore.jslocalStorage['tinyarmy.roster.v1']
    • IndexedDB (portraits, voices blob stores). No backend DB; we extend this.
  • Combat is in the browser: source in ../auto-battler/src/, bundled into web/*.js by build.sh (esbuild). Roam combat = src/render/comboBattler.js; resolver = src/engine/teamBattle.js; skill IR = src/engine/skills.js (CB_SKILLS).
  • Skill IR is a clean object: {id, name, profession, attribute, category, target, cost, cast, recharge, requires, effects[]}, icon at /gw/skills/<id>.jpg. This is the exact output contract for the Skill Forge model.
  • Models wired via facades: text = web/runtime.js + /text/generate/stream (SSE); image = web/imagen.js + /portrait (Klein on :7865); TTS = web/tts.js (Kokoro in-browser / Qwen3 / VoxCPM). Code model = BLS Mini-Code 30B (the "skill forge").
  • Context pattern to copy from woid: agent-sandbox/pi-bridge/buildContext.js — static system prompt + dynamic per-turn delta (perception event log, needs/mood line, verbatim memory of recent scenes). We port a single-player, client-side slice of this.

⚠️ Two-repo build: engine/skill changes are authored in ../auto-battler/src/**, then ./build.sh re-bundles into tiny-army/web/. UI shell + endpoints live in tiny-army (app.py, web/*.js). Verify on the auto-battler dev server (:5173) and the tiny-army server (:7860).


Phase 0 — Foundations: persistent progression state

Everything else depends on the soul being able to carry progression. Do this first.

0.1 Extend the persona schema (web/personaStore.js) Add, with a version bump + migration (default-fill on load so existing roster survives):

progression: { level: 1, xp: 0, kills: 0, attributePoints: 0 },
learnedSkills: [],        // skillIds owned by THIS character (refs into customSkills or CB_SKILLS)
equippedSkills: [],       // up to 3 skillIds → the bar comboBattler reads
customSkills: {},         // skillId → forged skill IR + {flavor, iconKey, forgedFrom, forgedAt}
chat: { history: [] },    // [{ role:'user'|'char', text, ts, audioKey? }]
events: [],               // append-only progression log (see Phase 3 memory)

New IndexedDB stores: skillIcons (PNG blobs, keyed by skillId), chatAudio (WAV blobs, keyed by message id). Mirror the existing portraits/voices blob pattern.

0.2 "Active hero" selection — a small shared store so the character page, combat, and chat all agree on which roster member is in play. Persist tinyarmy.activeHeroId.

0.3 XP curve util (web/progression.js) — pure functions: xpToNext(level), applyXp(progression, amount) → {progression, leveledUp, levelsGained}. Single source of truth; no curve math scattered in UI.

Acceptance: existing roster loads unchanged; new fields present with defaults; refresh preserves a hand-set xp/level.


Phase 1 — Leveling: defeat enemies → grow

1.1 Emit a defeat event from combat (../auto-battler/src/render/comboBattler.js) The roam loop already tracks foe .alive and culls corpses via a deadAt map (~L372-377). Add a one-shot hook when a foe transitions alive→dead and the player dealt the kill: opts.onEnemyDefeated?.({ id, name, enemyClass, x, y, t }). Fire it once per foe at the transition, before culling. Rebuild with ./build.sh.

1.2 Award XP in the shell (web/comboBattler.js host / web/tiny.js wiring) Pass onEnemyDefeated from tiny-army. On each defeat: look up enemy XP value (small table by enemy class/tier), applyXp on the active hero, persist, and if leveledUp push a level_up event + grant attributePoints.

1.3 Level-up feedback (mobile) — a lightweight toast/banner over the canvas ("⚔️ {name} reached level {n}!") + a dismissible level-up sheet offering: spend attribute point, and (Phase 2) "Forge a new skill." Bottom-sheet style, thumb-reachable.

1.4 Character page: progression header (web/heroCreator.js barracks card / character sheet) Show level, an XP progress bar, kill count, and attribute points available. Reuse existing .persona-* CSS tokens; keep single-column on ≤768px.

Acceptance: killing foes in roam raises the active hero's XP bar; crossing a threshold levels them up, persists across refresh, and grants a spendable point.


Phase 2 — Skill Forge: unique skills (code + flavor + art)

Each learned skill is generated, not picked from a list — unique IR from the code model, flavor text from the text model, an icon from Klein.

2.1 Skill IR validator (web/skillSchema.js, mirrors src/engine/skills.js) Whitelist of legal category, target, effects[].op, condition, and cost keys, with numeric clamps (so a forged skill can't be game-breaking or crash the resolver). The engine is the source of truth — keep the whitelist in sync with CB_SKILLS ops (apply_condition, bonus_damage, interrupt, lose_all_adrenaline, …). Assign a fresh non-colliding id (e.g. 9000+) so icons/refs don't clash with the canon 0–~700 range.

2.2 Forge endpoint (app.pyPOST /skill/forge, SSE) Input: { heroId, persona, level, theme? }. Pipeline:

  1. Code model (BLS Mini-Code 30B / TINY_BLS_CODE_SPACE) — system prompt embeds the skill IR spec + 2-3 CB_SKILLS examples; asked to emit one JSON skill object themed to the character's class/persona/specialty. Strip the model's reasoning block (already handled for BLS in llm.py). Validate via 2.1; one retry on invalid with the error fed back; fall back to a templated mutation of a canon skill if still invalid (never block a level-up on model failure).
  2. Text model — short flavor text (1-2 sentences, in-world) for the skill, using the persona's voice fields.
  3. Klein (/portrait reuse or /skill-icon) — generate a square icon from a prompt built off skill name + flavor + class palette; store blob in skillIcons IndexedDB. Stream progress events (forging-code, forging-flavor, forging-art, done) so the UI shows a live "forging…" state.

2.3 Save + learn — write the IR into customSkills[id], push id to learnedSkills, log a skill_learned event. Offer to equip (into the ≤3 equippedSkills). comboBattler already reads player.unit.skills (L320) — feed it equippedSkills resolved against customSkillsCB_SKILLS.

2.4 Two Klein renders per skill — icon + action shot. The forge generates two images:

  1. Icon — square, for the skill card (prompt: skill name + flavor + class palette).
  2. Action illustration — a larger scene of this character performing the skill, built from the persona's appearance/portrait fields + the skill's flavor + effect (e.g. "{name}, a {class}, mid-{skill} — {flavor}, motion + impact"). Stored in skillIcons (or a sibling skillArt) IndexedDB store keyed by skillId. Generate the action shot lazily (on first detail open) so forging stays fast, then cache.

2.5 Character page: Skills tab + detail sheet (mobile) — a grid of skill cards (icon, name, compact effect summary via the engine's effect-label helper, e.g. +bleeding). Tap a card → a larger skill detail sheet: the action illustration up top (the character performing the skill), then name, full flavor text, effect breakdown, cost/attribute, and equip/unequip (cap 3). Bottom-sheet style; single-column on phones; lazy-load art from IndexedDB.

Acceptance: on level-up you can forge a skill; it appears as a card with a unique Klein icon + flavor; opening it shows a bigger detail screen with a Klein image of the character performing the skill; equipping it makes it usable on the bar in roam combat; all survives refresh.


Phase 3 — Character Chat: talk to your fighter (woid-style context)

Port a single-player, client-assembled slice of woid's buildContext.js: a static system prompt (identity) + a dynamic state turn ("what's happening to you now") + verbatim memory of the recent conversation. No Colyseus/needs-decay complexity — just the context-assembly shape.

3.1 Context builder (web/characterContext.js, modeled on woid buildContext.js)

  • buildSystemPrompt(persona) — name, class, about, personality/vibe/specialty, plus a "stay in voice, you are a real fighter who lived these events" rule. Built once per session.
  • buildStateTurn(persona, userMsg) — the delta: current level, recent events (battles won, skills forged, level-ups — woid's "perception log", here from persona.events), equipped skills, last battle outcome / current HP if mid-roam. One compact block, like woid's needs/mood/trigger lines.
  • buildMemory(persona.chat.history) — verbatim last N turns (woid drops summarization; we do too), char-budgeted.

3.2 Chat endpoint (app.pyPOST /chat/stream, SSE) Thin wrapper over the existing /text/generate/stream path (souls are client-side, so the client sends the assembled system + turn; the server just streams the chosen text model — Tiny Aya / MiniCPM / Mellum via runtime.js). Append the streamed reply to chat.history.

3.3 Chat UI on the character screen (mobile) — a message thread under the character sheet: bubbles, a sticky bottom input + send (thumb zone), streamed tokens appear live (reuse the SSE parser pattern from personaStream.js). Persist history in chat.history.

Acceptance: on a character's page you can ask "how was that last fight?" and get an in-voice reply that references real state (their level, a skill they forged, the enemy they just beat); the thread persists across refresh.


Phase 4 — Voice: hear them speak

4.1 Per-message play button — every character chat bubble gets a ▶ button. On tap, run the existing TTS narrator (web/tts.js) with the persona's voiceId/voice design fields (the same pipeline the diary + persona preview already use). Show a loading→playing→replay state on the button.

4.2 Cache audio — store generated WAV in the chatAudio IndexedDB store keyed by message id (mirror voices/portraits); replay is instant and offline. Skip regeneration if cached.

4.3 (Optional) auto-speak toggle — a per-character setting to auto-play replies as they finish streaming.

Acceptance: tapping ▶ on a reply speaks it in the character's voice; replays from cache with no re-synth; works on mobile (respects the tap-to-unlock-audio gesture).


Phase 5 — Emotes: condition-driven character/enemy reactions

Heroes and enemies pop a little emote bubble above their sprite when something happens (took a hit, killed a foe, low HP, afflicted, leveled, idle). Uses the Minifantasy UI Overhaul "Character_Emotions" pack already in the repo — no model needed.

Assets (already present in ../auto-battler/public/assets/.../Character_Emotions/):

  • _Emotions.png (152×104) — a sheet of 8×8px emoticon faces: angry, happy, sad, surprised, dizzy/dazed, ❤ love, 💤 sleepy, ♪ music, sweat/fear, sick, etc.
  • _Bubble.png (280×72) — speech-bubble frames with the tail pointing 8 directions, so the bubble can sit above a sprite. (Bubble_Only / Bubble_Outline variants too.)

5.1 Curate the pack into tiny-army — add the emote + bubble PNGs to curate_assets.py's keep-list so ./build.sh copies them into web/assets/. They're tiny (KBs).

5.2 Emote sheet decoder (../auto-battler/src/render/emotes.js, bundled to web/) Decode _Emotions.png as an 8×8 grid → a named map ({ angry, happy, sad, surprised, dizzy, love, sleepy, music, sweat, sick, ... }), and the bubble frame by tail direction. Mirror the existing spriteSheet.js/sheet.js decode pattern (rows×cols, nearest-neighbor scale).

5.3 Emote render layer (../auto-battler/src/render/comboBattler.js + sprite scene) A small overlay attached to each actor sprite: showEmote(actorId, key, { ms }) spawns the bubble + 8×8 icon above the sprite (scaled to match groundScale), auto-expires, and is culled with the actor. Pixel-snapped, no blur. One emote at a time per actor (latest wins).

5.4 Condition → emote mapping (engine events → render) Drive it off events the resolver/roam loop already produces (teamBattle.js logs hits, conditions, deaths; comboBattler knows .alive/HP). A small, tunable table, e.g.:

Trigger (condition) Hero emote Enemy emote
Took a hit / dmg 😠 angry 😠 angry
Afflicted (bleeding/poison) 😣 sick 😣 sick
Stunned / interrupt / dazed 😵 dizzy 😵 dizzy
HP crosses low threshold 😨 sweat 😨 sweat
Killed a foe 😄 happy
On death 💀/😵 dizzy
Level-up (hero, Phase 1) ❤ love
Idle a while / out of combat 💤 sleepy 💤 sleepy

Debounce so a flurry of hits doesn't strobe (min interval per actor). Keep the table in one place so it's easy to retune. Rebuild via ./build.sh.

5.5 (small) Settings toggle — an "emotes" on/off in the existing settings; default on.

Acceptance: in roam combat, taking damage, getting bled/stunned, dropping low, and killing foes each pop the right 8×8 emote bubble over the correct sprite (hero and enemies); a level-up shows the ❤ emote; the bubble scales correctly on mobile and is pixel-crisp.


Cross-cutting

  • Mobile-first: every new surface is single-column ≤768px, controls in the thumb zone, bottom-sheets over modals, lazy-loaded media. Matches the existing joystick/attack mobile work.
  • Graceful degradation: any model call (forge/chat/voice) can fail → always a non-blocking fallback (templated skill, text-only reply, silent button). The app already degrades when sidecars are down; preserve that.
  • Build discipline: engine/skill edits → ../auto-battler/src./build.sh → verify. Run local model sidecars with ./run_sidecars.sh (Aya :7864, Klein :7865); BLS code model via its space/sidecar.
  • Persistence is client-side by design (hackathon). If cross-device sync is ever needed, the one new thing required is a /personas GET/POST backend store — out of scope here.

Dependency order

Phase 0 (state)  ──►  Phase 1 (leveling)  ──►  Phase 2 (skill forge + detail art)
                 └─►  Phase 3 (chat) ──► Phase 4 (voice)

Phase 5 (emotes) ── needs only engine/render access; richer once Phase 1 events exist.
                    Can be built in parallel; level-up emote depends on Phase 1.

Phase 1, 3, and 5 can proceed in parallel once Phase 0 lands (chat doesn't need leveling but reads richer state once leveling/forge exist; emotes are mostly self-contained render work).

Model assignments

Capability Model Path
Skill code (IR) BLS Mini-Code 30B TINY_BLS_CODE_SPACE, reasoning stripped
Skill flavor Tiny Aya / MiniCPM / Mellum /text/generate/stream
Skill icon + action shot FLUX.2 Klein 4B /portrait (:7865) — icon (square) + character-performing-skill illustration
Emote graphics none (Minifantasy art pack) Character_Emotions/_Emotions.png (8×8) + _Bubble.png
Character chat Tiny Aya / MiniCPM / Mellum /chat/stream/text/generate/stream
Voice Kokoro / Qwen3-TTS / VoxCPM web/tts.js

Open decisions (pick before/while building)

  1. XP source: kills-only (simplest), or also reward skill use / survival? → start kills-only.
  2. Forge trigger: auto-offer one skill per level, or a spendable "forge token"? → token, so forging is a deliberate, art-worthy moment.
  3. Equipped cap: keep the engine's 3-skill bar, or expand? → keep 3 for balance + UI fit.
  4. Chat state breadth: how much game state in the turn (just stats, or full recent event log)? → start with stats + last 5 events; expand if replies feel thin.