-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial commit w/ basic config, loop, state setup
- Loading branch information
0 parents
commit e1717e4
Showing
8 changed files
with
258 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.env | ||
gotato | ||
|
||
.nova/ | ||
.vscode/ | ||
.idea/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |