Module System
1. Overview and Philosophy
The platform follows an "Open Core" architecture. The core engine — responsible for rendering, canvas management, real-time socket synchronization, and database state — is decoupled from game systems, content packs, and third-party integrations.
All extensions are packaged as independent Modules. A module is an npm package that declares a purple-cat-module field in its package.json. Modules contribute to the platform through two complementary patterns:
- Declarative registration — UI extensions (artwork sources, settings panels) register themselves with the platform's
ModuleRegistryat startup. The platform renders whatever is registered, with no module-specific code. - Server-side API routes — Modules that need backend logic export an Express router that the platform auto-mounts at
/api/modules/{id}/.
A future API Facade will provide a versioned, stable interface for imperative operations (e.g. creating tokens, uploading images, querying game state). This facade is the boundary where compatibility guarantees and deprecation tracking apply; internal platform code is free to change behind it.
2. Module Integration Patterns
2.1. Declarative Registration (UI Extensions)
The ModuleRegistry singleton (packages/shared/src/services/moduleRegistry.ts) provides typed extension point maps. Modules call registration methods at load time:
import { moduleRegistry } from '@purple-cat/shared';
export function register() {
moduleRegistry.registerArtworkSource({
id: 'genai',
label: 'AI Generated',
icon: 'Sparkles',
component: GenAIPanel,
});
moduleRegistry.registerSettingsPanel({
id: 'genai-settings',
label: 'AI Image Generation',
component: GenAISettings,
});
}
The platform reads from the registry during render. Because the registry is populated before React mounts (via apps/web/src/moduleInit.ts), there is no flash of missing UI.
Current extension points:
- ArtworkSourceExtension — adds an artwork source to the token artwork editor
- SettingsPanelExtension — adds a section to the Settings page
New extension points can be added to the registry without changing the pattern.
2.2. Server-Side API Routes
A module's package.json can declare a server entry:
{
"purple-cat-module": {
"type": "assets",
"server": "dist/server/index.js"
}
}
The platform dynamically imports this entry at startup and expects it to conform to ModuleServerExport:
interface ModuleInitializeContext {
moduleId: string;
/** Persistent module-scoped data directory (created by the platform). */
dataDir: string;
}
interface ModuleServerExport {
router?: unknown; // Express Router
initialize?: (context: ModuleInitializeContext) => Promise<void>; // Optional async init
}
The router is auto-mounted at /api/modules/{moduleId} where moduleId is derived from the package name (e.g. @purple-cat/vtt-genai → vtt-genai).
2.3. Client-Side Loading
Client module code is delivered via dynamic imports (code splitting), not bundled into the main application chunk. Each module's client entry becomes a separate chunk that is loaded on demand when a user activates that module.
The Vite plugin (apps/web/vite-plugin-modules.ts) reads modules.local.json at build time and generates a virtual module that exposes lazy-loading wrappers for each module's client entry. The platform fetches the module list from GET /api/modules and the user's module preferences to determine which chunks to load.
This means:
- The core bundle stays small (~200–500 KB), regardless of how many modules exist.
- Each module's client chunk is loaded in parallel on first use, then cached by the CDN with
Cache-Control: immutable. - Users on slow connections only pay for the modules they actually use.
- No arbitrary code execution at runtime — the security model is simple. Modules are build-time dependencies.
- Adding a module requires a rebuild/redeploy.
2.4. API Facade for Imperative Operations (Future)
For imperative operations that modules need (image upload, authenticated fetch, game-state queries), a versioned PlatformAPI facade will wrap commonly-needed services. This is the counterpart to the declarative registry: the registry handles "what the module contributes to the platform," while the facade handles "what the module consumes from the platform."
The facade is the boundary where version compatibility and deprecation tracking apply.
3. Module Descriptor (purple-cat-module)
Every module declares its capabilities via a purple-cat-module field in package.json. This replaces the need for a separate manifest.json.
{
"name": "@purple-cat/vtt-genai",
"version": "1.0.0",
"purple-cat-module": {
"type": "integration",
"client": "dist/client/index.js",
"server": "dist/server/index.js",
"engine": "^1.0.0"
}
}
| Field | Required | Description |
|---|---|---|
type |
yes | Module category: "assets", "integration", etc. |
assetPaths |
no | For asset modules: mapping of category → relative path in dist/ |
client |
no | Relative path to the client ES module that exports register() |
server |
no | Relative path to the server ES module that exports ModuleServerExport |
buildCommand |
no | Build command override (default: npm run build) |
engine |
no | SemVer range of the platform module API this module targets |
4. Engine Version Targeting & Compatibility
4.1. Platform API Version
The platform declares a module API version (SemVer). This version increments only when the module-facing contract changes — it is independent of the product release version.
4.2. Startup Validation
During initializeModules(), the server checks each module's engine range against the platform API version:
- If the module's
enginerange is satisfied: load normally. - If incompatible: log a clear warning and skip the module without crashing.
- If
engineis omitted: treat as compatible (backwards compat with modules that predate this field).
This ensures the "Never Break a Game" rule — an incompatible module is disabled gracefully rather than allowed to corrupt session data.
4.3. Deprecation Pipeline
Changes to the module-facing API follow a three-phase cycle:
- Introduction (minor release) — new API available, old API still works.
- Deprecation (minor release) — old API triggers
console.warnwith the removal version and suggested replacement. - Removal (major release) — old API deleted. Modules still using it fail the startup validation check and are disabled.
This gives module authors at least one full minor release cycle to migrate.
5. Hosted vs. Self-Hosted Deployment Model
The module system is designed to work in two deployment contexts with different tradeoffs.
5.1. Hosted (Multi-Tenant)
On shared hosted servers, all first-party backend modules are loaded for all users. This is safe because the module API is additive only — modules can add API routes and functionality, but cannot modify or remove core behavior. A module that no user on the server has opted into merely creates API endpoints that are never called (negligible cost).
To keep backend module loading performant at scale (hundreds of modules), server-side initialization is lazy: route registration is immediate (milliseconds per module), but expensive initialization (DB connections, asset builds, external service handshakes) is deferred until the first request hits that module's routes.
Frontend modules are loaded per-user via code splitting (see §2.3). Users configure which modules they want through their account preferences. The GET /api/modules endpoint returns available modules; the client loads only the chunks for modules the user has enabled.
Custom/third-party backend modules are not supported on hosted servers. This is a deliberate business model boundary — self-hosted deployments have full module control. Sandboxed execution for hosted custom modules (e.g. Deno-style permissions, isolated-vm) is a future consideration.
5.2. Self-Hosted
Self-hosted operators have full control over all modules — frontend, backend, and third-party. The modules.local.json file (not committed) specifies which modules to load. Adding or removing a module requires a rebuild/redeploy.
5.3. Future: Per-Group Containers (Premium Hosted Tier)
For users who need custom backend modules on hosted infrastructure, a premium tier could spin up per-campaign-session Fargate Spot containers. A group of 4–6 players shares one container that starts on first player join and shuts down after idle timeout. This provides full module isolation at low compute cost (~$0.15/month at 12 hours/week). This model is not required for initial launch.
6. Key Design Decisions
Build-time vs. runtime loading. Modules are npm dependencies installed at build time. This keeps the security model simple and avoids the complexity of a runtime plugin loader. The tradeoff is that adding or updating a module requires a rebuild/redeploy.
Code splitting for frontend modules. Rather than one monolithic bundle or separate bundles per module, the Vite build produces a core chunk plus one chunk per module client entry. Chunks are loaded on demand based on user preferences, keeping initial load times fast regardless of how many modules exist.
Lazy server-side initialization. Route registration is cheap; heavy initialization (DB, external services, asset builds) is deferred to first request. This allows hundreds of backend modules to be registered on a shared server without impacting startup time.
Additive-only backend API. Backend modules can only add routes and functionality — they cannot modify or remove core behavior. This makes it safe to load all backend modules for all users on a shared server, even if individual users haven't opted into them.
Singleton registry vs. React context. The registry is populated before React renders (during module init). A singleton in packages/shared is importable by both the platform and modules without circular dependencies.
No database schema extensions per module. Modules should not require DB migrations in the platform. Module-specific data can be stored via the module's own API routes, as metadata on existing platform entities, or in the module's own SQLite file.
Orthogonal concerns tracked separately. Database backup strategy and LTS release tiers are documented in docs/architecture/draft/ and will be finalized independently of the module system.