├── .dockerignore ├── .github └── workflows │ └── fly-deploy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── fly.toml ├── go.mod ├── main.go ├── server └── server.go └── snake ├── arena.go ├── food.go ├── game.go ├── keyboard.go ├── render.go ├── render_test.go ├── snake.go └── snake_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | # Binaries for programs and plugins 3 | **/*.exe 4 | **/*.dll 5 | **/*.so 6 | **/*.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | **/*.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | **/*.out 13 | 14 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 15 | **/.glide 16 | fly.toml 17 | -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | RUN apk add build-base 3 | WORKDIR /root 4 | COPY go.mod . 5 | # COPY go.sum . 6 | # RUN go mod download 7 | COPY . . 8 | RUN CGO_ENABLED=0 GOOS=linux go build -o snakecmd main.go 9 | 10 | FROM alpine:latest 11 | RUN apk --no-cache add ca-certificates tzdata bash 12 | WORKDIR /root 13 | COPY --from=builder /root/snakecmd ./snakecmd 14 | 15 | EXPOSE 8080 16 | 17 | CMD ["./snakecmd"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alex Pliutau 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Snake Game over Telnet in Go 2 | 3 | ### Run it with go 4 | 5 | ```bash 6 | go get github.com/plutov/go-snake-telnet 7 | go-snake-telnet 8 | ``` 9 | 10 | ## Run with Docker 11 | 12 | ```bash 13 | docker build -t snake-telnet . 14 | docker run -d -p 8080:8080 snake-telnet 15 | ``` 16 | 17 | ## Play! 18 | 19 | Make sure to install telnet first: 20 | 21 | ```bash 22 | brew install telnet 23 | ``` 24 | 25 | Then connect to the game: 26 | ```bash 27 | telnet 127.0.0.1 8080 28 | ``` 29 | 30 | ### Tests 31 | 32 | ``` 33 | go test ./... -bench=. 34 | ``` 35 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "snake-telnet" 2 | primary_region = "ams" 3 | 4 | [[services]] 5 | auto_start_machines = true 6 | auto_stop_machines = "stop" 7 | internal_port = 8_080 8 | min_machines_running = 0 9 | processes = [ "app" ] 10 | protocol = "tcp" 11 | 12 | [[services.ports]] 13 | handlers = [ ] 14 | port = 8_080 15 | force_https = false 16 | 17 | [services.ports.http_options.response] 18 | pristine = true 19 | 20 | [[vm]] 21 | cpu_kind = "shared" 22 | cpus = 1 23 | memory = "512mb" 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/plutov/go-snake-telnet 2 | 3 | go 1.23 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/plutov/go-snake-telnet/server" 5 | ) 6 | 7 | func main() { 8 | s := server.New(":8080") 9 | s.Run() 10 | } 11 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/plutov/go-snake-telnet/snake" 12 | ) 13 | 14 | const ( 15 | leftTopASCII = "\033[0;0H" 16 | clearASCII = "\033[2J" 17 | ) 18 | 19 | // Server struct 20 | type Server struct { 21 | addr string 22 | } 23 | 24 | // New creates new Server instance 25 | func New(addr string) *Server { 26 | return &Server{ 27 | addr: addr, 28 | } 29 | } 30 | 31 | // Run the telnet server 32 | func (s *Server) Run() { 33 | listener, err := net.Listen("tcp", s.addr) 34 | if err != nil { 35 | log.Fatal("failed to start tcp server: " + err.Error()) 36 | } 37 | 38 | defer listener.Close() 39 | log.Printf("tcp server started on %s", s.addr) 40 | 41 | for { 42 | conn, err := listener.Accept() 43 | if err != nil { 44 | log.Printf("failed to accept connection: %s\n", err.Error()) 45 | continue 46 | } 47 | 48 | log.Printf("client connected: %s\n", conn.RemoteAddr().String()) 49 | 50 | go s.handleConnection(conn) 51 | } 52 | } 53 | 54 | func (s *Server) handleConnection(conn net.Conn) { 55 | game := snake.NewGame() 56 | 57 | // Clear screen and move to 0:0 58 | conn.Write([]byte(clearASCII + leftTopASCII)) 59 | conn.Write([]byte(leftTopASCII)) 60 | 61 | go s.read(conn, game) 62 | go game.Start() 63 | 64 | tick := time.Tick(300 * time.Millisecond) 65 | for range tick { 66 | // Move to 0:0 and render 67 | conn.Write([]byte(leftTopASCII + game.Render())) 68 | if game.IsOver { 69 | // Cancel ticker 70 | break 71 | } 72 | } 73 | 74 | conn.Close() 75 | } 76 | 77 | // Accept input and send it to KeyboardEventsChan 78 | func (s *Server) read(conn net.Conn, game *snake.Game) { 79 | reader := bufio.NewReader(conn) 80 | 81 | for { 82 | data, _, err := reader.ReadLine() 83 | if game.IsOver { 84 | break 85 | } 86 | if err != nil { 87 | if err == io.EOF { 88 | game.IsOver = true 89 | conn.Close() 90 | break 91 | } 92 | 93 | log.Println("read error: " + err.Error()) 94 | continue 95 | } 96 | 97 | key := strings.ToLower(strings.TrimSpace(string(data))) 98 | if len(key) > 0 { 99 | game.KeyboardEventsChan <- snake.KeyboardEvent{ 100 | Key: string(key[0]), 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /snake/arena.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "math/rand" 5 | ) 6 | 7 | type coord struct { 8 | x, y int 9 | } 10 | 11 | type arena struct { 12 | food *food 13 | snake *snake 14 | hasFood func(*arena, coord) bool 15 | height int 16 | width int 17 | pointsChan chan (int) 18 | } 19 | 20 | func newArena(s *snake, h, w int) *arena { 21 | a := &arena{ 22 | snake: s, 23 | height: h, 24 | width: w, 25 | hasFood: hasFood, 26 | } 27 | 28 | a.placeFood() 29 | 30 | return a 31 | } 32 | 33 | func (a *arena) placeFood() { 34 | var x, y int 35 | 36 | for { 37 | x = rand.Intn(a.width) 38 | y = rand.Intn(a.height) 39 | 40 | if !a.snake.hits(coord{x: x, y: y}) { 41 | break 42 | } 43 | } 44 | 45 | a.food = newFood(x, y) 46 | } 47 | 48 | func (a *arena) moveSnake() error { 49 | if err := a.snake.move(); err != nil { 50 | return err 51 | } 52 | 53 | if a.snakeLeftArena() { 54 | return a.snake.die() 55 | } 56 | 57 | if a.hasFood(a, a.snake.head()) { 58 | go func() { 59 | a.pointsChan <- a.food.points 60 | }() 61 | a.snake.length++ 62 | a.placeFood() 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (a *arena) snakeLeftArena() bool { 69 | h := a.snake.head() 70 | return h.x > a.width-1 || h.y > a.height-1 || h.x < 0 || h.y < 0 71 | } 72 | 73 | func hasFood(a *arena, c coord) bool { 74 | return c.x == a.food.x && c.y == a.food.y 75 | } 76 | -------------------------------------------------------------------------------- /snake/food.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | type food struct { 4 | points, x, y int 5 | } 6 | 7 | func newFood(x, y int) *food { 8 | return &food{ 9 | points: 10, 10 | x: x, 11 | y: y, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /snake/game.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "math" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | var ( 13 | topScoreFile = "/tmp/snake.score" 14 | topScoreChan chan int 15 | topScoreVal int 16 | ) 17 | 18 | func init() { 19 | // Get top score from the file 20 | line, readErr := ioutil.ReadFile(topScoreFile) 21 | if readErr == nil { 22 | var castErr error 23 | topScoreVal, castErr = strconv.Atoi(string(line)) 24 | if castErr != nil { 25 | log.Printf("can't cast score: %v", castErr) 26 | } 27 | } 28 | 29 | topScoreChan = make(chan int) 30 | go func() { 31 | for { 32 | s := <-topScoreChan 33 | if s > topScoreVal { 34 | topScoreVal = s 35 | ioutil.WriteFile(topScoreFile, []byte(fmt.Sprintf("%d", topScoreVal)), 0777) 36 | } 37 | } 38 | }() 39 | } 40 | 41 | // Game type 42 | type Game struct { 43 | KeyboardEventsChan chan KeyboardEvent 44 | PointsChan chan int 45 | arena *arena 46 | score int 47 | IsOver bool 48 | } 49 | 50 | // NewGame returns Game obj 51 | func NewGame() *Game { 52 | return &Game{ 53 | arena: initialArena(), 54 | score: initialScore(), 55 | } 56 | } 57 | 58 | // Start game func 59 | func (g *Game) Start() { 60 | g.KeyboardEventsChan = make(chan KeyboardEvent) 61 | g.PointsChan = make(chan int) 62 | g.arena.pointsChan = g.PointsChan 63 | 64 | for { 65 | select { 66 | case p := <-g.PointsChan: 67 | g.addPoints(p) 68 | topScoreChan <- g.score 69 | case e := <-g.KeyboardEventsChan: 70 | d := keyToDirection(e.Key) 71 | if d > 0 { 72 | g.arena.snake.changeDirection(d) 73 | } 74 | default: 75 | if g.IsOver { 76 | log.Printf("Game over, score: %d\n", g.score) 77 | return 78 | } 79 | 80 | if err := g.arena.moveSnake(); err != nil { 81 | g.IsOver = true 82 | } 83 | 84 | time.Sleep(g.moveInterval()) 85 | } 86 | } 87 | } 88 | 89 | func initialSnake() *snake { 90 | return newSnake(RIGHT, []coord{ 91 | coord{x: 1, y: 1}, 92 | coord{x: 1, y: 2}, 93 | coord{x: 1, y: 3}, 94 | coord{x: 1, y: 4}, 95 | }) 96 | } 97 | 98 | func initialScore() int { 99 | return 0 100 | } 101 | 102 | func initialArena() *arena { 103 | return newArena(initialSnake(), 20, 20) 104 | } 105 | 106 | func (g *Game) moveInterval() time.Duration { 107 | ms := 400 - math.Max(float64(g.score), 100) 108 | return time.Duration(ms) * time.Millisecond 109 | } 110 | 111 | func (g *Game) addPoints(p int) { 112 | g.score += p 113 | } 114 | -------------------------------------------------------------------------------- /snake/keyboard.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | // KeyboardEvent type 4 | type KeyboardEvent struct { 5 | Key string 6 | } 7 | 8 | func keyToDirection(k string) direction { 9 | switch k { 10 | case "a": 11 | return LEFT 12 | case "s": 13 | return DOWN 14 | case "d": 15 | return RIGHT 16 | case "w": 17 | return UP 18 | default: 19 | return 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /snake/render.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type screen struct { 9 | cells [][]string 10 | } 11 | 12 | const ( 13 | title = "🐍 Snake Game over Telnet v0.3" 14 | author = "Made by: @pliutau" 15 | move = "Move:" 16 | usage = "W,D,S,A & press ENTER" 17 | score = "Score: " 18 | topScore = "Top score: " 19 | input = "Your input: " 20 | horizontalLine = "-" 21 | verticalLine = "|" 22 | emptySymbol = " " 23 | snakeSymbol = "*" 24 | foodSymbol = "@" 25 | gameOver = "Game over!" 26 | fieldTop = 7 27 | fieldLeft = 1 28 | ) 29 | 30 | // Render returns game arena as string 31 | func (g *Game) Render() string { 32 | ascii := "" 33 | 34 | m := g.generateScreen() 35 | for _, row := range m.cells { 36 | ascii += strings.Join(row, "") + "\n" 37 | } 38 | 39 | return ascii 40 | } 41 | 42 | func (g *Game) generateScreen() *screen { 43 | m := new(screen) 44 | m.renderTitle(g.arena) 45 | m.renderArena(g.arena, g) 46 | if !g.IsOver { 47 | m.renderFood(g.arena.food.x, g.arena.food.y) 48 | m.renderSnake(g.arena.snake) 49 | } 50 | 51 | m.renderScore(g.arena, g.score) 52 | return m 53 | } 54 | 55 | func (m *screen) renderArena(a *arena, g *Game) { 56 | // Add horizontal line on top 57 | m.cells = append(m.cells, strings.Split(verticalLine+strings.Repeat(horizontalLine, a.width)+verticalLine, "")) 58 | 59 | // Render battlefield 60 | for i := 0; i < a.height; i++ { 61 | if i == 1 && g.IsOver { 62 | row := []string{verticalLine, emptySymbol} 63 | for _, r := range gameOver { 64 | row = append(row, string(r)) 65 | } 66 | for j := len(gameOver) + 1; j < a.width; j++ { 67 | row = append(row, emptySymbol) 68 | } 69 | row = append(row, verticalLine) 70 | m.cells = append(m.cells, row) 71 | } else { 72 | m.cells = append(m.cells, strings.Split(verticalLine+strings.Repeat(emptySymbol, a.width)+verticalLine, "")) 73 | } 74 | } 75 | 76 | // Add horizontal line on bottom 77 | m.cells = append(m.cells, strings.Split(verticalLine+strings.Repeat(horizontalLine, a.width)+verticalLine, "")) 78 | } 79 | 80 | func (m *screen) renderSnake(s *snake) { 81 | for _, b := range s.body { 82 | m.cells[b.x+fieldTop][b.y+fieldLeft] = snakeSymbol 83 | } 84 | } 85 | 86 | func (m *screen) renderFood(x, y int) { 87 | m.cells[x+fieldTop][y+fieldLeft] = foodSymbol 88 | } 89 | 90 | func (m *screen) renderScore(a *arena, scoreVal int) { 91 | m.cells = append(m.cells, []string{}) 92 | m.renderString(fmt.Sprintf("%s%d", score, scoreVal)) 93 | m.renderString(fmt.Sprintf("%s%d", topScore, topScoreVal)) 94 | m.cells = append(m.cells, []string{}) 95 | m.cells = append(m.cells, renderString(input)) 96 | } 97 | 98 | func (m *screen) renderTitle(a *arena) { 99 | m.cells = append(m.cells, renderString(title)) 100 | m.cells = append(m.cells, renderString(author)) 101 | m.cells = append(m.cells, []string{}) 102 | m.cells = append(m.cells, renderString(move)) 103 | m.cells = append(m.cells, renderString(usage)) 104 | m.cells = append(m.cells, []string{}) 105 | } 106 | 107 | func (m *screen) renderString(s string) { 108 | row := renderString(s) 109 | m.cells = append(m.cells, row) 110 | } 111 | 112 | func renderString(s string) []string { 113 | return strings.Split(s, "") 114 | } 115 | -------------------------------------------------------------------------------- /snake/render_test.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func BenchmarkRender(b *testing.B) { 8 | game := NewGame() 9 | 10 | for n := 0; n < b.N; n++ { 11 | game.Render() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /snake/snake.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | const ( 8 | // RIGHT const 9 | RIGHT direction = 1 + iota 10 | // LEFT const 11 | LEFT 12 | // UP const 13 | UP 14 | // DOWN const 15 | DOWN 16 | ) 17 | 18 | type direction int 19 | 20 | type snake struct { 21 | body []coord 22 | direction direction 23 | length int 24 | } 25 | 26 | func newSnake(d direction, b []coord) *snake { 27 | return &snake{ 28 | length: len(b), 29 | body: b, 30 | direction: d, 31 | } 32 | } 33 | 34 | func (s *snake) changeDirection(d direction) { 35 | opposites := map[direction]direction{ 36 | RIGHT: LEFT, 37 | LEFT: RIGHT, 38 | UP: DOWN, 39 | DOWN: UP, 40 | } 41 | 42 | if o := opposites[d]; o != 0 && o != s.direction { 43 | s.direction = d 44 | } 45 | } 46 | 47 | func (s *snake) head() coord { 48 | return s.body[len(s.body)-1] 49 | } 50 | 51 | func (s *snake) die() error { 52 | return errors.New("Game over") 53 | } 54 | 55 | func (s *snake) move() error { 56 | h := s.head() 57 | c := coord{x: h.x, y: h.y} 58 | 59 | switch s.direction { 60 | case RIGHT: 61 | c.y++ 62 | case LEFT: 63 | c.y-- 64 | case UP: 65 | c.x-- 66 | case DOWN: 67 | c.x++ 68 | } 69 | 70 | if s.hits(c) { 71 | return s.die() 72 | } 73 | 74 | if s.length > len(s.body) { 75 | s.body = append(s.body, c) 76 | } else { 77 | s.body = append(s.body[1:], c) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (s *snake) hits(c coord) bool { 84 | for _, b := range s.body { 85 | if b.x == c.x && b.y == c.y { 86 | return true 87 | } 88 | } 89 | 90 | return false 91 | } 92 | -------------------------------------------------------------------------------- /snake/snake_test.go: -------------------------------------------------------------------------------- 1 | package snake 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func BenchmarkMove(b *testing.B) { 8 | s := initialSnake() 9 | 10 | for n := 0; n < b.N; n++ { 11 | err := s.move() 12 | if err != nil { 13 | b.Fatalf("failed to move: %v", err) 14 | } 15 | } 16 | } 17 | 18 | func BenchmarkHits(b *testing.B) { 19 | s := initialSnake() 20 | 21 | for n := 0; n < b.N; n++ { 22 | s.hits(coord{ 23 | x: 1, 24 | y: 1, 25 | }) 26 | } 27 | } 28 | --------------------------------------------------------------------------------