Back to Projects

SquadJS Server Management Suite

Five MIT-licensed plugins for the SquadJS framework: TrueSkill ratings, skill-aware team balancing, join-time player assignment, team-switch enforcement, and a shared services layer. Running on live 100-player servers.

Featured
Category

Backend Tooling

Released

August 2026

JavaScript Node.js Sequelize TrueSkill Discord API RCON Game Server Backend Open Source

Squad has no matchmaking. A hundred players sort themselves onto two teams on a community-run server, and once one side is stronger the gap widens on its own. Experienced players read where a round is going long before it ends, and some of them move to the winning side, so the next round starts further apart than the last one did. Nothing resets at the map change. The imbalance compounds across a server for weeks.

This is five plugins that fix that with no admin in the loop, and it turned out to be a distributed systems problem wearing a game-modding costume. Three of them issue commands to the same hundred players over one unreliable channel, with no shared state and nothing to coordinate through. One has about three seconds to decide anything. The balancing algorithm was the easy half. Getting three independent actors to agree about who’s on which team was the rest of it.

On the server it was built for, blowout rounds are down more than 15%, and it’s been months since anyone scrambled teams by hand.


The problem

Squad ships with no balancing tools and no skill data, and its team-switch command is permissive enough to undo any balance that does emerge: a change every five minutes, uncapped, all round.

Five things feed each other. Strong players get frustrated on a losing team and leave it, which makes it worse. The losing team bleeds players generally, and whoever replaces them is on average less experienced. People gravitate toward the team with names they recognise, and the experienced team always has more of those, so stacking happens without anyone intending it. An admin who wants to fix it can ask people to move and hope they agree, or run a random scramble that’s as likely to make things worse. And anyone who dislikes the outcome can switch back in five minutes.

None of that is dramatic on its own. Together they compound, and only ever in one direction.

I used to argue this was a community problem, fixable by asking people to be better sports about it. I don’t think that anymore. The cycle runs on its own momentum whatever anyone intends, and good faith from individual players doesn’t interrupt it. It needs tooling that watches the server and reacts.


Metrics

Figures are current as of August 2026 and self-reported, measured on the servers I have data for against their own pre-suite baseline. They aren’t a benchmark against other Squad servers. Test counts are approximate, because the suites report in different units.

CategoryMetricDetail
Impact>15% fewer blowouts, 5% more close gamesMeasured against pre-suite baseline on the primary server
Adoption~6 serversTwo known to be running the full suite with native switching disabled; likely more that haven’t been in touch
Players rated12,000+Single-server rating database
Rating calibration1,448 historical matches, 11,016 playersTwo-pass grid search over the TrueSkill parameters, validated against actual outcomes
Predictive signal~90%How often the favoured team wins once the rating gap between teams passes 0.5
Scramble search2,000 candidate plans per scrambleSingle-phase search over whole-squad moves; squads are never split
Tests~900 automated, all green~720 cases across 39 registered service-layer files, ~197 across four consumer plugins. Counted August 2026
Consolidation3 implementations → 1Clan detection, player tracking and persistence each existed once per plugin; now each exists once, in a shared service layer

The plugins

The first balancer

This started as a replacement, not an ambition. When the community was founded we lost access to the proprietary scrambler we’d been relying on, and had nothing to put in its place.

The first TeamBalancer tracked consecutive wins and one-sided results, then shuffled. It preserved squads, so it didn’t break up friend groups, and beyond that it wasn’t clever. With no skill data on anyone, shuffling the deck and hoping was the best strategy available. Sometimes it worked. Other times it made things worse, and there was no way to tell which round you were about to get. That’s the honest ceiling on rebalancing without information: you can move people around, but you can’t say whether you improved anything.

The rating system

EloTracker implements a TrueSkill-derived rating, the same family Xbox Live uses, adapted for Squad’s format. Every player carries two numbers: an estimate of their skill, and the uncertainty around that estimate. They feed different consumers, and keeping them apart matters. The public leaderboard shows a deliberately conservative figure, the estimate minus three times the uncertainty, so a player has to be both good and well established before the number flatters them. The balancer never sees that display rating. It weighs teams on the estimate alone, because a strong newcomer whose public number is being held down is still as strong as the estimate says, and balancing on deliberately pessimistic figures balances toward a fiction. Rating changes are weighted by how long a player was actually present, which stops someone who joined at the end absorbing full credit for the round.

