agentsclimarketplace

Squadjs plugin creator skill

Skill Hans-Vader/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

Install
npx -y skills add Hans-Vader/squadjs-plugin-creator-skill

Assembled 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

  1. Brainstorm intent first. REQUIRED SUB-SKILL: superpowers:brainstorming. Pin down the concrete behaviours the admin wants, in plain language, before any mapping.
  2. 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.
  3. 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.
  4. Pick base class & connectors. DiscordBasePlugin if it posts to Discord, else BasePlugin. Add the sequelize connector only if state must survive restarts.
  5. Scaffold from a template. Copy the matching file from templates/ — they already encode the correct lifecycle and avoid the traps below.
  6. 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.*.
  7. 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)

RequestReality
get a single reliable "player left" event covering all departuresPLAYER_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 / gridNo event or RCON command exposes player or death coordinates. The player object has no x/y/z.
player health / HP / staminaNot exposed anywhere.
reliable team data immediately after NEW_GAMEteamID is null transiently (~30 s) for many players right after NEW_GAME.
kick / change layer / end match / disband squadNo 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)

MistakeFix
this.server.removeEventListener(...) in unmountIt 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 unmountremoveListener 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 triggerIt 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 methodsOnly 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 unmountStore the handle and clear it in unmount alongside listener removal.
A required: true option whose config value equals its defaultBasePlugin throws at load. Required options must be given a non-default value in config.
"plugin" name in config ≠ exported class nameThey must match exactly; SquadJS loads plugins by class name.
Using steamID as the primary keyeosID 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

templates/

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.