8
\$\begingroup\$

There are already many Tic Tac Toe posts. But as far as I can tell, none of the ones in Haskell are complete with a GUI

Here is my implementation with Gloss. Gist Link for convenience

module Board where
import Data.Map (Map, (!))
import qualified Data.Map as Map
import Data.List (intercalate)
data Player = X | O
 deriving (Eq, Ord, Show)
newtype Board = Board (Map (Int, Int) (Maybe Player))
 deriving (Eq, Ord)
initBoard :: Board
initBoard = Board $ Map.fromList [((x, y), Nothing) | x <- [0..2], y <- [0..2]]
getMark :: Board -> (Int, Int) -> Maybe Player
getMark (Board board) (x, y)
 | x < 0 || x > 2 || y < 0 || y > 2 = error "Invalid coordinates"
 | otherwise = board ! (x, y)
putMark :: Board -> Player -> (Int, Int) -> Maybe Board
putMark (Board board) player (x, y)
 | x < 0 || x > 2 || y < 0 || y > 2 = error $ "Invalid coordinates" ++ show (x, y)
 | board ! (x, y) /= Nothing = Nothing
 | otherwise = Just $ Board $ Map.insert (x, y) (Just player) board
emptySquares :: Board -> [(Int, Int)]
emptySquares (Board board) = [(x, y) | x <- [0..2], y <- [0..2], board ! (x, y) == Nothing]
instance Show Board where
 show (Board board) = 
 intercalate "\n- - - \n" 
 [ ( intercalate "|" [prettyShow $ board ! (x, y) | y <- [0..2]] ) 
 | x <- [0..2]]
 where
 prettyShow Nothing = " "
 prettyShow (Just X) = "X"
 prettyShow (Just O) = "O"
allX :: Board
allX = Board $ Map.fromList [((x, y), Just X) | x <- [0..2], y <- [0..2]]
allO :: Board
allO = Board $ Map.fromList [((x, y), Just O) | x <- [0..2], y <- [0..2]]
module Position where
import Control.Applicative
import Control.Monad.State
import Data.Maybe 
import Data.Map (Map)
import Data.List (minimumBy)
import qualified Data.Map as Map
import Board 
data Position = Position { curBoard :: Board, curPlayer :: Player }
 deriving (Eq, Ord, Show)
type Line = [(Int, Int)]
winningLines :: [Line]
winningLines = [ [(x, y) | x <- [0..2]] | y <- [0..2]] ++ -- vertical lines
 [ [(x, y) | y <- [0..2]] | x <- [0..2]] ++ -- horizontal lines
 [[(0, 0), (1, 1), (2, 2)], -- main diagonal
 [(0, 2), (1, 1), (2, 0)]] -- off diagonal 
lineWinner :: Board -> Line -> Maybe Player
lineWinner b l
 | all (== Just X) marks = Just X
 | all (== Just O) marks = Just O
 | otherwise = Nothing 
 where 
 marks = map (getMark b) l 
boardWinner :: Board -> Maybe Player
boardWinner b = foldr (<|>) Nothing $ map (lineWinner b) winningLines
nextPlayer :: Player -> Player
nextPlayer X = O
nextPlayer O = X
succPositions :: Position -> [Position]
succPositions (Position b p) = newPosition . fromJust . markSquare <$> (emptySquares b)
 where
 newPosition b' = Position { curBoard = b', curPlayer = nextPlayer p }
 markSquare = putMark b p
isDraw :: Board -> Bool
isDraw b = null (emptySquares b) && isNothing (boardWinner b)
data Label = Win | Lose | Draw
 deriving (Show, Eq)
data Score = Score { label :: Label, height :: Int }
 deriving (Show, Eq)
instance Ord Score where
 (Score Win i) <= (Score Win j) = i >= j
 (Score Win _) <= _ = False
 (Score Lose i) <= (Score Lose j) = i <= j
 (Score Lose _) <= _ = True
 (Score Draw i) <= (Score Draw j) = i >= j
 (Score Draw _) <= (Score Win _) = True 
 (Score Draw _) <= (Score Lose _) = False
