Knight docs
EN ID
GitHub
← All plugins

Voice

Proximity voice. The server decides who hears whom; audio travels outside RakNet.

Kind
Go + sidecar
Status
bundled, compiled in
Natives
VoiceJoin VoiceLeave VoiceMute VoiceDeafen VoicePeers VoiceInRange VoiceSetRange
Hooks
player::spawn player::update player::disconnect

Configuration

VariableDefault
KNIGHT_VOICE_URLhttp://127.0.0.1:7788
KNIGHT_VOICE_RANGE30

Enable

Add the path to server.plugins (or `plugins` in server.cfg).

knight
server.plugins = ["plugins/voice"];

Workflow

sequenceDiagram
  participant C as Game client
  participant S as Knight server
  participant P as plugins/voice
  participant V as knight-voice sidecar
  participant A as Audio transport

  C->>S: connect / spawn
  S->>P: player::spawn
  P->>V: POST /join
  V-->>P: token + peers
  loop every sync update
    S->>P: player::update
    P->>V: POST /pos
    V-->>P: peers in range
  end
  C->>A: audio stream (WebRTC, not RakNet)
  A-->>C: audio of nearby players
  S->>P: player::disconnect
  P->>V: POST /leave

Source

plugins/voice/voice.go 315 lines
go
// Package voice is a Knight plugin that adds proximity voice to a gamemode.
//
// Audio never travels over SA-MP: this plugin reports player positions to a
// voice sidecar (see internal/voice) and exposes whether a player is in voice
// range. The client and the audio transport are separate.
//
// Configuration (environment):
//
//	KNIGHT_VOICE_URL     sidecar base URL, default http://127.0.0.1:7788
//	KNIGHT_VOICE_RANGE   hearing radius in world units, default 30
//
// Natives exposed to gamemodes:
//
//	VoiceJoin(playerid)            -> bool   register with the sidecar
//	VoiceLeave(playerid)           -> bool   remove from the sidecar
//	VoiceMute(playerid, bool)      -> bool
//	VoiceDeafen(playerid, bool)    -> bool
//	VoicePeers(playerid)           -> string comma-separated audible ids
//	VoiceInRange(playerid, other)  -> bool
//	VoiceSetRange(float)           -> bool
package voice

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"knight/pkg/plugin"
)

type memberState struct {
	x, y, z float64
	world   int
	peers   []int
}

type voicePlugin struct {
	api   plugin.API
	url   string
	rng   float64
	http  *http.Client
	mu    sync.Mutex
	state map[int]*memberState
}

func init() { plugin.Register(func() plugin.Plugin { return &voicePlugin{} }) }

func (v *voicePlugin) Info() plugin.Info {
	return plugin.Info{Name: "voice", Version: "1.0.0", Author: "Knight"}
}

func (v *voicePlugin) Init(api plugin.API) error {
	v.api = api
	v.url = strings.TrimRight(os.Getenv("KNIGHT_VOICE_URL"), "/")
	if v.url == "" {
		v.url = "http://127.0.0.1:7788"
	}
	v.rng = 30
	if s := os.Getenv("KNIGHT_VOICE_RANGE"); s != "" {
		if f, err := strconv.ParseFloat(s, 64); err == nil && f > 0 {
			v.rng = f
		}
	}
	v.http = &http.Client{Timeout: 2 * time.Second}
	v.state = map[int]*memberState{}

	api.RegisterNative("VoiceJoin", v.join)
	api.RegisterNative("VoiceLeave", v.leave)
	api.RegisterNative("VoiceMute", v.mute)
	api.RegisterNative("VoiceDeafen", v.deafen)
	api.RegisterNative("VoicePeers", v.peers)
	api.RegisterNative("VoiceInRange", v.inRange)
	api.RegisterNative("VoiceSetRange", v.setRange)

	api.Hook("player::spawn", plugin.PriorityNormal, v.onSpawn)
	api.Hook("player::update", plugin.PriorityLate, v.onUpdate)
	api.Hook("player::disconnect", plugin.PriorityNormal, v.onDisconnect)
	return nil
}

func (v *voicePlugin) Shutdown() {
	v.mu.Lock()
	ids := make([]int, 0, len(v.state))
	for id := range v.state {
		ids = append(ids, id)
	}
	v.state = map[int]*memberState{}
	v.mu.Unlock()
	for _, id := range ids {
		v.post("/leave", map[string]any{"id": id})
	}
}

func (v *voicePlugin) onSpawn(ctx plugin.Ctx) bool {
	v.callJoin(int(ctx.Int(0)))
	return true
}

