Squadjs plugin creator skill
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.From its SKILL.md
npx -y skills add Hans-Vader/squadjs-plugin-creator-skillAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 1 command, including `grep -n removeEventListener <file>`.
SKILL.md
8.6 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
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.
DiscordBasePluginif it posts to Discord, elseBasePlugin. Add thesequelizeconnector 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()andunmount()must be exactly symmetric; handlers must be idempotent (events burst and duplicate); read config fromthis.options.*. - Verify + install.
references/installation.mdcovers theconfig.jsonblock and live-server verification (load it, watchverboseoutput, 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 throwsTypeErrorat runtime and leaks the listener; useremoveListener/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(...)inmount()has a matchingremoveListenerinunmount(), and everysetInterval/setTimeouthas a matchingclearInterval/clearTimeoutinunmount(). - No invented API: every event name you register, and every
rcon.*/server.*call, appears inreferences/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;teamID1/2 flips each round. Resolve a short name ("USA") via theroleclassname 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 inunmount); matchCHAT_MESSAGEyourself only when the trigger isn't a clean single!word(see §3). - Double-switch to unstick a bugged player (§4): call
switchTeamtwice 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. |
What ships with it: 8 files
82.5 KB alongside SKILL.md, 3 of them executable
references/
- api-reference.md16.8 KB
- event-reliability.md7.0 KB
- installation.md3.1 KB
templates/
- database-plugin.jsruns3.8 KB
- discord-plugin.jsruns2.6 KB
- minimal-plugin.jsruns2.7 KB