├── .gitignore ├── README.md ├── assets └── demo_snake.gif ├── go.mod ├── go.sum ├── main.go ├── serpent ├── helpers.go ├── helpers_test.go └── snake.go ├── tmux_out.txt └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | notes 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## yummychars 2 | ![snek go nom nom](./assets/demo_snake.gif) 3 | 4 | ## What is this? 5 | It's a silly game of snake in the terminal but with a twist; your current terminal 6 | output is the "food" for the snake. If you have time on your hands and want a funny 7 | terminal clearing activity, this is the one! 8 | 9 | ## How does it work? 10 | It's simple; terminals traditionally don't have an API to access content that has 11 | already been printed so this program relies heavily on tmux to use it's `capture-pane` 12 | command to get that already printed content. 13 | 14 | With that, two identical buffers are kept in memory; one of the content that is printed 15 | to the screen which includes all the ANSI escape sequences from the original tmux 16 | `capture-pane` output and another of just the text from the terminal. The second is 17 | need because if we try editing an ANSI escape sequence in place, it's a messy and 18 | tiresome process of reconstructing the new changes back in place without breaking 19 | and exposing other characters to the old ANSI sequences. 20 | 21 | With this second buffer, the snake can "eat" the characters and a function in 22 | helpers.go allows us to map the characters to each other while ignoring the ANSI 23 | escape sequences. So in reality the snake isn't "eating" the actual characters in 24 | your terminal but is instead "eating" a copy which is then mapped to the one on 25 | screen. 26 | 27 | Read a little on ANSI escape sequences; they're interesting! 28 | 29 | ## How to use? 30 | You'll need 31 | * tmux 32 | * go 33 | 34 | with those two installed; start a tmux session (currently looking for a way to do 35 | this without asking the user to launch a tmux session) and then do the following; 36 | ``` 37 | git clone https://github.com/willofdaedalus/yummychars 38 | cd yummychars/ 39 | go build . 40 | ./yummychars 41 | ``` 42 | and that's it! 43 | -------------------------------------------------------------------------------- /assets/demo_snake.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willofdaedalus/yummychars/94244807b30001b3b4abac233772a1ee765c2d6d/assets/demo_snake.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module willofdaedalus/yummychars 2 | 3 | go 1.22.6 4 | 5 | require golang.org/x/term v0.23.0 6 | 7 | require ( 8 | atomicgo.dev/cursor v0.2.0 // indirect 9 | golang.org/x/sys v0.24.0 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= 2 | atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= 3 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 4 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 5 | golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= 6 | golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | "willofdaedalus/yummychars/serpent" 8 | 9 | "golang.org/x/term" 10 | ) 11 | 12 | func main() { 13 | content, err := setupContent() 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | 18 | oldState, fd, err := setupTerminal() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | defer cleanUp(fd, oldState) 23 | 24 | sx, sy, err := term.GetSize(fd) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | s := serpent.InitSnake(10, sx, sy, content) 30 | s.TermContent = content 31 | dir := serpent.RIGHT 32 | 33 | buf := make([]byte, 1) 34 | for { 35 | s.DrawScreenContent() 36 | 37 | go func() { 38 | _, err := os.Stdin.Read(buf) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | }() 43 | 44 | switch buf[0] { 45 | case 'w': 46 | if s.MoveDir != serpent.DOWN { 47 | dir = serpent.UP 48 | } 49 | case 'a': 50 | if s.MoveDir != serpent.RIGHT { 51 | dir = serpent.LEFT 52 | } 53 | case 's': 54 | if s.MoveDir != serpent.UP { 55 | dir = serpent.DOWN 56 | } 57 | case 'd': 58 | if s.MoveDir != serpent.LEFT { 59 | dir = serpent.RIGHT 60 | } 61 | case 'q': 62 | s.ClearScreen() 63 | return 64 | } 65 | 66 | s.MoveSnake(dir) 67 | s.DrawSnake() 68 | 69 | // exit the game if the snake collides with the boundaries 70 | // don't know if checking the [][]rune every "frame" is efficient 71 | if s.CheckBoundaries() || s.WinConditionLogic() { 72 | time.Sleep(time.Second * 2) 73 | s.ClearScreen() 74 | break 75 | } 76 | 77 | // Add a short sleep to control the loop speed 78 | time.Sleep(time.Second / time.Duration(s.Speed)) 79 | 80 | // Clear the previous frames to remove smears 81 | s.ClearScreen() 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /serpent/helpers.go: -------------------------------------------------------------------------------- 1 | package serpent 2 | 3 | import "strings" 4 | 5 | // func (s *Snake) updateTermContent(y, x int) { 6 | // actualX := 0 7 | // inEscapeSeq := false 8 | // for i := range s.TermContent[y] { 9 | // if s.TermContent[y][i] == '\033' { 10 | // inEscapeSeq = true 11 | // } else if !inEscapeSeq { 12 | // // this conditional ensures we can't override the letters that are 13 | // // part of the escape sequences like the m in \033[36m which is 14 | // // essentially part of the escape sequence and not the underlying text 15 | // if actualX == x { 16 | // s.TermContent[y][i] = ' ' 17 | // return 18 | // } 19 | // actualX++ 20 | // } else if (s.TermContent[y][i] >= 'A' && s.TermContent[y][i] <= 'Z') || 21 | // (s.TermContent[y][i] >= 'a' && s.TermContent[y][i] <= 'z') { 22 | // inEscapeSeq = false 23 | // } 24 | // } 25 | // } 26 | 27 | // basically run through the TermContent while checking if the character we're 28 | // pointing to is part of an escape sequence 29 | func (s *Snake) updateTermContent(y, x int) { 30 | actualX := 0 31 | inEscapeSeq := false 32 | var currentColor string 33 | var escapeSeq strings.Builder 34 | 35 | for i := range s.TermContent[y] { 36 | if s.TermContent[y][i] == '\033' { 37 | inEscapeSeq = true 38 | escapeSeq.Reset() 39 | escapeSeq.WriteRune(s.TermContent[y][i]) 40 | } else if inEscapeSeq { 41 | escapeSeq.WriteRune(s.TermContent[y][i]) 42 | if (s.TermContent[y][i] >= 'A' && s.TermContent[y][i] <= 'Z') || 43 | (s.TermContent[y][i] >= 'a' && s.TermContent[y][i] <= 'z') { 44 | inEscapeSeq = false 45 | if strings.Contains(escapeSeq.String(), "[3") || strings.Contains(escapeSeq.String(), "[38;5;") { 46 | currentColor = escapeSeq.String() 47 | } 48 | } 49 | } else { 50 | if actualX == x { 51 | s.TermContent[y][i] = ' ' 52 | if currentColor != "" { 53 | s.colour = currentColor 54 | } 55 | return 56 | } 57 | actualX++ 58 | } 59 | } 60 | } 61 | 62 | // the idea is to make keep two buffers; one that is actually printed and another 63 | // for the snake to eat that way we don't mess up any ansi escaped sequences 64 | func stripAnsiCodes(rawContent [][]rune) [][]rune { 65 | filteredContent := make([][]rune, len(rawContent)) 66 | inEscapeSeq := false 67 | 68 | for i, line := range rawContent { 69 | filteredLine := make([]rune, 0, len(line)) 70 | for _, c := range line { 71 | if c == '\033' { 72 | inEscapeSeq = true 73 | continue 74 | } 75 | if inEscapeSeq { 76 | if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { 77 | inEscapeSeq = false 78 | } 79 | continue 80 | } 81 | filteredLine = append(filteredLine, c) 82 | } 83 | filteredContent[i] = filteredLine 84 | } 85 | return filteredContent 86 | } 87 | -------------------------------------------------------------------------------- /serpent/helpers_test.go: -------------------------------------------------------------------------------- 1 | package serpent 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestStripAnsiCodes(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input [][]rune 12 | expected [][]rune 13 | }{ 14 | { 15 | name: "Simple green text", 16 | input: [][]rune{[]rune("\033[32mThis is green\033[0m")}, 17 | expected: [][]rune{[]rune("This is green")}, 18 | }, 19 | { 20 | name: "Background color 0", 21 | input: [][]rune{[]rune("\033[48;5;0mColor 0\033[0m")}, 22 | expected: [][]rune{[]rune("Color 0")}, 23 | }, 24 | { 25 | name: "Background color 1", 26 | input: [][]rune{[]rune("\033[48;5;1mColor 1\033[0m")}, 27 | expected: [][]rune{[]rune("Color 1")}, 28 | }, 29 | { 30 | name: "Multiple lines with different colors", 31 | input: [][]rune{[]rune("\033[31mRed\033[0m"), []rune("\033[32mGreen\033[0m"), []rune("\033[34mBlue\033[0m")}, 32 | expected: [][]rune{[]rune("Red"), []rune("Green"), []rune("Blue")}, 33 | }, 34 | { 35 | name: "Text with no ANSI codes", 36 | input: [][]rune{[]rune("Plain text")}, 37 | expected: [][]rune{[]rune("Plain text")}, 38 | }, 39 | { 40 | name: "Empty input", 41 | input: [][]rune{}, 42 | expected: [][]rune{}, 43 | }, 44 | { 45 | name: "Multiple ANSI codes in one line", 46 | input: [][]rune{[]rune("\033[1m\033[31mBold Red\033[0m \033[32mGreen\033[0m")}, 47 | expected: [][]rune{[]rune("Bold Red Green")}, 48 | }, 49 | } 50 | 51 | // cheers to claude.ai 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | result := stripAnsiCodes(tt.input) 55 | if !reflect.DeepEqual(result, tt.expected) { 56 | t.Errorf("stripAnsiCodes() = %v, want %v", result, tt.expected) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /serpent/snake.go: -------------------------------------------------------------------------------- 1 | package serpent 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | UP = iota 9 | DOWN 10 | LEFT 11 | RIGHT 12 | ) 13 | 14 | const ( 15 | HEAD_R = '>' 16 | HEAD_L = '<' 17 | HEAD_U = '^' 18 | HEAD_D = 'v' 19 | BODY = 'o' 20 | ) 21 | 22 | type coords struct { 23 | x, y int 24 | } 25 | 26 | type Snake struct { 27 | MoveDir int 28 | Speed float64 29 | TermContent [][]rune 30 | 31 | colour string 32 | head rune 33 | actualChars [][]rune 34 | position coords 35 | fieldSize coords 36 | tail []coords 37 | } 38 | 39 | func InitSnake(speed float64, fx, fy int, rawContent [][]rune) *Snake { 40 | // initialize snake with the head position and an empty tail 41 | return &Snake{ 42 | head: HEAD_R, 43 | position: coords{0, 0}, 44 | Speed: speed, 45 | // make tail with a length of 4 so that I don't have to figure out self collision logic ;P 46 | tail: make([]coords, 4), 47 | fieldSize: coords{fx, fy}, 48 | TermContent: rawContent, 49 | actualChars: stripAnsiCodes(rawContent), 50 | } 51 | } 52 | 53 | func (s *Snake) ClearScreen() { 54 | fmt.Printf("\033[2J\033[H") 55 | } 56 | 57 | func (s *Snake) WinConditionLogic() bool { 58 | for _, row := range s.actualChars { 59 | for _, c := range row { 60 | if c != ' ' { 61 | return false 62 | } 63 | } 64 | } 65 | 66 | fmt.Printf("\033[%d;%dH%s", s.fieldSize.y/2, s.fieldSize.x/2, "you win!") 67 | return true 68 | } 69 | 70 | func (s *Snake) CheckBoundaries() bool { 71 | // allows the snake move along the boundaries without punishing the player 72 | if (s.position.x > s.fieldSize.x + 1 || s.position.x < -1) || 73 | (s.position.y > s.fieldSize.y + 1 || s.position.y < -1) { 74 | s.ClearScreen() 75 | fmt.Printf("\033[%d;%dH%s", s.fieldSize.y/2, s.fieldSize.x/2, "game over!") 76 | return true 77 | } 78 | 79 | return false 80 | } 81 | 82 | func (s *Snake) MoveSnake(dir int) { 83 | // move all segments of the tail except the first one 84 | for i := len(s.tail) - 1; i > 0; i-- { 85 | s.tail[i] = s.tail[i-1] 86 | } 87 | 88 | // move the first tail segment to the previous position of the head 89 | if len(s.tail) > 0 { 90 | s.tail[0] = s.position 91 | } 92 | 93 | // update the head's position based on direction 94 | switch dir { 95 | case UP: 96 | s.position.y -= 1 97 | s.head = HEAD_U 98 | case DOWN: 99 | s.position.y += 1 100 | s.head = HEAD_D 101 | case LEFT: 102 | s.position.x -= 1 103 | s.head = HEAD_L 104 | case RIGHT: 105 | s.position.x += 1 106 | s.head = HEAD_R 107 | } 108 | 109 | s.MoveDir = dir 110 | 111 | if s.position.y >= 0 && s.position.y < len(s.actualChars) && 112 | s.position.x >= 0 && s.position.x < len(s.actualChars[s.position.y]) { 113 | if s.actualChars[s.position.y][s.position.x] != ' ' { 114 | s.actualChars[s.position.y][s.position.x] = ' ' 115 | 116 | // update termcontent to reflect the change 117 | s.updateTermContent(s.position.y, s.position.x) 118 | } 119 | } 120 | } 121 | 122 | func (s *Snake) DrawScreenContent() { 123 | // draw the captured terminal content 124 | for y, line := range s.TermContent { 125 | fmt.Printf("\033[%d;1H%s", y+1, string(line)) 126 | } 127 | } 128 | 129 | func (s *Snake) DrawSnake() { 130 | // draw the head of the snake with the current colour 131 | fmt.Printf("%s\033[%d;%dH%c\033[0m", s.colour, s.position.y+1, s.position.x+1, s.head) 132 | 133 | // draw the tail of the snake with the current colour 134 | for _, segment := range s.tail { 135 | fmt.Printf("%s\033[%d;%dH%c\033[0m", s.colour, segment.y+1, segment.x+1, BODY) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tmux_out.txt: -------------------------------------------------------------------------------- 1 | ~/projects/golang/yummychars 2 | > logg 3 | * 975da59 (willofdaedalus) - character eating works fine without colours about 28 minutes ago  (HEAD -> dev) 4 | * 9a3cf7f (willofdaedalus) - basic character eating; not perfect but works about 10 hours ago 5 | * b2538d7 (willofdaedalus) - captured content and snake coexisting about 11 hours ago 6 | * 24aa856 (willofdaedalus) - simple game over about 15 hours ago  (origin/master, origin/dev, master) 7 | * 73061ff (willofdaedalus) - basic snake movement with segments correctly following about 33 hours ago 8 | * 69e6b19 (willofdaedalus) - fixed bug where user could override direction on the same axis about 2 days ago 9 | * b4f472d (willofdaedalus) - screen clearing to removing frame smearing about 2 days ago 10 | * 9e71673 (willofdaedalus) - head rendering in the correct direction about 2 days ago 11 | * b96ef4b (willofdaedalus) - hide cursor on start and restore on quit about 2 days ago 12 | * 7ed1b00 (willofdaedalus) - non-blocking input with a goroutine about 2 days ago 13 | * d08db99 (willofdaedalus) - feat: snake speed works about 9 days ago 14 | * 983be70 (willofdaedalus) - init commit about 9 days ago 15 | 16 | ~/projects/golang/yummychars 17 | > go run . 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | // "atomicgo.dev/cursor" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | 11 | "golang.org/x/term" 12 | ) 13 | 14 | func cleanUp(fd int, orig *term.State) { 15 | term.Restore(fd, orig) 16 | // ensure the cursor is shown again when the program exits 17 | fmt.Print("\033[?25h") 18 | } 19 | 20 | func setupTerminal() (*term.State, int, error) { 21 | fmt.Printf("\033[2J\033[H") // clear screen 22 | fmt.Print("\033[?25l") // hide cursor 23 | fd := int(os.Stdin.Fd()) 24 | 25 | oldState, err := term.MakeRaw(fd) 26 | if err != nil { 27 | return nil, -1, err 28 | } 29 | 30 | return oldState, fd, nil 31 | } 32 | 33 | func setupContent() ([][]rune, error) { 34 | cmd := exec.Command("tmux", "capture-pane", "-e", "-p") 35 | out, err := cmd.Output() 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | paneOut := string(out) 41 | lines := strings.Split(paneOut, "\n") 42 | if len(lines) == 0 { 43 | return nil, fmt.Errorf("nothing was read") 44 | } 45 | 46 | parsedData := make([][]rune, len(lines)) 47 | 48 | for i, line := range lines { 49 | parsedData[i] = []rune(line) 50 | } 51 | 52 | return parsedData, nil 53 | } 54 | 55 | func getCursorPosition() (int, int, error) { 56 | fmt.Print("\033[6n") 57 | var buf []byte 58 | n, err := os.Stdin.Read(buf) 59 | if err != nil { 60 | return -1, -1, err 61 | } 62 | 63 | // output comes as something like ^[[17;1R% 64 | res := string(buf[:n]) 65 | 66 | if strings.HasPrefix(res, "\033[") && strings.HasSuffix(res, "R") { 67 | res = res[2:len(res) - 1] 68 | splits := strings.Split(res, ";") 69 | 70 | if len(splits) == 2 { 71 | row, err1 := strconv.Atoi(splits[0]) 72 | col, err2 := strconv.Atoi(splits[1]) 73 | 74 | if err1 != nil || err2 != nil { 75 | return -1, -1, fmt.Errorf("failed to get cursor position") 76 | } 77 | 78 | return row, col, nil 79 | } 80 | } 81 | 82 | return -1, -1, nil 83 | } 84 | --------------------------------------------------------------------------------