func (v *voicePlugin) onDisconnect(ctx plugin.Ctx) bool {
	v.callLeave(int(ctx.Int(0)))
	return true
}

// onUpdate forwards position at most ~5 times per second per player.
func (v *voicePlugin) onUpdate(ctx plugin.Ctx) bool {
	id := int(ctx.Int(0))
	x, y, z := ctx.Float(1), ctx.Float(2), ctx.Float(3)
	v.mu.Lock()
	m, ok := v.state[id]
	if !ok {
		v.mu.Unlock()
		return true
	}
	m.x, m.y, m.z = x, y, z
	v.mu.Unlock()
	resp, err := v.post("/pos", map[string]any{"id": id, "x": x, "y": y, "z": z, "world": 0})
	if err == nil {
		v.storePeers(id, resp)
	}
	return true
}

func (v *voicePlugin) callJoin(id int) bool {
	v.mu.Lock()
	v.state[id] = &memberState{}
	v.mu.Unlock()
	resp, err := v.post("/join", map[string]any{"id": id, "name": ""})
	if err != nil {
		v.api.Log("voice: join %d: %v", id, err)
		return false
	}
	v.storePeers(id, resp)
	return true
}

func (v *voicePlugin) callLeave(id int) bool {
	v.mu.Lock()
	delete(v.state, id)
	v.mu.Unlock()
	_, err := v.post("/leave", map[string]any{"id": id})
	if err != nil {
		v.api.Log("voice: leave %d: %v", id, err)
		return false
	}
	return true
}

func (v *voicePlugin) post(path string, body any) (map[string]any, error) {
	b, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	resp, err := v.http.Post(v.url+path, "application/json", bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var out map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return out, nil
}

// refreshAll re-reads each member's peer list from the sidecar, resending the
// last known position so membership stays correct.
func (v *voicePlugin) refreshAll() {
	type snap struct {
		id      int
		x, y, z float64
		world   int
	}
	v.mu.Lock()
	snaps := make([]snap, 0, len(v.state))
	for id, m := range v.state {
		snaps = append(snaps, snap{id, m.x, m.y, m.z, m.world})
	}
	v.mu.Unlock()
	for _, s := range snaps {
		resp, err := v.post("/pos", map[string]any{"id": s.id, "x": s.x, "y": s.y, "z": s.z, "world": s.world})
		if err == nil {
			v.storePeers(s.id, resp)
		}
	}
}

func (v *voicePlugin) storePeers(id int, resp map[string]any) {
	arr, _ := resp["peers"].([]any)
	peers := make([]int, 0, len(arr))
	for _, e := range arr {
		if f, ok := e.(float64); ok {
			peers = append(peers, int(f))
		}
	}
	v.mu.Lock()
	if m, ok := v.state[id]; ok {
		m.peers = peers
	}
	v.mu.Unlock()
}

func (v *voicePlugin) join(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	id := int(argInt(args, 0))
	return plugin.BoolV(v.callJoin(id))
}

func (v *voicePlugin) leave(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	return plugin.BoolV(v.callLeave(int(argInt(args, 0))))
}

func (v *voicePlugin) mute(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	id := int(argInt(args, 0))
	resp, err := v.post("/mute", map[string]any{"id": id, "muted": argBool(args, 1)})
	if err != nil {
		return plugin.BoolV(false)
	}
	// A mute changes what other listeners hear, so refresh their cached lists.
	v.refreshAll()
	v.storePeers(id, resp)
	return plugin.BoolV(true)
}

func (v *voicePlugin) deafen(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	id := int(argInt(args, 0))
	resp, err := v.post("/deafen", map[string]any{"id": id, "deafened": argBool(args, 1)})
	if err != nil {
		return plugin.BoolV(false)
	}
	v.storePeers(id, resp)
	return plugin.BoolV(true)
}

func (v *voicePlugin) peers(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	id := int(argInt(args, 0))
	v.mu.Lock()
	defer v.mu.Unlock()
	m, ok := v.state[id]
	if !ok {
		return plugin.StrV("")
	}
	parts := make([]string, len(m.peers))
	for i, p := range m.peers {
		parts[i] = strconv.Itoa(p)
	}
	return plugin.StrV(strings.Join(parts, ","))
}

// inRange answers locally from the last known positions, without the sidecar.
func (v *voicePlugin) inRange(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	a := int(argInt(args, 0))
	b := int(argInt(args, 1))
	v.mu.Lock()
	defer v.mu.Unlock()
	ma, ok := v.state[a]
	if !ok {
		return plugin.BoolV(false)
	}
	mb, ok := v.state[b]
	if !ok {
		return plugin.BoolV(false)
	}
	dx, dy, dz := ma.x-mb.x, ma.y-mb.y, ma.z-mb.z
	return plugin.BoolV(dx*dx+dy*dy+dz*dz <= v.rng*v.rng)
}

func (v *voicePlugin) setRange(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	r := argFloat(args, 0)
	if r <= 0 {
		return plugin.BoolV(false)
	}
	v.mu.Lock()
	v.rng = r
	v.mu.Unlock()
	return plugin.BoolV(true)
}

func argInt(a []plugin.Value, i int) int64 {
	if i < len(a) {
		return a[i].Int
	}
	return 0
}

func argFloat(a []plugin.Value, i int) float64 {
	if i < len(a) {
		if a[i].Kind == plugin.KindFloat {
			return a[i].Flt
		}
		return float64(a[i].Int)
	}
	return 0
}

func argBool(a []plugin.Value, i int) bool {
	if i < len(a) {
		return a[i].Bool
	}
	return false
}

// FormatPeers is a small helper used by documentation examples.
func FormatPeers(peers []int) string {
	parts := make([]string, len(peers))
	for i, p := range peers {
		parts[i] = fmt.Sprint(p)
	}
	return strings.Join(parts, ",")
}
internal/voice/voice.go 274 lines
go
// Package voice implements proximity-based voice rooms for Knight.
//
// The SA-MP protocol has no voice channel, so audio never travels over RakNet.
// The server is only the authority on *who should hear whom*: the game plugin
// reports player positions and receives a membership update; the audio itself
// is carried by a WebRTC sidecar (or any other transport the client speaks).
//
// This package contains the transport-independent core (rooms, proximity
// membership, mute/deafen) so it can be tested without a network, plus a small
// HTTP sidecar in sidecar.go that exposes it to clients.
package voice

import (
	"math"
	"sort"
	"sync"
)

// Settings tunes proximity grouping.
type Settings struct {
	// Range is the hearing radius in world units. 0 means DefaultRange.
	Range float64
	// Teams, when true, only connects players whose Team values match.
	Teams bool
}

// DefaultRange is the hearing radius used when Settings.Range is zero.
const DefaultRange = 30.0

// Member is one connected player.
type Member struct {
	ID    int
	Name  string
	Team  int
	X     float64
	Y     float64
	Z     float64
	World int
	// Muted silences this member's outgoing audio.
	Muted bool
	// Deafened disables this member's incoming audio (still audible to others).
	Deafened bool
}

// Room is a set of voice members with proximity-based audio routing.
type Room struct {
	mu       sync.RWMutex
	settings Settings
	members  map[int]*Member
	// order preserves insertion order so membership output is deterministic.
	order []int
	// onUpdate is called (outside the lock) whenever membership changes.
	onUpdate func(id int, peers []int)
}

// NewRoom creates an empty room.
func NewRoom(settings Settings) *Room {
	if settings.Range <= 0 {
		settings.Range = DefaultRange
	}
	return &Room{settings: settings, members: map[int]*Member{}}
}

// SetUpdateHook installs a callback invoked with the audible peer list for a
// member after every change (join, leave, move, mute, settings).
func (r *Room) SetUpdateHook(fn func(id int, peers []int)) {
	r.mu.Lock()
	r.onUpdate = fn
	r.mu.Unlock()
}

// Settings returns the current settings.
func (r *Room) Settings() Settings {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.settings
}

// SetSettings replaces the settings and recomputes membership.
func (r *Room) SetSettings(s Settings) {
	if s.Range <= 0 {
		s.Range = DefaultRange
	}
	r.mu.Lock()
	r.settings = s
	r.mu.Unlock()
	r.notifyAll()
}

// Join adds or updates a member.
func (r *Room) Join(m Member) {
	r.mu.Lock()
	if _, exists := r.members[m.ID]; !exists {
		r.order = append(r.order, m.ID)
	}
	cp := m
	r.members[m.ID] = &cp
	peers := r.peersLocked(m.ID)
	ids := append([]int(nil), r.order...)
	r.mu.Unlock()
	r.deliver(m.ID, peers)
	// Existing members gain a potential speaker, so refresh them as well.
	for _, other := range ids {
		if other != m.ID {
			r.notify(other)
		}
	}
}

// Leave removes a member and tells the remaining members.
func (r *Room) Leave(id int) {
	r.mu.Lock()
	if _, ok := r.members[id]; !ok {
		r.mu.Unlock()
		return
	}
	delete(r.members, id)
	for i, v := range r.order {
		if v == id {
			r.order = append(r.order[:i], r.order[i+1:]...)
			break
		}
	}
	ids := append([]int(nil), r.order...)
	r.mu.Unlock()
	for _, other := range ids {
		r.notify(other)
	}
}

// UpdatePosition moves a member and pushes new membership to everyone affected.
func (r *Room) UpdatePosition(id int, x, y, z float64, world int) {
	r.mu.Lock()
	m, ok := r.members[id]
	if !ok {
		r.mu.Unlock()
		return
	}
	m.X, m.Y, m.Z, m.World = x, y, z, world
	ids := append([]int(nil), r.order...)
	r.mu.Unlock()
	// A move can change who hears whom for any member in range, so notify all.
	for _, other := range ids {
		r.notify(other)
	}
}

// SetMuted silences or unsilences a member.
func (r *Room) SetMuted(id int, muted bool) bool {
	r.mu.Lock()
	m, ok := r.members[id]
	if !ok {
		r.mu.Unlock()
		return false
	}
	m.Muted = muted
	ids := append([]int(nil), r.order...)
	r.mu.Unlock()
	for _, other := range ids {
		r.notify(other)
	}
	return true
}

// SetDeafened enables or disables a member's incoming audio.
func (r *Room) SetDeafened(id int, deafened bool) bool {
	r.mu.Lock()
	m, ok := r.members[id]
	if !ok {
		r.mu.Unlock()
		return false
	}
	m.Deafened = deafened
	r.mu.Unlock()
	r.notify(id)
	return true
}

// Member returns a copy of a member.
func (r *Room) Member(id int) (Member, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	m, ok := r.members[id]
	if !ok {
		return Member{}, false
	}
	return *m, true
}

// Peers returns the ids this member should hear, sorted.
func (r *Room) Peers(id int) []int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.peersLocked(id)
}

