Module Author Guide
This guide covers everything needed to create a Purple Cat platform module.
What Is a Module?
A module is an npm package that extends the platform through:
- Client-side UI extensions — artwork sources in the token editor, settings panels, etc.
- Server-side API routes — custom endpoints auto-mounted at
/api/modules/{id}/ - Static assets — texture packs, token art, map tiles, etc.
Modules are installed as npm dependencies at build time. Adding or updating a module requires a rebuild/redeploy.
Package.json: purple-cat-module Field
Every module must declare a purple-cat-module field in its package.json. This tells the platform what the module provides and how to load it.
{
"name": "@purple-cat-games/my-module",
"version": "1.0.0",
"purple-cat-module": {
"type": "integration",
"client": "dist/client/index.js",
"server": "dist/server/index.js",
"buildCommand": "npm run build",
"engine": "^1.0.0"
}
}
Field Reference
| Field | Required | Type | Description |
|---|---|---|---|
type |
yes | string |
Module category: "assets", "integration", etc. |
client |
no | string |
Relative path to the client ES module that exports a register() function. |
server |
no | string |
Relative path to the server ES module that exports ModuleServerExport. |
assetPaths |
no | Record<string, string> |
For asset modules: mapping of category → relative path in dist/. |
buildCommand |
no | string |
Build command override (default: npm run build). |
engine |
no | string |
SemVer range of the platform module API this module targets (e.g. "^1.0.0"). |
Client Entry: The register() Function
If your module provides UI extensions, export a register() function from the client entry point specified in purple-cat-module.client.
// src/client/index.ts
import { moduleRegistry } from '@purple-cat/shared';
import { MyArtworkSource } from './MyArtworkSource';
import { MySettingsPanel } from './MySettingsPanel';
export function register(): void {
moduleRegistry.registerArtworkSource({
id: 'my-source',
label: 'My Custom Source',
icon: 'Sparkles', // Tabler icon name (without "Icon" prefix)
moduleId: 'my-module', // Must match deriveModuleId(packageName)
component: MyArtworkSource,
});
moduleRegistry.registerSettingsPanel({
id: 'my-settings',
label: 'My Module Settings',
icon: 'Settings',
moduleId: 'my-module',
component: MySettingsPanel,
});
}
Key Details
register()is called before React renders, during app initialization.- The
moduleIdfield should match the ID derived from your package name (e.g.@purple-cat-games/my-module→my-module). When set, extensions are only visible if the server has loaded the module. - Components are typed as
anyin the shared types to avoid a React dependency in@purple-cat/shared. Cast them to the correct type in the platform.
How It Works at Build Time
A Vite plugin (apps/web/vite-plugin-modules.ts) reads modules.local.json and generates a virtual module that imports and calls register() for each configured module. You don't need to manually import your module in the platform — just add it to modules.local.json:
{
"modules": [
{ "package": "@purple-cat-games/my-module", "path": "../my-module" }
]
}
Server Entry: ModuleServerExport
If your module needs backend logic, export an object conforming to ModuleServerExport from the server entry point.
// src/server/index.ts
import { Router } from 'express';
import type { ModuleInitializeContext } from '@purple-cat/shared';
const router = Router();
router.get('/status', (_req, res) => {
res.json({ ok: true });
});
router.post('/generate', async (req, res) => {
// Your custom logic here
res.json({ result: '...' });
});
export const router = router;
export async function initialize(context: ModuleInitializeContext): Promise<void> {
// `context.dataDir` is a per-module persistent directory created by the
// platform. Use it for caches, generated artifacts, etc.
console.log(`My module initialized; data dir: ${context.dataDir}`);
}
ModuleServerExport Interface
interface ModuleInitializeContext {
moduleId: string;
/** Persistent module-scoped data directory (created by the platform). */
dataDir: string;
}
interface ModuleServerExport {
/** Express Router — auto-mounted at /api/modules/{moduleId} */
router?: Router;
/** Optional async initializer called once at server startup */
initialize?: (context: ModuleInitializeContext) => Promise<void>;
}
Key Details
routeris mounted at/api/modules/{moduleId}/wheremoduleIdis derived from the package name (e.g.@purple-cat-games/my-module→my-module).initialize(context)is called after the router is mounted. If it throws, the error is logged but the server continues running. Modules whose initializer ignores thecontextargument (the pre-1.1.0 signature) remain fully supported.- The platform creates
context.dataDir(recursive) before invokinginitialize. Default location is<repoRoot>/data/modules/<moduleId>/; override the parent withMODULES_DATA_PATH. - Auth: Module routes are currently mounted without the platform's auth middleware. If your module needs authentication, use
req.auth?.userIdafter adding Clerk middleware, or call the platform's auth endpoints.
Extension Points
ArtworkSourceExtension
Adds an artwork source option to the token artwork editor's "Change Artwork" menu.
interface ArtworkSourceExtension {
id: string; // Unique key, e.g. "genai"
label: string; // Menu display label
icon?: string; // Tabler icon name (without "Icon" prefix)
moduleId?: string; // Module ID for conditional visibility
component: ComponentType<ArtworkSourceProps>;
}
Your component receives these props:
interface ArtworkSourceProps {
/** Call when the module has produced a usable image URL */
onImageReady: (imageUrl: string) => void;
/** Call when the user cancels / goes back */
onCancel: () => void;
/** Whether the parent form is disabled */
disabled?: boolean;
/** Authenticated fetch — window.fetch with auth headers injected */
authFetch: (url: string, options?: RequestInit) => Promise<Response>;
}
When your component produces an image, call onImageReady(url) with the image URL. The platform handles the rest (creating artwork entries, resizing, WebP conversion, etc.).
SettingsPanelExtension
Adds a collapsible settings section to the Settings page.
interface SettingsPanelExtension {
id: string; // Unique key, e.g. "my-settings"
label: string; // Section header text
icon?: string; // Tabler icon name (without "Icon" prefix)
moduleId?: string; // Module ID for conditional visibility
component: ComponentType<Record<string, never>>;
}
Your component receives no props. Use it for configuration inputs (API keys, model preferences, etc.). Settings are typically stored via your module's own API routes.
DiceRollerExtension
Replaces the platform's built-in 3D dice with a roller provided by your module. When an active DiceRollerExtension is registered, the platform suppresses its own 3D dice (DiceBoxOverlay) and delegates all dice rolls — action roll() calls, dice-bag buttons, and chat dice expressions — to your roller.
interface DiceRollerExtension {
id: string; // Unique key, e.g. "dddice"
label: string; // Display label
moduleId: string; // Module id (required; gates by active modules)
rollExpression: (ctx: DiceRollContext) => Promise<DiceRollResolution>;
OverlayComponent?: ComponentType<{}>; // Optional fixed-position overlay
chatSettingsComponent?: ComponentType<{}>; // Optional Chat Settings modal section
}
interface DiceRollContext {
expression: string; // e.g. "2d20+5"
campaignId?: string;
userId?: string;
userDisplayName?: string;
theme?: string;
}
interface DiceRollResolution {
total: number;
rolls: { faces: number; value: number }[];
breakdown?: string;
}
Notes:
- Only one roller is active at a time. The first registered roller belonging to an active module wins.
rollExpressionshould resolve after the user-visible result is final (i.e. after any animation completes).OverlayComponentis rendered once on the play screen — use it for canvases, fixed overlays, etc.chatSettingsComponentis rendered inside the in-game Chat Settings modal so the user can configure the roller (API keys, room ids).
Cross-client sync (important): when an extension is active, the platform's own dice:roll socket broadcast is suppressed. Your roller is expected to handle multi-user sync itself — most third-party dice services (dddice, etc.) already do this via per-room servers. If your roller has no built-in sync, only the rolling user will see the 3D animation.
Reference implementation: @purple-cat-games/dddice-roller is a working example (linked as it stood before vtt-modules was cut down to vtt-assets in v0.118.0; it was never deployed). It registers a roller, mounts a fixed-position <canvas> via OverlayComponent, exposes API key / room slug / theme id inputs via chatSettingsComponent, and resolves rolls by awaiting ThreeDDiceRollEvent.RollFinished from the dddice SDK.
Module API Route Mounting
Routes defined in your server entry's router are automatically mounted under:
/api/modules/{moduleId}/
Where moduleId is derived from your package name by:
- Stripping the
@scope/prefix (e.g.@purple-cat-games/is removed) - Replacing non-alphanumeric characters with hyphens
Examples:
| Package Name | Module ID | Route Prefix |
|---|---|---|
@purple-cat-games/vtt-assets |
vtt-assets |
/api/modules/vtt-assets/ |
@purple-cat-games/genai |
genai |
/api/modules/genai/ |
my-custom-module |
my-custom-module |
/api/modules/my-custom-module/ |
Your client code should use the module ID to construct API URLs:
const response = await authFetch(`${API_BASE_URL}/api/modules/my-module/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
Accessing Platform Services
Authenticated Fetch (Client)
Module components that implement ArtworkSourceProps receive authFetch as a prop. This is a fetch() wrapper that injects the current user's auth token:
function MyArtworkSource({ authFetch, onImageReady }: ArtworkSourceProps) {
const handleGenerate = async () => {
const res = await authFetch('/api/modules/my-module/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'a dragon' }),
});
const { imageUrl } = await res.json();
onImageReady(imageUrl);
};
// ...
}
Image Upload (Client → Server)
Use the platform's image pipeline to upload and process images:
POST /api/images/upload— Upload from a URL. Send{ url }in the body.POST /api/images/upload-file— Multipart form upload from the user's device.POST /api/images/presigned-url— Get a presigned S3 URL for direct upload (preferred for large files).
All image endpoints require authentication (use authFetch).
Auth Context (Server)
Server-side routes can access the authenticated user via Clerk's req.auth:
router.post('/generate', (req, res) => {
const userId = (req as any).auth?.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// ...
});
Note: Module routes are not automatically protected by auth middleware. Apply your own auth checks if needed.
Module Discovery (Client)
Use the useModules() hook to discover what modules the server has loaded:
import { useModules } from '../hooks/useModules';
function MyComponent() {
const { modules, isLoading } = useModules();
// modules: ModuleInfo[] with { id, packageName, version, hasClient, hasServer }
}
Module ID Derivation
The platform derives a URL-safe module ID from the npm package name using deriveModuleId():
deriveModuleId('@purple-cat-games/genai') // → 'genai'
deriveModuleId('@scope/my-module') // → 'my-module'
deriveModuleId('standalone-module') // → 'standalone-module'
Use this same ID as the moduleId field in your extension registrations and to construct API URLs.
API Versioning & Compatibility
Engine Requirement
Declare the platform API version your module targets with the engine field:
{
"purple-cat-module": {
"type": "integration",
"engine": "^1.0.0"
}
}
At startup, the platform checks if its current MODULE_API_VERSION satisfies the module's engine range. Incompatible modules are logged with a clear warning and skipped — they won't crash the server.
If engine is omitted, the module is always treated as compatible.
API Deprecation Lifecycle
Changes to module-facing APIs follow a three-phase cycle:
- Introduction (minor release) — New API is available. Old API still works with no warnings.
- Deprecation (minor release) — Old API triggers a
console.warnon first use, with the removal version and suggested replacement. Use themoduleDeprecated()utility from@purple-cat/shared. - Removal (major release) — Old API is deleted. Modules still using it fail the startup engine validation check and are disabled.
This gives module authors at least one full minor release cycle to migrate.
Using moduleDeprecated()
If your module calls a platform API that's been deprecated, you'll see a one-time warning in the console:
[Module Deprecation] "oldMethod" used by module "genai" is deprecated
and will be removed in module API version 2.0.0. Use "newMethod" instead.
Platform developers can mark APIs as deprecated using:
import { moduleDeprecated } from '@purple-cat/shared';
function legacyUpload(url: string) {
moduleDeprecated('myModule', 'legacyUpload', '2.0.0', 'uploadImage');
// ... old implementation
}