Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/agents/viewer.agent.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
---
name: "[User] Viewer"
description: "Use when: thinking from the viewer's perspective, gathering requirements for player-facing features, evaluating the guess command (!sx guess), hint system (!sx hint), status display (!sx status), overlay readability, onboarding new players, or validating that game features are fun and understandable for a typical Twitch chat participant."
description: "Use when: thinking from the viewer's perspective, gathering requirements for player-facing features, evaluating the guess command (!sx guess), channel points hint redemptions, top guesses leaderboard (!sx top), status display (!sx status), overlay readability, onboarding new players, or validating that game features are fun and understandable for a typical Twitch chat participant."
tools: []
---
You are a **Twitch viewer / player** persona for the Streamantix project. You represent a member of the stream's audience who participates in the semantic word-guessing game.

## Who You Are

- You watch the stream in a browser or mobile app and interact via Twitch chat
- You play the game using: `!sx guess <word>`, `!sx hint`, `!sx status`, `!sx help`
- You play the game using: `!sx guess <word>`, `!sx top`, `!sx status`, `!sx help`
- You can spend channel points to redeem hints (semantic clues) if the streamer has enabled the feature
- You may be new to the game — you don't read docs, you learn by trying commands
- You care about **instant feedback**, **understanding your score**, and **having fun**
- You are competitive and want to know where you rank compared to other viewers
Expand Down
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Players guess a secret word by submitting words in chat. The bot uses word embed
| `MODEL_PATH` | Path to the Word2Vec binary model file | `models/frWac_no_postag_no_phrase_700_skip_cut50.bin` |
| `OVERLAY_ENABLED` | Start the web overlay server | `false` |
| `OVERLAY_PORT` | TCP port for the overlay server | `8080` |
| `HINT_REWARD_NAME` | Exact name of the Twitch channel points reward for hints (leave empty to disable) | `""` |
| `HINTS_PER_GAME` | Maximum number of hints that can be revealed per game | `5` |
| `HINT_POOL_SIZE` | Number of Word2Vec neighbors pre-computed as hint candidates | `50` |

## Twitch Authentication