type KnowledgeBase = Map Position Score
scorePosition :: Position -> State KnowledgeBase Score
scorePosition pos@(Position b p)
 | isDraw b = pure $ Score { label = Draw, height = 0 }
 | (boardWinner b) == Just p = pure $ Score { label = Win, height = 0 }
 | Just _ <- (boardWinner b) = pure $ Score { label = Lose, height = 0 }
scorePosition pos@(Position b p) = 
 do
 knowledge <- gets (Map.lookup pos)
 case knowledge of
 Just s -> return s
 Nothing -> do
 let nextPositions = succPositions pos
 nextScores <- mapM scorePosition nextPositions
 let bestSuccScore = minimum nextScores
 let score = curScore bestSuccScore
 modify (Map.insert pos score)
 return score
bestResponse :: Position -> State KnowledgeBase Position
bestResponse pos@(Position b p) = 
 do
 let nextPositions = succPositions pos
 nextScores <- mapM scorePosition nextPositions
 let bestSucc = snd $ minimumBy (\(s1, p1) (s2, p2) -> compare s1 s2) $ zip nextScores nextPositions
 return bestSucc
-- given the minimum score among the successors,
-- compute the current score
curScore :: Score -> Score
curScore (Score Win i) = Score Lose (i + 1)
curScore (Score Lose i) = Score Win (i + 1)
curScore (Score Draw i) = Score Draw (i + 1)
module GlossUI where
import Data.Map (Map)
import qualified Data.Map as Map
import Control.Monad
import Control.Monad.State
import Control.Applicative
import Graphics.Gloss
import Graphics.Gloss.Interface.Pure.Game
import Debug.Trace
import Board
import Position
-- copying some code from https://gist.github.com/gallais/0d61677fe97aa01a12d5
data GameState = GameState {
 pos :: Position
 , kb :: KnowledgeBase
 , playersTurn :: Bool
 , needToEval :: Bool
 }
 deriving Show
type Size = Float
resize :: Size -> Path -> Path
resize k = fmap (\ (x, y) -> (x * k, y * k))
drawO :: Size -> (Int, Int) -> Picture
drawO k (i, j) =
 let x' = k * (fromIntegral j - 1)
 y' = k * (1 - fromIntegral i)
 in color (greyN 0.8) $ translate x' y' $ thickCircle (0.1 * k) (0.3 * k)
drawX :: Size -> (Int, Int) -> Picture
drawX k (i, j) =
 let x' = k * (fromIntegral j - 1)
 y' = k * (1 - fromIntegral i)
 in color black $ translate x' y' $ Pictures
 $ fmap (polygon . resize k)
 [ [ (-0.35, -0.25), (-0.25, -0.35), (0.35,0.25), (0.25, 0.35) ]
 , [ (0.35, -0.25), (0.25, -0.35), (-0.35,0.25), (-0.25, 0.35) ]
 ]
drawBoard :: Size -> Board -> Picture
drawBoard k b = Pictures $ grid : markPics where
 markPics = [drawAt (i, j) (getMark b (i, j)) | i <- [0..2], j <- [0..2]]
 drawAt :: (Int, Int) -> (Maybe Player) -> Picture
 drawAt (_, _) Nothing = Blank
 drawAt (i, j) (Just X) = drawX k (i, j)
 drawAt (i, j) (Just O) = drawO k (i, j)
 grid :: Picture
 grid = color black $ Pictures $ fmap (line . resize k)
 [ [(-1.5, -0.5), (1.5 , -0.5)]
 , [(-1.5, 0.5) , (1.5 , 0.5)]
 , [(-0.5, -1.5), (-0.5, 1.5)]
 , [(0.5 , -1.5), (0.5 , 1.5)]
 ]
checkCoordinateY :: Size -> Float -> Maybe Int
checkCoordinateY k f' =
 let f = f' / k
 in 2 <$ guard (-1.5 < f && f < -0.5)
 <|> 1 <$ guard (-0.5 < f && f < 0.5)
 <|> 0 <$ guard (0.5 < f && f < 1.5)
