The plugin API
A plugin's entry (src/index.ts) default-exports definePlugin({ ... }). Your code gets ctx in setup, in every command, and from usePlugin() in a component.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
requires: ['game'],
async setup(ctx) {
const { flash, aj, keybinds } = ctx.phantom();
const me = (await aj.player.current())?.userName;
const label = await flash.overlay.text(`Hello, ${me}`, { x: 12, y: 12 });
const key = keybinds.on(ctx.config.toggleKey, () =>
label.update({ visible: false })
);
return async () => {
await key();
await label.remove();
};
},
});definePlugin
| Member | What it is |
|---|---|
requires | What the game must have got to before setup runs: game, room, language, hud, interface, shops. Added to the manifest's own requires. See Waiting for the game. |
setup(ctx) | Runs when the plugin's logic starts: once per game for an each-game plugin, once for a single one. May return a cleanup. |
commands | { [id]: (input, phantom, ctx) => result }, by the ids contributes.commands declares. |
pages, panels, widgets, commandViews | Vue components, by their contribution ids. See Building UI with Vue. |
automations | Long-running loops Phantom supervises. See Automations. |
The lifecycle
setupthrowsNotReady(or the SDK'sPhantomErrorwith the codeNOT_READY): Phantom runs it again with a growing wait, and shows the player what it's waiting for.setupthrows anything else: the plugin crashes. The player sees the message, the code in words and the stack, on the plugin and in its log. It isn't retried.- Stopping (turned off, game closed, uninstalled, or
phantom-plugin devreloading the code): the cleanupsetupreturned runs and is waited for, up to two seconds. Then the SDK releases everything the plugin's code still held: hooks, UI rules, overlays, watches, keys, traffic and channel subscriptions, andctxlisteners. So nothing is left behind even without a cleanup; write one when you want to restore the game your own way first. - Hot reload: in development, saving the code runs the same stop, then
setupagain with the new code, in the same frame. See The dev loop.
ctx.phantom(id?)
The SDK for one game. Without an id it's the game this code is bound to (an each-game logic frame, a panel, a command's view). A page or a single plugin's logic passes an id from ctx.games().
| Member | What it is | Needs |
|---|---|---|
id | The game's id. | nothing |
flash | The whole client, typed: flash.use('room.RoomManagerWorld'), flash.global(...), hooks, overrides, waits, the overlay, flash.ui.rule. See Reaching the game client. | game: read, act to call, set, override, drive |
aj | Animal Jam's features as calls: aj.room, aj.player, aj.buddies, aj.chat, aj.community and the rest. | game: read, act for what acts |
packets | The traffic, typed: packets.server.watch(Def, fn), packets.server.send(Def, body), packets.request(...). | packets: read, send to send or change |
keybinds | keybinds.on('Ctrl+Shift+D', handler): a key pressed in the game runs your code. The game still hears it unless { consume: true }. | game: read |
The SDK runs in your plugin's frame, and Phantom checks each message it sends the game by what it does. See Permissions. Its types and values (PhantomError, UiRule, step, the colour matrices, every area's types) and the game's packet definitions come from @phantomclient/plugin/sdk. Five packet definitions whose names clash with an area's type are exported with Packet on the end (CurrencyExchangePacket, ItemPrizePacket, …).
import { DenJoinRequest, type UiRule } from '@phantomclient/plugin/sdk';@phantomclient/plugin/eslint is the lint config for a plugin (export { default } from '@phantomclient/plugin/eslint'; in eslint.config.js), leaving formatting to Prettier. It flags imports of Phantom's unpublished @phantom/* packages.
Actions throw PhantomError with a code (NO_GAME, NOT_READY, TIMEOUT, REFUSED, ABORTED). Reads return null or [] for "not there yet" instead of throwing. errorWords(code) from @phantomclient/plugin turns any code into words.
Settings: ctx.config
Declare settings in the manifest's config:
"config": {
"showOverlay": { "type": "boolean", "name": "Show the overlay", "default": true },
"colour": { "type": "colour", "name": "Colour", "default": "#8b5cf6", "section": "Look" },
"toggleKey": { "type": "keybind", "name": "Key to toggle it", "default": "Ctrl+H" },
"mode": { "type": "select", "name": "Mode", "options": ["fast", "slow"], "default": "fast" },
"count": { "type": "number", "name": "How many", "min": 1, "max": 9, "default": 3, "scope": "player" }
}Types are boolean, number, string, select, colour and keybind. Phantom draws the form on the plugin's page (and wherever you put <Preferences />), and stores and syncs the values, per plugin or per Animal Jam player ("scope": "player").
phantom-plugin writes src/phantom-config.d.ts from package.json whenever it reads the manifest, so ctx.config.showOverlay is a boolean, ctx.config.mode is 'fast' | 'slow', and a misspelt name won't compile. A setting with no default is null until the player fills it in.
| Member | What it does |
|---|---|
ctx.config | The settings, typed, for the game this code is bound to. |
ctx.onConfigChange((config, changed) => ...) | Runs on a change, with the names that changed. |
ctx.setConfig(values, { game? }) | Saves settings as the player's form would; checked against each field. |
ctx.preferencesFor(gameId) | The settings for one game's player, from code not bound to a game. |
useConfig() | In a component: ctx.config, reactive. |
The older preferences array in the manifest still works and becomes the same settings. ctx.preferences and ctx.onPreferences are the untyped versions.
State
| Member | What it does |
|---|---|
ctx.keep(name, starting) | State for one game's player that survives reconnects, server switches and restarts, and updates in the plugin's panels. See Keeping state. |
ctx.storage.get/set/remove/keys | The plugin's own JSON storage. |
Talking to the player
| Member | What it does | Needs |
|---|---|---|
ctx.toast(message, { tone, detail }) | A toast in Phantom with the plugin's name. | nothing |
ctx.confirm(message, options) | A toast with two buttons: true, false, or null if unanswered. | nothing |
ctx.notify(title, body?) | A desktop notification, titled with the plugin's name. | notifications |
ctx.snag(name, reason) | "I'm running but can't do this", shown beside the setting of that name and on the plugin's page; null clears it. | nothing |
ctx.ready.waitingFor(reason) | What the plugin is waiting for, in its own words. | nothing |
ctx.log(...values) | The plugin's log, and the terminal in development. console goes there too. | nothing |
ctx.openPanel(id), ctx.closePanel(id) | Opens or closes one of the plugin's own panels beside a game. | nothing |
Reaching outside
| Member | What it does | Needs |
|---|---|---|
ctx.fetch(url, init) | HTTPS to a host the manifest lists; as: 'data-url' for a picture. | network: [host] |
ctx.copy(text) | Copies to the clipboard. | clipboard |
ctx.openUrl(url) | Opens an https link in the browser, after the player agrees. | nothing |
About the plugin and its games
| Member | What it is |
|---|---|
ctx.plugin | { id, version }. |
ctx.granted, ctx.can(scope) | What the player allowed. Check ctx.can('game:act') before an optional act. |
ctx.games(), ctx.onGames(fn) | The games this code may work with, and their changes: state, room, screen name. |
ctx.ready | missing, check and wait for what the game has reached. See Waiting for the game. |
ctx.automations | Each supervised automation's progress, and starting or stopping one. |
Testing
@phantomclient/plugin/testing runs a plugin's real logic against the SDK's fake loader and fake network, with Phantom's own permission checks:
import { fakeLoader, runPlugin } from '@phantomclient/plugin/testing';
import { expect } from 'vitest';
import pkg from '../package.json';
import plugin from './index';
const loader = fakeLoader({ answers: { 'server.isWorldXtReady': true } });
const run = await runPlugin(plugin, { manifest: pkg, loader });
expect(run.crashes).toEqual([]);
await run.command('toggle');
await run.reload(plugin);
await run.stop();
expect(loader.of('dispose').length).toBeGreaterThan(0);run.refused lists what Phantom would have refused and why. run.logs, run.toasts, run.storage and run.automations are what the plugin did. run.store(key, value) changes its storage as another frame would, so ctx.storage.onChange fires.
- Panels. Make
vitest.config.tsthe one lineexport { default } from '@phantomclient/plugin/vitest';..vuefiles are then compiled, so an entry that imports its panels runs underrunPlugin, and a test can mount a component. For more,phantomVitest({ test: { ... } })merges your settings over it. - Services.
services: { content: { all: async () => [] } }fakes only what the test uses (FakeServices). A call you didn't fake rejects saying so, and an event you didn't fake never fires.NO_PERKSbuilds a nearby Phantom user. - Overlay. The fake loader answers
overlay.measureandnode.bounds()unless youranswerssay otherwise. - Traffic.
network.carriedlists every frame the watches heard, withdroppedandinjected, so a hook that drops a message and sends a replacement shows both. - Missing hooks. "Phantom could not hear X" goes to
run.logsrather than the console;warnings: truelets it through. - The fake types (
FakeAnswers,FakeLoaderOptions,PathAnswer,FakeHook, …) and helpers (keeps,fire,hookTable,REFUSED) are exported from@phantomclient/plugin/testingtoo.
Testing logic against the whole SDK
fakePhantom() is the real SDK over the same fakes, for testing a function that takes a Phantom without a plugin around it. Its packets are the real ones, so a packet sent or heard on the wrong end throws as it would in the game. stub replaces members of the real areas, as deep as you need, and refuses any the SDK doesn't have:
import { type Phantom, PlayerBio, PlayerBioRequest } from '@phantomclient/plugin/sdk';
import { fakePhantom } from '@phantomclient/plugin/testing';
import { expect } from 'vitest';
declare function askForBio(phantom: Phantom, username: string): Promise<void>;
const fake = fakePhantom({
stub: { aj: { buddies: { card: { current: async () => null } } } },
});
await askForBio(fake.phantom, 'localhost');
expect(fake.sent(PlayerBioRequest)).toEqual([{ room: -1, username: 'localhost' }]);
expect(
(await fake.fromServer(PlayerBio, { room: -1, username: 'localhost', bio: 'hi' }))
.dropped
).toBe(true);Pass display: new FakeDisplay() for a display list that follows Flash's rules: children in order, bounds through every parent, a clip placed by a timeline (timeline: true) refusing a new name with Error #2078, and events reaching both the game's listeners and yours.
The examples in plugins/examples are tested this way.