├── .gitignore ├── Dockerfile ├── README.MD ├── go.mod ├── go.sum ├── main.go └── whackboard.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | whack 3 | .ssh 4 | .idea 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15-alpine3.12 AS builder 2 | 3 | # Copying over all the files: 4 | COPY . /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | # Installing dependencies/ 8 | RUN go get -v -t -d ./... 9 | 10 | # Build the app 11 | RUN go build -o app . 12 | 13 | # hadolint ignore=DL3006,DL3007 14 | FROM alpine:latest 15 | WORKDIR / 16 | COPY --from=builder /usr/src/app/app . 17 | 18 | EXPOSE 23234 19 | 20 | CMD ["./app"] 21 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Whack a Mole 2 | 3 | #### But over SSH and multiplayer 4 | 5 | ```shell 6 | ssh whack.pranav.land 7 | ``` 8 |

9 | image 10 |

11 | 12 | Made by [Ishan](https://github.com/quackduck) and [Pranav](https://github.com/pranavnt) for [CodeDay Seattle](https://event.codeday.org/seattle) 2022 with [Wish](https://github.com/charmbracelet/wish). 13 | 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module whack 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.20.0 7 | github.com/charmbracelet/wish v0.4.0 8 | github.com/gliderlabs/ssh v0.3.4 9 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 10 | ) 11 | 12 | require ( 13 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 14 | github.com/caarlos0/sshmarshal v0.1.0 // indirect 15 | github.com/charmbracelet/keygen v0.3.0 // indirect 16 | github.com/charmbracelet/lipgloss v0.4.0 // indirect 17 | github.com/containerd/console v1.0.3 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-isatty v0.0.14 // indirect 20 | github.com/mattn/go-runewidth v0.0.13 // indirect 21 | github.com/mitchellh/go-homedir v1.1.0 // indirect 22 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 23 | github.com/muesli/reflow v0.3.0 // indirect 24 | github.com/rivo/uniseg v0.2.0 // indirect 25 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 // indirect 26 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 27 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I= 4 | github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= 5 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= 6 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= 7 | github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y= 8 | github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM= 9 | github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= 10 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= 11 | github.com/charmbracelet/wish v0.4.0 h1:MLo8JjyvSK1lYkhPCzpI+8pwpKO8cUDP4GZtEoOKu4c= 12 | github.com/charmbracelet/wish v0.4.0/go.mod h1:jRL2Shd80OlP77bR8x3v8PrLCtkYCc/1nUV1eGexNj0= 13 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 14 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 15 | github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw= 16 | github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 20 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 21 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 22 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 24 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 25 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 27 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 28 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 30 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 31 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 32 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 33 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 34 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 35 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 40 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 h1:syTAU9FwmvzEoIYMqcPHOcVm4H3U5u90WsvuYgwpETU= 41 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 42 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 43 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 50 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= 53 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 54 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 55 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/wish" 17 | bm "github.com/charmbracelet/wish/bubbletea" 18 | lm "github.com/charmbracelet/wish/logging" 19 | "github.com/gliderlabs/ssh" 20 | "github.com/muesli/termenv" 21 | ) 22 | 23 | const ( 24 | port = 23234 25 | 26 | rules = `Rules: 27 | 28 | • Clicking a target wins a point for your team 29 | 30 | • Clicking the other team's emoji loses a point and makes water 31 | 32 | • Clicking on water takes away a point` 33 | ) 34 | 35 | var ( 36 | b = NewBoard() 37 | 38 | fireScore = 0 39 | iceScore = 0 40 | ) 41 | 42 | func main() { 43 | rand.Seed(time.Now().UnixNano()) 44 | s, err := wish.NewServer( 45 | wish.WithAddress(fmt.Sprintf(":%d", port)), 46 | wish.WithHostKeyPath(".ssh/term_info_ed25519"), 47 | wish.WithMiddleware( 48 | myCustomBubbleteaMiddleware(), 49 | lm.Middleware(), 50 | ), 51 | ) 52 | if err != nil { 53 | log.Fatalln(err) 54 | } 55 | 56 | done := make(chan os.Signal, 1) 57 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 58 | log.Printf("Starting SSH server on port %d", port) 59 | go func() { 60 | if err = s.ListenAndServe(); err != nil { 61 | log.Fatalln(err) 62 | } 63 | }() 64 | 65 | <-done 66 | log.Println("Stopping SSH server") 67 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 68 | defer func() { cancel() }() 69 | if err := s.Shutdown(ctx); err != nil { 70 | log.Fatalln(err) 71 | } 72 | } 73 | 74 | var programs = make([]*tea.Program, 0, 100) 75 | 76 | var currTeam = true 77 | 78 | func myCustomBubbleteaMiddleware() wish.Middleware { 79 | teaHandler := func(s ssh.Session) *tea.Program { 80 | m := &model{ 81 | team: currTeam, 82 | } 83 | currTeam = !currTeam 84 | 85 | p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen(), 86 | tea.WithMouseCellMotion()) 87 | 88 | m.thisProgram = p 89 | programs = append(programs, p) 90 | return p 91 | } 92 | return bm.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256) 93 | } 94 | 95 | type model struct { 96 | team bool // true means fire 97 | thisProgram *tea.Program 98 | x int 99 | y int 100 | 101 | comment string 102 | } 103 | 104 | func (m model) Init() tea.Cmd { 105 | return nil 106 | } 107 | 108 | // I tried not using this and it _looked_ like it still worked. 109 | var lock = new(sync.Mutex) 110 | 111 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 112 | // fmt.Println(fmt.Sprintf("%p", m.thisProgram), msg) 113 | switch msg := msg.(type) { 114 | //case tea.WindowSizeMsg: 115 | // m.height = msg.Height 116 | // m.width = msg.Width 117 | case tea.KeyMsg: 118 | switch msg.String() { 119 | case "q", "ctrl+c": 120 | for i, p := range programs { 121 | if p == m.thisProgram { 122 | programs = append(programs[:i], programs[i+1:]...) 123 | break 124 | } 125 | } 126 | fmt.Println(m.thisProgram, "quitting") 127 | return m, tea.Quit 128 | } 129 | case tea.MouseMsg: 130 | if msg.Type != tea.MouseRelease { // trigger on release only - no dragging allowed 131 | return m, nil 132 | } 133 | m.x = (msg.X - 1) / 2 // divide by 2: each emoji is two cells wide 134 | m.y = msg.Y - 3 // subtract 2: the top two rows are not part of the board and the border isn't either 135 | 136 | m.comment = b.Click(m.x, m.y, m.team) 137 | 138 | lock.Lock() 139 | 140 | for _, p := range programs { 141 | if p == m.thisProgram { 142 | continue 143 | } 144 | p.Send(tea.Msg(true)) // trigger render 145 | } 146 | 147 | lock.Unlock() 148 | } 149 | 150 | return m, nil 151 | } 152 | 153 | var gameDoneMsg = "" 154 | 155 | func (m model) View() string { 156 | if len(gameDoneMsg) > 0 { 157 | l := len([]rune(gameDoneMsg)) 158 | return strings.Repeat("\n", height/2) + strings.Repeat(" ", width) + "Game over!\n" + strings.Repeat(" ", width-l/2) + gameDoneMsg + strings.Repeat(" ", width-l/2) 159 | } 160 | t := "" 161 | if m.team { 162 | t = "🔥" 163 | } else { 164 | t = "🧊" 165 | } 166 | return "You're in the " + t + " team! Click on targets to win " + t + "s for your team!\n" + 167 | "\n" + 168 | b.RenderBoard(t, fireScore, iceScore, m.comment) + "\n" + rules 169 | } 170 | -------------------------------------------------------------------------------- /whackboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | Tree rune = '🌳' 11 | TreeHot rune = '🌴' 12 | TreeCold rune = '🌲' 13 | Fire rune = '🔥' 14 | Ice rune = '🧊' 15 | Whack rune = '🎯' 16 | Water rune = '💧' 17 | width int = 15 18 | height int = 15 19 | ) 20 | 21 | type Board struct { 22 | board [][]rune 23 | whackX int 24 | whackY int 25 | } 26 | 27 | func NewBoard() *Board { 28 | board := make([][]rune, height) 29 | for i := range board { 30 | board[i] = make([]rune, width) 31 | for j := range board[i] { 32 | r := rand.Float64() 33 | 34 | if r < 0.02 { 35 | board[i][j] = Fire 36 | } else if r < 0.04 { 37 | board[i][j] = Ice 38 | } else { 39 | board[i][j] = Tree 40 | } 41 | } 42 | } 43 | 44 | b := &Board{ 45 | board: board, 46 | } 47 | 48 | b.Generate() 49 | 50 | return b 51 | } 52 | 53 | func (b *Board) RenderBoard(t string, fireScore, iceScore int, comment string) string { 54 | s := "" 55 | border := "─" 56 | scoreStr := "🔥 " + strconv.Itoa(fireScore) + " 🧊 " + strconv.Itoa(iceScore) 57 | 58 | l := len([]rune(scoreStr)) 59 | s += t + strings.Repeat(border, width-l/2-l%2-2) + scoreStr + strings.Repeat(border, width-l/2-2) + t + "\n" 60 | 61 | for _, row := range b.board { 62 | s += "│" + string(row) + "│\n" 63 | } 64 | 65 | l = len([]rune(comment)) 66 | s += t + strings.Repeat(border, width-l/2-l%2-1) + comment + strings.Repeat(border, width-l/2-1) + t + "\n" 67 | 68 | return s 69 | } 70 | 71 | func (b *Board) Generate() { 72 | b.whackX = rand.Intn(width) 73 | b.whackY = rand.Intn(height) 74 | if b.board[b.whackY][b.whackX] == Tree || b.board[b.whackY][b.whackX] == TreeHot || b.board[b.whackY][b.whackX] == TreeCold { 75 | b.board[b.whackY][b.whackX] = Whack 76 | return 77 | } 78 | b.Generate() 79 | } 80 | 81 | func (b *Board) Click(x, y int, team bool) string { 82 | if x >= width || y >= height || x < 0 || y < 0 { 83 | return "Out of bounds!" 84 | } 85 | 86 | comment := "" 87 | 88 | if b.board[y][x] == Whack { 89 | if team { 90 | b.board[y][x] = Fire 91 | fireScore++ 92 | } else { 93 | b.board[y][x] = Ice 94 | iceScore++ 95 | } 96 | comment = "Nice!" 97 | b.Generate() 98 | } else if b.board[y][x] == Fire { 99 | if !team { 100 | b.board[y][x] = Water 101 | iceScore-- 102 | comment = "Ouch! You made water!" 103 | } 104 | } else if b.board[y][x] == Ice { 105 | if team { 106 | b.board[y][x] = Water 107 | fireScore-- 108 | comment = "Ouch! You made water!" 109 | } 110 | } else if b.board[y][x] == Tree { 111 | if team { 112 | b.board[y][x] = TreeHot 113 | comment = "Palm tree!" 114 | } else { 115 | b.board[y][x] = TreeCold 116 | comment = "Pine tree!" 117 | } 118 | } else if b.board[y][x] == Water { 119 | if team { 120 | fireScore-- 121 | comment = "Ouch! Water puts out fire!" 122 | } else { 123 | iceScore-- 124 | comment = "Ouch! Water melts ice!" 125 | } 126 | } 127 | 128 | out: 129 | for r, row := range b.board { // detect game end 130 | for c, cell := range row { 131 | if cell == TreeHot || cell == TreeCold || cell == Tree { 132 | break out 133 | } 134 | 135 | if r == len(b.board)-1 && c == len(row)-1 { 136 | s := "" 137 | if fireScore > iceScore { 138 | s += "🔥 wins!" 139 | } else if iceScore > fireScore { 140 | s += "🧊 wins!" 141 | } else { 142 | s += "It's a tie!" 143 | } 144 | gameDoneMsg = s 145 | return "" 146 | } 147 | } 148 | } 149 | 150 | return comment 151 | } 152 | --------------------------------------------------------------------------------