checkCoordinateX :: Size -> Float -> Maybe Int
checkCoordinateX k f' =
 let f = f' / k
 in 0 <$ guard (-1.5 < f && f < -0.5)
 <|> 1 <$ guard (-0.5 < f && f < 0.5)
 <|> 2 <$ guard (0.5 < f && f < 1.5)
getCoordinates :: Size -> (Float, Float) -> Maybe (Int, Int)
getCoordinates k (x, y) =
 (,) <$> checkCoordinateY k y <*> checkCoordinateX k x
gameUpdate' :: Size -> Event -> GameState -> GameState
gameUpdate' _ e gs
 | playersTurn gs == False || needToEval gs = gs
gameUpdate' k (EventKey (MouseButton LeftButton) Down _ (x', y')) gs =
 let newBoard = do 
 (i, j) <- getCoordinates k (x', y')
 putMark (curBoard $ pos gs) (curPlayer $ pos gs) (i, j)
 in case newBoard of
 Nothing -> gs
 Just b' -> gs { pos = Position { 
 curBoard = b'
 , curPlayer = nextPlayer (curPlayer $ pos gs) 
 }
 , playersTurn = False
 , needToEval = True
 }
gameUpdate' _ _ gs = gs
gameTime :: Float -> GameState -> GameState
-- let the player move
gameTime _ gs
 | playersTurn gs && not (needToEval gs) = gs
-- check if player has won
gameTime t gs
 | (needToEval gs) =
 case (boardWinner $ curBoard $ pos gs) of
 Just X -> gs { pos = (pos gs) { curBoard = allX } }
 Just O -> gs { pos = (pos gs) { curBoard = allO } }
 Nothing -> gs { needToEval = False }
-- make computers move
gameTime _ gs =
 let (pos', kb') = runState (bestResponse $ pos gs) (kb gs)
 in GameState {pos = pos', kb = kb', playersTurn = True, needToEval = True}
initGameState :: GameState
initGameState = 
 GameState {
 pos = Position {
 curBoard = initBoard
 , curPlayer = X
 }
 , kb = Map.empty
 , playersTurn = True
 , needToEval = False
 }
main :: IO ()
main =
 let window = InWindow "Tic Tac Toe" (300, 300) (10, 10)
 size = 100.0
 in play 
 window 
 white 
 1 
 initGameState
 (\ gs -> drawBoard size $ curBoard $ pos gs) 
 (gameUpdate' size) 
 gameTime

I would like some feedback on the following:

  • Is the best way to implement the scorePosition using the state monad? See [Line 63 in Position.hs]
  • In general, is there a better way to implement the mechanism to pick the next move?
  • Is there a better way to implement the main UI loop in GlossUI.hs? Or is the way of marking explicit points in the state space the best way?
  • Is it a better idea to define the board in some other way in Board.hs?
  • Anything else that is worth changing?

later edit: I made certain changes to my code. If you are curious, see Link

asked Nov 7, 2021 at 14:30
\$\endgroup\$
3
  • 1
    \$\begingroup\$ two small tips: gloss has scale, you can use it instead of resize; don't do two options guards, specially if you need to re-evaluate a expression, that's what ifs are meant for. \$\endgroup\$ Commented Nov 21, 2021 at 1:15
  • \$\begingroup\$ @pedrofuria what are options guards? \$\endgroup\$ Commented Nov 21, 2021 at 2:10
  • \$\begingroup\$ guards with two branchs \$\endgroup\$ Commented Nov 21, 2021 at 15:18

1 Answer 1

1
\$\begingroup\$

For tic-tac-toe the efficiency of the scoring function doesn't matter much; you can do exhaustive search (as in your scorePosition) because the search space is very small.

Perhaps you could encode the positions as a product-of-sum type rather than a tuple of integers, which would eliminate a bunch of error checking:

data Coord = C1 | C2 | C3 deriving (Eq, Ord, Show, Enum, Bounded)
type Pos = (Coord, Coord)

Re. code review : your Position and Board modules are very readable.

However for larger games (like chess) you need to prioritize the search schedule.

answered Nov 21, 2021 at 11:19
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.