What a plugin adds
List what your plugin adds under contributes in the manifest, then supply each one by its id in definePlugin. A contribution with no matching entry throws an error when it's opened, rather than showing a blank view.
Panels
A panel sits in the dock next to a game: a status line, a list or some controls for that game. The view is a Vue component.
"contributes": {
"panels": [{ "id": "status", "title": "Status", "icon": "activity" }]
}import { definePlugin } from '@phantomclient/plugin';
import Status from './panels/Status.vue';
export default definePlugin({
panels: { status: Status },
});The dock shows one icon per open panel along its top, and whatever doesn't fit goes behind More, which opens the panel picker (Ctrl Shift P). Give your panel an icon, since that's how the player finds it. Make the title say what the panel is rather than your plugin's name: it's what they see on hover and in the picker. A player can close your panel there and open it again from the same list.
Your plugin can open its own panel with ctx.openPanel('status'), for example when the player clicks something your plugin drew in the game. It opens beside the game the click came from, and needs no permission, since you declared the panel and the player allowed you on that game.
Widgets
A widget is a tile on the canvas, the page where the player lays out whatever they want to keep an eye on. Use one for a number worth glancing at, like what has sold today. It sits among other plugins' widgets, so keep it to a figure and a line or two.
"contributes": {
"widgets": [
{
"id": "sales-today",
"title": "Sales today",
"icon": "receipt",
"size": { "w": 7, "h": 4 },
"minSize": { "w": 5, "h": 3 }
}
]
}import { definePlugin } from '@phantomclient/plugin';
import SalesToday from './widgets/SalesToday.vue';
export default definePlugin({
widgets: { 'sales-today': SalesToday },
});size is the tile's starting size on the canvas grid, which is 24 columns wide with 40 pixel rows. The most a widget can ask for is half the canvas wide and eight rows tall. The player resizes it and Phantom remembers it, so size is only a first guess. minSize is the smallest they can drag it; leave it out and Phantom picks a small one that fits inside your size.
A plugin can contribute up to six widgets. The player adds each one from Add widget.
Widgets aren't bound to a game
A widget isn't bound to a game. Like a page, it sees every game your plugin is allowed on, and keeps drawing when the player switches tab or closes the last game. Write it to show something about all of them at once. useGames() gives you the games, and usePhantom(id) reaches one of them.
<script setup lang="ts">
import { useGames, usePlugin } from '@phantomclient/plugin/vue';
const plugin = usePlugin();
const games = useGames();
</script>If it only makes sense for one game, such as the room the player is in, make it a panel.
Removing a widget from the canvas removes its frame, and anything it had running stops with it. Building UI with Vue applies to widgets as it does to panels: stop your listeners and clear your timers in onBeforeUnmount.
Pages
A page is listed under Plugins in the sidebar, gets a tab, and fills the window like Packets and Scripts do. Use one when you need more room than a panel: a sortable table, a searchable history, a chart. Declare it under pages and supply the component by the same id.
"contributes": {
"pages": [
{
"id": "dashboard",
"title": "Sales dashboard",
"description": "What your shops have on the shelf and what they have taken.",
"icon": "chart-column"
}
]
}import { definePlugin } from '@phantomclient/plugin';
import Dashboard from './pages/Dashboard.vue';
export default definePlugin({
pages: { dashboard: Dashboard },
});Phantom draws the bar across the top from your title and icon, with your plugin's name after it. So don't add a heading of your own; start with the content and fill the height you're given.
A plugin can declare up to ten pages.
Pages aren't bound to a game
Like a widget, a page isn't bound to a game. usePhantom() with no id throws, so pick one from useGames() and use usePhantom(game.id). A page also keeps drawing when the last game closes. What your plugin has in ctx.storage can still be shown then; only the parts that ask the game go quiet, so make clear which is which rather than showing an empty page.
There's no ctx.openPage, so a panel can only point the player to your page in words. A page can open one of your panels, but since panels sit beside one game, name the game:
await ctx.openPanel('sales', { game: gameId });Without game, from a page, that rejects with NO_GAME. ctx.closePanel closes it and works the same way.
Shops & Trading's sales dashboard is a page: a table of every sale across every account the player has open, which doesn't depend on whichever game is in focus.
Commands
The player runs a command from a game's plugins menu, against that game. Your handler gets the arguments the player filled in, the game and ctx, and can return a message to show as a toast.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
commands: {
dance: async (_input, phantom) => {
await phantom.aj.player.avatar.act('dance');
return { message: 'Dancing', tone: 'success' };
},
},
});A command declared with view opens a component instead, passing it the props the command returns.
Preferences
preferences in the manifest are your plugin's settings. Phantom stores them, draws the form with its own controls, checks each value against its type and tells your logic when one changes. Use them instead of keeping settings in ctx.storage with your own defaults, form and polling.
{
"preferences": [
{
"name": "stayLoggedIn",
"title": "Stay logged in while I am away",
"description": "Counts as a click once a minute, so the game does not sign you out.",
"type": "checkbox",
"scope": "player",
"needs": ["game:act"]
},
{
"name": "size",
"title": "Chat size",
"type": "range",
"min": 10,
"max": 18,
"step": 1,
"default": 12,
"group": "Chat"
}
]
}| Type | Value |
|---|---|
text, password | a string; a password is sealed with the computer's keychain, never written in plain text |
number | a number, inside min and max if you give them |
range | a number on a slider; min and max are required, step defaults to 1 |
checkbox | true or false |
dropdown | one of its options |
colour | #rrggbb; with nullable: true it can be empty, read as null |
list | words, one per row; or rows of columns (see below), up to maxItems (100 at most) |
A list with columns holds rows with every column in each: text, number, checkbox or colour, each with a title and an optional default. In config the same is { "type": "list", "of": { "text": { "type": "string", "name": "Line" } } }, with "of": "string" (the default) for words, and a slider is { "type": "range", "min": 0, "max": 100, "step": 5 }. Phantom draws a list as rows with an Add button, checks it when saving, and types it in src/phantom-config.d.ts (readonly string[], or a ReadonlyArray of the row). Lists show on your plugin's page and in <Preferences />, not inside Animal Jam's settings. <Preferences :omit="['keepList']" /> leaves out one you draw yourself.
Each preference can also have:
| Field | What it does |
|---|---|
scope | plugin (the default) is one value for the plugin. player keeps a value for each Animal Jam player, so a switch belongs to the player who turned it on. |
group | A heading to group it under, so a long list reads in sections. |
needs | Scopes the plugin must have been granted for this preference to work. Phantom shows the field turned off when one was declined, and says which. Each must be a scope your permissions ask for. |
default | The value until the player sets one. Changing it in a new version won't touch a value they've already saved. |
inGame | Also draw this preference inside Animal Jam's own Settings window. See below. |
A password always belongs to the plugin: the keychain holds one for the plugin, not one per player.
Reading them
ctx.preferences has every declared preference by name, at its default until the player sets one. A player scoped preference reads as the value for the player of the game your code is bound to. From a page or a single plugin's logic, name a game with ctx.preferencesFor(gameId).
ctx.onPreferences(listener) runs whenever any of them changes, whoever changed it.
apply(ctx.preferences['stayLoggedIn'] === true);
const stop = ctx.onPreferences((preferences) =>
apply(preferences['stayLoggedIn'] === true)
);In a component, usePreferences() gives the same values and updates as they change.
Drawing them inside Animal Jam's settings
"inGame": true also draws a preference as a row in Animal Jam's own Settings window. title is the row's label and description its tooltip. A checkbox becomes the client's On/Off switch; the other types become one of its labelled buttons, which opens the value to set in the game. A password can't be drawn there and is refused, since a settings row has nowhere to type a secret.
The row and the page are the same preference, so changing one changes the other. A player scoped preference belongs to the account in that game pane, so the switch in that pane's Settings window is that account's own.
It's built on phantom.aj.ui.contribute, covered in Building interfaces in the game. It's a declaration rather than an insert, because the Settings window rebuilds its list every time it opens. plugins/examples/game-settings shows it in about a hundred lines, mostly comments.
inGame only covers the Settings window. phantom.aj.ui.contribute also reaches the world picker, the buddy list and both item grids, with your own picture on a row if you like. Use it directly for anything that isn't a preference.
Drawing them in your own panel
Phantom draws the form on your plugin's page under Preferences. To put the same form in your own panel or page, use <Preferences /> from @phantomclient/plugin/vue. It saves each change as it's made and shows any snag beside its preference.
<script setup lang="ts">
import { Preferences } from '@phantomclient/plugin/vue';
</script>
<template>
<Preferences />
</template>Your plugin can set its own preferences with ctx.setPreferences({ stayLoggedIn: true }). Values are checked like the form's, and a player scoped one is saved for the player of the game your code is bound to. Passwords can only be filled in by the player and are refused here.
Saying what isn't working
A plugin that's running but quietly doing nothing looks the same as one that's working. ctx.snag(name, reason) tells the player why something isn't working, and ctx.snag(name, null) clears it.
const part = await ctx.phantom().aj.hud.part('char');
ctx.snag(
'clothesOnHud',
part ? null : 'The HUD is not on screen yet, so there is nothing to hover over.'
);Phantom shows it beside the preference of that name wherever it's drawn, on your plugin's page, and once in your plugin's log. Use the preference's name if there is one, or any name of your own. A snag applies to the game your code is bound to, and clears when your plugin stops.
Storage
ctx.storage stores small values for your plugin, per Phantom account, with get, set, remove and keys. Use it to remember things between runs; the welcome example records whether it has already said hello. Settings the player changes belong in preferences instead.
const greeted = await ctx.storage.get<boolean>('greeted');
if (!greeted) await ctx.storage.set('greeted', true);ctx.storage.onChange(key?, listener) runs when a key changes, from any frame of the plugin, so a panel can follow what the logic writes without polling. See Keeping state.