Spaces:
Running
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
invulnUntilin 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 —
buildStateTurntakes 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,voicesblob stores). No backend DB; we extend this.
- IndexedDB (
- Combat is in the browser: source in
../auto-battler/src/, bundled intoweb/*.jsbybuild.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.shre-bundles intotiny-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.py → POST /skill/forge, SSE)
Input: { heroId, persona, level, theme? }. Pipeline:
- Code model (BLS Mini-Code 30B /
TINY_BLS_CODE_SPACE) — system prompt embeds the skill IR spec + 2-3CB_SKILLSexamples; 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 inllm.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). - Text model — short flavor text (1-2 sentences, in-world) for the skill, using the persona's voice fields.
- Klein (
/portraitreuse or/skill-icon) — generate a square icon from a prompt built off skill name + flavor + class palette; store blob inskillIconsIndexedDB. 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:
- Icon — square, for the skill card (prompt: skill name + flavor + class palette).
- 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 siblingskillArt) 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: currentlevel, recentevents(battles won, skills forged, level-ups — woid's "perception log", here frompersona.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_Outlinevariants 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
/personasGET/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)
- XP source: kills-only (simplest), or also reward skill use / survival? → start kills-only.
- Forge trigger: auto-offer one skill per level, or a spendable "forge token"? → token, so forging is a deliberate, art-worthy moment.
- Equipped cap: keep the engine's 3-skill bar, or expand? → keep 3 for balance + UI fit.
- 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.