Skip to content

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 moduleId field 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 any in 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

  • router is mounted at /api/modules/{moduleId}/ where moduleId is 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 the context argument (the pre-1.1.0 signature) remain fully supported.
  • The platform creates context.dataDir (recursive) before invoking initialize. Default location is <repoRoot>/data/modules/<moduleId>/; override the parent with MODULES_DATA_PATH.
  • Auth: Module routes are currently mounted without the platform's auth middleware. If your module needs authentication, use req.auth?.userId after 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.
  • rollExpression should resolve after the user-visible result is final (i.e. after any animation completes).
  • OverlayComponent is rendered once on the play screen — use it for canvases, fixed overlays, etc.
  • chatSettingsComponent is 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:

  1. Stripping the @scope/ prefix (e.g. @purple-cat-games/ is removed)
  2. 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:

  1. Introduction (minor release) — New API is available. Old API still works with no warnings.
  2. Deprecation (minor release) — Old API triggers a console.warn on first use, with the removal version and suggested replacement. Use the moduleDeprecated() utility from @purple-cat/shared.
  3. 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
}