Building UI with Vue
A plugin's screens are Vue components, and there are four places to put one.
| Where | Manifest | In definePlugin | Works with |
|---|---|---|---|
| A page of its own, under Plugins | contributes.pages | pages | all games the plugin is allowed on |
| A panel in the dock beside a game | contributes.panels | panels | that one game |
| A widget on the canvas | contributes.widgets | widgets | all games the plugin is allowed on |
| A command view, opened by a command | contributes.commands with "view": true | commandViews | the game the command ran on, and the props it returned |
Each component runs in its own sandboxed frame next to the game, not inside it. Phantom gives the frame its theme, fonts and a shared copy of Vue, passes the component's calls on to the game and checks each one against the player's permissions. Your bundle only contains your own code, and your components match Phantom in whatever theme the player uses.
@phantomclient/plugin/vue has five composables for talking to Phantom, plus Phantom's own components. @phantomclient/plugin and vue come from the frame; other packages are bundled as normal.
To draw inside the game itself, in Animal Jam's style or Phantom's, see Building interfaces in the game.
Declaring views
The manifest names each view and where it goes, and definePlugin gives the component for each id. The two have to match. A view declared but not implemented shows as a problem on the plugin's page in Plugins, and phantom-plugin validate flags it.
{
"name": "phantom-plugin-room-list",
"version": "1.0.0",
"phantom": {
"id": "acme.room-list",
"title": "Room list",
"description": "Who is in the room, beside the game.",
"author": "Acme",
"permissions": { "game": "read" },
"contributes": {
"panels": [{ "id": "players", "title": "Players", "icon": "users" }],
"pages": [{ "id": "rooms", "title": "Rooms", "icon": "map" }]
}
}
}import { definePlugin } from '@phantomclient/plugin';
import Players from './panels/Players.vue';
import Rooms from './pages/Rooms.vue';
export default definePlugin({
panels: { players: Players },
pages: { rooms: Rooms },
});A panel
A panel belongs to one game, and usePhantom() with no id gives you that game. The calls are the same as in the SDK, with two differences from a script: calls return promises, and you read properties by calling them. A script's phantom.aj.room.current is await phantom.aj.room.current() here. Listeners like onEnter still return their stop function straight away.
This panel lists the players in the room with their avatar names and a search box, and updates as players come and go:
<script setup lang="ts">
import type { PluginPhantom } from '@phantomclient/plugin';
import { EmptyState, Input, Spinner, usePhantom } from '@phantomclient/plugin/vue';
import { Search } from 'lucide-vue-next';
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
type RoomPlayer = Awaited<ReturnType<PluginPhantom['room']['players']['list']>>[number];
const phantom = usePhantom();
const players = shallowRef<readonly RoomPlayer[]>([]);
const loading = ref(true);
const query = ref('');
const shown = computed(() =>
players.value
.filter((player) =>
player.userName.toLowerCase().includes(query.value.toLowerCase())
)
.sort((a, b) => a.userName.localeCompare(b.userName))
);
async function load(): Promise<void> {
players.value = await phantom.aj.room.players.all();
loading.value = false;
}
const stops: Array<() => void> = [];
onMounted(() => {
stops.push(
phantom.aj.room.players.onEnter((player) => {
players.value = [
...players.value.filter((p) => p.userId !== player.userId),
player,
];
}),
phantom.aj.room.players.onLeave((player) => {
players.value = players.value.filter((p) => p.userId !== player.userId);
}),
phantom.aj.room.onJoin(() => void load())
);
void load();
});
onBeforeUnmount(() => stops.forEach((stop) => stop()));
</script>
<template>
<div class="flex h-full flex-col gap-3 p-3">
<Input v-model="query" size="sm" placeholder="Find a player" :icon="Search" />
<div v-if="loading" class="flex justify-center py-6"><Spinner /></div>
<EmptyState
v-else-if="!shown.length"
title="Nobody here"
description="Nobody matches."
/>
<ul v-else class="flex flex-col gap-0.5">
<li
v-for="player in shown"
:key="player.userId"
class="flex items-center justify-between rounded-md px-2 py-1 hover:bg-phantom-surface"
>
<span class="truncate text-xs text-phantom-text">{{ player.userName }}</span>
<span class="truncate text-2xs text-phantom-subtle">{{
player.avatarName
}}</span>
</li>
</ul>
</div>
</template>Notes:
- Load the list once, then keep it up to date from the events, which only tell you what changed.
- Stop your listeners. One left running after the panel closes still costs the game a relay. Keep the stop functions and call them in
onBeforeUnmount. - Use
shallowReffor lists from the game. They're replaced, not changed in place, so Vue doesn't need to watch inside them. - Use the colour tokens.
text-phantom-text,bg-phantom-surfaceand the others follow the theme; a hard-coded colour will look wrong in someone's theme. - To show an item's picture,
phantom.aj.shops.picture(item, { maxSize: 64 })gives you a PNG as adata:URL, drawn by the game like a shop row or inventory tile. It takes a definition id or anything with adefId(a shop's offering, a den shop's listing or a trade list's clothing). Pass{ kind: 'den-item' }for a den item, andcolorfor the colour the player owns it in. Pictures are cached while the game is open, so the same item is only loaded once. Ask for them as rows come into view rather than all at once, and fall back to the name, since the call rejects if the art never arrives.phantom.aj.player.pets.picturedoes the same for a pet, from its look.
A page
A page isn't tied to one game. useGames() gives you the games the plugin can work with and updates when a game opens or closes, changes room, or the player allows the plugin on another account. Pass a game's id to usePhantom(id) to work with that game.
<script setup lang="ts">
import { Badge, EmptyState, PageHeader, useGames } from '@phantomclient/plugin/vue';
const games = useGames();
</script>
<template>
<div class="flex flex-col gap-4 p-6">
<PageHeader
title="Rooms"
description="Each game this plugin runs on, and where it is."
/>
<EmptyState
v-if="!games.length"
title="No games yet"
description="Allow the plugin on a game in Plugins, then open it."
/>
<ul v-else class="flex flex-col gap-2">
<li
v-for="game in games"
:key="game.id"
class="flex items-center justify-between rounded-lg border border-phantom-border px-4 py-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-phantom-text">
{{ game.screenName ?? 'Signing in' }}
</p>
<p class="text-2xs text-phantom-muted">
{{ game.room?.name ?? 'Not in a room' }}
</p>
</div>
<Badge :tone="game.state === 'playing' ? 'success' : 'neutral'" dot>{{
game.state
}}</Badge>
</li>
</ul>
</div>
</template>useGames() needs no permission beyond the games the player chose for the plugin. It tells you which games exist, not what's happening in them.
A command view
A command runs on one game, from that game's plugin menu. Declared with "view": true, it can return props, and Phantom opens its view with them. Use this to show the player a result instead of a toast.
import { definePlugin } from '@phantomclient/plugin';
import Inventory from './views/Inventory.vue';
export default definePlugin({
commands: {
inventory: async (_input, phantom) => {
const items = await phantom.aj.inventory.all();
return { props: { count: items.length } };
},
},
commandViews: { inventory: Inventory },
});<script setup lang="ts">
import { PageHeader } from '@phantomclient/plugin/vue';
defineProps<{ count: number }>();
</script>
<template>
<div class="p-4">
<PageHeader title="Inventory" :description="`${count} items on this account.`" />
</div>
</template>The return value is sent from the plugin's logic to the view as a message, so it has to be plain data: numbers, strings, and arrays and objects of those.
Preferences, storage and the theme
<Preferences />draws the settings your manifest declares, with Phantom's own controls, in your panel or page. Each change is saved as it's made, and anything you've reported withctx.snagshows beside its preference.<Preferences group="Chat" />draws one section. That's a complete settings panel.usePreferences()returns what the player set in the plugin's settings (the manifest'spreferences) and updates when they change it. It's read-only: the player edits it in Plugins or wherever you put<Preferences />, and your code writes it withusePlugin().setPreferences.useSnags()returns what your plugin has reported it can't do, to show somewhere of your own.<Preferences />already shows each beside its preference.usePlugin()returns the plugin's context, the same onesetupgets:storage,toastandnotify,can(scope)to check a permission before offering a feature, pluscopy,openUrlandfetch.useThemeMode()returnslightordark, for things like chart libraries. For colours, use the tokens, which already follow the theme.
<script setup lang="ts">
import { Button, usePlugin, usePreferences } from '@phantomclient/plugin/vue';
import { computed } from 'vue';
const plugin = usePlugin();
const preferences = usePreferences();
const greeting = computed(() => String(preferences.value['greeting'] ?? 'Hello'));
async function save(): Promise<void> {
await plugin.storage.set('last-greeting', greeting.value);
await plugin.toast('Saved', { tone: 'success' });
}
</script>
<template>
<div class="flex items-center gap-2 p-3">
<span class="text-xs text-phantom-text">{{ greeting }}</span>
<Button size="sm" variant="outline" @click="save">Keep it</Button>
</div>
</template>Styling
Frames come with Tailwind set up for Phantom's theme: the app's type scale (13px body, 12px text-xs, 11px text-2xs) and the colour tokens as classes, such as text-phantom-text, text-phantom-muted, bg-phantom-panel, bg-phantom-surface, border-phantom-border and text-phantom-danger. phantom-plugin build compiles the classes your components use into dist/plugin.css. In a <style> block, use the CSS variables instead, like rgb(var(--phantom-panel)).
Don't add your own background: a panel already sits inside the dock's panel, and a page already has the page background. Leave out window chrome, navigation and dialogs too, since Phantom draws those.
Common mistakes
- Reading a property without calling it. In a plugin
phantom.aj.room.currentis a function; the room isawait phantom.aj.room.current(). TypeScript will catch this. - Not stopping listeners. Keep the stop functions and call them in
onBeforeUnmount. - Returning a class or function from a command. Only plain data gets through to a view.
- Calling something that wasn't granted. It's refused with a
PluginError. CheckusePlugin().can('game:act')first and offer something else if it'sfalse. - A view declared but missing from
definePlugin, or the other way round. Runphantom-plugin validate.
The @phantomclient/plugin/vue reference lists every composable and component, with their props and live examples.