kb://architecture/character-stack-unificationdraft2026-05-23

Character-stack unification — openalice-world consumes persona packages

architecturevrmworldpersonamigrationlegodedup

Character-stack unification — migration plan

Why

NAO 2026-05-23 directive: unify VRM + movement + speech + lipsync across live / persona / world / social so they snap together like LEGO. The first concrete cleanup is the parallel-impl problem between openalice-world and openalice-persona: both ship a character-stack monorepo, the world version scopes its packages as @openalice/* while persona scopes them as @openalicelabs/persona-*, and on file content they are ≥ 99% duplicates. Single canonical home = one source of truth, one place to fix bugs, one runtime under audit.

This note is the work-plan for daylight. Tonight is audit only; the merge happens with proper regression testing.

Today's ground truth

Package pair (world ↔ persona)World filesPersona filesIdenticalDivergentWorld-only
avatar ↔ core223002 (shim)
engine ↔ engine12 + 3 tests8173 tests
multiplayer ↔ multiplayer9 + 3 tests5321 + 3 tests
voice ↔ voice34120
world ↔ world14151040

Total impact to apps/web: ~48 @openalice/* import statements across ~20 files in openalice-world/apps/web/src/**. ~18 source files have content divergence and need careful merge (not auto sed-rename).

Divergent files needing careful merge

engine (7 divergent)

  • audio/soundManager.ts — world adds disposeSoundManager() + null-checks on singleton reset (SPA-navigation memory safety). Persona's version lacks these; world's is the better baseline.
  • audio/proceduralAudio.ts — similar enhancement-style drift.
  • terrain/generator.ts, particles/engine.ts, quality/adaptive.ts, geometry/procedural.ts, shaders/grass.ts — 10–50 line deltas each, likely independent tuning. Need side-by-side diff before merge.

multiplayer (2 divergent + 1 world-only)

  • index.ts — world exports WorldAgent + types; persona doesn't.
  • spatialAudio.ts, useInterpolation.ts, usePrediction.ts, useWorldSync.ts — likely diverged between player-centric (world) and agent-centric (persona) flows.
  • worldAgent.ts (219 lines, world-only) — FPS player state + world sync protocol. Either promote to persona or keep world-only.

voice (2 divergent + 1 persona-only)

  • voiceChat.ts — world's version imports useVoiceClient from @openalicelabs/voice-client and adds game-audio ducking via @openalice/engine. Persona's version is older + simpler. World's is the better baseline.
  • voiceInput.ts — flag for diff review.
  • clientLog.ts (persona-only, 19 lines) — small logging util.

world (4 divergent + 2 world-uses)

  • PlayerAvatar.tsx (218 → 239 lines) — world has ~21 lines of player-specific rendering on top of persona's base.
  • AnimePostProcessing.tsx, DynamicSky.tsx, IslandScene.tsx, MobileControls.tsx, RemotePlayer.tsx, ScreenShareBillboard.tsx, VRControls.tsx, WorldHUD.tsx, WorldParticles.tsx — incremental drifts, sizes 1–50 lines.
  • useAgentMovement.ts (persona-only, just shipped 2026-05-23) — the new agent-driven walk runtime. Lives only in persona; world picks it up automatically post-merge.

Package.json semantics

AspectWorldPersonaResolution
Scope@openalice/*@openalicelabs/persona-*Rename all; update 48 imports in apps/web
avatar/coreNo exports mapFull exports mapAdopt persona's; world's avatar becomes a re-export shim from core
enginesideEffects:false + vitestNo flagsAdd to persona on merge
multiplayersideEffects:false + agent-protocol depNoneAdd if WorldAgent stays
voicedepends on @openalice/engineNonePersona's voiceChat needs optional engine adapter for ducking
worlddepends on 4 world packagesdepends on 3 persona packagesUpdate to persona scope; persona world adds useAgentMovement automatically

Migration plan (4 phases — daylight work)

Phase A — pre-flight regression baseline (S, ~1–2 h)

  1. Branch unify/character-stack-phase-a-baseline in both repos.
  2. Run pnpm --filter @openalice/world-web test and pnpm --filter @openalicelabs/persona-web test; capture pass/fail counts.
  3. Smoke-test live world.blal.pro + persona.blal.pro manually: IslandScene loads, voice connects, avatar moves, weather cycles. Screenshot each, attach to PR body.

Phase B — file-by-file dedup (M, ~4–6 h + review)

For each of the 18 divergent files (listed above):

  1. Run diff -u between world's and persona's copy.
  2. Identify which version has the bugfix / perf win / extra feature.
  3. Port the win into persona's copy. Persona becomes canonical.
  4. Add a regression test where the win is non-obvious (e.g. soundManager dispose path).
  5. Decide on WorldAgent (219 lines): promote to persona OR keep as a world-app local. Recommendation: promote — persona-world becomes "any world", world apps reuse.
  6. Commit per logical merge: feat(persona-engine): adopt world's soundManager dispose path, etc.

Branch: unify/character-stack-phase-b-dedup. Run engine + core tests, manual diff spot-check on 3–4 merged files.

Phase C — import sweep in openalice-world/apps/web (M, ~2–3 h)

48 @openalice/* imports across ~20 files in openalice-world/apps/web/src/{hooks,components,lib,app}/.

Batch rename via sed (run from openalice-world/apps/web/):

git grep -lE '@openalice/(avatar|engine|voice|multiplayer|world)' src \
  | xargs sed -i \
    -e 's|@openalice/avatar|@openalicelabs/persona-core|g' \
    -e 's|@openalice/engine|@openalicelabs/persona-engine|g' \
    -e 's|@openalice/voice|@openalicelabs/persona-voice|g' \
    -e 's|@openalice/multiplayer|@openalicelabs/persona-multiplayer|g' \
    -e 's|@openalice/world|@openalicelabs/persona-world|g'

Verify clean:

git grep -E '@openalice/' src && echo "STRAGGLERS" || echo "clean"

Update apps/web/package.json lines 13–18 — six workspace deps renamed from @openalice/* to @openalicelabs/persona-* with file: paths pointing into persona repo (or NPM once published). Update pnpm-workspace.yaml to include the persona path. Update docker-compose mount to include persona repo if not already.

Run pnpm install and pnpm --filter @openalice/world-web build; TypeScript catches anything sed missed.

Branch: unify/character-stack-phase-c-imports.

Phase D — package.json + cleanup (M, ~3–4 h)

  1. Decide on avatar package shape. Persona's core covers what world's avatar exports. Either: - (a) world-app imports @openalicelabs/persona-core directly, @openalice/avatar becomes a thin re-export shim, OR - (b) @openalicelabs/persona-avatar is added as an alias package that re-exports core. Recommendation: (a) — fewer packages, simpler.
  2. Delete or deprecate openalice-world/packages/{avatar,engine, multiplayer,voice,world}/. Two options: - (i) Delete outright (world has no other consumers). - (ii) Leave as stubs with // DEPRECATED — see @openalicelabs/persona-* comments + one-line re-exports for transition safety. Recommendation: (ii) for one release cycle, (i) after.
  3. Consolidate tests. Move world's __tests__ directories into persona packages, OR leave as integration tests in apps/web. Recommendation: move package-level tests into persona; keep app-level integration in apps/web.
  4. Atlas manifest — update openalice-world/.atlas-deps.yml runtime_deps to declare it consumes persona's packages. Update openalice-persona/.atlas-deps.yml to note world as a consumer.

Branch: unify/character-stack-phase-d-final. Full E2E: apps/web build + dev server + world.blal.pro smoke test. PR for team review.

Risks + mitigations

RiskLikelihoodMitigation
World loses soundManager.dispose memory-safety win during mergeMediumPhase B: explicit port + a regression test that asserts dispose nulls the singleton
Import sweep misses a file → build breaksLowPhase C: git grep -E '@openalice/' verification + tsc catches
useAgentMovement (new tonight) coordinates badly with world's WASD player inputMediumPhase B: wire isAgentDriving ref into useAvatarMovement to dampen keys; test side-by-side
Voice ducking (@openalice/engine import) has no persona equivalentMediumPhase D: optional dynamic import in persona's voiceChat — engine becomes a runtime-detected dep
Tests fail because of FPS-specific assumptionsMediumPhase D: classify tests as "world-app integration" vs "package unit"; move accordingly
Indirect external consumers (other apps in monorepo) of @openalice/*LowPhase D: rg "@openalice/" /home/blal/projects/org-openalicelabs/ --type ts before deleting stubs

What this unlocks

  • Single source of truth for VRM + terrain + audio + voice + multiplayer — bug fixes land once.
  • `useAgentMovement` ships to world for free — agent-walks works in world.blal.pro without re-implementing.
  • Live's persona iframe stays the same — already consumes persona's runtime; this just makes world consistent.
  • Cleaner monorepo: world becomes thin (apps/web only); the character stack has one canonical home.
  • Setup for Social to embed avatars later — same package surface, no further forks.

Effort summary

PhaseEffortWall timeSequencing
A — baselineS1–2 hFirst
B — file dedupM4–6 h + reviewAfter A
C — import sweepM2–3 hAfter B merged
D — package.json + cleanupM3–4 hAfter C merged
Total10–15 h over 1–2 dayslinear

Next actions

  1. Approval of this plan + decisions on the two open questions: - WorldAgent: promote to persona or keep world-local? - Avatar package shape: (a) re-export shim or (b) alias package?
  2. Phase A can start whenever — non-destructive baseline.
  3. Phase B is the long pole; need full daylight + concentration.

This file lives in atlas KB as the canonical work-plan. When execution starts, add commit refs inline so the trail stays linked.