What I left out was the deliberate part. TrueSkill takes win/loss and overall team strength, nothing else, and I kept it that way instead of adding kill/death ratio to make ratings converge faster. Kills are a poor proxy for contribution here. A squad lead can spend a round building habs, running resupply and holding the team together over comms, and finish with almost nothing on the scoreboard. Margin of victory is out for the same reason. A win is a win.

Every metric you add to a rating system encodes a belief about what makes a player good. I didn’t trust my beliefs there more than I trusted the match result, and a system tuned toward what I think skill looks like can easily predict outcomes worse than one that just watches who wins. The cost is patience: with only win/loss to learn from, a rating takes many rounds to mean anything.

Refusing to add signals puts more weight on the two parameters TrueSkill does expose, so I measured those rather than guessing. I pulled 1,448 historical matches and a database snapshot of 11,016 players and ran a two-pass grid search over them: a coarse sweep of a thousand parameter combinations, then a fine sweep around the best three.

The first run failed in a way that looked like a bounds problem and wasn’t. My scoring function penalised prediction variance, and every run pinned itself to the edge of the search space no matter where I put the edge. Both parameters increase variance when pushed in the direction the penalty rewarded, so the penalty had an optimum of its own and the search kept finding that instead of finding accuracy. Rerunning with no penalty at all landed on an interior optimum immediately, which pointed at the penalty rather than the data. Replacing it with straight calibration error, the gap between predicted win probability and observed results, gave a search that converged where it should.

What came out was a slightly wider skill-chain parameter, about 16% above stock, and a dynamic-uncertainty parameter roughly three times stock. The second one is why it was worth doing. The default assumes ratings should settle and then stay settled, which is right for ladder matchmaking and wrong for a community server where the same names show up for years and genuinely change over that time. Together they cut calibration error by a factor of 3.6. Past a skill gap of 0.5 between teams, the favoured team goes on to win around 90% of the time.

EloTracker leaderboard showing the top 25 ranked players
2,640 players have played enough rounds to rank, out of 12,213 tracked. The listed figure is the conservative one, so a good run of form alone won't put someone near the top.
Per-player stats showing competitive rank, estimated skill and system certainty separately
The same player from the balancer's side. It reads the estimated skill of 46.8, not the 29.5 shown publicly, and the uncertainty is reported rather than folded in.

Join-time assignment

Scrambling fixes a team after it’s already gone wrong. The other lever is the moment a player arrives, before there’s anything to fix.

By default Squad puts a joining player on whichever team has fewer people. That’s reasonable, and it ignores everything that matters. It doesn’t know the player is the strongest person on the server, and it doesn’t know their clan is already six deep on the other side. SmartAssign makes that call using what the rest of the suite already knows, weighing skill, clan affiliation and team size, and places the player before they finish loading in.

The deadline is the hard part. The whole decision, rating lookups and server command included, has to finish inside roughly three seconds. Miss it and the player watches themselves get teleported to the other team, which is worse than a slightly unbalanced round. So the assignment path got tuned as hard as it would go. The straightforward version looked up each player’s rating on its own, up to 98 round trips per evaluation for around 1,650ms, which fit the window but left nothing spare. Folding those into one batched pre-fetch took the whole decision to 20-50ms, far enough inside the deadline that a slow database or a busy server can’t eat the budget.

On its own SmartAssign is a marginal improvement. Its value is cumulative. It stops the roster drifting between scrambles, so the scrambler has less to undo.

The scramble engine

With ratings available, TeamBalancer stopped shuffling and started searching. It builds 2,000 randomised candidate plans in a single pass and keeps the best-scoring one.

Every move in a plan is a whole-squad move, and that constraint shapes the rest of the design. The only units the search can pick up are squads, unassigned players treated as squads of one, and, with clan grouping enabled, clan members folded into a virtual squad. There’s no escalation tier that breaks a squad open when a good split proves hard to reach, so a split that would need one is simply never reached. Even the corrective pass that trims an over-cap team draws only from unassigned players, and tolerates the overage rather than pulling someone out of a squad.

That costs accuracy and it’s worth it. Without clan grouping the search lands within one player of even on every run. With grouping on, about 97% land within two, because keeping a clan together is a hard rejection rather than a preference. The alternative is a scramble that balances perfectly and splits up the six people who showed up to play together, which is the fastest way to make players hate an automated system.

