├── 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 | [](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 |  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 || 58 | {{field.symbol}} 59 | 60 | | 61 |
| 64 | {{field.symbol}} 65 | 66 | 67 | | 68 |
| 71 | {{field.symbol}} 72 | 73 | 74 | | 75 |