Skip to content

Commit

Permalink
Initial commit w/ basic config, loop, state setup
Browse files Browse the repository at this point in the history
  • Loading branch information
glotchimo committed Jul 8, 2023
0 parents commit e1717e4
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.env
gotato

.nova/
.vscode/
.idea/
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# gotato

A Twitch chat interaction that simulates a game of hot potato with chatters.

## Setup

Environment variables are used to configure things like the timer, rewards, timeouts, etc.
The following are available:

- `GOTATO_CHANNEL`: The channel to connect to
- `GOTATO_USERNAME`: Twitch username to connect with
- `GOTATO_PASSWORD`: Twitch password/token to connect with
- `GOTATO_TIMER_MIN`: Minimum game length in seconds
- `GOTATO_TIMER_MAX`: Maximim game length in seconds
- `GOTATO_TIMEOUT`: Loss timeout in seconds
- `GOTATO_REWARD`: Channel points rewarded to winner
- `GOTATO_COOLDOWN`: Cooldown between games in seconds

Once those are set (or not if you want the defaults), just run the binary:

./gotato

## Design

### Interaction

We use Twitch's chat IRC to interact with chat. This is obviously necessary for interactions:

glotchimo: !gotato
gotato: The potato's been heated and is in @somebody's hands!
somebody: !gotato
gotato: The potato's been passed to @other!
...
gotato: Time's up! @other lost to potato LUL -5m
gotato: @glotchimo held the potato the longest! +100 channel points PogChamp

By listening for the `!gotato` command we can progress state easily & atomically - whatever
IRC feeds us, we act on.

### Configuration

To give users the freedom to tweak the experience, we have a series of environment variables
that set the timer range, cooldown between games, timeout length, etc.

### State

Initially, state only requires two things. First, we need a random `timer` that gets set on a
first-cycle call (i.e. `!gotato` issued in chat) and gets decremented once every second or so.
Then we need a `holder` string that contains the ID of the chatter holding the potato, which
would be randomly set from the list of active viewers upon every subsequent call, potentially
with some additional logic to limit that list to recently active chatters.

An additional state element that would facilitate more competition and interesting interactions
would be a map of participants that gets populated and its values incremented whenever a given
chatter has the potato. For example, if somebody calls `!gotato` and I get the potato, I can hold it for as long as I want, and *then* pass it, and that duration would be counted. At the
end of the game, the winner would be the chatter who held onto the potato the longest.

### Conditions

In order to prevent multiple potatoes from being passed around, we need to make sure `holder` is
empty before starting a new game. That also requires that we clear `holder` after the timer has
run out and the timeout has been executed.

We also need to prevent chatters who are still active but have been timed out from
being included in the list of potato catchers. This could be achieved by watching for
"clear chat" messages from the IRC.

### Concurrency

We need three goroutines to progress state cleanly. First, we need a goroutine listening for
`!gotato` messages. When it receives one, it sends that event to a channel that gets consumed
by the state goroutine. Having a separate goroutine for state allows us to keep receiving calls
without breaking state up with the runtime conditions. Then we have a third that handles things
like timeouts and channel point rewards since those aren't that time-sensitive.

### Advanced Features

These are features that don't need to be in at first but would be cool to add in the future:

- **Channel point wager pool**: Chatters spend channel points to join, winner gets the pot.
- **Leaderboard**: A database is maintained with scores and points won over time.
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/glotchimo/gotato

go 1.20

require github.com/gempir/go-twitch-irc/v4 v4.0.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/gempir/go-twitch-irc/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8=
github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg=
25 changes: 25 additions & 0 deletions listen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import (
"fmt"
"log"

"github.com/gempir/go-twitch-irc/v4"
)

func listen(events chan string, errors chan error) {
// Initialize client with a callback that listens for calls and sends IDs to game loop
client := twitch.NewClient(USERNAME, PASSWORD)
client.OnPrivateMessage(func(message twitch.PrivateMessage) {
if message.Message == "!gotato" {
events <- message.User.ID
}
})

// Join channel and connect client
client.Join(CHANNEL)
if err := client.Connect(); err != nil {
errors <- fmt.Errorf("error connecting to channel: %w", err)
}
log.Println("connection established")
}
36 changes: 36 additions & 0 deletions loop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

import (
"log"
"math/rand"
"time"
)

func loop(events chan string, errors chan error) {
// Initialize state with a random timer
state := State{
Timer: rand.Intn(TIMER_MAX-TIMER_MIN+1) + TIMER_MIN,
Holder: "",
LastEvent: time.Now(),
Scores: map[string]int{},
}

// Consume events and update state
for event := range events {
if event == "" {
log.Println("no-op received")
continue
}

// Update score
if event != state.Holder && state.Holder != "" {
state.Scores[state.Holder] = int(time.Since(state.LastEvent).Seconds())
}

// Update holder
state.Holder = event

state.LastEvent = time.Now()
log.Println("state updated:", state)
}
}
93 changes: 93 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"log"
"os"
"os/signal"
"strconv"
)

var (
CHANNEL string = ""
USERNAME string = ""
PASSWORD string = ""
TIMER_MIN int = 30
TIMER_MAX int = 120
TIMEOUT int = 30
REWARD int = 100
COOLDOWN int = 300
)

func init() {
if CHANNEL = os.Getenv("GOTATO_CHANNEL"); CHANNEL == "" {
panic("channel cannot be blank")
}

if USERNAME = os.Getenv("GOTATO_USERNAME"); USERNAME == "" {
panic("username cannot be blank")
}

if PASSWORD := os.Getenv("GOTATO_PASSWORD"); PASSWORD == "" {
panic("password cannot be blank")
}

if timerMin, err := strconv.Atoi(os.Getenv("GOTATO_TIMER_MIN")); err == nil {
TIMER_MIN = timerMin
}

if timerMax, err := strconv.Atoi(os.Getenv("GOTATO_TIMER_MAX")); err == nil {
TIMER_MAX = timerMax
}

if timeout, err := strconv.Atoi(os.Getenv("GOTATO_TIMEOUT")); err == nil {
TIMEOUT = timeout
}

if reward, err := strconv.Atoi(os.Getenv("GOTATO_REWARD")); err == nil {
REWARD = reward
}

if cooldown, err := strconv.Atoi(os.Getenv("GOTATO_COOLDOWN")); err == nil {
COOLDOWN = cooldown
}
}

func main() {
log.Println("playing gotato with the following settings:")
log.Println(" channel:", CHANNEL)
log.Println(" minimum time:", TIMER_MIN)
log.Println(" maximum time:", TIMER_MAX)
log.Println(" loss timeout:", TIMEOUT)
log.Println(" win reward:", REWARD)
log.Println(" cooldown between games:", COOLDOWN)
log.Println()

// Initialize event and error channels
events := make(chan string)
errors := make(chan error)

// Launch game loop and listener concurrently
log.Println("launching game loop and listener")
go loop(events, errors)
go listen(events, errors)

// Send a no-op to verify loop aliveness
log.Println("sending no-op")
events <- ""

// Wait for errors or interrupt signals
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt)

select {
case err := <-errors:
log.Fatal("error received:", err)
case sig := <-signals:
log.Println("received", sig.String())

// Clean up the channels and exit
close(events)
close(errors)
os.Exit(0)
}
}
10 changes: 10 additions & 0 deletions state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package main

import "time"

type State struct {
Timer int
Holder string
LastEvent time.Time
Scores map[string]int
}

0 comments on commit e1717e4

Please sign in to comment.