Scoring depends on what data is present. With ratings, the score is a composite of the mean skill difference between teams, the difference across each team’s top fifteen, veteran parity and numerical balance, and it replaces the heuristic penalties rather than adding to them. With no ratings it falls back to numerical balance silently.

A full scramble fires on a dominant win streak or a single round with an extreme ticket margin. Ticket margin is a lagging signal though. It tells you a round went badly, not that the next one is set up to. So a second, quieter trigger watches the skill gap directly: when the average rating gap between the two teams crosses a threshold at the end of a round, a micro scramble moves the fewest players needed to bring that gap back under about 0.05, hard-capped at a fifth of the server so it can’t escalate into a full scramble by accident. It’s opt-in, it can be run by hand, and it doesn’t trip the switch lockdown a full scramble does, because moving four people over a small gap doesn’t warrant one.

One full run, from the trigger to the lockdown that keeps the result from being undone. This one fired on ticket margin; the win-streak trigger and the micro scramble look much the same from the outside.

Extreme ticket difference detected, followed by the round-end report
A 273-ticket round trips the extreme-margin trigger. The round-end report breaks the two teams down by veterancy and rating before anything moves.
Scramble execution plan listing whole squads with per-squad ratings
The plan for 42 players, found in 68ms. Squads move whole, which is why the unit of the search is a squad and not a player.
Scramble completed, 42 of 42 players moved successfully
42 of 42 moved in four seconds, no failures and nobody dropped.
Scramble lockdown and post-scramble team balance
The lockdown that stops the teams reassembling themselves, and the result: 0.21 rating difference, an even split of regulars.

Switch enforcement

Those three plugins narrowed ticket differences measurably, and they shared one assumption: that players would broadly go along with the result. Nothing enforced it. The game’s own team-change command stayed available all round, so every balancing decision was a suggestion a player could decline, and groups declined it deliberately. Entire clans would switch straight back after a scramble to reassemble the stack they’d just been broken out of.

Closing the loop meant taking the native command away and replacing it with something less permissive but still defensible. Mid-round changes are blocked unless the player has only just joined, and blocked entirely for a window after a scramble.

The cooldown is a token bucket rather than a timer, which was a deliberate correction to how a flat cooldown feels from the other side of it. A flat timer punishes the player who spent their switch early for a good reason exactly as hard as the one gaming it, and it makes the honest pattern impossible: move over to fill a squad that needs a medic, move back twenty minutes later. Players hold two tokens by default, each regenerating on its own clock, so one legitimate move doesn’t lock someone out for the evening while repeated moves still drain the bucket. Set the cap to one and it degrades to exactly the old flat behaviour, which is what makes it safe to hand to an operator who liked the old behaviour.

Seeding earns its own allowance on top. People who sit on an empty server for an hour to get it populated are doing unpaid work for the operator, so they accrue bonus tokens for time spent in seed rounds, stacking above the normal cap, with a consolation token for anyone still there when the round finally fills. A configurable population floor gates the accrual, or two people idling at 2am would mint tokens indefinitely.

The queue exists because the restriction created a new problem. Once the native path is gone, the switch command is the only way to change teams, so a player refused for balance reasons has nowhere to go but retry. In practice that meant spamming the command to beat other people to the next open slot, which isn’t obviously fairer than the free-for-all it replaced. A queue that holds a player’s place, pairs them with someone going the other way, and moves them the moment it’s safe removes the incentive. Making that work needs player state accurate within seconds rather than within a polling cycle, which is much of why the shared services layer had to exist at all.

Thirty-day switch statistics with denial reasons and queue outcomes
Thirty days across 462 rounds. Most denials are the post-scramble lockdown doing its job, not the cooldown.
2267 requests, 86.1% granted. Of the 13.8% refused, more than half hit the scramble lockdown and another quarter fell outside the mid-round window, so the token bucket itself accounts for about one denial in nine.
Of the requests that queued rather than being refused outright, 83.7% ended in a move. Median wait was one minute.
The data-quality note at the bottom is there because the aggregate spans a schema change. Rounds recorded before the current format can't answer every field, and the report says so rather than quietly averaging over them.

Two problems ran underneath all of this and got worse as the system grew. Neither is about balancing teams. The platform changes underneath you without announcing it, and three plugins acting on the same players will eventually act on the same player at once.


Two underlying problems

Platform drift

The framework reports player state on a roughly 30-second polling cycle while surfacing join events almost immediately. Two sources of truth with different freshness guarantees, and they drift. A player can join, switch and leave between polls. No plugin can treat the framework’s player list as authoritative, so each one forces a refresh and infers changes itself.

Then the game changed. The developers added map voting and faction voting at the end of a round, running in sequence, and team changes are blocked during faction voting while staying allowed during map voting. The scramble had been written against an end-of-round timeline with no such restriction, so it issued moves into a window that silently refused them. Working out that the boundary was faction voting rather than the end of the round took log observation, because the game enforces the restriction and never surfaces it to plugins.

The identity layer moved too. Squad is migrating from Steam authentication to Epic accounts, so steamID is increasingly undefined. Every plugin keying on it was quietly broken for those players, and nothing raised an error. The framework version that added the new identifier came with a real backward-compatibility wrinkle: servers still on the older version don’t have it at all.

That one has an honest coda. Player name works reliably for team-change commands and is always present. The new identifier sometimes fails, silently, and I haven’t isolated a cause. What I built was a fallback cascade rather than a diagnosis: prefer what’s always there, fall back through the rest, and treat that layer as something to be defensive around rather than something I understand.

Concurrent moves on the same player

SmartAssign moves players on join. Switch moves players in response to change events. TeamBalancer moves players during a scramble. Any two, sometimes all three, can try to move the same player at the same moment, over a channel with no transactions and no ordering guarantees.

Before assuming that was a real problem I measured it. Across 994 rounds and 59,594 join events of production logs, 12.3% of joins landed within a second of another, 62.4% of rounds contained a cluster of three or more inside one second, and the worst case was nine joins in two seconds. Frequent enough to matter, rare enough that it would never have reproduced in testing.

The fix was a priority-based per-player locking system, with the scrambler holding a global lock at the highest priority, assignment holding per-player locks beneath it, and the switch queue deferring to both. An earlier bug shows why this needed to be explicit: SmartAssign checked whether anyone else held a lock but never acquired one itself, so it was politely checking a door it never closed behind it.

Underneath that sat the real motivation for what came next. Each plugin had grown its own independent player-tracking system, which was tech debt I hadn’t anticipated when I built the first version, and not the quiet kind.


The S³ service layer

Four plugins had independently grown the same machinery: clan tag parsing in three of them, player state tracking in two with subtly different behaviour and both competing for the same framework event, database bootstrapping in all four, game phase resolution everywhere. A fix to clan detection meant three separate changes, three test suites and three releases, and drift between the copies was inevitable.

S³ is a service container that owns the ground truth the others read. Game state, player registry and locking, clan grouping, faction identification, server configuration, persistence and logging each exist exactly once, mount in dependency order, and are read rather than cached by the plugins above them. The lines that disappeared aren’t the interesting part. Collapsing three clan parsers into one saved a couple of hundred lines and I’d have done it if it had saved none, because the value is that there’s now one answer to each question instead of three that agree right up until they quietly stop agreeing.

S3 service container status and per-plugin database schema versions
Every service that has to be up, the current lock state, and the schema version each plugin is running against.
The top panel is the container itself: each service reports its own health, and the round phase, resolved teams and global lock are read from one place instead of being inferred separately by five plugins.
The bottom panel is why the plugins can ship independently. Each owns a schema version, the container knows which versions are current, and a plugin that needs a newer one says so instead of failing at the first query.

That turned out to be load-bearing, because several things weren’t merely duplicated before. They were out of reach for a standalone plugin however well written, since they need a shared source of truth no single plugin can hold.

The framework’s team-change event carries no source flag, so nothing can tell a player’s own switch apart from an automated move. A standalone plugin could only resolve that by having one other named plugin emit a custom marker for it to listen for, which is a pairwise arrangement that doesn’t scale and can’t account for a plugin nobody has written yet. One shared attribution point means every consumer, third-party ones included, reads the same answer. Locking is the same story from further back: there was no locking primitive at all before, not even between my own four plugins, and now any plugin can register itself into the same priority system the built-in ones use.

The ambiguous window took longest to see. For the first thirty to ninety seconds of a round the framework reports no team for most players. A single plugin can reason about that carefully on its own, and the old assignment plugin did, by waiting it out. But two plugins reasoning independently have no guarantee of reaching the same conclusion about the same player, and nothing to check against. There’s now one projected answer that every consumer reads at the same moment, which closes a disagreement window that stayed open even while each plugin’s own logic was correct.

