├── README.md ├── server.go ├── LICENSE ├── webroot ├── app.js ├── app.css └── index.html ├── .gitignore ├── game_test.go ├── connectionPair.go ├── connection.go └── game.go /README.md: -------------------------------------------------------------------------------- 1 | # websocket tic-tac-toe 2 | [![Go Report Card](http://goreportcard.com/badge/riscie/websocket-tic-tac-toe)](http://goreportcard.com/report/riscie/websocket-tic-tac-toe) 3 | #### multiplayer tic-tac-toe using golang with gorilla websockets as backend and vuejs as frontend 4 | ![game sample](http://langhard.com/github/tic-tac-toe2.gif "game sample") 5 | 6 | ### get and run 7 | * `go get https://github.com/riscie/websocket-tic-tac-toe` 8 | * `go build` 9 | * run the produced binary 10 | * connect to http://localhost:8080 11 | 12 | ### gin (allows live-rebuilding on backend changes) 13 | * install gin: https://github.com/codegangsta/gin 14 | * start with gin: `gin -a 8080 r .\server.go` 15 | 16 | ### Todos: 17 | * some methods need refactoring 18 | * could implement cookies to identify clients 19 | * writing tests 20 | 21 | 22 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/joho/godotenv" 10 | ) 11 | 12 | func main() { 13 | host, port := "", "" 14 | //reading environment specific settings .env file. If missing; assume dev env 15 | err := godotenv.Load() 16 | if err != nil { 17 | log.Print("Error loading .env file") 18 | host = "127.0.0.1" 19 | port = "8080" 20 | } else { 21 | host = os.Getenv("HOST") 22 | port = os.Getenv("PORT") 23 | } 24 | 25 | //preparing mux and server 26 | conn := fmt.Sprint(host, ":", port) 27 | router := http.NewServeMux() 28 | router.Handle("/", http.FileServer(http.Dir("./webroot"))) 29 | router.Handle("/ws", wsHandler{}) 30 | 31 | //serving 32 | log.Printf("serving tic-tac-toe on %v", conn) 33 | log.Fatal(http.ListenAndServe(conn, router)) 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matthias Langhard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /webroot/app.js: -------------------------------------------------------------------------------- 1 | // creating the websocket connection. Test for dev environment 2 | var socket; 3 | 4 | if (window.location.host === "127.0.0.1:8080") { 5 | socket = new WebSocket("ws://127.0.0.1:8080/ws"); 6 | } else { 7 | socket = new WebSocket("ws://tic-tac-toe.langhard.com/ws"); 8 | } 9 | // when an update is received via ws connection, we update the model 10 | socket.onmessage = function(evt){ 11 | var newData = JSON.parse(evt.data); 12 | console.log(evt.data); //TODO: Remove in production 13 | tictactoe.gameState = newData; 14 | }; 15 | 16 | 17 | // vuejs debug mode 18 | Vue.config.debug = true; //TODO: Remove in production 19 | 20 | 21 | // transistions 22 | Vue.transition('board', { 23 | enterClass: 'bounceInDown', 24 | leaveClass: 'bounceOutDown' 25 | }); 26 | 27 | // creating the vue instance here 28 | // trying to have all my logic in the backend, only updating the view 29 | // on model changes and passing moves back to the backend 30 | var tictactoe = new Vue({ 31 | el: '#tictactoe', 32 | data: { 33 | gameState: { 34 | started: false, 35 | fields: [], 36 | }, 37 | //Special Move coding scheme 38 | RESTART: 10, 39 | }, 40 | computed: { 41 | row1: function() { 42 | return this.gameState.fields.slice(0,3); 43 | }, 44 | row2: function() { 45 | return this.gameState.fields.slice(3,6); 46 | }, 47 | row3: function() { 48 | return this.gameState.fields.slice(6,9); 49 | }, 50 | }, 51 | methods: { 52 | makeMove: function(fieldNum){ 53 | socket.send(fieldNum); 54 | }, 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go,intellij 3 | 4 | ### Go ### 5 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 6 | *.o 7 | *.a 8 | *.so 9 | 10 | # Folders 11 | _obj 12 | _test 13 | 14 | # Architecture specific extensions/prefixes 15 | *.[568vq] 16 | [568vq].out 17 | 18 | *.cgo1.go 19 | *.cgo2.c 20 | _cgo_defun.c 21 | _cgo_gotypes.go 22 | _cgo_export.* 23 | 24 | _testmain.go 25 | 26 | *.exe 27 | *.exe~ 28 | *.test 29 | *.prof 30 | 31 | ### Intellij ### 32 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 33 | 34 | *.iml 35 | 36 | ## Directory-based project format: 37 | .idea/ 38 | # if you remove the above rule, at least ignore the following: 39 | 40 | # User-specific stuff: 41 | # .idea/workspace.xml 42 | # .idea/tasks.xml 43 | # .idea/dictionaries 44 | # .idea/shelf 45 | 46 | # Sensitive or high-churn files: 47 | # .idea/dataSources.ids 48 | # .idea/dataSources.xml 49 | # .idea/sqlDataSources.xml 50 | # .idea/dynamic.xml 51 | # .idea/uiDesigner.xml 52 | 53 | # Gradle: 54 | # .idea/gradle.xml 55 | # .idea/libraries 56 | 57 | # Mongo Explorer plugin: 58 | # .idea/mongoSettings.xml 59 | 60 | ## File-based project format: 61 | *.ipr 62 | *.iws 63 | 64 | ## Plugin-specific files: 65 | 66 | # IntelliJ 67 | /out/ 68 | 69 | # mpeltonen/sbt-idea plugin 70 | .idea_modules/ 71 | 72 | # JIRA plugin 73 | atlassian-ide-plugin.xml 74 | 75 | # Crashlytics plugin (for Android Studio and IntelliJ) 76 | com_crashlytics_export_strings.xml 77 | crashlytics.properties 78 | crashlytics-build.properties 79 | fabric.properties 80 | 81 | # Project specific files 82 | /project_info 83 | 84 | # Environment specific variables 85 | .env 86 | # The Linux binary on do 87 | websocket-tic-tac-toe -------------------------------------------------------------------------------- /game_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestCheckForWin(t *testing.T) { 9 | 10 | var winPatterns []gameState 11 | 12 | // Checking winning patterns 13 | gs := newGameState() 14 | x := field{Set: true, Symbol: "X"} 15 | 16 | // Different winning patterns 17 | // top row 18 | gs.Fields = []field{x, x, x, {}, {}, {}, {}, {}, {}} 19 | winPatterns = append(winPatterns, gs) 20 | // second row 21 | gs.Fields = []field{{}, {}, {}, x, x, x, {}, {}, {}} 22 | winPatterns = append(winPatterns, gs) 23 | // third row 24 | gs.Fields = []field{{}, {}, {}, {}, {}, {}, x, x, x} 25 | winPatterns = append(winPatterns, gs) 26 | // first column 27 | gs.Fields = []field{x, {}, {}, x, {}, {}, x, {}, {}} 28 | winPatterns = append(winPatterns, gs) 29 | // second column 30 | gs.Fields = []field{{}, x, {}, {}, x, {}, {}, x, {}} 31 | winPatterns = append(winPatterns, gs) 32 | // third column 33 | gs.Fields = []field{{}, {}, x, {}, {}, x, {}, {}, x} 34 | winPatterns = append(winPatterns, gs) 35 | // diagonal 1 36 | gs.Fields = []field{x, {}, {}, {}, x, {}, {}, {}, x} 37 | winPatterns = append(winPatterns, gs) 38 | // diagonal 2 39 | gs.Fields = []field{{}, {}, x, {}, x, {}, x, {}, {}} 40 | winPatterns = append(winPatterns, gs) 41 | 42 | // Checking non-win patterns 43 | var nonWinPatterns []gameState 44 | 45 | // Different non-winning patterns 46 | gs.Fields = []field{x, {}, x, {}, {}, {}, {}, {}, x} 47 | nonWinPatterns = append(nonWinPatterns, gs) 48 | 49 | gs.Fields = []field{{}, {}, {}, x, x, {}, {}, {}, x} 50 | nonWinPatterns = append(nonWinPatterns, gs) 51 | 52 | gs.Fields = []field{x, {}, {}, {}, {}, {}, {}, x, x} 53 | nonWinPatterns = append(nonWinPatterns, gs) 54 | 55 | gs.Fields = []field{x, {}, {}, x, {}, {}, {}, {}, x} 56 | nonWinPatterns = append(nonWinPatterns, gs) 57 | 58 | fmt.Printf("Testing %v winning patterns\n", len(winPatterns)) 59 | 60 | for _, p := range winPatterns { 61 | if w, _ := p.checkForWin(); !w { 62 | t.Error("No Player detected as winning") 63 | } 64 | } 65 | fmt.Printf("Testing %v non-winning patterns\n", len(nonWinPatterns)) 66 | 67 | for _, p := range nonWinPatterns { 68 | if w, _ := p.checkForWin(); w { 69 | t.Error("Detected win which is non-win") 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /connectionPair.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // timeoutBeforeReBroadcast sets the time in seconds after where we rebroadcast the gameState 10 | // to all clients. This way we see if the opponent is still there 11 | const timeoutBeforeReBroadcast = 5 //TODO: should probably be set higher in real world usages... 12 | // timeoutBeforeConnectionDrop sets the time in seconds after after we drop a connection 13 | // which is not answering 14 | const timeoutBeforeConnectionDrop = 1 15 | 16 | // connectionPair handles the update of the gameState between two players 17 | type connectionPair struct { 18 | // the mutex to protect connections 19 | connectionsMx sync.RWMutex 20 | // Registered connections. 21 | connections map[*connection]struct{} 22 | // Inbound messages from the connections. 23 | receiveMove chan bool 24 | logMx sync.RWMutex 25 | log [][]byte 26 | gs gameState 27 | } 28 | 29 | // newConnectionPair is the constructor for the connectionPair struct 30 | func newConnectionPair() *connectionPair { 31 | cp := &connectionPair{ 32 | connectionsMx: sync.RWMutex{}, 33 | receiveMove: make(chan bool), 34 | connections: make(map[*connection]struct{}), 35 | gs: newGameState(), 36 | } 37 | 38 | go func() { 39 | for { 40 | select { 41 | //waiting for an update of one of the clients in the connection pair 42 | case <-cp.receiveMove: 43 | case <-time.After(timeoutBeforeReBroadcast * time.Second): //After x seconds we do broadcast again to see if the opp. is still there 44 | } 45 | 46 | cp.connectionsMx.RLock() 47 | for c := range cp.connections { 48 | select { 49 | case c.doBroadcast <- true: 50 | // stop trying to send to this connection after trying for 1 second. 51 | // if we have to stop, it means that a reader died so remove the connection also. 52 | case <-time.After(timeoutBeforeConnectionDrop * time.Second): 53 | cp.removeConnection(c) 54 | } 55 | } 56 | cp.connectionsMx.RUnlock() 57 | } 58 | }() 59 | return cp 60 | } 61 | 62 | // addConnection adds a players connection to the connectionPair 63 | func (h *connectionPair) addConnection(conn *connection) { 64 | h.connectionsMx.Lock() 65 | defer h.connectionsMx.Unlock() 66 | // TODO: Should be checking if the same user gets paired to himself 67 | // TODO: by reloading the page. We could achieve that with setting 68 | // TODO: cookies to re-identify users 69 | h.connections[conn] = struct{}{} 70 | 71 | } 72 | 73 | // removeConnection removes a players connection from the connectionPair 74 | func (h *connectionPair) removeConnection(conn *connection) { 75 | h.connectionsMx.Lock() 76 | defer h.connectionsMx.Unlock() 77 | if _, ok := h.connections[conn]; ok { 78 | delete(h.connections, conn) 79 | close(conn.doBroadcast) 80 | } 81 | log.Println("Player disconnected") 82 | h.gs.resetGame() 83 | } 84 | -------------------------------------------------------------------------------- /webroot/app.css: -------------------------------------------------------------------------------- 1 | /* Main Site */ 2 | body { 3 | padding-top: 50px; 4 | } 5 | 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | table { 12 | border-collapse: collapse; 13 | border-spacing: 0; 14 | } 15 | 16 | .main { 17 | margin-top: 25px; 18 | } 19 | 20 | /* StatusMessage */ 21 | #statusMessage{ 22 | text-align: center; 23 | } 24 | 25 | /* Board -https://coderbyte.com/tutorial/create-a-tic-tac-toe-game-using-html-css-and-jquery-*/ 26 | #board { 27 | padding: 0px; 28 | margin: 0 auto; 29 | } 30 | 31 | .free-field { 32 | color: white; 33 | } 34 | 35 | #board tr td { 36 | width: 80px; 37 | height: 80px; 38 | border: 2px solid #b3b3b3; 39 | font-family: Helvetica, sans-serif; 40 | font-size: 30px; 41 | text-align: center; 42 | background: white; 43 | } 44 | /* top row */ 45 | #board tr td.top { 46 | border-top: none; 47 | } 48 | #board tr td.top:nth-child(1) { 49 | border-left: none; 50 | } 51 | #board tr td.top:nth-child(3) { 52 | border-right: none; 53 | } 54 | /* middle row */ 55 | #board tr td.middle:nth-child(1) { 56 | border-left: none; 57 | } 58 | #board tr td.middle:nth-child(3) { 59 | border-right: none; 60 | } 61 | 62 | /* bottom row */ 63 | #board tr td.bottom { 64 | border-bottom: none; 65 | } 66 | #board tr td.bottom:nth-child(1) { 67 | border-left: none; 68 | } 69 | #board tr td.bottom:nth-child(3) { 70 | border-right: none; 71 | } 72 | 73 | #board tr td:hover { 74 | background: lightgreen; 75 | cursor: pointer; 76 | } 77 | 78 | 79 | /* Jumbotron */ 80 | .jumbotron{ 81 | text-align: center; 82 | color: #334046; 83 | } 84 | 85 | /* Spinner -http://tobiasahlin.com/spinkit/-*/ 86 | .sk-cube-grid { 87 | width: 100px; 88 | height: 100px; 89 | margin: 100px auto; 90 | } 91 | 92 | .sk-cube-grid .sk-cube { 93 | width: 33%; 94 | height: 33%; 95 | background-color: white; 96 | float: left; 97 | -webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; 98 | animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; 99 | } 100 | .sk-cube-grid .sk-cube1 { 101 | -webkit-animation-delay: 0.2s; 102 | animation-delay: 0.2s; } 103 | .sk-cube-grid .sk-cube2 { 104 | -webkit-animation-delay: 0.3s; 105 | animation-delay: 0.3s; } 106 | .sk-cube-grid .sk-cube3 { 107 | -webkit-animation-delay: 0.4s; 108 | animation-delay: 0.4s; } 109 | .sk-cube-grid .sk-cube4 { 110 | -webkit-animation-delay: 0.1s; 111 | animation-delay: 0.1s; } 112 | .sk-cube-grid .sk-cube5 { 113 | -webkit-animation-delay: 0.2s; 114 | animation-delay: 0.2s; } 115 | .sk-cube-grid .sk-cube6 { 116 | -webkit-animation-delay: 0.3s; 117 | animation-delay: 0.3s; } 118 | .sk-cube-grid .sk-cube7 { 119 | -webkit-animation-delay: 0s; 120 | animation-delay: 0s; } 121 | .sk-cube-grid .sk-cube8 { 122 | -webkit-animation-delay: 0.1s; 123 | animation-delay: 0.1s; } 124 | .sk-cube-grid .sk-cube9 { 125 | -webkit-animation-delay: 0.2s; 126 | animation-delay: 0.2s; } 127 | 128 | @-webkit-keyframes sk-cubeGridScaleDelay { 129 | 0%, 70%, 100% { 130 | -webkit-transform: scale3D(1, 1, 1); 131 | transform: scale3D(1, 1, 1); 132 | } 35% { 133 | -webkit-transform: scale3D(0, 0, 1); 134 | transform: scale3D(0, 0, 1); 135 | } 136 | } 137 | 138 | @keyframes sk-cubeGridScaleDelay { 139 | 0%, 70%, 100% { 140 | -webkit-transform: scale3D(1, 1, 1); 141 | transform: scale3D(1, 1, 1); 142 | } 35% { 143 | -webkit-transform: scale3D(0, 0, 1); 144 | transform: scale3D(0, 0, 1); 145 | } 146 | } 147 | 148 | 149 | .animated { 150 | animation-duration: 0.8s; 151 | -webkit-animation-duration: 0.8s; 152 | -moz-animation-duration: 0.8s; 153 | } 154 | 155 | .noTransition { 156 | -moz-transition: none; 157 | -webkit-transition: none; 158 | -o-transition: color 0 ease-in; 159 | transition: none; 160 | } -------------------------------------------------------------------------------- /webroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | websocket tic-tac-toe 7 | 8 | 9 | 10 | 11 | 12 | 13 | 31 | 32 |
33 |
34 |
35 |

