Creating SquadJS Plugins
Overview
A SquadJS plugin is an ES-module class — extending BasePlugin or DiscordBasePlugin — that
reacts to a fixed set of server events and acts through RCON. SquadJS gives you those
events, a small set of RCON/server methods, and a fixed set of player fields. Nothing else.
Core principle: map every requirement onto a real SquadJS event, method, and field
before writing code. If a requirement maps to nothing — or to something unreliable — say so
and propose the closest reliable alternative. Never fabricate a capability. A dead code
branch that reads a field which does not exist (death coordinates, player health) is worse
than telling the user the truth, because it ships as if it worked.
When to use
- Creating or scaffolding a new SquadJS plugin, or adding a server-side feature via SquadJS.
- Answering "can SquadJS do / detect X?" — use the capability surface + hard limits below.
- Reviewing a plugin for the common traps (see Common Mistakes).
Not for: modifying the SquadJS core itself, or non-SquadJS Discord bots.
Workflow
- Brainstorm intent first. REQUIRED SUB-SKILL:
superpowers:brainstorming. Pin down the
concrete behaviours the admin wants, in plain language, before any mapping.
- Map each requirement → capability. For every behaviour, find the event that triggers it
and the method that performs it in
references/api-reference.md. Write the mapping down.
- Feasibility gate. Check each mapping against
references/event-reliability.md. If a
requirement maps to nothing, or to an unreliable signal, STOP and tell the user before
coding. The hard-limits table below lists the usual culprits.
- Pick base class & connectors.
DiscordBasePlugin if it posts to Discord, else
BasePlugin. Add the sequelize connector only if state must survive restarts.
- Scaffold from a template. Copy the matching file from
templates/ — they already encode
the correct lifecycle and avoid the traps below.
- Implement. Bind every handler in the constructor;
mount() and unmount() must be
exactly symmetric; handlers must be idempotent (events burst and duplicate); read config
from this.options.*.
- Verify + install.
references/installation.md covers the config.json block and
live-server verification (load it, watch verbose output, exercise it in-game).
Before you finish — mechanical self-checks (run these on your plugin file)
Do not rely on memory or on copied code — run each check; every one must pass:
grep -n removeEventListener <file> returns nothing. It throws TypeError at runtime
and leaks the listener; use removeListener/off. This is the single most common bug, and
the bundled core plugins (discord-teamkill.js, auto-tk-warn.js, …) contain it — so if you
modelled your code on a core/example plugin, you almost certainly copied it. Fix it.
- Listener symmetry: every
this.server.on(...) in mount() has a matching
removeListener in unmount(), and every setInterval/setTimeout has a matching
clearInterval/clearTimeout in unmount().
- No invented API: every event name you register, and every
rcon.*/server.* call,
appears in references/api-reference.md. If it's not there, it doesn't exist.
The feasibility-gate decision (where it goes wrong)
digraph feasibility {
rankdir=LR;
req [label="A requirement", shape=oval];
maps [label="Maps to a real\nevent + method + field?", shape=diamond];
reliable [label="Reliable per\nevent-reliability.md?", shape=diamond];
build [label="Build it", shape=box];
tell [label="Tell the user it is impossible;\npropose the closest alternative", shape=box];
caveat [label="Tell the user the caveat;\ndesign around it (timer /\nUPDATED_PLAYER_INFORMATION diff)", shape=box];
req -> maps;
maps -> tell [label="no"];
maps -> reliable [label="yes"];
reliable -> caveat [label="no"];
reliable -> build [label="yes"];
}
Hard limits — SquadJS CANNOT do these (verified against the core)
| Request |
Reality |
| get a single reliable "player left" event covering all departures |
PLAYER_DISCONNECTED fires for clean disconnects only — not kicks/bans (#287) — and can pass a null player (#289). Diff server.players on UPDATED_PLAYER_INFORMATION for complete departure detection. |
| "react when the round goes live / staging ends" |
No such event. NEW_GAME fires when staging begins (~260 s before live). Approximate with a setTimeout from NEW_GAME. |
| player position / map coordinates / grid |
No event or RCON command exposes player or death coordinates. The player object has no x/y/z. |
| player health / HP / stamina |
Not exposed anywhere. |
reliable team data immediately after NEW_GAME |
teamID is null transiently (~30 s) for many players right after NEW_GAME. |
| kick / change layer / end match / disband squad |
No dedicated wrapper — use rcon.execute('Admin…'). Wrappers exist only for broadcast, warn, ban, switchTeam, setFogOfWar. |
Quick reference
Most-used events: CHAT_COMMAND:<name>, CHAT_MESSAGE, PLAYER_WOUNDED, PLAYER_DIED,
TEAMKILL, PLAYER_TEAM_CHANGE, NEW_GAME, ROUND_ENDED, UPDATED_PLAYER_INFORMATION.
Act via this.server.rcon.warn(anyID, msg), .broadcast(msg), .switchTeam(anyID),
.execute(rawAdminCommand). Full catalog + every payload shape: references/api-reference.md.
Three non-obvious patterns (see references/api-reference.md §3, §4, §7):
- Team by faction short name, not
teamID. No faction field exists; teamID 1/2 flips each
round. Resolve a short name ("USA") via the role classname prefix (USA_Rifleman_01) — a
naming convention, not an API guarantee.
- Configurable / aliased command lists: loop and register
CHAT_COMMAND:${cmd} per alias
(normalize a string-or-array option to an array, bind once, mirror in unmount); match
CHAT_MESSAGE yourself only when the trigger isn't a clean single !word (see §3).
- Double-switch to unstick a bugged player (§4): call
switchTeam twice with a short delay —
the player leaves and returns to their own team, re-spawning the pawn with no net side change.
Common mistakes (every one observed in baseline testing)
| Mistake |
Fix |
this.server.removeEventListener(...) in unmount |
It does not exist on the Node EventEmitter server — it throws TypeError and the listener leaks. Use this.server.removeListener(...) (or .off(...)). Many bundled/example plugins (including activity-tracker) use the broken form — do not copy it. |
.on(event, (data) => this.onFoo(data)) then .removeListener(event, this.onFoo) in unmount |
removeListener matches by function reference, not name — the anonymous arrow registered in mount is a different function object than this.onFoo, so the call is a silent no-op and the listener leaks (no error thrown). Bind once in the constructor (this.onFoo = this.onFoo.bind(this)) and pass that same bound reference — never a fresh wrapper — to both .on() and .removeListener(). |
Relying on PLAYER_DISCONNECTED as your sole departure trigger |
It catches clean disconnects only (not kicks/bans) and can fire with a null player. Use it as a fast supplement; diff server.players on UPDATED_PLAYER_INFORMATION for complete detection. |
this.server.rcon.kick(...) or other invented methods |
Only broadcast/setFogOfWar/warn/ban/switchTeam/get*/execute exist. Kick = execute('AdminKick "<id>" <reason>'). |
Reading invented payload fields (data.location.x, health) |
They do not exist. Use only the fields listed in api-reference.md. Do not write dead branches for fields that "might" exist later. |
setInterval/setTimeout not cleared in unmount |
Store the handle and clear it in unmount alongside listener removal. |
A required: true option whose config value equals its default |
BasePlugin throws at load. Required options must be given a non-default value in config. |
"plugin" name in config ≠ exported class name |
They must match exactly; SquadJS loads plugins by class name. |
Using steamID as the primary key |
eosID is the primary id (always present); steamID is the fallback. |
1---2name: creating-squadjs-plugins3description: Use when creating, writing, or scaffolding a new SquadJS plugin, adding a server-side feature to a Squad server via SquadJS, or deciding whether SquadJS can support a desired behaviour ("can SquadJS detect X / do Y on a Squad server?"). Covers the plugin lifecycle, the full event/RCON capability surface, and the hard limits that make some requests impossible.4---56# Creating SquadJS Plugins78## Overview910A SquadJS plugin is an ES-module class — extending `BasePlugin` or `DiscordBasePlugin` — that11reacts to a **fixed set** of server events and acts through RCON. SquadJS gives you those12events, a small set of RCON/server methods, and a fixed set of player fields. Nothing else.1314**Core principle:** map every requirement onto a real SquadJS event, method, and field15*before* writing code. If a requirement maps to nothing — or to something unreliable — say so16and propose the closest reliable alternative. **Never fabricate a capability.** A dead code17branch that reads a field which does not exist (death coordinates, player health) is worse18than telling the user the truth, because it ships as if it worked.1920## When to use2122- Creating or scaffolding a new SquadJS plugin, or adding a server-side feature via SquadJS.23- Answering "can SquadJS do / detect X?" — use the capability surface + hard limits below.24- Reviewing a plugin for the common traps (see Common Mistakes).2526Not for: modifying the SquadJS core itself, or non-SquadJS Discord bots.2728## Workflow29301. **Brainstorm intent first.** REQUIRED SUB-SKILL: `superpowers:brainstorming`. Pin down the31 concrete behaviours the admin wants, in plain language, before any mapping.322. **Map each requirement → capability.** For every behaviour, find the event that triggers it33 and the method that performs it in `references/api-reference.md`. Write the mapping down.343. **Feasibility gate.** Check each mapping against `references/event-reliability.md`. If a35 requirement maps to nothing, or to an unreliable signal, STOP and tell the user *before*36 coding. The hard-limits table below lists the usual culprits.374. **Pick base class & connectors.** `DiscordBasePlugin` if it posts to Discord, else38 `BasePlugin`. Add the `sequelize` connector only if state must survive restarts.395. **Scaffold from a template.** Copy the matching file from `templates/` — they already encode40 the correct lifecycle and avoid the traps below.416. **Implement.** Bind every handler in the constructor; `mount()` and `unmount()` must be42 exactly symmetric; handlers must be idempotent (events burst and duplicate); read config43 from `this.options.*`.447. **Verify + install.** `references/installation.md` covers the `config.json` block and45 live-server verification (load it, watch `verbose` output, exercise it in-game).4647### Before you finish — mechanical self-checks (run these on your plugin file)4849Do not rely on memory or on copied code — run each check; every one must pass:5051- **`grep -n removeEventListener <file>` returns nothing.** It throws `TypeError` at runtime52 and leaks the listener; use `removeListener`/`off`. This is the single most common bug, and53 the bundled core plugins (`discord-teamkill.js`, `auto-tk-warn.js`, …) contain it — so if you54 modelled your code on a core/example plugin, you almost certainly copied it. Fix it.55- **Listener symmetry:** every `this.server.on(...)` in `mount()` has a matching56 `removeListener` in `unmount()`, and every `setInterval`/`setTimeout` has a matching57 `clearInterval`/`clearTimeout` in `unmount()`.58- **No invented API:** every event name you register, and every `rcon.*`/`server.*` call,59 appears in `references/api-reference.md`. If it's not there, it doesn't exist.6061### The feasibility-gate decision (where it goes wrong)6263```dot64digraph feasibility {65 rankdir=LR;66 req [label="A requirement", shape=oval];67 maps [label="Maps to a real\nevent + method + field?", shape=diamond];68 reliable [label="Reliable per\nevent-reliability.md?", shape=diamond];69 build [label="Build it", shape=box];70 tell [label="Tell the user it is impossible;\npropose the closest alternative", shape=box];71 caveat [label="Tell the user the caveat;\ndesign around it (timer /\nUPDATED_PLAYER_INFORMATION diff)", shape=box];7273 req -> maps;74 maps -> tell [label="no"];75 maps -> reliable [label="yes"];76 reliable -> caveat [label="no"];77 reliable -> build [label="yes"];78}79```8081## Hard limits — SquadJS CANNOT do these (verified against the core)8283| Request | Reality |84|---|---|85| get a single reliable "player left" event covering **all** departures | `PLAYER_DISCONNECTED` fires for clean disconnects only — not kicks/bans (#287) — and can pass a `null` player (#289). Diff `server.players` on `UPDATED_PLAYER_INFORMATION` for complete departure detection. |86| "react when the round goes live / staging ends" | No such event. `NEW_GAME` fires when staging *begins* (~260 s before live). Approximate with a `setTimeout` from `NEW_GAME`. |87| player position / map coordinates / grid | No event or RCON command exposes player or death coordinates. The player object has no x/y/z. |88| player health / HP / stamina | Not exposed anywhere. |89| reliable team data immediately after `NEW_GAME` | `teamID` is `null` transiently (~30 s) for many players right after `NEW_GAME`. |90| kick / change layer / end match / disband squad | No dedicated wrapper — use `rcon.execute('Admin…')`. Wrappers exist only for `broadcast`, `warn`, `ban`, `switchTeam`, `setFogOfWar`. |9192## Quick reference9394Most-used events: `CHAT_COMMAND:<name>`, `CHAT_MESSAGE`, `PLAYER_WOUNDED`, `PLAYER_DIED`,95`TEAMKILL`, `PLAYER_TEAM_CHANGE`, `NEW_GAME`, `ROUND_ENDED`, `UPDATED_PLAYER_INFORMATION`.96Act via `this.server.rcon.warn(anyID, msg)`, `.broadcast(msg)`, `.switchTeam(anyID)`,97`.execute(rawAdminCommand)`. Full catalog + every payload shape: `references/api-reference.md`.9899Three non-obvious patterns (see `references/api-reference.md` §3, §4, §7):100- **Team by faction short name, not `teamID`.** No faction field exists; `teamID` 1/2 flips each101 round. Resolve a short name (`"USA"`) via the `role` classname prefix (`USA_Rifleman_01`) — a102 naming convention, not an API guarantee.103- **Configurable / aliased command lists**: loop and register `CHAT_COMMAND:${cmd}` per alias104 (normalize a string-or-array option to an array, bind once, mirror in `unmount`); match105 `CHAT_MESSAGE` yourself only when the trigger isn't a clean single `!word` (see §3).106- **Double-switch to unstick a bugged player** (§4): call `switchTeam` twice with a short delay —107 the player leaves and returns to their own team, re-spawning the pawn with no net side change.108109## Common mistakes (every one observed in baseline testing)110111| Mistake | Fix |112|---|---|113| `this.server.removeEventListener(...)` in `unmount` | **It does not exist** on the Node `EventEmitter` server — it throws `TypeError` and the listener leaks. Use `this.server.removeListener(...)` (or `.off(...)`). Many bundled/example plugins (including `activity-tracker`) use the broken form — do not copy it. |114| `.on(event, (data) => this.onFoo(data))` then `.removeListener(event, this.onFoo)` in `unmount` | `removeListener` matches by function **reference**, not name — the anonymous arrow registered in `mount` is a different function object than `this.onFoo`, so the call is a silent no-op and the listener leaks (no error thrown). Bind once in the constructor (`this.onFoo = this.onFoo.bind(this)`) and pass that same bound reference — never a fresh wrapper — to both `.on()` and `.removeListener()`. |115| Relying on `PLAYER_DISCONNECTED` as your sole departure trigger | It catches clean disconnects only (not kicks/bans) and can fire with a `null` player. Use it as a fast supplement; diff `server.players` on `UPDATED_PLAYER_INFORMATION` for complete detection. |116| `this.server.rcon.kick(...)` or other invented methods | Only `broadcast`/`setFogOfWar`/`warn`/`ban`/`switchTeam`/`get*`/`execute` exist. Kick = `execute('AdminKick "<id>" <reason>')`. |117| Reading invented payload fields (`data.location.x`, `health`) | They do not exist. Use only the fields listed in `api-reference.md`. Do not write dead branches for fields that "might" exist later. |118| `setInterval`/`setTimeout` not cleared in `unmount` | Store the handle and clear it in `unmount` alongside listener removal. |119| A `required: true` option whose config value equals its `default` | `BasePlugin` throws at load. Required options must be given a non-default value in config. |120| `"plugin"` name in config ≠ exported class name | They must match exactly; SquadJS loads plugins by class name. |121| Using `steamID` as the primary key | `eosID` is the primary id (always present); `steamID` is the fallback. |