Files
tiennm99 4c56a23e58 feat: add Go game server with matchmaking, lobby, and WebSocket support
Implement a real-time game server using protobuf over WebSocket:
- Protobuf schema with 14 message types in oneof Envelope
- Room-based lobby with join/leave/chat broadcast
- ELO-based matchmaking with expanding range (±100 to ±500)
- Turn-based game session lifecycle management
- Multi-stage Docker build (Alpine)
- Unit tests for matchmaking and game session logic
2026-04-04 21:25:03 +07:00

140 lines
3.0 KiB
Go

package ws
import (
"log"
"net/http"
"github.com/gorilla/websocket"
"github.com/tiennm99/260404/internal/game"
"github.com/tiennm99/260404/internal/lobby"
"github.com/tiennm99/260404/internal/matchmaking"
"github.com/tiennm99/260404/internal/player"
"github.com/tiennm99/260404/pkg/pb"
"google.golang.org/protobuf/proto"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
}
// Handler manages WebSocket connections and routes messages.
type Handler struct {
lobby *lobby.Manager
matchmaker *matchmaking.Matchmaker
games *game.Manager
nextID int
}
// NewHandler creates a WebSocket handler with all subsystems.
func NewHandler(
lobby *lobby.Manager,
matchmaker *matchmaking.Matchmaker,
games *game.Manager,
) *Handler {
return &Handler{
lobby: lobby,
matchmaker: matchmaker,
games: games,
}
}
// ServeHTTP upgrades the connection and starts the read loop.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade error: %v", err)
return
}
h.nextID++
p := player.New(itoa(h.nextID), "", 1000, conn)
log.Printf("player connected: %s", p.ID)
defer h.cleanup(p)
h.readLoop(p)
}
func (h *Handler) readLoop(p *player.Player) {
for {
_, data, err := p.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("read error player=%s: %v", p.ID, err)
}
return
}
var env pb.Envelope
if err := proto.Unmarshal(data, &env); err != nil {
_ = p.SendError(400, "invalid message format")
continue
}
h.route(p, &env)
}
}
func (h *Handler) route(p *player.Player, env *pb.Envelope) {
switch msg := env.Payload.(type) {
case *pb.Envelope_JoinLobby:
p.Name = msg.JoinLobby.PlayerName
roomID := msg.JoinLobby.RoomId
if roomID == "" {
roomID = "default"
}
h.lobby.Join(p, roomID)
case *pb.Envelope_LeaveLobby:
h.lobby.Leave(p)
case *pb.Envelope_Chat:
h.lobby.Broadcast(p, msg.Chat.Text)
case *pb.Envelope_Queue:
p.Elo = msg.Queue.Elo
h.matchmaker.Enqueue(p, msg.Queue.Elo)
case *pb.Envelope_CancelQueue:
h.matchmaker.Dequeue(p.ID)
case *pb.Envelope_GameAction:
s := h.games.GetByPlayer(p.ID)
if s == nil {
_ = p.SendError(404, "not in a game session")
return
}
if !s.ApplyAction(p.ID, msg.GameAction) {
_ = p.SendError(403, "not your turn or game is over")
return
}
s.BroadcastState()
default:
_ = p.SendError(400, "unknown message type")
}
}
func (h *Handler) cleanup(p *player.Player) {
h.lobby.Leave(p)
h.matchmaker.Dequeue(p.ID)
_ = p.Conn.Close()
log.Printf("player disconnected: %s", p.ID)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
buf := make([]byte, 0, 10)
for n > 0 {
buf = append(buf, byte('0'+n%10))
n /= 10
}
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
buf[i], buf[j] = buf[j], buf[i]
}
return string(buf)
}