{{gameState.statusMessage}}

36 | 37 |
38 | 39 |
40 |

{{gameState.statusMessage}}

41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 |
55 | 56 | 57 | 61 | 62 | 63 | 68 | 69 | 70 | 75 | 76 |
58 | {{field.symbol}} 59 | 60 |
64 | {{field.symbol}} 65 | 66 | 67 |
71 | {{field.symbol}} 72 | 73 | 74 |
77 |
78 | 79 | 80 |
81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/websocket" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | "sync" 9 | ) 10 | 11 | // connections stores all the hubs 12 | var connections []*connectionPair 13 | 14 | type connection struct { 15 | // Channel which triggers the connection to update the gameState 16 | doBroadcast chan bool 17 | // The connectionPair. Holds up to 2 connections. 18 | cp *connectionPair 19 | // playerNum represents the players Slot. Either 0 or 1 20 | playerNum int 21 | } 22 | 23 | // wsHandler implements the Handler Interface 24 | type wsHandler struct{} 25 | 26 | // reader reads the moves from the clients ws-connection 27 | func (c *connection) reader(wg *sync.WaitGroup, wsConn *websocket.Conn) { 28 | defer wg.Done() 29 | for { 30 | //Reading next move from connection here 31 | _, clientMoveMessage, err := wsConn.ReadMessage() 32 | if err != nil { 33 | break 34 | } 35 | 36 | field, _ := strconv.ParseInt(string(clientMoveMessage[:]), 10, 32) //Getting FieldValue From Player Action 37 | c.cp.gs.makeMove(c.playerNum, int(field)) 38 | c.cp.receiveMove <- true //telling connectionPair to broadcast the gameState 39 | } 40 | } 41 | 42 | // writer broadcasts the current gameState to the two players in a connectionPair 43 | func (c *connection) writer(wg *sync.WaitGroup, wsConn *websocket.Conn) { 44 | defer wg.Done() 45 | for range c.doBroadcast { 46 | sendGameStateToConnection(wsConn, c) 47 | } 48 | } 49 | 50 | // getConnectionPairWithEmptySlot looks trough all connectionPairs and finds one which has only 1 player 51 | // if there is none a new connectionPair is created and the player is added to that pair 52 | func getConnectionPairWithEmptySlot() (*connectionPair, int) { 53 | sizeBefore := len(connections) 54 | // find connections with 1 player first and pair if possible 55 | for _, h := range connections { 56 | if len(h.connections) == 1 { 57 | log.Printf("Players paired") 58 | return h, len(h.connections) 59 | } 60 | } 61 | 62 | //TODO: I need to remove orphaned connectionPairs from the stack 63 | 64 | // if no emtpy slow was found at all, we create a new connectionPair 65 | h := newConnectionPair() 66 | connections = append(connections, h) 67 | log.Printf("Player seated in new connectionPair no. %v", len(connections)) 68 | return connections[sizeBefore], 0 69 | } 70 | 71 | // ServeHTTP is the routers HandleFunc for websocket connections 72 | // connections are upgraded to websocket connections and the player is added 73 | // to a connection pair 74 | func (wsh wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 75 | 76 | // upgrader is needed to upgrade the HTTP Connection to a websocket Connection 77 | upgrader := &websocket.Upgrader{ 78 | ReadBufferSize: 1024, 79 | WriteBufferSize: 1024, 80 | CheckOrigin: func(r *http.Request) bool { return true }, //TODO: Remove in production. Needed for gin proxy 81 | } 82 | 83 | //Upgrading HTTP Connection to websocket connection 84 | wsConn, err := upgrader.Upgrade(w, r, nil) 85 | if err != nil { 86 | log.Printf("error upgrading %s", err) 87 | return 88 | } 89 | 90 | //Adding Connection to connectionPair 91 | cp, pn := getConnectionPairWithEmptySlot() 92 | c := &connection{doBroadcast: make(chan bool), cp: cp, playerNum: pn} 93 | c.cp.addConnection(c) 94 | 95 | //If the connectionPair existed before but one player was disconnected 96 | //we can now reinitialize the gameState after the remaining player has 97 | //been paired again 98 | if c.cp.gs.StatusMessage == resetWaitPaired { 99 | c.cp.gs = newGameState() 100 | //there is already one player connected when we re-pair 101 | c.cp.gs.numberOfPlayers = 1 102 | log.Println("gamestate resetted") 103 | } 104 | 105 | //inform the gameState about the new player 106 | c.cp.gs.addPlayer() 107 | //telling connectionPair to broadcast the gameState 108 | c.cp.receiveMove <- true 109 | 110 | //creating the writer and reader goroutines 111 | //the websocket connection is open as long as these goroutines are running 112 | var wg sync.WaitGroup 113 | wg.Add(2) 114 | go c.writer(&wg, wsConn) 115 | go c.reader(&wg, wsConn) 116 | wg.Wait() 117 | wsConn.Close() 118 | } 119 | 120 | // sendGameStateToConnection broadcasts the current gameState as JSON to all players 121 | // within a connectionPair 122 | func sendGameStateToConnection(wsConn *websocket.Conn, c *connection) { 123 | err := wsConn.WriteMessage(websocket.TextMessage, c.cp.gs.gameStateToJSON()) 124 | //removing connection if updating gameState fails 125 | if err != nil { 126 | c.cp.removeConnection(c) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /game.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | ) 7 | 8 | // these constants represent different game status messages 9 | const waitPaired = "Waiting to get paired" 10 | const gameBegins = "Game begins!" 11 | const draw = "Draw!" 12 | const resetWaitPaired = "Opponent has been disconnected... Waiting to get paired again" 13 | const winner = " wins! Congratulations!" 14 | 15 | // gameState is the struct which represents the gameState between two players 16 | type gameState struct { 17 | //renaming json values here to confirm the standard (lowercase var names) 18 | StatusMessage string `json:"statusMessage"` 19 | Fields []field `json:"fields"` 20 | PlayerSymbols []string `json:"playerSymbols"` 21 | Started bool `json:"started"` 22 | Over bool `json:"over"` 23 | //These are not exported to JSON 24 | numberOfPlayers int 25 | playersTurn int 26 | numberOfMoves int 27 | } 28 | 29 | // field represents one of the nine tic-tac-toe fields 30 | type field struct { 31 | Set bool `json:"set"` 32 | Symbol string `json:"symbol"` 33 | } 34 | 35 | // newGameState is the constructor for the gameState struct and creates the initial gameState Struct (empty board) 36 | func newGameState() gameState { 37 | gs := gameState{ 38 | StatusMessage: waitPaired, 39 | Fields: emptyFields(), 40 | PlayerSymbols: []string{0: "X", 1: "O"}, 41 | Started: false, 42 | //These are not exported to JSON 43 | numberOfPlayers: 0, 44 | playersTurn: 0, 45 | } 46 | return gs 47 | } 48 | 49 | func emptyFields() []field { 50 | return []field{ 51 | {}, {}, {}, // row1 52 | {}, {}, {}, // row2 53 | {}, {}, {}, // row3 54 | } 55 | } 56 | 57 | // addPlayer informs the gameState about the new player and alters the statusMessage 58 | func (gs *gameState) addPlayer() { 59 | gs.numberOfPlayers++ 60 | switch gs.numberOfPlayers { 61 | case 1: 62 | gs.StatusMessage = waitPaired 63 | case 2: 64 | gs.StatusMessage = gameBegins 65 | gs.Started = true 66 | } 67 | } 68 | 69 | // makeMove checks if it's the 70 | func (gs *gameState) makeMove(playerNum int, moveNum int) { 71 | if moveNum <= 9 { 72 | if gs.isPlayersTurn(playerNum) { 73 | if gs.isLegalMove(moveNum) { 74 | gs.Fields[moveNum].Set = true 75 | gs.Fields[moveNum].Symbol = gs.PlayerSymbols[playerNum] 76 | gs.switchPlayersTurn(playerNum) 77 | gs.numberOfMoves++ 78 | if won, symbol := gs.checkForWin(); won { 79 | gs.setWinner(symbol) 80 | } else { 81 | gs.checkForDraw() 82 | } 83 | } 84 | } 85 | } else { 86 | gs.specialMove(moveNum) 87 | } 88 | } 89 | 90 | // special move processes moves which are not board moves like restarting the game 91 | func (gs *gameState) specialMove(moveNum int) { 92 | switch moveNum { 93 | //restart game 94 | case 10: 95 | gs.restartGame() 96 | } 97 | } 98 | 99 | // restartGame sets the gameState to a state so that a new game between the same 100 | // players can begin 101 | func (gs *gameState) restartGame() { 102 | gs.StatusMessage = gameBegins 103 | gs.Fields = emptyFields() 104 | gs.Over = false 105 | gs.numberOfMoves = 0 106 | 107 | } 108 | 109 | // resetGame is needed, when one player drops out. It sets the gameState to a state so that 110 | // the player who is left can wait for a new opponent. 111 | func (gs *gameState) resetGame() { 112 | gs.restartGame() 113 | gs.Started = false 114 | gs.StatusMessage = resetWaitPaired 115 | } 116 | 117 | // checkForWin checks if a player has three in a row 118 | func (gs *gameState) checkForWin() (bool, string) { 119 | // TODO: There are way beter, more dynamic implementations for this. We could limit the search space 120 | // TODO: by looking at the field where the last move was made eg. 121 | //rows 122 | //check row1 123 | if gs.Fields[0].Symbol == gs.Fields[1].Symbol && gs.Fields[1].Symbol == gs.Fields[2].Symbol && gs.Fields[2].Symbol != "" { 124 | return true, gs.Fields[0].Symbol 125 | } 126 | //check row2 127 | if gs.Fields[3].Symbol == gs.Fields[4].Symbol && gs.Fields[4].Symbol == gs.Fields[5].Symbol && gs.Fields[5].Symbol != "" { 128 | return true, gs.Fields[3].Symbol 129 | } 130 | //check row2 131 | if gs.Fields[6].Symbol == gs.Fields[7].Symbol && gs.Fields[7].Symbol == gs.Fields[8].Symbol && gs.Fields[8].Symbol != "" { 132 | return true, gs.Fields[7].Symbol 133 | } 134 | 135 | //columns 136 | //column 1 137 | if gs.Fields[0].Symbol == gs.Fields[3].Symbol && gs.Fields[3].Symbol == gs.Fields[6].Symbol && gs.Fields[6].Symbol != "" { 138 | return true, gs.Fields[0].Symbol 139 | } 140 | //column 2 141 | if gs.Fields[1].Symbol == gs.Fields[4].Symbol && gs.Fields[4].Symbol == gs.Fields[7].Symbol && gs.Fields[7].Symbol != "" { 142 | return true, gs.Fields[1].Symbol 143 | } 144 | //column 3 145 | if gs.Fields[2].Symbol == gs.Fields[5].Symbol && gs.Fields[5].Symbol == gs.Fields[8].Symbol && gs.Fields[8].Symbol != "" { 146 | return true, gs.Fields[2].Symbol 147 | } 148 | 149 | //diagonals 150 | //diagonal1 151 | if gs.Fields[0].Symbol == gs.Fields[4].Symbol && gs.Fields[4].Symbol == gs.Fields[8].Symbol && gs.Fields[8].Symbol != "" { 152 | return true, gs.Fields[0].Symbol 153 | } 154 | //diagonal2 155 | if gs.Fields[2].Symbol == gs.Fields[4].Symbol && gs.Fields[4].Symbol == gs.Fields[6].Symbol && gs.Fields[6].Symbol != "" { 156 | return true, gs.Fields[2].Symbol 157 | } 158 | return false, "" 159 | } 160 | 161 | func (gs *gameState) setWinner(symbol string) { 162 | gs.StatusMessage = symbol + winner 163 | gs.Over = true 164 | } 165 | 166 | // checkForDraw checks for draws 167 | func (gs *gameState) checkForDraw() { 168 | if gs.numberOfMoves == 9 { 169 | gs.StatusMessage = draw 170 | gs.Over = true 171 | } 172 | } 173 | 174 | // isLegalMove checks if a move is legal 175 | func (gs *gameState) isLegalMove(field int) bool { 176 | return !gs.Fields[field].Set 177 | } 178 | 179 | // isPlayersTurn checks if it's the players turn 180 | func (gs *gameState) isPlayersTurn(playerNum int) bool { 181 | return playerNum == gs.playersTurn 182 | } 183 | 184 | // switchPlayersTurn switches the playersTurn variable to the id of the other player 185 | func (gs *gameState) switchPlayersTurn(playerNum int) { 186 | switch playerNum { 187 | case 0: 188 | gs.playersTurn = 1 189 | case 1: 190 | gs.playersTurn = 0 191 | } 192 | } 193 | 194 | // gameStateToJSON marshals the gameState struct to JSON represented by a slice of bytes 195 | func (gs *gameState) gameStateToJSON() []byte { 196 | json, err := json.Marshal(gs) 197 | if err != nil { 198 | log.Fatal("Error in marshalling json:", err) 199 | } 200 | return json 201 | } 202 | --------------------------------------------------------------------------------