game-bass 的实时联机模块用 Hub 模式。所有 WebSocket 连接汇聚到一个 Hub,单 goroutine 事件循环统一调度。房间管理和匹配系统都跑在这个 Hub 上。
房间生命周期 数据结构 房间的核心定义在 internal/realtime/room.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 type RoomState int const ( RoomStateWaiting RoomState = 0 RoomStatePlaying RoomState = 1 RoomStateFinished RoomState = 2 ) type Room struct { ID string Players map [string ]*Player Config RoomConfig State RoomState CreatedAt time.Time PlayerStates map [string ]*PlayerState mu sync.RWMutex }
RoomConfig 支持最大玩家数、游戏类型、是否私有、密码等配置:
1 2 3 4 5 6 7 type RoomConfig struct { MaxPlayers int GameType string IsPrivate bool Password string Metadata map [string ]interface {} }
创建房间 RoomManager.CreateRoom 在创建时填充默认值:
1 2 3 4 5 6 7 8 9 10 11 12 func (rm *RoomManager) CreateRoom(id string , config RoomConfig) *Room { if config.MaxPlayers <= 0 { config.MaxPlayers = 4 } room := &Room{ ID: id, Players: make (map [string ]*Player), Config: config, State: RoomStateWaiting, CreatedAt: time.Now(), PlayerStates: make (map [string ]*PlayerState), } rm.rooms[id] = room return room }
客户端通过 WebSocket 发送 create_room 消息触发创建,Handler 解析配置后调用 Hub:
1 2 3 4 5 6 7 8 func (h *Handler) handleCreateRoom(player *Player, msg *Message) { config, ok := parseRoomConfig(msg.Content) roomID := h.hub.CreateRoom(config) h.hub.JoinRoom(player.ID, roomID) h.hub.SendToPlayer(player.ID, Message{ Type: "room_created" , Room: roomID, ... }) }
加入房间 加入逻辑在 Hub.joinRoom 中处理,有一个关键细节:如果玩家已经在另一个房间里,会先自动离开旧房间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func (h *Hub) joinRoom(roomID string , player *Player) { room, ok := h.rooms[roomID] if !ok { room = &Room{ID: roomID, Players: make (map [string )*Player), ...} h.rooms[roomID] = room } if player.Room != "" && player.Room != roomID { oldRoom.RemovePlayer(player.ID) } room.AddPlayer(player) player.Room = roomID }
这样做的好处是客户端不需要显式调用”离开旧房间”接口,简化了前端逻辑。
离开与销毁 玩家断开连接时,Hub 在 handleUnregister 中处理离开和自动销毁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func (h *Hub) handleUnregister(player *Player) { if player.Room != "" { room.RemovePlayer(player.ID) if room.PlayerCount() == 0 { delete (h.rooms, player.Room) } } if player.MatchID != "" { match.RemovePlayer(player.ID) if match.PlayerCount() == 0 { delete (h.matches, player.MatchID) match.Stop() } } }
空房间和空 Match 都会在最后一个玩家离开时自动清理,不需要定时器或 TTL 机制。这种设计的前提是 Hub 是单 goroutine 事件循环——所有状态变更都在同一个 goroutine 内完成,不需要加锁。
房间发现 公开房间通过 ListRooms 查询,支持按 gameType 过滤、排除私有房间:
1 2 3 4 5 6 7 8 9 func (rm *RoomManager) ListRooms(gameType string ) []*Room { var result []*Room for _, room := range rm.rooms { if room.Config.IsPrivate { continue } if gameType != "" && room.Config.GameType != gameType { continue } result = append (result, room) } return result }
还提供了 FindAvailableRoom,返回第一个未满且处于等待状态的房间,供快速加入使用。
玩家状态追踪 连接层状态 每个玩家有两个层面的状态:连接层和游戏层。
连接层由 Player 结构体管理:
1 2 3 4 5 6 7 8 type Player struct { ID string Conn *websocket.Conn Room string MatchID string Send chan []byte LastPing time.Time }
LastPing 用于心跳检测——如果超过 60 秒没有收到 pong,Hub 会主动断开连接并触发 handleUnregister 清理。
游戏层状态 游戏层由 PlayerState 管理,存储在 Room 的 PlayerStates map 中:
1 2 3 4 5 6 7 8 type PlayerState struct { PlayerID string Position map [string ]float64 Health int Score int Ready bool Metadata map [string ]interface {} }
客户端发送 state_update 消息更新状态,Handler 收到后更新 Room 数据并广播给同房间的其他玩家:
1 2 3 4 5 6 func (h *Handler) handleStateUpdate(player *Player, msg *Message) { state, _ := parsePlayerState(player.ID, msg.Content) h.hub.UpdatePlayerState(player.Room, player.ID, state) h.hub.SendToRoom(player.Room, Message{Type: "player_state" , Content: msg.Content}, "" ) }
最后一个参数是排除的 playerID,传空字符串表示发给房间内所有人(包括发送者自己)。
匹配系统 MatchTicket 匹配的基本单位是 MatchTicket,代表一个玩家的匹配请求:
1 2 3 4 5 6 7 8 9 10 type MatchTicket struct { PlayerID string GameType string Skill float64 MinCount int MaxCount int Props map [string ]interface {} Query string Created time.Time }
Props 和 Query 支持属性匹配——比如只匹配同区域的玩家。Query 是一个简单的表达式字符串,按 gameType + "|" + query 分组。
提交与取消 一人一票,同一个 PlayerID 重复提交会覆盖旧 ticket:
1 2 3 4 5 6 7 8 func (h *Hub) SubmitTicket(ticket *MatchTicket) { h.poolMu.Lock() h.pool[ticket.PlayerID] = ticket } func (h *Hub) CancelTicket(playerID string ) { delete (h.pool, playerID) }
匹配扫描 Hub 启动时创建一个 2 秒间隔的 ticker,定期调用 scanMatchPool:
1 2 3 4 5 poolTicker: time.NewTicker(2 * time.Second), case <-h.poolTicker.C: h.scanMatchPool()
匹配算法分三步:
分组 :按 gameType + "|" + query 将 ticket 分入不同组
排序 :每组内按 skill 升序排列(冒泡排序,池子通常很小)
配对 :从排序后的列表中依次取相邻的两个 ticket 配对
1 2 3 4 5 6 7 for len (tickets) >= 2 { p1, p2 := tickets[0 ], tickets[1 ] tickets = tickets[2 :] h.CancelTicket(p1.PlayerID) h.CancelTicket(p2.PlayerID) h.OnMatch([]string {p1.PlayerID, p2.PlayerID}, gameType) }
超过 30 秒未匹配的 ticket 会被清理:
1 2 3 4 5 for _, t := range h.pool { if time.Since(t.Created) > 30 *time.Second { delete (h.pool, t.PlayerID) } }
匹配回调 匹配成功后触发 OnMatch 回调,默认实现是自动创建房间并加入匹配到的玩家:
1 2 3 4 5 6 7 h.OnMatch = func (players []string , gameType string ) string { roomID := h.CreateRoom(RoomConfig{MaxPlayers: 4 , GameType: gameType}) for _, pid := range players { h.JoinRoom(pid, roomID) } return roomID }
这个回调可以被替换——比如接入 ELO 排位系统,在创建房间前先验证玩家的段位是否匹配。
与 ELO 排位系统的配合 game-bass 的 internal/competitive 模块提供了 ELO 评分和天梯排行,为 skill-based 匹配提供分数来源。
排位系统的核心流程:
玩家完成一场比赛后,客户端上报比赛结果
后端通过 Lua 脚本或 Go 内置公式计算 ELO 变化
更新 Redis 中的分数和段位(Bronze → Diamond 五档)
赛季结束时,排行榜归档并开启新赛季
匹配时从排位系统读取玩家的 Skill 分数填入 MatchTicket,这样 scanMatchPool 的 skill 排序就能实现水平相近的玩家优先匹配。
整体架构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 客户端 WebSocket │ ▼ Handler.handleMessage ─── 按 Type 路由 │ ├── create_room ──── Hub.CreateRoom → RoomManager ├── join/leave ───── Hub.joinRoom / Hub.LeaveRoom ├── state_update ─── Hub.UpdatePlayerState → 广播 ├── matchmaking ──── Hub.SubmitTicket → 匹配池 │ │ │ poolTicker (2s) → scanMatchPool() │ │ │ skill 排序 → 配对 → OnMatch 回调 │ │ │ CreateRoom + JoinRoom × N │ └── disconnect ──── handleUnregister → 空房间/空 Match 自动销毁
设计取舍 取舍比较明确。Hub 用单 goroutine 串行处理所有状态变更,避免锁竞争,但匹配扫描和广播不能做太重的计算。中小规模的独立游戏后端够用。匹配池放内存,不依赖外部消息队列,简单直接。排序用冒泡,池子通常只有几十到几百个 ticket。
目前这套方案在 100 人同时在线的场景下表现稳定,匹配延迟 2-4 秒。更大的规模还没压测过,内存匹配池和冒泡排序大概率撑不住,到时候得换方案。