Examples

Two ready-to-run plugins. Both live in the examples/ folder of the repo — copy, build, drop into scripts/, done.

hello — the smallest plugin

One command, one join listener. This is exactly what paperscript init scaffolds:

examples/hello/src/index.ts
ps.onEnable(() => {
  ps.logger.info('Hello from a TypeScript plugin!');

  ps.commands.register(
    'hello',
    (ctx) => {
      ctx.sender.sendMessage('Hello, ' + ctx.sender.name + '!');
    },
    'Say hello',
    '/hello'
  );

  ps.events.onPlayerJoin((event) => {
    event.player.sendMessage('Welcome, ' + event.player.name + '!');
  });
});

ps.onDisable(() => {
  ps.logger.info('Hello plugin disabled.');
});

essentials — a real command suite

A full Essentials-style plugin in ~370 lines of TypeScript:

/spawn /setspawn /tp /tpa /tpaccept /heal /feed /fly /gamemode /sethome /home /delhome

Chat theme & helpers

Every message goes through one gradient prefix and a few MiniMessage helpers, so the whole plugin looks consistent:

examples/essentials/src/index.ts
// One chat theme for the whole plugin — MiniMessage.
const PREFIX = "<dark_gray>[<gradient:#ff7a18:#af52de>Essentials</gradient>]</dark_gray> ";

const ok   = (t: string) => `<green>${t}</green>`;
const fail = (t: string) => `<red>${t}</red>`;
const info = (t: string) => `<gray>${t}</gray>`;
const hi   = (t: string) => `<gold>${t}</gold>`;
const aqua = (t: string) => `<aqua>${t}</aqua>`;

interface MessageTarget { sendMessage(msg: string): void; }

function tell(target: MessageTarget, text: string): void {
  target.sendMessage(PREFIX + text);
}

// Resolve the sender as a Player, or tell them why not.
function self(ctx: CommandContext): Player | null {
  if (!ctx.sender.player) {
    tell(ctx.sender, fail("Players only."));
    return null;
  }
  const p = ps.players.get(ctx.sender.name);
  if (!p) tell(ctx.sender, fail("Could not find your player."));
  return p;
}

// Find an online player by name, with a themed error if missing.
function findPlayer(name: string | null, ctx: CommandContext): Player | null {
  if (!name) { tell(ctx.sender, info("Specify a player name.")); return null; }
  const p = ps.players.get(name);
  if (!p) tell(ctx.sender, fail(`Player ${hi(name)} is not online.`));
  return p;
}

function needOp(ctx: CommandContext): boolean {
  if (!ctx.sender.op) { tell(ctx.sender, fail("Not enough permissions.")); return false; }
  return true;
}

/spawn

examples/essentials/src/index.ts
ps.commands.register(
  "spawn",
  (ctx) => {
    const p = self(ctx);
    if (!p) return;
    const w = ps.worlds.get(p.location.world) ?? ps.worlds.all()[0];
    if (!w) { tell(ctx.sender, fail("World not found.")); return; }
    p.teleport(w.spawnLocation);
    tell(ctx.sender, ok("Teleported to spawn."));
  },
  "Teleport to spawn",
  "/spawn"
);

/tp — overloads & op checks

examples/essentials/src/index.ts
ps.commands.register(
  "tp",
  (ctx) => {
    const a = ctx.arg(0);
    const b = ctx.arg(1);
    if (a && b) {
      // /tp <from> <to> — operators only
      if (!needOp(ctx)) return;
      const from = findPlayer(a, ctx);
      const to = findPlayer(b, ctx);
      if (!from || !to) return;
      from.teleport(to.location);
      tell(ctx.sender, ok(`${hi(from.name)} teleported to ${hi(to.name)}.`));
    } else if (a) {
      // /tp <to> — self teleport
      const p = self(ctx);
      if (!p) return;
      const to = findPlayer(a, ctx);
      if (!to) return;
      p.teleport(to.location);
      tell(ctx.sender, ok(`Teleported to ${hi(to.name)}.`));
    } else {
      tell(ctx.sender, info("Usage: /tp <player> or /tp <a> <b>"));
    }
  },
  "Teleportation",
  "/tp <player>"
);

/tpa — scheduler for expiring requests

examples/essentials/src/index.ts
interface TpaRequest { from: string; task: number; }
const pending = new Map<string, TpaRequest>(); // key = target name

ps.commands.register("tpa", (ctx) => {
  const p = self(ctx);
  if (!p) return;
  const t = findPlayer(ctx.arg(0), ctx);
  if (!t) return;

  const prev = pending.get(t.name);
  if (prev) ps.scheduler.cancelTask(prev.task);
  // auto-expire after 60 seconds
  const task = ps.scheduler.runTaskLater(() => pending.delete(t.name), 20 * 60);
  pending.set(t.name, { from: p.name, task });

  tell(ctx.sender, ok(`Teleport request sent to ${hi(t.name)}.`));
  t.sendMessage(PREFIX + info(`${hi(p.name)} wants to teleport to you. `)
    + aqua(`/tpaccept ${p.name}`) + info(" (60s)."));
}, "Request a teleport", "/tpa <player>");

/sethome + /home — persistent storage

examples/essentials/src/index.ts
type HomeMap = Record<string, Location>;

function loadHomes(): HomeMap {
  const raw = ps.storage.get("homes");
  if (!raw) return {};
  try { return JSON.parse(raw) as HomeMap; } catch { return {}; }
}

function saveHomes(map: HomeMap): void {
  ps.storage.set("homes", JSON.stringify(map));
}

ps.commands.register("sethome", (ctx) => {
  const p = self(ctx);
  if (!p) return;
  const name = ctx.arg(0) ?? "home";
  const homes = loadHomes();
  homes[name] = p.location; // Location is a plain JSON-safe object
  saveHomes(homes);
  tell(ctx.sender, ok(`Home ${aqua(name)} saved.`));
}, "Save a home point", "/sethome [name]");

ps.commands.register("home", (ctx) => {
  const p = self(ctx);
  if (!p) return;
  const name = ctx.arg(0) ?? "home";
  const loc = loadHomes()[name];
  if (!loc) { tell(ctx.sender, fail(`Home ${aqua(name)} not found.`)); return; }
  p.teleport(loc);
  tell(ctx.sender, ok(`Welcome home (${aqua(name)}).`));
}, "Teleport home", "/home [name]");

Bundling for legacy (1.12.2 / 1.16.5)

The same source runs on legacy hosts — the only change is the bundle target in plugin.json, so the SDK emits ES2015 for the Nashorn engine:

plugin.json
{
  "name": "essentials",
  "version": "1.0.0",
  "main": "dist/index.js",
  "apiVersion": "1",
  "target": "es2015"
}

Replace MiniMessage strings with &-color codes (&a, &l…) and deploy to plugins/PaperScriptLegacy/scripts/ instead.