package main import ( "context" "database/sql" "encoding/json" "github.com/heroiclabs/nakama-common/runtime" ) const ( OpMove int64 = 1 OpState int64 = 2 ) // Server-side game state type MatchState struct { Board [3][3]string `json:"board"` Players []string `json:"players"` Turn int `json:"turn"` // index in Players Winner string `json:"winner"` // "X", "O", "draw", "forfeit" GameOver bool `json:"game_over"` // true when finished } // Struct that implements runtime.Match type TicTacToeMatch struct{} // Factory for RegisterMatch func NewMatch( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, ) (runtime.Match, error) { logger.Info("TicTacToe NewMatch factory called") return &TicTacToeMatch{}, nil } // ---- MatchInit ---- // Return initial state, tick rate (ticks/sec), and label func (m *TicTacToeMatch) MatchInit( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}, ) (interface{}, int, string) { state := &MatchState{ Board: [3][3]string{}, Players: []string{}, Turn: 0, Winner: "", GameOver: false, } tickRate := 5 // 5 ticks per second (~200ms) label := "tictactoe" logger.Info("TicTacToe MatchInit: tickRate=%v label=%s", tickRate, label) return state, tickRate, label } // ---- MatchJoinAttempt ---- func (m *TicTacToeMatch) MatchJoinAttempt( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string, ) (interface{}, bool, string) { s := state.(*MatchState) if len(s.Players) >= 2 { return s, false, "match full" } return s, true, "" } // ---- MatchJoin ---- func (m *TicTacToeMatch) MatchJoin( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence, ) interface{} { s := state.(*MatchState) for _, p := range presences { userID := p.GetUserId() // avoid duplicates if indexOf(s.Players, userID) == -1 { s.Players = append(s.Players, userID) } } logger.Info("MatchJoin: now %d players", len(s.Players)) return s } // ---- MatchLeave ---- func (m *TicTacToeMatch) MatchLeave( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence, ) interface{} { s := state.(*MatchState) // End the game if anyone leaves if !s.GameOver { s.GameOver = true s.Winner = "forfeit" logger.Info("MatchLeave: game ended by forfeit") } return s } // ---- MatchLoop ---- func (m *TicTacToeMatch) MatchLoop( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData, ) interface{} { s := state.(*MatchState) if s.GameOver { return s } for _, msg := range messages { if msg.GetOpCode() != OpMove { continue } var move struct { Row int `json:"row"` Col int `json:"col"` } if err := json.Unmarshal(msg.GetData(), &move); err != nil { logger.Warn("Invalid move payload: %v", err) continue } playerID := msg.GetUserId() playerIdx := indexOf(s.Players, playerID) if playerIdx != s.Turn { // not your turn continue } if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 { continue } if s.Board[move.Row][move.Col] != "" { continue } symbols := []string{"X", "O"} if playerIdx < 0 || playerIdx >= len(symbols) { continue } s.Board[move.Row][move.Col] = symbols[playerIdx] if winner := checkWinner(s.Board); winner != "" { s.Winner = winner s.GameOver = true } else if fullBoard(s.Board) { s.Winner = "draw" s.GameOver = true } else { s.Turn = 1 - s.Turn } } // Broadcast updated state to everyone stateJSON, _ := json.Marshal(s) if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { logger.Error("BroadcastMessage failed: %v", err) } return s } // ---- MatchTerminate ---- func (m *TicTacToeMatch) MatchTerminate( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int, ) interface{} { logger.Info("MatchTerminate: grace=%d", graceSeconds) return state } // ---- MatchSignal (not used, but required) ---- func (m *TicTacToeMatch) MatchSignal( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string, ) (interface{}, string) { logger.Info("MatchSignal: %s", data) // no-op; just echo back return state, "" } // ---- Helpers ---- func indexOf(arr []string, v string) int { for i, s := range arr { if s == v { return i } } return -1 } func checkWinner(b [3][3]string) string { lines := [][][2]int{ {{0, 0}, {0, 1}, {0, 2}}, {{1, 0}, {1, 1}, {1, 2}}, {{2, 0}, {2, 1}, {2, 2}}, {{0, 0}, {1, 0}, {2, 0}}, {{0, 1}, {1, 1}, {2, 1}}, {{0, 2}, {1, 2}, {2, 2}}, {{0, 0}, {1, 1}, {2, 2}}, {{0, 2}, {1, 1}, {2, 0}}, } for _, l := range lines { a, b2, c := l[0], l[1], l[2] if b[a[0]][a[1]] != "" && b[a[0]][a[1]] == b[b2[0]][b2[1]] && b[a[0]][a[1]] == b[c[0]][c[1]] { return b[a[0]][a[1]] } } return "" } func fullBoard(b [3][3]string) bool { for _, row := range b { for _, v := range row { if v == "" { return false } } } return true }