// Members returns every member id, in join order.
func (r *Room) Members() []int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return append([]int(nil), r.order...)
}

// Len returns the member count.
func (r *Room) Len() int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return len(r.members)
}

// peersLocked computes the audible peers of id. A peer is heard when it is in
// the same world, inside the range, not muted, and allowed by the team setting.
// The listener's deafen flag removes everyone.
func (r *Room) peersLocked(id int) []int {
	m, ok := r.members[id]
	if !ok || m.Deafened {
		return nil
	}
	var out []int
	for other, om := range r.members {
		if other == id {
			continue
		}
		if om.Muted || om.World != m.World {
			continue
		}
		if r.settings.Teams && om.Team != m.Team {
			continue
		}
		if distance(m, om) > r.settings.Range {
			continue
		}
		out = append(out, other)
	}
	sort.Ints(out)
	return out
}

func distance(a, b *Member) float64 {
	dx, dy, dz := a.X-b.X, a.Y-b.Y, a.Z-b.Z
	return math.Sqrt(dx*dx + dy*dy + dz*dz)
}

func (r *Room) notify(id int) {
	r.mu.RLock()
	fn := r.onUpdate
	var peers []int
	if _, ok := r.members[id]; ok {
		peers = r.peersLocked(id)
	}
	r.mu.RUnlock()
	if fn != nil {
		fn(id, peers)
	}
}

func (r *Room) notifyAll() {
	r.mu.RLock()
	ids := append([]int(nil), r.order...)
	r.mu.RUnlock()
	for _, id := range ids {
		r.notify(id)
	}
}

func (r *Room) deliver(id int, peers []int) {
	r.mu.RLock()
	fn := r.onUpdate
	r.mu.RUnlock()
	if fn != nil {
		fn(id, peers)
	}
}
internal/voice/sidecar.go 173 lines
go
package voice

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"sync"
	"time"
)

// Sidecar is the HTTP service the game server and voice clients talk to.
//
// Endpoints (all JSON):
//
//	POST /join        {id,name}            -> {token, peers}
//	POST /leave       {id}
//	POST /pos         {id,x,y,z,world}     -> {peers}
//	POST /mute        {id,muted}           -> {ok}
//	POST /deafen      {id,deafened}        -> {ok}
//	GET  /peers?id=N                       -> {peers}
//	GET  /health                           -> {ok,members}
//
// The game server uses /join, /leave and /pos; the audio client uses /peers to
// learn which streams to subscribe to. No audio passes through this service.
type Sidecar struct {
	room *Room
	mu   sync.Mutex
	// tokens authenticate a player id (kept simple and testable).
	tokens map[int]string
	next   int
}

