package main import ( cryptoRand "crypto/rand" "encoding/hex" "flag" "log" "math/rand" "net/http" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/vmihailenco/msgpack/v5" ) // -------------------- Config -------------------- const ( AddrHTTP = ":8080" MaxPlayers = 10 TickRateHz = 15 NameMaxLen = 25 ChatMaxLen = 500 StateBufLimit = 256 // Chat Anti-spam MinChatInterval = 400 * time.Millisecond ) // Allowed specified origins for browser WS connections var allowedOrigins = map[string]bool{ "https://world.seppjm.com": true, "https://seppjm.com": true, "https://cv.seppjm.com": true, "https://seppdroid.com": true, "https://sepp.mx": true, //"http://world.seppjm.com": true, // (dev) //"http://localhost:5173": true, // (dev) //"http://localhost:8080": true, // (dev) } // -------------------- Types -------------------- type Role string const ( RolePlayer Role = "player" RoleSpectator Role = "spectator" ) type Vec3 [3]float64 type Player struct { ID string `msgpack:"id"` Name string `msgpack:"name"` Pos Vec3 `msgpack:"pos"` RotY float64 `msgpack:"rotY"` } type Client struct { Conn *websocket.Conn Hub *Hub ID string Name string Role Role Send chan []byte Closed chan struct{} LastChat time.Time connected time.Time } type Hub struct { mu sync.RWMutex clients map[string]*Client // id -> client players map[string]*Player // id -> player joinCh chan *Client leaveCh chan *Client broadcast chan []byte } func NewHub() *Hub { return &Hub{ clients: make(map[string]*Client), players: make(map[string]*Player), joinCh: make(chan *Client, 64), leaveCh: make(chan *Client, 64), broadcast: make(chan []byte, 1024), } } func (h *Hub) Run() { ticker := time.NewTicker(time.Second / TickRateHz) defer ticker.Stop() for { select { case c := <-h.joinCh: h.addClient(c) case c := <-h.leaveCh: h.removeClient(c) case msg := <-h.broadcast: h.mu.RLock() for _, cl := range h.clients { select { case cl.Send <- msg: default: } } h.mu.RUnlock() case <-ticker.C: h.tickState() } } } func (h *Hub) addClient(c *Client) { h.mu.Lock() h.clients[c.ID] = c h.mu.Unlock() } func (h *Hub) removeClient(c *Client) { h.mu.Lock() defer h.mu.Unlock() delete(h.clients, c.ID) if _, ok := h.players[c.ID]; ok { delete(h.players, c.ID) packet := map[string]any{"type": "player_leave", "id": c.ID} if b, _ := msgpack.Marshal(packet); b != nil { for _, cl := range h.clients { select { case cl.Send <- b: default: } } } } } func (h *Hub) tickState() { h.mu.RLock() if len(h.players) == 0 || len(h.clients) == 0 { h.mu.RUnlock() return } updates := make([]map[string]any, 0, len(h.players)) for _, p := range h.players { updates = append(updates, map[string]any{ "id": p.ID, "pos": p.Pos, "rotY": p.RotY, }) } h.mu.RUnlock() packet := map[string]any{"type": "state", "updates": updates} if b, _ := msgpack.Marshal(packet); b != nil { select { case h.broadcast <- b: default: } } } // -------------------- WS Handling -------------------- var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, EnableCompression: true, HandshakeTimeout: 10 * time.Second, CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") return allowedOrigins[origin] }, } type inbound struct { Type string `msgpack:"type"` Name string `msgpack:"name"` Role Role `msgpack:"role"` Pos *Vec3 `msgpack:"pos"` RotY *float64 `msgpack:"rotY"` Text string `msgpack:"text"` } func wsHandler(h *Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade:", err) return } id := genID() c := &Client{ Conn: conn, Hub: h, ID: id, Send: make(chan []byte, StateBufLimit), Closed: make(chan struct{}), connected: time.Now(), } h.joinCh <- c go writer(c) reader(c) } } func reader(c *Client) { defer func() { c.Hub.leaveCh <- c close(c.Closed) _ = c.Conn.Close() }() c.Conn.SetReadLimit(1 << 20) _ = c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetPongHandler(func(string) error { return c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) }) // Expect a join first mt, data, err := c.Conn.ReadMessage() if err != nil { return } if mt != websocket.BinaryMessage && mt != websocket.TextMessage { return } var first inbound if msgpack.Unmarshal(data, &first) != nil || first.Type != "join" { sendPacket(c, map[string]any{"type": "error", "message": "expected join"}) return } name := sanitizeName(first.Name) if name == "" { name = randomName() } role := first.Role if role != RoleSpectator { role = RolePlayer } // Enforce player cap c.Hub.mu.Lock() nPlayers := 0 for _, cl := range c.Hub.clients { if cl.Role == RolePlayer { nPlayers++ } } if role == RolePlayer && nPlayers >= MaxPlayers { c.Hub.mu.Unlock() sendPacket(c, map[string]any{"type": "full", "maxPlayers": MaxPlayers}) return } c.Name = name c.Role = role // Register player entity if c.Role == RolePlayer { spawn := Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0} c.Hub.players[c.ID] = &spawn } // Welcome snapshot players := make([]Player, 0, len(c.Hub.players)) for _, p := range c.Hub.players { players = append(players, *p) } c.Hub.mu.Unlock() sendPacket(c, map[string]any{ "type": "welcome", "id": c.ID, "players": players, "maxPlayers": MaxPlayers, }) if c.Role == RolePlayer { announce := map[string]any{"type": "player_join", "player": Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0}} if b, _ := msgpack.Marshal(announce); b != nil { select { case c.Hub.broadcast <- b: default: } } } // loop for { mt, msg, err := c.Conn.ReadMessage() if err != nil { return } if mt != websocket.BinaryMessage && mt != websocket.TextMessage { continue } var in inbound if msgpack.Unmarshal(msg, &in) != nil { continue } switch in.Type { case "state": if c.Role != RolePlayer || in.Pos == nil || in.RotY == nil { continue } c.Hub.mu.Lock() if p, ok := c.Hub.players[c.ID]; ok { p.Pos = *in.Pos p.RotY = *in.RotY } c.Hub.mu.Unlock() case "chat": now := time.Now() if now.Sub(c.LastChat) < MinChatInterval { continue } c.LastChat = now text := trimLen(strings.TrimSpace(in.Text), ChatMaxLen) if text == "" { continue } packet := map[string]any{ "type": "chat", "id": c.ID, "name": c.Name, "text": text, "ts": now.Unix(), } if b, _ := msgpack.Marshal(packet); b != nil { select { case c.Hub.broadcast <- b: default: } } } } } func writer(c *Client) { ping := time.NewTicker(20 * time.Second) defer ping.Stop() for { select { case msg := <-c.Send: _ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.Conn.WriteMessage(websocket.BinaryMessage, msg); err != nil { return } case <-ping.C: _ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } case <-c.Closed: return } } } func sendPacket(c *Client, v any) { b, _ := msgpack.Marshal(v) _ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) _ = c.Conn.WriteMessage(websocket.BinaryMessage, b) } // -------------------- Utis -------------------- func genID() string { var b [8]byte _, _ = cryptoRand.Read(b[:]) return "p_" + hex.EncodeToString(b[:]) } func sanitizeName(s string) string { s = strings.TrimSpace(s) s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") if len(s) > NameMaxLen { s = s[:NameMaxLen] } return s } func trimLen(s string, max int) string { if len(s) > max { return s[:max] } return s } func randomName() string { adjs := []string{"Blue", "Crimson", "Silent", "Swift", "Lucky", "Rusty", "Cosmic", "Mossy"} nouns := []string{"Fox", "Raven", "Badger", "Otter", "Falcon", "Lynx", "Marmot", "Golem"} return adjs[rand.Intn(len(adjs))] + nouns[rand.Intn(len(nouns))] } // -------------------- main -------------------- func main() { rand.Seed(time.Now().UnixNano()) addr := flag.String("addr", AddrHTTP, "http listen addr") flag.Parse() h := NewHub() go h.Run() mux := http.NewServeMux() mux.HandleFunc("/ws", wsHandler(h)) srv := &http.Server{ Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 75 * time.Second, } log.Println("SEPPJM-World WebSocket server listening on", *addr, "path /ws") if err := srv.ListenAndServe(); err != nil { log.Fatal(err) } }