package tictactoe
import (
"bytes"
"strconv"
"strings"
"gno.land/p/nt/avl/v0"
"chain"
)
type cell uint8
const (
empty cell = 0
xMark cell = 1
oMark cell = 2
)
type gameStatus uint8
const (
statusOngoing gameStatus = 0
statusXWins gameStatus = 1
statusOWins gameStatus = 2
statusDraw gameStatus = 3
)
type game struct {
id int
board [9]cell
playerX address
playerO address
turn cell
status gameStatus
}
var (
games avl.Tree
counter int
)
// NewGame creates a new tic-tac-toe game. The caller becomes player X.
// Returns the game ID to share with your opponent.
func NewGame(cur realm) int {
if !cur.IsCurrent() {
panic("spoofed realm")
}
if !cur.Previous().IsUserCall() {
panic("EOA call only")
}
counter++
g := &game{
id: counter,
playerX: cur.Previous().Address(),
turn: xMark,
status: statusOngoing,
}
games.Set(strconv.Itoa(counter), g)
chain.Emit("GameCreated",
"gameID", strconv.Itoa(counter),
"playerX", g.playerX.String(),
)
return counter
}
// Join lets a second player join an existing game as player O.
func Join(cur realm, gameID int) {
if !cur.IsCurrent() {
panic("spoofed realm")
}
if !cur.Previous().IsUserCall() {
panic("EOA call only")
}
g := mustGetGame(gameID)
caller := cur.Previous().Address()
if g.playerO != address("") {
panic("game already has two players")
}
if g.playerX == caller {
panic("cannot play against yourself")
}
g.playerO = caller
chain.Emit("PlayerJoined",
"gameID", strconv.Itoa(gameID),
"playerO", caller.String(),
)
}
// Move places the caller's mark at the given board position.
// Positions are 0-8, laid out row-major:
//
// 0 | 1 | 2
// 3 | 4 | 5
// 6 | 7 | 8
func Move(cur realm, gameID int, pos int) {
if !cur.IsCurrent() {
panic("spoofed realm")
}
if !cur.Previous().IsUserCall() {
panic("EOA call only")
}
g := mustGetGame(gameID)
caller := cur.Previous().Address()
if g.status != statusOngoing {
panic("game is already over")
}
if g.playerO == address("") {
panic("waiting for second player to join")
}
if g.turn == xMark && caller != g.playerX {
panic("not your turn")
}
if g.turn == oMark && caller != g.playerO {
panic("not your turn")
}
if pos < 0 || pos > 8 {
panic("position out of range: must be 0-8")
}
if g.board[pos] != empty {
panic("position already occupied")
}
g.board[pos] = g.turn
chain.Emit("MoveMade",
"gameID", strconv.Itoa(gameID),
"player", caller.String(),
"pos", strconv.Itoa(pos),
"mark", markStr(g.turn),
)
if checkWin(g.board, g.turn) {
if g.turn == xMark {
g.status = statusXWins
} else {
g.status = statusOWins
}
chain.Emit("GameOver",
"gameID", strconv.Itoa(gameID),
"result", markStr(g.turn)+"Wins",
"winner", caller.String(),
)
return
}
if isBoardFull(g.board) {
g.status = statusDraw
chain.Emit("GameOver",
"gameID", strconv.Itoa(gameID),
"result", "draw",
)
return
}
if g.turn == xMark {
g.turn = oMark
} else {
g.turn = xMark
}
}
// Render returns the home page ("") or a specific game ("game/1").
func Render(path string) string {
if path == "" {
return renderHome()
}
if strings.HasPrefix(path, "game/") {
idStr := strings.TrimPrefix(path, "game/")
id, err := strconv.Atoi(idStr)
if err != nil || id < 1 {
return "> [!WARNING]\n> Invalid game ID.\n"
}
g, ok := getGame(id)
if !ok {
return "> [!WARNING]\n> Game not found.\n"
}
return renderGame(g)
}
return "> [!WARNING]\n> Path not found.\n"
}
func renderHome() string {
var b bytes.Buffer
b.WriteString("# Tic-Tac-Toe\n\n")
b.WriteString("Challenge friends on-chain — create a game and share the link!\n\n")
b.WriteString("## New Game\n\n")
b.WriteString("\n\n\n")
if counter == 0 {
b.WriteString("_No games yet — be the first!_\n")
return b.String()
}
b.WriteString("## Games\n\n")
b.WriteString("| # | Status | Player X | Player O |\n")
b.WriteString("|---|--------|----------|----------|\n")
games.Iterate("", "", func(key string, value any) bool {
g := value.(*game)
playerO := "_(open)_"
if g.playerO != address("") {
playerO = g.playerO.String()
}
b.WriteString("| [#" + key + "](:game/" + key + ") | " +
gameStatusStr(g) + " | " +
g.playerX.String() + " | " +
playerO + " |\n")
return false
})
return b.String()
}
func renderGame(g *game) string {
var b bytes.Buffer
id := strconv.Itoa(g.id)
b.WriteString("# Game #" + id + "\n\n")
b.WriteString(renderBoard(g.board))
b.WriteString("\n\n")
switch g.status {
case statusXWins:
b.WriteString("> [!SUCCESS]\n> **X wins!** Congratulations!\n\n")
case statusOWins:
b.WriteString("> [!SUCCESS]\n> **O wins!** Congratulations!\n\n")
case statusDraw:
b.WriteString("> [!INFO]\n> **Draw!** Well played, both.\n\n")
case statusOngoing:
if g.playerO == address("") {
b.WriteString("> [!NOTE]\n> Waiting for Player O to join.\n\n")
b.WriteString("## Join as Player O\n\n")
b.WriteString("\n")
b.WriteString("\n")
b.WriteString("\n\n")
} else {
b.WriteString("> [!INFO]\n> It's **" + markStr(g.turn) + "'s** turn.\n\n")
b.WriteString("## Make a Move\n\n")
b.WriteString("\n")
b.WriteString("\n")
b.WriteString("\n")
b.WriteString("\n\n")
}
}
b.WriteString("**Player X:** " + g.playerX.String() + "\n\n")
if g.playerO != address("") {
b.WriteString("**Player O:** " + g.playerO.String() + "\n\n")
}
b.WriteString("[← All games](:)\n")
return b.String()
}
// renderBoard renders the 3x3 board as a markdown table.
// Empty cells show their position number (0-8) to guide the next move.
// The top row doubles as the markdown table header.
func renderBoard(board [9]cell) string {
var b bytes.Buffer
b.WriteString("| ")
for col := 0; col < 3; col++ {
b.WriteString(cellDisplay(board[col], col) + " | ")
}
b.WriteString("\n|:---:|:---:|:---:|\n")
for row := 1; row < 3; row++ {
b.WriteString("| ")
for col := 0; col < 3; col++ {
pos := row*3 + col
b.WriteString(cellDisplay(board[pos], pos) + " | ")
}
b.WriteString("\n")
}
return b.String()
}
func cellDisplay(c cell, pos int) string {
if c == empty {
return strconv.Itoa(pos)
}
return markStr(c)
}
func markStr(c cell) string {
if c == xMark {
return "X"
}
return "O"
}
func gameStatusStr(g *game) string {
switch g.status {
case statusXWins:
return "X Wins"
case statusOWins:
return "O Wins"
case statusDraw:
return "Draw"
default:
if g.playerO == address("") {
return "Open"
}
return "Ongoing"
}
}
func mustGetGame(id int) *game {
g, ok := getGame(id)
if !ok {
panic("game not found: " + strconv.Itoa(id))
}
return g
}
func getGame(id int) (*game, bool) {
v, ok := games.Get(strconv.Itoa(id))
if !ok {
return nil, false
}
return v.(*game), true
}
var winLines = [8][3]int{
{0, 1, 2}, {3, 4, 5}, {6, 7, 8},
{0, 3, 6}, {1, 4, 7}, {2, 5, 8},
{0, 4, 8}, {2, 4, 6},
}
func checkWin(board [9]cell, mark cell) bool {
for _, line := range winLines {
if board[line[0]] == mark && board[line[1]] == mark && board[line[2]] == mark {
return true
}
}
return false
}
func isBoardFull(board [9]cell) bool {
for _, c := range board {
if c == empty {
return false
}
}
return true
}