// NewSidecar wraps a room in an HTTP handler.
func NewSidecar(settings Settings) *Sidecar {
	return &Sidecar{room: NewRoom(settings), tokens: map[int]string{}}
}

// Room exposes the underlying room (tests and the game plugin).
func (s *Sidecar) Room() *Room { return s.room }

type joinReq struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
	Team int    `json:"team"`
}

type peersResp struct {
	Token string `json:"token,omitempty"`
	Peers []int  `json:"peers"`
	Error string `json:"error,omitempty"`
}

// newPeersResp guarantees "peers" is a JSON array (never null), so clients can
// iterate it without a nil check.
func newPeersResp(token string, peers []int) peersResp {
	if peers == nil {
		peers = []int{}
	}
	return peersResp{Token: token, Peers: peers}
}

type posReq struct {
	ID    int     `json:"id"`
	X     float64 `json:"x"`
	Y     float64 `json:"y"`
	Z     float64 `json:"z"`
	World int     `json:"world"`
}

type boolReq struct {
	ID       int  `json:"id"`
	Muted    bool `json:"muted"`
	Deafened bool `json:"deafened"`
}

func (s *Sidecar) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.Method == http.MethodPost && r.URL.Path == "/join":
		var req joinReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, err)
			return
		}
		token := s.issueToken(req.ID)
		s.room.Join(Member{ID: req.ID, Name: req.Name, Team: req.Team})
		writeJSON(w, newPeersResp(token, s.room.Peers(req.ID)))
	case r.Method == http.MethodPost && r.URL.Path == "/leave":
		var req joinReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, err)
			return
		}
		s.room.Leave(req.ID)
		s.revoke(req.ID)
		writeJSON(w, newPeersResp("", nil))
	case r.Method == http.MethodPost && r.URL.Path == "/pos":
		var req posReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, err)
			return
		}
		s.room.UpdatePosition(req.ID, req.X, req.Y, req.Z, req.World)
		writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
	case r.Method == http.MethodPost && r.URL.Path == "/mute":
		var req boolReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, err)
			return
		}
		s.room.SetMuted(req.ID, req.Muted)
		writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
	case r.Method == http.MethodPost && r.URL.Path == "/deafen":
		var req boolReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, err)
			return
		}
		_ = s.room.SetDeafened(req.ID, req.Deafened)
		writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
	case r.Method == http.MethodGet && r.URL.Path == "/peers":
		id, _ := strconv.Atoi(r.URL.Query().Get("id"))
		writeJSON(w, newPeersResp("", s.room.Peers(id)))
	case r.Method == http.MethodGet && r.URL.Path == "/health":
		writeJSON(w, map[string]any{"ok": true, "members": s.room.Len()})
	default:
		writeErr(w, http.StatusNotFound, fmt.Errorf("unknown endpoint %s %s", r.Method, r.URL.Path))
	}
}

func (s *Sidecar) issueToken(id int) string {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	tok := fmt.Sprintf("v%d-%d", id, s.next)
	s.tokens[id] = tok
	return tok
}

func (s *Sidecar) revoke(id int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.tokens, id)
}

// ValidateToken reports whether token belongs to id.
func (s *Sidecar) ValidateToken(id int, token string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.tokens[id] != "" && s.tokens[id] == token
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}

func writeErr(w http.ResponseWriter, code int, err error) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

// ListenAndServe starts the sidecar on addr and blocks.
func ListenAndServe(addr string, settings Settings) error {
	srv := &http.Server{
		Addr:              addr,
		Handler:           NewSidecar(settings),
		ReadHeaderTimeout: 5 * time.Second,
	}
	return srv.ListenAndServe()
}
cmd/knight-voice/main.go 30 lines
go
// Command knight-voice runs the voice sidecar: it decides which players hear
// each other and exposes that over HTTP. Audio itself is carried by the client
// transport (WebRTC or another), never by the SA-MP server.
package main

import (
	"flag"
	"log"
	"net/http"
	"time"

	"knight/internal/voice"
)

func main() {
	addr := flag.String("addr", ":7788", "HTTP listen address")
	rng := flag.Float64("range", voice.DefaultRange, "hearing radius in world units")
	teams := flag.Bool("teams", false, "only connect players on the same team")
	flag.Parse()

	handler := voice.NewSidecar(voice.Settings{Range: *rng, Teams: *teams})
	srv := &http.Server{
		Addr:              *addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("voice sidecar listening on %s (range %.1f, teams=%v)", *addr, *rng, *teams)
	log.Fatal(srv.ListenAndServe())
}