Consolidation also made things possible that weren’t before. The switch queue needs per-tick player diffs to pair people and move them the moment a slot opens, and no plugin polling on its own 30-second cycle could have done it.

All five plugins now live in one repository, with a build script that assembles a chosen subset into a deployable layout, detects file collisions between plugins, and always includes S³ because everything depends on it. That repository also carries a drop-in replacement for one of the framework’s own core plugins, hardened against the database failures below.


Reliability

Most of the work since consolidation has gone into what happens when conditions aren’t ideal.

The suite supports SQLite, MySQL and Postgres, and those aren’t interchangeable. Postgres folds unquoted identifiers to lower case while the ORM creates them quoted, so camelCase column names silently stop matching. That defect is invisible on SQLite and MySQL and fatal on Postgres, which means a green test run on a developer machine proves nothing about it. There’s now a dialect portability suite that runs against real engines rather than mocks, because identifier folding and collation are properties of the database, not of the code, and a hand-written mock will happily report success on broken SQL. Deployment permissions get the same treatment: the live database user can create tables and indexes but can’t alter or drop them, so there’s a permission tier that mirrors that grant exactly. It caught a real defect that passed cleanly under full privileges.

Schema changes get the same caution. A migration is announced, not applied: the engine reports what it wants to do and waits for a token that expires, so nobody upgrades a live server by reflex at the wrong moment.

Migration from switch schema v5 to v6 behind an expiring confirmation token
A pending migration names the version it moves to and cancels itself after five minutes. Declining leaves it pending rather than half-applied.
Full database export listing 22 tables and their row counts
A full export streams 22 tables and 3.45 million rows to a compressed backup. It's the same command an operator runs before accepting a migration.

Degradation was the other change in posture. One missing column at round end used to crash the process and take all four plugins down with it. Now, when the database is unreachable, the switch queue keeps its state in memory and reconciles at round end, game state still emits phase changes even if persisting them fails, and the scrambler falls back to memory rather than aborting. Eligibility checks fail open. An unattended plugin on someone else’s server doesn’t get to wait for a human.

The most useful test in the suite doesn’t test code. An audit found the developer guide describing two configuration flags as defaulting to off when both default to on, and documenting a callback signature that would leave anyone coding from it with silently undefined values. None of that would fail a normal test run, because documentation has no runtime. So the guide got one: a suite that parses the documentation and checks its claims against the source, turning doc drift into a build failure like any other.

These plugins are licensed to independent communities that meet conduct and staffing requirements, and those operators are the ones dealing with it at 2am when something breaks.


Results

A year of live rounds, weekly. Ticket differences fall from around 150 to around 110 while rounds get longer, which is the combination worth having: closer games that also last longer, rather than closer games because everyone left.

Weekly average ticket difference and round duration from September 2025 to August 2026
Weekly average ticket difference against average round duration, September 2025 to August 2026.
Distribution of end-of-match ticket differences with and without ratings
End-of-match ticket differences, rating-assisted balancing against the heuristics alone.
The distribution moves in both directions at once. Rounds finishing within 49 tickets go from 17.9% to 23.5% of the total, and blowouts past 300 tickets fall from 7.1% to 2.9%.
The crossover sits between the 100 to 149 and 150 to 199 buckets, which is roughly where a round stops being close and starts being decided. Ratings pull mass to the left of that line and take it off the right.
Both series are RAAS rounds on the same server, so the comparison isn't across different maps or player populations.

The qualitative change is the part I care about and the hardest to put a number on. Rounds feel competitive rather than decided early, and complaints about team stacking have gone from constant background noise to essentially nothing. The system runs unattended, which was the point.

The suite is open source and deliberately not all-or-nothing. Running the balancer and the rating tracker while leaving native switching in place will already cut complaints, and that’s a sensible place for most servers to stop. The full setup means disabling native team changes and running everything, which is a real commitment for an operator and worth it only if the problem is bad enough to justify it. Two servers I know of have gone that far. There are probably others that have never had reason to tell me.


Source

All five plugins live in a single MIT-licensed monorepo.

Before the consolidation, four of them were separate repositories with their own releases and their own users. They’re left up rather than deleted, because servers still running them deserve the code and the issue history to stay where their links point.