A production-grade Go framework for building Squad dedicated server bots and admin automation tools. Inspired by SquadJS, built from the ground up in Go.
SquadGo connects to a Squad server via RCON and optionally tails the server log file to produce a unified, type-safe stream of game events. You build plugins that subscribe to those events and call back into the server (warn, kick, ban, broadcast, and more).
- Source RCON client — authenticated connection, automatic reconnect, keepalive pings
- Log file tailing — real-time parsing of
SquadGame.logwith rotation handling - 40+ typed events — from
PlayerConnectedtoVehicleDamaged, covering RCON polls and log lines - Type-safe event bus — zero allocations per dispatch, generics-based
Subscribe[T] - Live state snapshot — always-current players/squads/map state accessible from any plugin
- Chat command router — prefix-based dispatcher with alias support
- Plugin/Module interface — drop in any number of plugins at startup
- Multi-server — run as many servers as you need in a single process with goroutine-per-server
- Zero external dependencies — pure Go standard library
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ srv := server.New(...) │
│ srv.AddPlugin(myplugin.New()) │
│ srv.Run(ctx) │
└───────────────────────┬─────────────────────────────────────┘
│
┌───────────▼───────────┐
│ server.Server │ ← core runtime
│ │
│ ┌─────────────────┐ │
│ │ events.Bus │ │ ← type-safe pub/sub
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ state.Store │ │ ← live server state
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ commands.Router │ │ ← chat command dispatch
│ └─────────────────┘ │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ adapter.ServerAdapter│ ← interface
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ adapter/squad │ ← Squad implementation
│ │
│ ┌─────────┐ │
│ │ RCON │ ─────────┼──► Squad Server :21114
│ └─────────┘ │
│ ┌─────────┐ │
│ │ Tailer │ ◄────────┼─── SquadGame.log
│ └─────────┘ │
└───────────────────────┘
| Package | Responsibility |
|---|---|
adapter |
ServerAdapter interface — every method a server exposes |
adapter/squad |
Squad implementation: RCON client, log tailer, all parsers |
events |
Generic type-safe publish/subscribe bus |
model |
All event and state types |
game |
Diff logic that turns raw snapshots into semantic events |
state |
Thread-safe in-process server state (players, squads, map, etc.) |
commands |
Chat command router (!kick, !ban, etc.) |
server |
Core runtime: wires everything together, exposes AddPlugin / Run |
plugins/* |
Example plugins (chatlogger, autobroadcast, teamkillwarn) |
cmd/squadgo |
Runnable example (single-server and multi-server) |
- Go 1.23+
- A Squad dedicated server with RCON enabled
go get github.com/VagifPashayev/squadgopackage main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/VagifPashayev/squadgo/adapter/squad"
"github.com/VagifPashayev/squadgo/server"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
adpt, err := squad.New(squad.Options{
Host: "game.example.com",
Port: 21114,
Password: "rcon-password",
}, logger)
if err != nil {
logger.Error("adapter error", "error", err)
os.Exit(1)
}
srv := server.New("s1", "My Server", "!", adpt, logger)
srv.AddPlugin(myplugin.New())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv.Run(ctx)
}squad.Options controls the connection and all polling intervals.
| Field | Type | Default | Description |
|---|---|---|---|
Host |
string |
— | RCON host (IP or hostname) |
Port |
int |
— | RCON port (typically 21114) |
Password |
string |
— | RCON password |
LocalAddress |
string |
"" |
Local bind address (optional) |
LogFilePath |
string |
"" |
Path to SquadGame.log. Leave empty to disable log tailing. |
PollPlayersInterval |
int |
15 |
Seconds between ListPlayers RCON polls |
PollSquadsInterval |
int |
15 |
Seconds between ListSquads RCON polls |
PollServerInfoInterval |
int |
15 |
Seconds between ShowServerInfo polls |
PollMapsInterval |
int |
30 |
Seconds between map polls |
PingInterval |
int |
120 |
Seconds between RCON keepalive pings |
ReconnectDelay |
int |
10 |
Seconds to wait before reconnect attempts |
DialTimeout |
int |
10 |
Seconds for initial TCP dial timeout |
All events are plain Go structs in the model package. Subscribe using events.Subscribe.
| Event | Trigger |
|---|---|
model.PlayersUpdated |
Every ListPlayers poll |
model.PlayersSnapshot |
Raw players poll result |
model.SquadsSnapshot |
Raw squads poll result |
model.CurrentMap |
Current map poll |
model.NextMap |
Next map poll |
model.ServerInfo |
ShowServerInfo poll |
| Event | Trigger |
|---|---|
model.PlayerConnected |
Player appears in new poll, absent from previous |
model.PlayerDisconnected |
Player absent from new poll, present in previous |
model.PlayerTeamChanged |
Player's TeamID changed between polls |
model.PlayerSquadChanged |
Player's SquadID changed between polls |
model.PlayerRoleChanged |
Player's role changed between polls |
model.PlayerLeaderChanged |
Player's squad leader status changed between polls |
| Event | Trigger |
|---|---|
model.SquadCreated |
New squad appears in ListSquads poll |
| Event | Trigger |
|---|---|
model.PlayerSessionOpened |
LogSquadTrace: PostLogin in log |
model.PlayerSessionClosed |
LogSquadTrace: Logout in log |
| Event | Trigger |
|---|---|
model.PlayerDamaged |
Player takes any damage |
model.PlayerWounded |
Player is downed (incapacitated) |
model.PlayerDied |
Player killed (final death) |
model.PlayerRevived |
Medic revives a downed player |
model.PlayerSuicide |
Player kills themselves |
model.VehicleDamaged |
Vehicle takes damage |
model.DeployableDamaged |
FOB / HAB / deployable takes damage |
model.ExplosiveDamaged |
Area explosion deals damage |
| Event | Trigger |
|---|---|
model.NewGame |
Layer changes between ShowServerInfo polls |
model.LogNewGame |
LogWorld: Bringing World in log |
model.MatchStateChanged |
Any match state transition in log |
model.RoundEnded |
State transitions InProgress → WaitingPostMatch |
model.RoundTickets |
End-of-round ticket counts in log |
model.RoundWinner |
Winner announced in log |
| Event | Trigger |
|---|---|
model.PlayerPossessed |
Player controller takes control of a pawn |
model.PlayerUnpossessed |
Player controller releases its pawn |
| Event | Trigger |
|---|---|
model.AdminBroadcast |
Admin issues a broadcast command |
model.FactionVoteWillStart |
Upcoming faction vote announced |
model.LogSquadCreated |
Squad creation in log (with identity fields) |
model.ServerTickRate |
Server TPS reported in log |
model.SatConfigHook |
SAT anti-cheat flag |
Implement the server.Module interface:
type Module interface {
Name() string
Setup(ctx context.Context, srv *server.Server) error
}Setup is called once before the event loop starts. Use it to subscribe to events and register commands. Everything runs until ctx is cancelled.
package teamkillwarn
import (
"context"
"fmt"
"log/slog"
"github.com/VagifPashayev/squadgo/events"
"github.com/VagifPashayev/squadgo/model"
"github.com/VagifPashayev/squadgo/server"
)
type Plugin struct {
warnMsg string
logger *slog.Logger
}
func New(warnMsg string, logger *slog.Logger) *Plugin {
if warnMsg == "" {
warnMsg = "You team-killed %s. Please be careful!"
}
return &Plugin{warnMsg: warnMsg, logger: logger}
}
func (p *Plugin) Name() string { return "teamkillwarn" }
func (p *Plugin) Setup(_ context.Context, srv *server.Server) error {
events.Subscribe(srv.Bus(), func(ctx context.Context, e model.PlayerDied) error {
if e.AttackerSteamID == "" {
return nil
}
snap := srv.Snapshot()
var attackerTeam, victimTeam string
for _, player := range snap.Players {
if player.SteamID == e.AttackerSteamID {
attackerTeam = player.TeamID
}
if player.Name == e.VictimName {
victimTeam = player.TeamID
}
}
if attackerTeam == "" || victimTeam == "" || attackerTeam != victimTeam {
return nil
}
return srv.WarnPlayer(ctx, e.AttackerSteamID, fmt.Sprintf(p.warnMsg, e.VictimName))
})
return nil
}events are not required — plugins can also use plain goroutines:
func (p *Plugin) Setup(ctx context.Context, srv *server.Server) error {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
srv.Broadcast(ctx, "Welcome to the server!")
}
}
}()
return nil
}Register commands with the chat router:
func (p *Plugin) Setup(_ context.Context, srv *server.Server) error {
return srv.Router().Register(&kickCommand{srv: srv})
}
type kickCommand struct{ srv *server.Server }
func (c *kickCommand) Name() string { return "kick" }
func (c *kickCommand) Aliases() []string { return []string{"kick", "k"} }
func (c *kickCommand) Handle(ctx context.Context, cmd commands.Context) error {
// cmd.Args = everything after the command word
// cmd.Message.SteamID = the player who typed the command
// cmd.Host = the server
return cmd.Host.Broadcast(ctx, "Kick command received: "+cmd.Args)
}Commands are triggered when a player sends a chat message beginning with the prefix (default !):
!kick 76561198xxxxxxxxx griefing
Matching is case-insensitive. Multiple aliases can map to the same handler.
*server.Server (and commands.Host) expose:
// Actions
WarnPlayer(ctx, steamID, message string) error
KickPlayer(ctx, steamID, reason string) error
BanPlayer(ctx, steamID, duration, reason string) error
Broadcast(ctx, message string) error
ForceTeamChange(ctx, steamID string) error
DisbandSquad(ctx, teamID, squadID string) error
EndMatch(ctx) error
// State
Snapshot() model.ServerSnapshot // atomic snapshot of players/squads/map
LookupPlayer(steamID string) (model.Player, bool)
ConnectionState() adapter.ConnectionState
// Infrastructure
Bus() *events.Bus
Router() *commands.RouterRun multiple servers in a single process. Each server gets its own goroutine and shares the parent context for graceful shutdown:
var wg sync.WaitGroup
for _, cfg := range serverConfigs {
cfg := cfg
wg.Add(1)
go func() {
defer wg.Done()
adpt, _ := squad.New(squad.Options{
Host: cfg.Host,
Port: cfg.Port,
Password: cfg.Password,
}, logger)
srv := server.New(cfg.ID, cfg.Name, "!", adpt, logger)
srv.AddPlugin(myplugin.New())
srv.Run(ctx)
}()
}
wg.Wait()A single Ctrl-C cancels the shared context and triggers clean shutdown of every server.
See cmd/squadgo/main.go for the full runnable example.
srv.Snapshot() returns an atomic copy of the current server state:
snap := srv.Snapshot()
// All connected players
for _, p := range snap.Players {
fmt.Println(p.SteamID, p.Name, p.TeamID, p.SquadID, p.IsLeader)
}
// Current map info
fmt.Println(snap.CurrentMap.Layer)
// Server info
fmt.Println(snap.ServerInfo.PlayerCount, snap.ServerInfo.MaxPlayers)srv.LookupPlayer(steamID) is a fast single-player lookup that does not copy the full roster.
SquadGo ships with the adapter/squad implementation. You can write your own by implementing adapter.ServerAdapter:
type ServerAdapter interface {
Events(ctx context.Context) (<-chan any, error)
ConnectionState() ConnectionState
ForceTeamChange(ctx context.Context, steamID string) error
DisbandSquad(ctx context.Context, teamID, squadID string) error
KickPlayer(ctx context.Context, steamID, reason string) error
BanPlayer(ctx context.Context, steamID, duration, reason string) error
EndMatch(ctx context.Context) error
WarnPlayer(ctx context.Context, steamID, message string) error
Broadcast(ctx context.Context, message string) error
}The Events channel must send the typed model/log events. The server runtime handles everything else.
| Plugin | Package | Description |
|---|---|---|
| Chat Logger | plugins/chatlogger |
Logs all chat messages via slog |
| Auto Broadcast | plugins/autobroadcast |
Sends rotating messages on a configurable interval |
| Team Kill Warn | plugins/teamkillwarn |
Warns players who commit team kills (requires log tailing) |
MIT