package main import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "hash/fnv" "log" "math" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgxpool" ) const ( worldSize = 3600.0 spawnX = 1200.0 spawnY = 1550.0 playerMaxHull = 100 playerMaxSpeed = 180.0 playerReverseSpeed = -55.0 playerAcceleration = 92.0 playerBrake = 118.0 playerTurnRate = 76.0 playerCoastDrag = 0.992 broadsideReload = 850 * time.Millisecond shotSpeed = 520.0 shotLifetime = 900 * time.Millisecond shotDamage = 16 shipHitRadius = 44.0 respawnDelay = 2200 * time.Millisecond simTick = time.Second / 30 snapshotTick = 50 * time.Millisecond ) type config struct { Port string DatabaseURL string Issuer string ClientID string AllowedOrigins map[string]bool WorldSeed string FakePlayers int } type claims struct { Subject string `json:"sub"` PreferredUsername string `json:"preferred_username"` Name string `json:"name"` AuthorizedParty string `json:"azp"` Audience interface{} `json:"aud"` } type contextKey string const claimsKey contextKey = "claims" type ticket struct { Subject string Name string Expires time.Time } type ticketStore struct { mu sync.Mutex items map[string]ticket } func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} } func (s *ticketStore) issue(subject, name string) (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } value := base64.RawURLEncoding.EncodeToString(buf) s.mu.Lock() s.items[value] = ticket{Subject: subject, Name: name, Expires: time.Now().Add(30 * time.Second)} s.mu.Unlock() return value, nil } func (s *ticketStore) consume(value string) (ticket, bool) { s.mu.Lock() defer s.mu.Unlock() item, ok := s.items[value] delete(s.items, value) if !ok || time.Now().After(item.Expires) { return ticket{}, false } return item, true } func (s *ticketStore) prune() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for key, item := range s.items { if now.After(item.Expires) { delete(s.items, key) } } } type playerState struct { ID string `json:"id"` Name string `json:"name"` X float64 `json:"x"` Y float64 `json:"y"` Angle float64 `json:"angle"` Speed float64 `json:"speed"` Hull int `json:"hull"` MaxHull int `json:"maxHull"` Alive bool `json:"alive"` Online bool `json:"online"` Kills int `json:"kills"` Deaths int `json:"deaths"` InputSeq int64 `json:"inputSeq"` RespawnAt int64 `json:"respawnAt,omitempty"` LastKiller string `json:"lastKiller,omitempty"` Bot bool `json:"bot,omitempty"` } type inputState struct { Throttle float64 Steer float64 Stop bool Docked bool Seq int64 } type projectileState struct { ID string `json:"id"` OwnerID string `json:"ownerId"` X float64 `json:"x"` Y float64 `json:"y"` VX float64 `json:"vx"` VY float64 `json:"vy"` Damage int `json:"damage"` bornAt time.Time } type clientMessage struct { Type string `json:"type"` Seq int64 `json:"seq"` Throttle float64 `json:"throttle"` Steer float64 `json:"steer"` Stop bool `json:"stop"` Docked bool `json:"docked"` Side string `json:"side"` } type serverMessage struct { Type string `json:"type"` PlayerID string `json:"playerId,omitempty"` WorldSeed string `json:"worldSeed,omitempty"` Players []playerState `json:"players,omitempty"` Projectiles []projectileState `json:"projectiles,omitempty"` ServerTime int64 `json:"serverTime,omitempty"` } type client struct { conn *websocket.Conn writeMu sync.Mutex state playerState input inputState lastFire map[string]time.Time botIndex int } func (c *client) writeJSON(v any) error { if c.conn == nil { return nil } c.writeMu.Lock() defer c.writeMu.Unlock() _ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) return c.conn.WriteJSON(v) } type hub struct { mu sync.RWMutex clients map[string]*client projectiles map[string]projectileState nextProjectile uint64 worldSeed string } func newHub(seed string, fakePlayers ...int) *hub { h := &hub{ clients: map[string]*client{}, projectiles: map[string]projectileState{}, worldSeed: seed, } count := 0 if len(fakePlayers) > 0 { count = clampInt(fakePlayers[0], 0, 8) } h.addFakeCaptains(count) return h } var fakeCaptainNames = []string{ "Mara Voss", "Brass Jack", "Finn Gale", "Iris Vane", "Rowan Pike", "Ada Flint", "Silas Crow", "Nell Mercer", } func (h *hub) addFakeCaptains(count int) { if count <= 0 { return } for index := 0; index < count; index++ { name := fakeCaptainNames[index%len(fakeCaptainNames)] id := "bot:" + strings.ToLower(strings.ReplaceAll(name, " ", "-")) state := newPlayerState(id, name, 0, 0) phase := math.Pi * 2 * float64(index) / float64(count) state.X = clampFloat(spawnX+math.Cos(phase)*300, 90, worldSize-90) state.Y = clampFloat(spawnY+math.Sin(phase)*300, 90, worldSize-90) state.Angle = normalizeAngle(phase*180/math.Pi + 90) state.Bot = true h.clients[id] = &client{ state: state, botIndex: index, lastFire: map[string]time.Time{}, } } } func newPlayerState(id, name string, kills, deaths int) playerState { x, y := spawnFor(id) return playerState{ ID: id, Name: name, X: x, Y: y, Angle: 0, Speed: 0, Hull: playerMaxHull, MaxHull: playerMaxHull, Alive: true, Online: true, Kills: kills, Deaths: deaths, } } func spawnFor(id string) (float64, float64) { h := fnv.New32a() _, _ = h.Write([]byte(id)) v := h.Sum32() dx := float64(int(v%241) - 120) dy := float64(int((v/241)%181) - 90) return clampFloat(spawnX+dx, 90, worldSize-90), clampFloat(spawnY+dy, 90, worldSize-90) } func (h *hub) add(c *client) { h.mu.Lock() if previous := h.clients[c.state.ID]; previous != nil && previous != c { _ = previous.conn.Close() } h.clients[c.state.ID] = c h.mu.Unlock() } func (h *hub) remove(id string, c *client) { h.mu.Lock() if h.clients[id] == c { delete(h.clients, id) } h.mu.Unlock() } func (h *hub) count() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.clients) } func (h *hub) updateInput(id string, msg clientMessage) { h.mu.Lock() defer h.mu.Unlock() c := h.clients[id] if c == nil || msg.Seq <= c.input.Seq { return } c.input = inputState{ Throttle: clampFloat(msg.Throttle, -1, 1), Steer: clampFloat(msg.Steer, -1, 1), Stop: msg.Stop, Docked: msg.Docked, Seq: msg.Seq, } } func (h *hub) fire(id, side string, now time.Time) bool { h.mu.Lock() defer h.mu.Unlock() return h.fireLocked(h.clients[id], side, now) } func (h *hub) fireLocked(c *client, side string, now time.Time) bool { if c == nil || !c.state.Alive || (side != "port" && side != "starboard") { return false } if c.lastFire == nil { c.lastFire = map[string]time.Time{} } if last := c.lastFire[side]; !last.IsZero() && now.Sub(last) < broadsideReload { return false } c.lastFire[side] = now sideSign := 1.0 if side == "port" { sideSign = -1 } base := headingRadians(c.state.Angle) + sideSign*math.Pi/2 shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed shipVY := math.Sin(headingRadians(c.state.Angle)) * c.state.Speed for _, offset := range []float64{-7, 0, 7} { angle := base + offset*math.Pi/180 h.nextProjectile++ projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10) h.projectiles[projectileID] = projectileState{ ID: projectileID, OwnerID: c.state.ID, X: c.state.X + math.Cos(base)*34, Y: c.state.Y + math.Sin(base)*34, VX: math.Cos(angle)*shotSpeed + shipVX*0.25, VY: math.Sin(angle)*shotSpeed + shipVY*0.25, Damage: shotDamage, bornAt: now, } } return true } func (h *hub) step(now time.Time, dt float64) { h.mu.Lock() defer h.mu.Unlock() for _, c := range h.clients { if c.state.Bot { input, fireSide := h.fakeCaptainDecisionLocked(c, now) c.input = input stepPlayer(&c.state, c.input, now, dt) if fireSide != "" { h.fireLocked(c, fireSide, now) } continue } stepPlayer(&c.state, c.input, now, dt) } for id, shot := range h.projectiles { if now.Sub(shot.bornAt) >= shotLifetime { delete(h.projectiles, id) continue } shot.X += shot.VX * dt shot.Y += shot.VY * dt if shot.X < -80 || shot.X > worldSize+80 || shot.Y < -80 || shot.Y > worldSize+80 { delete(h.projectiles, id) continue } hit := false for _, target := range h.clients { if target.state.ID == shot.OwnerID || !target.state.Alive { continue } dx := target.state.X - shot.X dy := target.state.Y - shot.Y if dx*dx+dy*dy > shipHitRadius*shipHitRadius { continue } target.state.Hull = clampInt(target.state.Hull-shot.Damage, 0, target.state.MaxHull) if target.state.Hull <= 0 { target.state.Alive = false target.state.Speed = 0 target.state.Deaths++ target.state.RespawnAt = now.Add(respawnDelay).UnixMilli() if owner := h.clients[shot.OwnerID]; owner != nil { owner.state.Kills++ target.state.LastKiller = owner.state.Name } } delete(h.projectiles, id) hit = true break } if hit { continue } h.projectiles[id] = shot } } func signedAngleDelta(value float64) float64 { value = math.Mod(value+180, 360) if value < 0 { value += 360 } return value - 180 } func bearingDegrees(fromX, fromY, toX, toY float64) float64 { return normalizeAngle(math.Atan2(toY-fromY, toX-fromX)*180/math.Pi + 90) } func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, string) { seq := c.input.Seq + 1 if !c.state.Alive { return inputState{Seq: seq}, "" } var target *client bestRange := math.Inf(1) for _, candidate := range h.clients { if candidate == c || candidate.state.Bot || !candidate.state.Alive { continue } dx := candidate.state.X - c.state.X dy := candidate.state.Y - c.state.Y distance := math.Hypot(dx, dy) if distance < bestRange { target = candidate bestRange = distance } } if target == nil { phase := float64(c.botIndex)*2.1 + float64(now.UnixMilli()%60000)/60000*math.Pi*2 radius := 420.0 + float64(c.botIndex)*75 targetX := spawnX + math.Cos(phase)*radius targetY := spawnY + math.Sin(phase)*radius bearing := bearingDegrees(c.state.X, c.state.Y, targetX, targetY) delta := signedAngleDelta(bearing - c.state.Angle) return inputState{ Throttle: 0.58, Steer: clampFloat(delta/55, -1, 1), Seq: seq, }, "" } bearing := bearingDegrees(c.state.X, c.state.Y, target.state.X, target.state.Y) relative := signedAngleDelta(bearing - c.state.Angle) if bestRange > 620 { return inputState{ Throttle: 0.9, Steer: clampFloat(relative/55, -1, 1), Seq: seq, }, "" } side := "starboard" desiredRelative := 90.0 if relative < 0 { side = "port" desiredRelative = -90 } broadsideError := signedAngleDelta(relative - desiredRelative) throttle := 0.62 if bestRange < 250 { throttle = 0.28 } fireSide := "" if bestRange >= 130 && bestRange <= 560 && math.Abs(broadsideError) <= 20 { fireSide = side } return inputState{ Throttle: throttle, Steer: clampFloat(broadsideError/50, -1, 1), Seq: seq, }, fireSide } func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) { state.InputSeq = input.Seq if !state.Alive { if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt { x, y := spawnFor(state.ID) state.X = x state.Y = y state.Angle = 0 state.Speed = 0 state.Hull = state.MaxHull state.Alive = true state.RespawnAt = 0 state.LastKiller = "" } return } if input.Docked { state.Speed = 0 return } if input.Throttle > 0 { state.Speed += playerAcceleration * input.Throttle * dt } else if input.Throttle < 0 { state.Speed += playerBrake * input.Throttle * dt } else if !input.Stop { state.Speed *= math.Pow(playerCoastDrag, dt*60) } if input.Stop { state.Speed *= math.Pow(0.025, dt) } state.Speed = clampFloat(state.Speed, playerReverseSpeed, playerMaxSpeed) turnStrength := clampFloat(math.Abs(state.Speed)/100, 0.28, 1) directionSign := 1.0 if state.Speed < 0 { directionSign = -1 } state.Angle = normalizeAngle(state.Angle + playerTurnRate*turnStrength*directionSign*input.Steer*dt) heading := headingRadians(state.Angle) state.X = clampFloat(state.X+math.Cos(heading)*state.Speed*dt, 0, worldSize) state.Y = clampFloat(state.Y+math.Sin(heading)*state.Speed*dt, 0, worldSize) } func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 } func (h *hub) snapshot() ([]*client, []playerState, []projectileState) { h.mu.RLock() defer h.mu.RUnlock() clients := make([]*client, 0, len(h.clients)) players := make([]playerState, 0, len(h.clients)) projectiles := make([]projectileState, 0, len(h.projectiles)) for _, c := range h.clients { if c.conn != nil { clients = append(clients, c) } players = append(players, c.state) } for _, shot := range h.projectiles { projectiles = append(projectiles, shot) } return clients, players, projectiles } func (h *hub) run(ctx context.Context) { simTicker := time.NewTicker(simTick) snapshotTicker := time.NewTicker(snapshotTick) defer simTicker.Stop() defer snapshotTicker.Stop() lastSim := time.Now() for { select { case <-ctx.Done(): return case now := <-simTicker.C: dt := now.Sub(lastSim).Seconds() if dt <= 0 || dt > 0.1 { dt = simTick.Seconds() } lastSim = now h.step(now, dt) case now := <-snapshotTicker.C: clients, players, projectiles := h.snapshot() msg := serverMessage{ Type: "snapshot", Players: players, Projectiles: projectiles, ServerTime: now.UnixMilli(), } for _, c := range clients { if err := c.writeJSON(msg); err != nil { _ = c.conn.Close() } } } } } func clampFloat(v, min, max float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) { return min } if v < min { return min } if v > max { return max } return v } func clampInt(v, min, max int) int { if v < min { return min } if v > max { return max } return v } func normalizeAngle(v float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) { return 0 } v = math.Mod(v, 360) if v < 0 { v += 360 } return v } func loadConfig() (config, error) { origins := map[string]bool{} for _, origin := range strings.Split(env("ALLOWED_ORIGINS", "https://airship.arrowstack.dev"), ",") { origin = strings.TrimSpace(origin) if origin != "" { origins[origin] = true } } cfg := config{ Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"), Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"), FakePlayers: envInt("FAKE_PLAYERS", 3), } if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" { return cfg, errors.New("DATABASE_URL, KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID are required") } return cfg, nil } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func migrate(ctx context.Context, db *pgxpool.Pool) error { _, err := db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS player_profiles ( keycloak_sub TEXT PRIMARY KEY, display_name TEXT NOT NULL, credits INTEGER NOT NULL DEFAULT 650, scrap INTEGER NOT NULL DEFAULT 0, progression JSONB NOT NULL DEFAULT '{}'::jsonb, pvp_kills INTEGER NOT NULL DEFAULT 0, pvp_deaths INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_kills INTEGER NOT NULL DEFAULT 0; ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_deaths INTEGER NOT NULL DEFAULT 0 `) return err } func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error { _, err := db.Exec(ctx, ` INSERT INTO player_profiles (keycloak_sub, display_name) VALUES ($1, $2) ON CONFLICT (keycloak_sub) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = now(), last_seen_at = now() `, sub, name) return err } func loadPvPStats(ctx context.Context, db *pgxpool.Pool, sub string) (int, int, error) { var kills, deaths int err := db.QueryRow(ctx, `SELECT pvp_kills, pvp_deaths FROM player_profiles WHERE keycloak_sub = $1`, sub).Scan(&kills, &deaths) return kills, deaths, err } func savePvPStats(ctx context.Context, db *pgxpool.Pool, state playerState) error { _, err := db.Exec(ctx, ` UPDATE player_profiles SET pvp_kills = $2, pvp_deaths = $3, updated_at = now(), last_seen_at = now() WHERE keycloak_sub = $1 `, state.ID, state.Kills, state.Deaths) return err } type app struct { cfg config db *pgxpool.Pool verifier *oidc.IDTokenVerifier tickets *ticketStore hub *hub upgrader websocket.Upgrader } func (a *app) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { http.Error(w, "unauthorized", http.StatusUnauthorized) return } raw := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) token, err := a.verifier.Verify(r.Context(), raw) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) return } var c claims if err := token.Claims(&c); err != nil || c.Subject == "" { http.Error(w, "invalid claims", http.StatusUnauthorized) return } if !audienceMatches(c, a.cfg.ClientID) { http.Error(w, "wrong client", http.StatusForbidden) return } if c.PreferredUsername == "" { c.PreferredUsername = c.Name } if c.PreferredUsername == "" { c.PreferredUsername = "Captain" } if err := ensurePlayer(r.Context(), a.db, c.Subject, c.PreferredUsername); err != nil { http.Error(w, "database unavailable", http.StatusServiceUnavailable) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c))) }) } func audienceMatches(c claims, clientID string) bool { if c.AuthorizedParty == clientID { return true } switch aud := c.Audience.(type) { case string: return aud == clientID case []interface{}: for _, item := range aud { if s, ok := item.(string); ok && s == clientID { return true } } } return false } func (a *app) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" && a.cfg.AllowedOrigins[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func jsonResponse(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func (a *app) health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Second) defer cancel() if err := a.db.Ping(ctx); err != nil { jsonResponse(w, 503, map[string]any{"ok": false, "database": false}) return } jsonResponse(w, 200, map[string]any{ "ok": true, "database": true, "players": a.hub.count(), "worldSeed": a.cfg.WorldSeed, "authority": "movement+pvp", }) } func (a *app) me(w http.ResponseWriter, r *http.Request) { c := r.Context().Value(claimsKey).(claims) jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed}) } func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } c := r.Context().Value(claimsKey).(claims) value, err := a.tickets.issue(c.Subject, c.PreferredUsername) if err != nil { http.Error(w, "ticket unavailable", http.StatusInternalServerError) return } jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30}) } func (a *app) ws(w http.ResponseWriter, r *http.Request) { value := r.URL.Query().Get("ticket") t, ok := a.tickets.consume(value) if !ok { http.Error(w, "invalid websocket ticket", http.StatusUnauthorized) return } conn, err := a.upgrader.Upgrade(w, r, nil) if err != nil { return } kills, deaths, err := loadPvPStats(r.Context(), a.db, t.Subject) if err != nil { kills, deaths = 0, 0 } c := &client{ conn: conn, state: newPlayerState(t.Subject, t.Name, kills, deaths), lastFire: map[string]time.Time{}, } a.hub.add(c) defer func() { a.hub.remove(t.Subject, c) _ = conn.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = ensurePlayer(ctx, a.db, t.Subject, t.Name) _ = savePvPStats(ctx, a.db, c.state) }() _ = c.writeJSON(serverMessage{ Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli(), }) conn.SetReadLimit(4096) for { _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) var msg clientMessage if err := conn.ReadJSON(&msg); err != nil { return } switch msg.Type { case "input": a.hub.updateInput(t.Subject, msg) case "fire": a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now()) } } } func main() { cfg, err := loadConfig() if err != nil { log.Fatal(err) } ctx := context.Background() db, err := pgxpool.New(ctx, cfg.DatabaseURL) if err != nil { log.Fatal(err) } defer db.Close() if err := db.Ping(ctx); err != nil { log.Fatalf("database ping: %v", err) } if err := migrate(ctx, db); err != nil { log.Fatalf("migration: %v", err) } provider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { log.Fatalf("oidc provider: %v", err) } verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) application := &app{ cfg: cfg, db: db, verifier: verifier, tickets: newTicketStore(), hub: newHub(cfg.WorldSeed, cfg.FakePlayers), } application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") return origin == "" || cfg.AllowedOrigins[origin] }} go application.hub.run(ctx) go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for range ticker.C { application.tickets.prune() } }() mux := http.NewServeMux() mux.HandleFunc("/health", application.health) mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me)))) mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket)))) mux.HandleFunc("/ws", application.ws) server := &http.Server{ Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, } log.Printf( "airship game server listening on :%s world=%s authority=movement+pvp fake_players=%d", cfg.Port, cfg.WorldSeed, cfg.FakePlayers, ) log.Fatal(server.ListenAndServe()) }