Skip to content

VagifPashayev/SquadGo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SquadGo

Go License No external deps

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).


Features

  • Source RCON client — authenticated connection, automatic reconnect, keepalive pings
  • Log file tailing — real-time parsing of SquadGame.log with rotation handling
  • 40+ typed events — from PlayerConnected to VehicleDamaged, 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

Architecture

┌─────────────────────────────────────────────────────────────┐
│                        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 layout

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)

Quick Start

Prerequisites

  • Go 1.23+
  • A Squad dedicated server with RCON enabled

Install

go get github.com/VagifPashayev/squadgo

Minimal single-server example

package 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)
}

Adapter Options

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

Events Reference

All events are plain Go structs in the model package. Subscribe using events.Subscribe.

RCON poll-derived events

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

Player lifecycle events (diff between polls)

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

Squad events (diff between polls)

Event Trigger
model.SquadCreated New squad appears in ListSquads poll

Session events (log-derived)

Event Trigger
model.PlayerSessionOpened LogSquadTrace: PostLogin in log
model.PlayerSessionClosed LogSquadTrace: Logout in log

Combat events (log-derived)

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

Round / match events

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

Player movement events (log-derived)

Event Trigger
model.PlayerPossessed Player controller takes control of a pawn
model.PlayerUnpossessed Player controller releases its pawn

Admin / server events (log-derived)

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

Writing a Plugin

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.

Example: warn team-killers

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
}

Example: periodic broadcasts

events are not requiredplugins 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
}

Chat Commands

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 API

*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.Router

Multi-Server Setup

Run 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.


Live State

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.


Implementing a Custom Adapter

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.


Bundled Plugins

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)

License

MIT

About

Go framework for building Squad dedicated server bots and admin automation

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages