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 } 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"` } 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 } func (c *client) writeJSON(v any) error { 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) *hub { return &hub{ clients: map[string]*client{}, projectiles: map[string]projectileState{}, worldSeed: seed, } } 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() c := h.clients[id] 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 := id + ":" + strconv.FormatUint(h.nextProjectile, 10) h.projectiles[projectileID] = projectileState{ ID: projectileID, OwnerID: 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 { 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 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 { 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"), } 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 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), } 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", cfg.Port, cfg.WorldSeed) log.Fatal(server.ListenAndServe()) }