Squadjs plugin creator skill
Creating, writing, or scaffolding a SquadJS plugin, adding a server-side feature to a Squad server via SquadJS, or deciding whether SquadJS can support a desired behaviour
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.
One thing 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.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
8.6 KB, 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. |