Expand Down Expand Up @@ -163,7 +166,8 @@ All commands are prefixed with the configured `COMMAND_PREFIX` (default: `!sx`).
| `!sx help` | Anyone | Show available commands |
| `!sx start [easy\|medium\|hard]` | Broadcaster only | Start a new game round. Defaults to the difficulty set with `setdifficulty` (initially `easy`) |
| `!sx guess <word>` | Anyone | Submit a guess for the current game |
| `!sx hint` | Anyone | Show the top 10 best guesses so far (proximity leaderboard) |
| `!sx hint` | Broadcaster only | Manually reveal the next semantic hint from the pool (bypasses channel points) |
| `!sx top` | Anyone | Show the top 10 best guesses so far (proximity leaderboard) |
| `!sx status` | Anyone | Show current game status: attempts, best guess, and whether the word has been found |
| `!sx setprefix <prefix>` | Mod / Broadcaster | Change the command prefix (session only) |
| `!sx setcooldown <seconds>` | Mod / Broadcaster | Change the guess cooldown duration (session only) |
Expand All @@ -176,13 +180,99 @@ All commands are prefixed with the configured `COMMAND_PREFIX` (default: `!sx`).
!sx start
!sx start hard
!sx guess maison
!sx top
!sx hint
!sx status
!sx setprefix ?sx
!sx setcooldown 10
!sx setdifficulty hard
```

## Channel Points Hint System

Streamantix supports an optional **semantic hint system** powered by Twitch Channel Points. When enabled, viewers can redeem a custom reward to reveal progressively closer words from the Word2Vec neighbor pool.

### How it works

1. **At game start**, the bot pre-computes a pool of Word2Vec neighbors of the target word (default: 50 words)
2. **Hints are revealed from farthest to closest**: hint 1 is moderately related, hint 5 is very close
3. **Viewers redeem a custom reward** you create in your Twitch dashboard (e.g., "Streamantix Hint")
4. **The bot posts the next hint in chat** and displays it persistently in the overlay
5. **Redemptions are automatically refunded** when:
- No game is currently running
- The hint limit has been reached (default: 5 hints per game)
- The hint pool is exhausted (all pool words have been guessed by players)

### Setup

#### 1. Create a Twitch Channel Points reward

1. Go to your **Twitch Creator Dashboard** → **Viewer Rewards** → **Channel Points**
2. Click **Add New Custom Reward**
3. Configure the reward:
- **Title**: Choose a name (e.g., `Streamantix Hint` or `Indice Sémantique`)
- **Cost**: Set the channel points cost (recommended: 500-2000 points)
- **User Input**: Set to **Disabled** (the bot does not use text input)
- **Cooldown** (optional): Add a global cooldown between redemptions if desired
4. Click **Save**

#### 2. Update your `.env` file

Set `HINT_REWARD_NAME` to the **exact name** of the reward you just created (case-insensitive, but must match):

```env
HINT_REWARD_NAME=Streamantix Hint
```

Optional: Adjust the hint limits if desired:

```env
HINTS_PER_GAME=5 # Max hints per game (default: 5)
HINT_POOL_SIZE=50 # Number of neighbors pre-computed (default: 50)
```

#### 3. Grant required OAuth scopes

The hint system requires two additional Twitch OAuth scopes:

- `channel:read:redemptions` — to receive channel points redemption events
- `channel:manage:redemptions` — to cancel (refund) redemptions when the limit is reached

**If you're using the OAuth flow**, these scopes are included by default. Run the login command to re-authorize:

```bash
poetry run python main.py auth-login
```

**If you're using a manual token** (`TWITCH_TOKEN`), generate a new token with these scopes:
<https://twitchtokengenerator.com/> (select **Custom Scopes** → `chat:read`, `chat:edit`, `channel:read:redemptions`, `channel:manage:redemptions`)

#### 4. Start the bot

```bash
poetry run python main.py
```

On startup, the bot will:

- Subscribe to channel points redemption events via EventSub WebSocket
- Log confirmation: `Subscribed to channel points redemptions for '<channel>' (reward: '<reward_name>')`
- If `HINT_REWARD_NAME` is empty or unset, the hint system is disabled (no error)

### Broadcaster manual hints

The broadcaster can trigger hints manually without spending channel points using:

```text
!sx hint
```

This is useful for:

- Testing the hint system before going live
- Providing free hints during special events
- Recovering from technical issues mid-stream

## Testing

The test suite is split into two layers:
Expand Down
2 changes: 1 addition & 1 deletion auth/twitch_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(
client_id: str,
client_secret: str,
redirect_uri: str = "http://localhost:4343/callback",
scopes: str = "chat:read chat:edit",
scopes: str = "chat:read chat:edit channel:read:redemptions channel:manage:redemptions",
token_path: str = ".secrets/twitch_tokens.json",
) -> None:
self.client_id = client_id
Expand Down
153 changes: 147 additions & 6 deletions bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
from collections.abc import Awaitable, Callable
from typing import Any

from twitchio.ext import commands
from twitchio.ext import commands, eventsub

import config
from bot.cooldown import CooldownManager
from game.state import Difficulty, GameState
from game.state import Difficulty, GameState, HintResult
from game.word_utils import load_word_list

_DATA_DIR = pathlib.Path(__file__).parent.parent / "data"
Expand All @@ -25,7 +26,8 @@
"help — show this message | "
"start [easy|medium|hard] — start a new game (broadcaster only) | "
"guess <word> — submit a guess | "
"hint — show top 10 guesses | "
"hint — reveal next semantic hint (broadcaster only) | "
"top — show top 10 guesses | "
"status — show current game status | "
"solution — reveal the answer (broadcaster only) | "
"setprefix <prefix> — change prefix (mod/broadcaster) | "
Expand Down Expand Up @@ -89,11 +91,15 @@ def __init__(self, **kwargs: object) -> None:
"on_state_change", None
)
scorer = kwargs.pop("scorer", None) # type: ignore[assignment]
self._token: str = str(kwargs.get("token", ""))
self._command_prefix: str = initial_prefix
self._cooldown = CooldownManager(int(initial_cooldown))
self._game_state = GameState(scorer=scorer) # type: ignore[arg-type]
self._scorer = scorer
self._on_state_change = on_state_change
self._next_difficulty: Difficulty = Difficulty.EASY
self._eventsub: eventsub.EventSubWSClient | None = None
self._channel_id: int | None = None
super().__init__(prefix=lambda bot, msg: bot._command_prefix, **kwargs)

async def _notify_overlay(self) -> None:
Expand All @@ -108,11 +114,106 @@ async def _notify_overlay(self) -> None:
async def event_ready(self) -> None:
"""Called once the bot has successfully connected to Twitch."""
print(f"Logged in as {self.nick}")
if config.HINT_REWARD_NAME:
await self._setup_eventsub()
else:
print("Hint system disabled (HINT_REWARD_NAME not set).")

async def _setup_eventsub(self) -> None:
"""Subscribe to channel points redemption events via EventSub WebSocket."""
try:
users = await self.fetch_users(names=[config.TWITCH_CHANNEL])
except Exception as e:
print(f"Warning: Could not resolve channel ID for '{config.TWITCH_CHANNEL}': {e}")
return

if not users:
print(f"Warning: Channel '{config.TWITCH_CHANNEL}' not found. Hint system disabled.")
return

self._channel_id = users[0].id
self._eventsub = eventsub.EventSubWSClient(self)
try:
await self._eventsub.subscribe_channel_points_redeemed(
broadcaster=self._channel_id,
token=self._token,
)
print(
f"Subscribed to channel points redemptions for '{config.TWITCH_CHANNEL}' "
f"(reward: '{config.HINT_REWARD_NAME}')."
)
except Exception as e:
print(
f"Warning: Could not subscribe to channel points events: {e}. "
"Ensure 'channel:read:redemptions' scope is granted and re-run auth-login."
)

async def event_error(self, error: Exception, data: str | None = None) -> None:
"""Called when an error occurs; logs it without crashing the bot."""
print(f"Error: {error}")

async def event_eventsub_notification_channel_reward_redeem(
self, payload: eventsub.NotificationEvent
) -> None:
"""Handle a channel points redemption event from EventSub."""
data: eventsub.CustomRewardRedemptionAddUpdateData = payload.data
if data.reward.title.strip().lower() != config.HINT_REWARD_NAME.strip().lower():
return
await self._handle_hint_redemption(data)

async def _handle_hint_redemption(
self, data: eventsub.CustomRewardRedemptionAddUpdateData
) -> None:
"""Process a hint redemption: reveal next hint or refund on failure."""
channel = self.get_channel(config.TWITCH_CHANNEL)
if channel is None:
return

username = data.user.name

if self._game_state.target_word is None:
await channel.send(
f"@{username} No game is currently in progress. Redemption refunded."
)
await self._cancel_redemption(data)
return

result, word = self._game_state.next_hint()

if result == HintResult.OK:
n = self._game_state.hints_given
max_n = config.HINTS_PER_GAME
await channel.send(f'@{username} A hint: "{word}" (hint {n}/{max_n})')
await self._notify_overlay()
elif result == HintResult.LIMIT_REACHED:
await channel.send(
f"@{username} The hint limit has been reached "
f"({config.HINTS_PER_GAME}/{config.HINTS_PER_GAME}). Redemption refunded."
)
await self._cancel_redemption(data)
elif result == HintResult.POOL_EXHAUSTED:
await channel.send(
f"@{username} No more hints available for this game. Redemption refunded."
)
await self._cancel_redemption(data)

async def _cancel_redemption(
self, data: eventsub.CustomRewardRedemptionAddUpdateData
) -> None:
"""Cancel (refund) a channel points redemption via the Helix API."""
if self._channel_id is None or not self._token:
return
try:
await self._http.update_reward_redemption_status(
self._token,
self._channel_id,
data.id,
data.reward.id,
False,
)
except Exception as e:
print(f"Warning: Could not cancel redemption {data.id}: {e}")

@commands.command()
async def help(self, ctx: commands.Context) -> None:
"""Show available commands.
Expand Down Expand Up @@ -165,7 +266,17 @@ async def start_game(self, ctx: commands.Context, difficulty: str = "") -> None:
return

target = random.choice(words)
self._game_state.start_new_game(target, diff)

hint_pool: list[str] = []
if self._scorer is not None and hasattr(self._scorer, "get_hints"):
raw_pool: list[str] = self._scorer.get_hints( # type: ignore[attr-defined]
target, n=config.HINT_POOL_SIZE
)
hint_pool = list(reversed(raw_pool))

self._game_state.start_new_game(
target, diff, hint_pool=hint_pool, hints_per_game=config.HINTS_PER_GAME
)
await ctx.send(
f"A new {diff.value} game has started! "
f"Guess the secret word using '{self._command_prefix} guess <word>'."
Expand Down Expand Up @@ -296,10 +407,10 @@ async def setcooldown(self, ctx: commands.Context, seconds: str = "") -> None:
await ctx.send(f"Cooldown set to {value} seconds (session only).")

@commands.command()
async def hint(self, ctx: commands.Context) -> None:
async def top(self, ctx: commands.Context) -> None:
"""Show the top 10 best guesses so far (proximity leaderboard).

Usage: <prefix> hint
Usage: <prefix> top
"""
if self._game_state.target_word is None:
await ctx.send("No game is currently in progress.")
Expand All @@ -316,6 +427,36 @@ async def hint(self, ctx: commands.Context) -> None:
]
await ctx.send("Top guesses: " + " | ".join(parts))

@commands.command()
async def hint(self, ctx: commands.Context) -> None:
"""Reveal the next semantic hint from the pool (broadcaster only).

Usage: <prefix> hint

Triggers the next hint without requiring a channel points redemption.
"""
if not ctx.author.is_broadcaster:
await ctx.send("Only the broadcaster can trigger a hint.")
return

if self._game_state.target_word is None:
await ctx.send("No game is currently in progress.")
return

result, word = self._game_state.next_hint()

if result == HintResult.OK:
n = self._game_state.hints_given
max_n = config.HINTS_PER_GAME
await ctx.send(f'Hint: "{word}" (hint {n}/{max_n})')
await self._notify_overlay()
elif result == HintResult.LIMIT_REACHED:
await ctx.send(
f"Hint limit reached ({config.HINTS_PER_GAME}/{config.HINTS_PER_GAME})."
)
elif result == HintResult.POOL_EXHAUSTED:
await ctx.send("No more hints available in the pool.")

@commands.command()
async def status(self, ctx: commands.Context) -> None:
"""Show the current game status.
Expand Down
10 changes: 9 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,13 @@ def validate() -> None:
TWITCH_CLIENT_ID: str | None = os.getenv("TWITCH_CLIENT_ID")
TWITCH_CLIENT_SECRET: str | None = os.getenv("TWITCH_CLIENT_SECRET")
TWITCH_REDIRECT_URI: str = os.getenv("TWITCH_REDIRECT_URI", "http://localhost:4343/callback")
TWITCH_SCOPES: str = os.getenv("TWITCH_SCOPES", "chat:read chat:edit")
TWITCH_SCOPES: str = os.getenv(
"TWITCH_SCOPES",
"chat:read chat:edit channel:read:redemptions channel:manage:redemptions",
)
TWITCH_TOKEN_PATH: str = os.getenv("TWITCH_TOKEN_PATH", ".secrets/twitch_tokens.json")

# Channel points hint system
HINT_REWARD_NAME: str = os.getenv("HINT_REWARD_NAME", "")
HINTS_PER_GAME: int = int(os.getenv("HINTS_PER_GAME", "5"))
HINT_POOL_SIZE: int = int(os.getenv("HINT_POOL_SIZE", "50"))
Loading
Loading