# Crafter Plugin Guide
This document is the complete specification for writing a **Crafter server plugin**.
It is written to be handed to an AI coder as context: give it this file plus the
user's wish ("make mining give double drops", "add a `/heal` command", …) and it
can produce a valid plugin.
## What a plugin is
A plugin is a small JavaScript file that runs **on the game server, inside a
sandbox**, and affects **every player in the room**. Players install nothing —
plugins are purely server-side (this is the Bukkit/Spigot model, not the
Forge/Fabric client-mod model).
Plugins are **event-driven**: the server calls your functions when things happen
(a player joins, a block breaks, a chat message is sent, a timer ticks), and your
code responds by calling the `api` to change the game (give items, place blocks,
teleport, broadcast messages, cancel the action).
## File format
A plugin is a plain object assigned to `module.exports`. Every handler is
optional — include only the ones you need.
```js
module.exports = {
name: 'my-plugin', // optional; defaults to the filename
onEnable(api) { /* ... */ },
onPlayerJoin(player, api) { /* ... */ },
onBlockBreak(e, api) { /* ... */ },
// ...etc
}
```
Use **CommonJS** (`module.exports = {…}`), **not** ESM `export default`.
## Where plugins live
The **catalog** (plugins a game can choose from) is loaded from:
1. `plugins/` in the project — shipped/global.
2. `<data>/plugins/` — user global.
Files directly in those folders are the catalog; **subfolders are ignored** (e.g.
`plugins/examples/` is not part of the catalog).
**Per-game selection & upload (easiest):** in the **Create game** screen (next to
the World and Texture-pack inputs) each catalog plugin appears as a checkbox (on
by default), plus an **Upload plugin (.js)** button. The game you create enables
exactly the plugins you tick, and any files you upload are attached to that game
only (stored in `<data>/rooms/<roomId>/plugins/` and always enabled). So to add a
brand-new plugin: write the `.js`, click *Upload plugin*, and create the game.
A room's enabled set is saved in its `room.json` (`enabledPlugins`); legacy rooms
with no selection load the whole catalog. Plugins (re)load when the room's first
player joins.
## Events
Handlers you can export. `api` (see below) is always the last argument.
| Handler | Signature | Cancelable | Fires when |
|---|---|---|---|
| `onEnable` | `(api)` | no | plugin loads (room's first player joins) |
| `onPlayerJoin` | `(player, api)` | no | a player joins the room |
| `onPlayerLeave` | `(player, api)` | no | a player leaves |
| `onBlockBreak` | `(e, api)` | **yes** | a player breaks a block (sets it to air) |
| `onBlockPlace` | `(e, api)` | **yes** | a player places a block |
| `onChat` | `(e, api)` | **yes** | a player sends a non-command chat message |
| `onCommand` | `(e, api)` | no | a player sends `/name arg1 arg2 …` |
| `onTick` | `(api)` | no | about once per second |
**Cancel** a cancelable event by returning `false` **or** calling `e.cancel()`.
Cancelling a block break/place reverts it for the actor; cancelling chat hides it.
### Event object shapes
- **player** (in `onPlayerJoin`/`onPlayerLeave`, and as `e.player` elsewhere):
`{ id, name, account, x, y, z }`. `id` is the network id used by all `api`
calls; `account` is the portal account id or `null` for guests.
- **onBlockBreak / onBlockPlace `e`**: `{ player, x, y, z, block, cancel() }`.
`block` is the block **name** (see names below) — for a break it's the block
that was there; for a place it's the block being placed.
- **onChat `e`**: `{ player, text, cancel() }`.
- **onCommand `e`**: `{ player, name, args }`. For `/tp 10 64 -5`, `name` is
`"tp"` and `args` is `["10","64","-5"]` (strings — convert as needed).
## The `api` object
All game actions. `pid` means a player id (`player.id`).
| Call | Effect |
|---|---|
| `api.broadcast(text)` | chat line to everyone in the room |
| `api.tell(pid, text)` | chat line to one player |
| `api.setBlock(x, y, z, name)` | authoritatively set a block for all players (persisted) |
| `api.give(pid, item, count=1)` | add an item to a player's inventory |
| `api.setHealth(pid, value)` | set health (0–20) |
| `api.heal(pid, amount)` | add health (capped at 20) |
| `api.damage(pid, amount)` | deal damage (can kill) |
| `api.teleport(pid, x, y, z)` | move a player |
| `api.every(ms, fn)` | run `fn` repeatedly (min 250 ms) — a scheduler |
| `api.store.get(key)` / `api.store.set(key, value)` / `api.store.all()` | persistent per-room key/value storage (survives restarts) |
| `api.setRule(key, value)` / `api.getRule(key)` | set/read a **game rule** (below) — synced to every client, live |
| `api.setHardness(block, seconds)` | shortcut for `setRule('hardness.'+block, seconds)` |
| `api.log(...args)` | write to the server console (debugging) |
## Game rules (client-side gameplay tunables)
`api.setRule(key, value)` changes how the game *plays* for everyone in the room —
it's synced to every client instantly and persists in the room. Keys:
| Key | Type | Default | Effect |
|---|---|---|---|
| `hardness.<block>` | number | per-block | seconds to mine that block by hand (e.g. `hardness.stone`). Use `setHardness`. |
| `instantBreak` | bool | false | every block breaks in one hit |
| `fallDamage` | bool | true | take damage from long falls |
| `drowning` | bool | true | lose air / drown underwater |
| `hunger` | bool | true | hunger drain + starvation (false = food stays full) |
| `regen` | bool | true | food-gated health regen |
| `voidY` | number | -12 | fall below this Y = death |
| `dayLength` | number | 0 | seconds per full day-night cycle (0 = engine default ≈300) |
| `freezeTime` | number\|null | null | fix time of day 0..1 (0.25 ≈ midday, 0.75 ≈ midnight); null = normal |
| `gravity` | number | 1 | gravity multiplier (0.35 = moon-like) |
| `walkSpeed` | number | 1 | movement-speed multiplier |
| `jump` | number | 1 | jump-height multiplier |
| `reach` | number | 6 | block interaction distance |
```js
module.exports = {
name: 'hardcore-ish',
onEnable(api) {
api.setRule('regen', false) // no natural healing
api.setRule('hardness.stone', 4) // stone takes 4s by hand
api.setRule('gravity', 0.4) // floaty
api.setRule('freezeTime', 0.7) // permanent dusk
},
}
```
Rules are the co-op-trust model like other directives: the server tells clients
the rules and they honor them (great for shared experiences, not anti-cheat).
Block names for `hardness.*` are the same lowercase names used elsewhere (see below).
`api` calls are fire-and-forget — they do not return game state. Read state from
the event object you were given (position, block, player).
## Block & item names
Use these lowercase names with `setBlock` / `give` / compare against `e.block`:
**Blocks:** `air`, `stone`, `dirt`, `grass`, `sand`, `water`, `log` (aka `wood`,
`oak_log`), `leaves`, `planks` (`oak_planks`), `bedrock`, `gravel`, `cobblestone`
(`cobble`), `glowstone`.
**Items:** every block name above (except `air`/`water`), plus food: `apple`,
`bread`, `carrot`, `melon` (`melon_slice`).
Unknown names are ignored (no crash). This is a curated subset — if you need a
name that isn't here, pick the closest one.
## Sandbox limits (important)
Your code runs in a locked-down context. **Available:** `api`, `console`, `Math`,
`JSON`, `Date`, `Number`, `String`, `Boolean`, `Array`, `Object`, `RegExp`,
`Map`, `Set`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`.
**NOT available:** `require`, `import`, `process`, `fs`, `fetch`, network, the
filesystem, `setTimeout`/`setInterval` (use `api.every` instead), `window`,
`document`. There is no persistence except `api.store`. Keep handlers fast — a
handler that blocks too long is treated as "no objection" for cancelable events,
and a persistently unresponsive plugin is disabled.
## Worked examples
**Double ore drops**
```js
module.exports = {
name: 'double-drops',
onBlockBreak(e, api) {
if (e.block === 'stone') api.give(e.player.id, 'cobblestone', 1)
},
}
```
**A `/heal` command**
```js
module.exports = {
name: 'heal-command',
onCommand(e, api) {
if (e.name === 'heal') { api.setHealth(e.player.id, 20); api.tell(e.player.id, 'Fully healed!') }
},
}
```
**Spawn protection (cancel edits near origin)**
```js
module.exports = {
name: 'spawn-protect',
onBlockBreak(e, api) { return keepOut(e, api) },
onBlockPlace(e, api) { return keepOut(e, api) },
}
function keepOut(e, api) {
if (Math.abs(e.x) <= 6 && Math.abs(e.z) <= 6) { api.tell(e.player.id, 'Spawn is protected.'); return false }
}
```
**No-swearing chat filter**
```js
module.exports = {
name: 'no-swearing',
onChat(e, api) {
if (/badword/i.test(e.text)) { api.tell(e.player.id, 'Watch your language!'); return false }
},
}
```
**Timed event with persistent counter**
```js
module.exports = {
name: 'diamond-rain', // (uses glowstone — no diamonds in this palette)
onEnable(api) {
const total = api.store.get('given') || 0
api.log('given so far:', total)
api.every(30000, () => api.broadcast('A meteor shower is coming! (30s timer)'))
},
}
```
**Build a small platform under each joining player**
```js
module.exports = {
name: 'safe-landing',
onPlayerJoin(p, api) {
const y = Math.floor(p.y) - 1
for (let dx = -1; dx <= 1; dx++)
for (let dz = -1; dz <= 1; dz++)
api.setBlock(Math.floor(p.x) + dx, y, Math.floor(p.z) + dz, 'glowstone')
},
}
```
## How to test a plugin
1. Save the `.js` file into `plugins/` (global) or a room's `plugins/` folder.
2. Restart the game service (`ps-svc restart app`) or have the room's last player
leave and rejoin — plugins load when the room's first player joins.
3. Join a multiplayer game (not "Play solo" — plugins are server-side only) and
trigger the event. Use **T** to chat and run commands.
4. `api.log(...)` output appears in the server log: `ps-svc logs app`.
## Constraints & scope (v1)
- Plugins affect what the **server owns**: block edits, chat, and directives it
sends clients (give/health/teleport). Deep client mechanics (movement, custom
block *models*, new UI) are not yet moddable — that's a future tier.
- `onBlockBreak`/`onBlockPlace` fire from the player's edit; cancelling reverts
the actor's view. Plugin `setBlock` changes are authoritative and persisted.
- Health/inventory live on the client; `api` directives nudge them. A malicious
client could ignore a directive — fine for co-op, not anti-cheat.