# 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 chat** — `buildStateTurn` 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.js` → `localStorage['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/.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): ```js 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.py` → `POST /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 `customSkills` ∪ `CB_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.py` → `POST /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.