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)) // If we have enough players to start, broadcast initial state immediately if len(s.Players) == 2 { stateJSON, err := json.Marshal(s) if err != nil { logger.Error("Failed to marshal state on join: %v", err) } else { if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { logger.Error("BroadcastMessage (initial state) failed: %v", err) } else { logger.Info("Broadcasted initial state to 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") // broadcast final state so clients see the forfeit stateJSON, err := json.Marshal(s) if err != nil { logger.Error("Failed to marshal state on leave: %v", err) } else { if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { logger.Error("BroadcastMessage (forfeit) failed: %v", err) } else { logger.Info("Broadcasted forfeit state to remaining players") } } } 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 } changed := false for _, msg := range messages { if msg.GetOpCode() != OpMove { logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode()) 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 from %s: %v", msg.GetUserId(), err) continue } playerID := msg.GetUserId() playerIdx := indexOf(s.Players, playerID) logger.Info("Received move from %s (playerIdx=%d): row=%d col=%d", playerID, playerIdx, move.Row, move.Col) if playerIdx == -1 { logger.Warn("Move rejected: player %s not in player list", playerID) continue } if playerIdx != s.Turn { logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn) continue } if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 { logger.Warn("Move rejected: out of bounds (%d,%d)", move.Row, move.Col) continue } if s.Board[move.Row][move.Col] != "" { logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col) continue } symbols := []string{"X", "O"} if playerIdx < 0 || playerIdx >= len(symbols) { logger.Warn("Move rejected: invalid player index %d", playerIdx) continue } // Apply move s.Board[move.Row][move.Col] = symbols[playerIdx] changed = true logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col) // Check win/draw if winner := checkWinner(s.Board); winner != "" { s.Winner = winner s.GameOver = true logger.Info("Game over! Winner: %s", winner) } else if fullBoard(s.Board) { s.Winner = "draw" s.GameOver = true logger.Info("Game over! Draw") } else { s.Turn = 1 - s.Turn logger.Info("Turn advanced to %d", s.Turn) } } // If anything changed (or periodically if you want), broadcast updated state to everyone if changed { stateJSON, err := json.Marshal(s) if err != nil { logger.Error("Failed to marshal state: %v", err) } else { if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { logger.Error("BroadcastMessage failed: %v", err) } else { logger.Info("Broadcasted updated state to players") } } } 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 }