20 KiB
Architecture
Analysis Date: 2026-09-07
System Overview
This is mota-ts — an HTML5 "魔塔" (Mota / Tower of the Sorcerer) game engine plus a sample game, organized as a pnpm monorepo. The core engine lives in packages/ (scoped @motajs/*), user-facing game code lives in packages-user/ (scoped @user/*), and the game entry point lives in src/. Legacy "mota-js" sample content (uncompiled game data and the old runtime) lives in public/.
┌──────────────────────────────────────────────────────────────────────────┐
│ Game Entry (src/) │
│ src/main.ts (render/client entry) src/data.ts (data entry, replay) │
│ src/App.vue (Vue UI root) src/content/ (JSONC game data) │
└───────────────────────────────┬──────────────────────────────────────────┘
│ depends on (@user/*)
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ User Layer (packages-user/ → @user/*) │
│ entry-client / entry-data ← composition root + module registry │
│ client-base (系统层) client-modules (实现层) [render end] │
│ data-common(L0) data-base(L1) data-system(L2) data-state(L3) [data end] │
│ data-fallback / legacy-plugin-client / legacy-plugin-data │
└───────────────────────────────┬──────────────────────────────────────────┘
│ depends on (@motajs/*)
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Core Engine (packages/ → @motajs/*) │
│ common legacy-common types client client-base system │
│ render render-vue animate audio loader legacy-* │
└───────────────────────────────┬──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Legacy runtime & assets (public/) + Build tooling (script/, vite) │
│ public/main.js (legacy mota-js core), public/project/*, public/libs/* │
└──────────────────────────────────────────────────────────────────────────┘
Dependency direction is strictly one-way: src → packages-user → packages. packages and packages-user are independently buildable as libraries; src is the game entry code. This is stated in dev.md.
Component Responsibilities
| Component (package) | Scope | Responsibility | Key file |
|---|---|---|---|
src (@user/main) |
Game entry | Composes the game: createGame() + mounts Vue App |
src/main.ts |
@user/entry-client |
Composition | Registers client-side modules into Mota, then runs create() on each |
packages-user/entry-client/src/create.ts |
@user/entry-data |
Composition | Defines the Mota module registry (IMota/MotaSystem) and registers data-side modules |
packages-user/entry-data/src/mota.ts |
@user/client-base |
Render system layer | Render-side core: asset loading + material/autotile management | packages-user/client-base/src/index.ts |
@user/client-modules |
Render impl layer | Concrete renderer, UI, weather, action (hotkey/move) | packages-user/client-modules/src/index.ts |
@user/data-common |
Data Layer 0 | Common/utility interfaces (face, mover), event, replay, save, store | packages-user/data-common/src/index.ts |
@user/data-base |
Data Layer 1 | Saveable game data: maps, hero, enemy, flag, loading/hook | packages-user/data-base/src/index.ts |
@user/data-system |
Data Layer 2 | Game logic: combat/damage + trigger registry/collector | packages-user/data-system/src/index.ts |
@user/data-state |
Data Layer 3 | CoreState singleton that wires L0–L3 together |
packages-user/data-state/src/core.ts |
@user/data-fallback |
Compatibility | Patches legacy globals onto new state (patchAll) |
packages-user/data-fallback/src/index.ts |
@motajs/common |
Core utility | utils, logger, hook/Hookable, dirtyTracker |
packages/common/src/index.ts |
@motajs/legacy-common |
Legacy util | Patch system, legacy EventEmitter, utils |
packages/legacy-common/src/index.ts |
@motajs/system |
Input + UI sys | Hotkey/keyboard (action) + UIController/GameUI (ui) |
packages/system/src/index.ts |
@motajs/render |
Graphics engine | MotaRenderer WebGL/Canvas render tree, assets, style |
packages/render/src/core/render.ts |
@motajs/render-vue |
Vue renderer | Custom Vue createRenderer over IRenderItem |
packages/render-vue/src/renderer.ts |
@motajs/animate |
Animation | RafExcitation, ExcitationDivider, transitions |
packages/animate/src/index.ts |
@motajs/audio |
Audio | MotaAudioContext, BGM/effect/sound, decoders |
packages/audio/src/index.ts |
@motajs/loader |
Loader | LoadTask, LoadProgressTotal, stream |
packages/loader/src/index.ts |
Pattern Overview
Overall: Layered monorepo + inversion-of-control module registry + event-driven lifecycle + strict dual-end separation (data end vs render end).
Key Characteristics:
- Module registry (
Mota) instead of static imports across layers.window.Mota.register(key, ns)/Mota.require(key)is a runtime DI container that lets the data end reference render-side code lazily without creating bundle-level circular imports (seepackages-user/entry-data/src/mota.ts). - No side effects at module scope. Packages only declare functions/classes/constants; initialization happens via
createXxx()functions bubbled up to the composition root (dev.mdmodule principles). - Event-driven loading.
loading(aGameLoadingEventEmitter) andhook(aGameEventEventEmitter) inpackages-user/data-base/src/game.tscoordinate startup and gameplay events. - Dual-end separation. The data end (
src/data.ts) runs standalone in Node for replay verification and contains zero rendering; the render end (src/main.ts) only sends input and never computes logic. - Legacy bridge via
Patch.@motajs/legacy-common'sPatchclass monkey-patches the legacymain.jsglobals (core,main,data,enemys, …) so new TypeScript code coexists with the uncompiled mota-js sample.
Layers
Data end (three layers, per dev.md and CoreState):
-
Layer 0 — 公共层 (
@user/data-common):- Purpose: dependency-free common interfaces/utilities (
IDataCommon); no saveable state. - Location:
packages-user/data-common/src/ - Contains:
common/(face, faceManager, indexer, mover),event/,replay/(ReplaySystem),save/(SaveSystem, Dexie),store/(tile/item/map/event stores). - Depends on:
@motajs/common,@motajs/loader,@motajs/typesonly. - Used by: Layers 1–3 and the render end.
- Purpose: dependency-free common interfaces/utilities (
-
Layer 1 — 数据层 (
@user/data-base):- Purpose: all saveable game data and its interfaces (
IDataBase). - Location:
packages-user/data-base/src/ - Contains:
game.ts(loading/hook/gameListener),map/(MapState,MapLayer,Tile),hero/,enemy/,flag/,load/(MotaDataLoader). - Depends on:
@user/data-common,@motajs/common,@motajs/types,@motajs/loader. - Used by: Layer 2, Layer 3, and render modules.
- Purpose: all saveable game data and its interfaces (
-
Layer 2 — 系统层 (
@user/data-system):- Purpose: game-logic actions that mutate Layer 1 state but are themselves not saved (
IDataSystem). - Location:
packages-user/data-system/src/ - Contains:
combat/(DamageSystem,EnemyContext,MapDamage),trigger/(TriggerRegistry,TriggerCollector). - Depends on:
@user/data-base,@motajs/common. - Used by: Layer 3.
- Purpose: game-logic actions that mutate Layer 1 state but are themselves not saved (
-
Layer 3 — 顶层模块 (
@user/data-state):- Purpose: composition/initialization only; exposes
CoreState(singletonstate) to the render end. - Location:
packages-user/data-state/src/ - Contains:
core.ts(CoreStatewires L0–L3),enemy/(calculators/comparers/specials),hero/,legacy/,content/,ins.ts(state = new CoreState()). - Depends on: L0–L2 plus
lodash-es,@motajs/loader. - Used by:
@user/entry-data(and viaMota.require('@user/data-state'), the render end).
- Purpose: composition/initialization only; exposes
Render end (two layers):
- 系统层 (
@user/client-base): render-side core — asset loading (load/) and material/autotile managers (material/). Entrycreate()inpackages-user/client-base/src/index.tscallscreateMaterial(). - 实现层 (
@user/client-modules): depends on the system layer to implement actual rendering and interaction —render/(map renderer, UI panels, weather, fx),action/(hotkey, move),fallback/.
Data Flow
Startup / Composition Path
- Render entry
src/main.tscallscreateGame()(from@user/entry-client), thencreateApp(App).mount('#root'), then legacymain.init('play')+main.listen(). createGame()(packages-user/entry-client/src/index.ts) callscreateData()thencreate().createData()(packages-user/entry-data/src/index.ts) callscreateMota()(installswindow.Mota),patchAll(state), andcreate().create()(entry-data/src/create.ts) registers data-side namespaces intoMota, then emitsloading.emit('dataRegistered').- Client
create()(entry-client/src/create.ts) registers client namespaces intoMota, emitsloading.emit('clientRegistered'). GameLoading.checkRegistered()(packages-user/data-base/src/game.ts) emitsregisteredonce both ends are registered.- On
registered,createModule()runsUserClientBase.create(),ClientModules.create(),LegacyUI.create(); then async-imports Ant Design CSS, setsmain.renderLoaded, emitshook.emit('renderLoaded').
Gameplay Loop
- Input (keyboard/mouse) →
@motajs/systemHotkey(gameKey) dispatches (seepackages/system/src/action/hotkey.ts, DOM listeners at bottom). - Action handlers (e.g.
@user/client-modules/src/action/move.ts) send intents to the data end. - Data end (
@user/data-systemcombat/trigger +@user/data-stateCoreState) mutates Layer 1 state (maps,hero,enemyManager,flags). hookevents (e.g.moveOneStep,afterBattle,setBlock) notify render modules.- Render end reads state reactively and re-renders via the WebGL
MotaRenderer/ custom Vue renderer.
State Management:
- Single source of truth is the data-end
CoreState(packages-user/data-state/src/core.ts), exposed as singletonstate(ins.ts). It holds saveable stores (tileStore,itemStore,mapStore,maps,hero,enemyManager,flags) plus execution objects (enemyContext,triggerRegistry,triggerCollector). - Persistence via
SaveSystem(packages-user/data-common/src/save/system.ts) over Dexie (IndexedDB), with undo/redo stacks and compression levels. - Render state is derived/passive — the render end never pushes updates to the data end (arch constraint #17 in
.agents/code.md).
Key Abstractions
Mota module registry:
- Purpose: runtime DI container bridging the data end and render end without static import cycles.
- Interface:
IMotawithrequire(key)/register(key, data), plusr(fn)/rf(fn)helpers (seepackages-user/entry-data/src/mota.ts). - Pattern:
Mota.register('@user/data-state', DataState)…Mota.require('@user/data-state'). r()/rf()are critical: they wrap code that must run only in the render process and never during replay verification (main.replayChecking). Userfto wrap a function,rto run a block.
CoreState (data-end singleton):
- Purpose: top-level object that wires Layer 0–3 and is the single data-end state.
- Files:
packages-user/data-state/src/core.ts(class),ins.ts(statesingleton). - Pattern: constructor initializes
#region L0→L1→L2→L3, registering saveable content (addSaveableContent('@system/hero', this.hero), …).
loading / hook event emitters:
- Purpose: startup coordination (
GameLoadingingame.ts) and gameplay lifecycle (GameEventingame.ts). - Pattern: typed
EventEmitterfromeventemitter3; events declared as interface maps (GameLoadEvent,GameEvent,ListenerEvent).
Patch (legacy bridge):
- Purpose: monkey-patch legacy mota-js globals (
core,main,data,enemys,events,icons,items,loader,maps,ui,utils, …) viaPatchClassenum. - Files:
packages/legacy-common/src/patch.ts, applied inpackages-user/entry-data/src/index.ts(Patch.patchAll()) andpackages-user/data-fallback/src/index.ts(patchAll).
MotaRenderer + custom Vue renderer:
- Purpose: WebGL/Canvas rendering tree, plus a Vue
createRendererthat renders Vue VNodes ontoIRenderItem(so Vue reactivity drives the game canvas). - Files:
packages/render/src/core/render.ts(renderer),packages/render-vue/src/renderer.ts(Vue adapter),packages-user/client-modules/src/render/renderer.ts(instantiation,mainRenderer,createApp).
SaveSystem / ReplaySystem:
- Purpose: persistence (Dexie, undo/redo, compression) and replay verification (command recording + sandbox for deterministic replay in Node).
- Files:
packages-user/data-common/src/save/system.ts,packages-user/data-common/src/replay/system.ts.
Entry Points
Render/client entry:
- Location:
src/main.ts - Triggers: browser page load (
index.htmlloadsmain.jsthen/src/main.tsas module). - Responsibilities:
createGame(), mount VueApp, start legacymain.init('play')/main.listen().
Data entry (replay verification):
- Location:
src/data.ts - Triggers:
pnpm build:gamebuilds it separately viascript/build-game.ts(buildData), run in Node. - Responsibilities:
createData()only — no rendering, no DOM.
HTML entry:
- Location:
index.html - Responsibilities: defines
#render-maincanvas,#rootVue mount, legacy third-party scripts, and legacymain.js.
Editor/dev servers:
- Location:
script/dev.ts - Responsibilities: Vite dev server (game), Express static/file API server (editor at
/editor.html), WebSocket hot reload. Proxies/readFile,/writeFile, etc.
Build pipeline:
- Location:
script/build-game.ts(game zip),script/build-lib.ts,script/build-packages.ts,script/declare.ts(type generation),script/pack-template.ts.
Architectural Constraints
- One-way dependency:
src→packages-user→packages. Never invert. - No circular imports: enforced by convention (
dev.md) andpnpm check:circular(madge onsrc/main.ts). If a cycle is tempting, use theMotaregistry or refactor the interface design. - No module side effects: packages must only export declarations; initialize via
createXxx()functions. - No
import type: use regular imports (only very exceptional cases allowed) —dev.mdmodule principles. - Render end is passive: it never pushes updates to the data end; it only reacts via hooks (
.agents/code.mdrule #17). - Threading / process model: the render end is single-threaded browser JS; the data end is a separate bundle designed to run standalone in Node (for replay verification). No web workers used in the data path.
- Global state: the legacy mota-js runtime maintains globals
core,main, and hashed data globals (data_a1e2fb4a…,enemys_fcae963b…,icons_4665ee12…). The new engine addswindow.Mota(IMota) andstate(CoreState). These globals are intentional bridge points, not free-for-all state.
Anti-Patterns
Putting render code in the data end
What happens: Adding DOM/rendering calls directly on data-end objects (e.g. inside CoreState or data-system logic).
Why it's wrong: The data end runs in Node during replay verification and has no DOM; such code breaks replay determinism and will error. This is explicitly documented in packages-user/data-state/src/ins.ts.
Do this instead: Wrap render-only effects with Mota.r(() => { ... }) / rf(...) (see packages-user/entry-data/src/mota.ts), or route through hook events and let the render end subscribe.
Creating a module with top-level side effects
What happens: A package file runs initialization code at module scope (e.g. instantiating a singleton and wiring it immediately).
Why it's wrong: Breaks the "no side effects" principle (dev.md), makes import order load-bearing, and risks duplicate/incorrect initialization across the client/data bundles.
Do this instead: Export a createXxx() function and call it from the package index.ts, bubbled up to entry-client/entry-data.
Referencing classes instead of interfaces as member types
What happens: Declaring a member as map: GameMap instead of map: IGameMap.
Why it's wrong: Violates dev.md type rules and .agents/code.md rule #16; couples consumers to concrete implementations and breaks the layered abstraction.
Do this instead: Declare the interface (e.g. IGameMap, IEnemyManager) and type members with it.
Using as casts / silent error handling
What happens: Type assertions (as, as unknown as X) or swallowing errors with return.
Why it's wrong: .agents/code.md forbids as and requires errors to be reported through logger with a meaningful code.
Do this instead: Use logger.error(code, ...) / logger.warn(code, ...) (from @motajs/common) with a non-zero, non-reused code; avoid assertions.
Error Handling
Strategy: Centralized logger interface from @motajs/common (packages/common/src/logger.ts). Errors/warnings are reported with numeric codes; logger never throws or halts the game.
Patterns:
logger.warn(code, ...args)for non-fatal issues (e.g. duplicate registration warnings, unknown lookups).logger.error(code, ...args)for unexpected states; the game continues.- Direct
throw new Error(...)only where the contract genuinely requires it (e.g.Mota.requireof an unregistered module,Realize nonexistent key). - Non-null checks:
if (!object)for objects,isNil(value)(lodash-es) for literals — per.agents/code.mdrule #13.
Cross-Cutting Concerns
Logging: @motajs/common logger (packages/common/src/logger.ts) with numeric codes; documented under docs/logger/.
Validation / type safety: TypeScript strict mode (tsconfig.json), vue-tsc --noEmit (check:type). Generated legacy typings live in src/types/source/*.d.ts (regenerated by script/declare.ts from public/project/*.js).
Authentication: Not applicable (client-side game; no auth). The editor file API (script/dev.ts) does path-safety checks (resolvePath/withSafeCheck) but is a local dev tool, not a secured service.
Persistence: SaveSystem over Dexie/IndexedDB (data end), with compression (SaveCompression) and undo/redo stacks.
Replay/determinism: ReplaySystem records commands into typed arrays and replays in a sandbox; render-only code must be gated by main.replayChecking/main.mode (see r()/rf()).
Architecture analysis: 2026-09-07