// Package config loads runtime configuration from environment variables. // // On startup Load() reads a .env file (if present) from the current // working directory and merges its values into the process environment. // Existing process env vars take precedence over .env so deployments // can override .env without modifying the file. package config import ( "fmt" "os" "strconv" "time" "github.com/joho/godotenv" ) // Config holds all runtime parameters. type Config struct { HTTPAddr string // Address to listen on, e.g. ":8080" TickRate int // Race tick rate (Hz) TickInterval time.Duration // Derived from TickRate LogLevel string // "debug" | "info" | "warn" | "error" MaxClients int // Maximum concurrent WS clients SnapshotRate int // Snapshot fan-out rate (Hz) DevMode bool // If true, allow multiple clients without auth DatabaseURL string // Postgres connection string; required at runtime UDPVideoAddr string // UDP address to listen on for video, e.g. ":9999" ESPCmdPort int // Port on ESP32 that listens for commands, e.g. 8888 } // Load reads configuration from environment variables, applying defaults. // // A .env file in the current working directory (or any path listed in // X0GP_DOTENV) is loaded before reading the environment. Missing .env // is not an error — production deployments set vars directly. func Load() (*Config, error) { loadDotEnv() c := &Config{ HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"), TickRate: getEnvInt("X0GP_TICK_RATE", 60), LogLevel: getEnv("X0GP_LOG_LEVEL", "info"), MaxClients: getEnvInt("X0GP_MAX_CLIENTS", 100), SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30), DevMode: getEnvBool("X0GP_DEV_MODE", true), DatabaseURL: getEnv("DATABASE_URL", ""), UDPVideoAddr: getEnv("X0GP_UDP_VIDEO_ADDR", ":9999"), ESPCmdPort: getEnvInt("X0GP_ESP_CMD_PORT", 8888), } if c.TickRate <= 0 || c.TickRate > 240 { return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate) } c.TickInterval = time.Second / time.Duration(c.TickRate) if c.SnapshotRate <= 0 || c.SnapshotRate > c.TickRate { return nil, fmt.Errorf("X0GP_SNAPSHOT_RATE must be in (0, %d], got %d", c.TickRate, c.SnapshotRate) } return c, nil } func getEnv(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback } // loadDotEnv merges a .env file into the process environment. Files // that do not exist are silently ignored. Paths can be overridden with // X0GP_DOTENV (colon-separated). Existing process env vars take // precedence over .env values so deploys can override without // editing the file (godotenv.Load, not Overload). func loadDotEnv() { if paths := os.Getenv("X0GP_DOTENV"); paths != "" { for _, p := range splitPaths(paths) { _ = godotenv.Load(p) } return } _ = godotenv.Load(".env") } func splitPaths(s string) []string { out := make([]string, 0, 4) start := 0 for i := 0; i < len(s); i++ { if s[i] == ':' || s[i] == ';' { if i > start { out = append(out, s[start:i]) } start = i + 1 } } if start < len(s) { out = append(out, s[start:]) } return out } func getEnvInt(key string, fallback int) int { if v, ok := os.LookupEnv(key); ok { if n, err := strconv.Atoi(v); err == nil { return n } } return fallback } func getEnvBool(key string, fallback bool) bool { if v, ok := os.LookupEnv(key); ok { if b, err := strconv.ParseBool(v); err == nil { return b } } return fallback }