User Story
As a viewer, I want to spend channel points to receive a semantic hint — a word semantically close to the secret word — so that I can progress in the game when I am stuck, without the hint being free or spammable.
As a streamer, I want to configure a single Twitch custom reward and have the bot handle all logic automatically, so that I can enable hints during a live stream with minimal setup and no risk of breaking the game mid-broadcast.
Description
Streamantix currently has no channel points integration. This feature adds a hint system driven by Twitch Channel Points redemptions:
- The streamer creates a custom reward in the Twitch dashboard (e.g. Streamantix Hint) and sets
HINT_REWARD_NAME in .env
- When a viewer redeems the reward, the bot reveals the next hint from a pre-computed pool of Word2Vec neighbors of the target word
- Hints are revealed from farthest to closest (hint 1 = moderately related, hint 5 = very close), building progressive tension
- Maximum
HINTS_PER_GAME (default: 5) hints per game; redemptions beyond the limit are automatically cancelled/refunded
Hint reveal behaviour
- Pool size: top-50 Word2Vec neighbors (
HINT_POOL_SIZE=50), reversed (farthest first)
- Words already guessed by players are silently skipped; the next unguessed word in the pool is used
- Redemption is auto-cancelled (points refunded) if: no game is running, limit is reached, or pool is exhausted
- Chat message:
@user A hint: "lumière" (hint 1/5)
- All revealed hints are displayed persistently in the overlay (not just the last one)
New configuration variables
| Variable |
Default |
Description |
HINT_REWARD_NAME |
`` (disabled) |
Exact name of the Twitch custom reward to listen for |
HINTS_PER_GAME |
5 |
Maximum hints per game |
HINT_POOL_SIZE |
50 |
Number of Word2Vec neighbors pre-computed at game start |
Acceptance Criteria
Technical Specification
Architecture notes (from [Tech] Architect review)
EventSub WebSocket — not PubSub. Twitch deprecated PubSub; use twitchio.ext.eventsub with WebSocket transport. Same user token, no additional infrastructure.
Scorer protocol boundary. GameState must not call the engine directly. bot.py pre-computes the hint pool via engine.get_hints(target), reverses it, and passes it to start_new_game(hint_pool=...). GameState receives plain list[str] data.
Discriminated hint result. next_hint() returns a (HintResult, str | None) tuple where HintResult is an enum: OK, LIMIT_REACHED, POOL_EXHAUSTED. The bot posts a different chat message for each case.
Async safety. next_hint() must stay synchronous (no await); asyncio cooperative scheduling makes synchronous GameState mutations atomic.
Channel ID resolution. Use twitchio.Client.fetch_users() (async Helix) to resolve channel_id at startup — not a blocking urllib call.
Implementation phases
Phase 1 — Semantic engine
game/engine.py: add SemanticEngine.get_hints(word: str, n: int = 50) -> list[str]
- Calls
model.most_similar(vocab_key, topn=n + buffer)
- Filters results through
_cleaned_key_map
- Returns up to
n cleaned words
Phase 2 — Game state
game/state.py:
- New
HintResult enum: OK / LIMIT_REACHED / POOL_EXHAUSTED
- New private fields:
_hint_pool: list[str], _hints_revealed: list[str]
- Updated signature:
start_new_game(target, difficulty, hint_pool: list[str] | None = None)
- New method:
next_hint() -> tuple[HintResult, str | None]
- New property:
hints_revealed: list[str]
start_new_game() resets all hint state
Phase 3 — Twitch integration
config.py: add HINT_REWARD_NAME, HINTS_PER_GAME, HINT_POOL_SIZE
auth/twitch_auth.py: add channel:read:redemptions and channel:manage:redemptions to default TWITCH_SCOPES
main.py:
- Resolve
channel_id via async Helix client
- Subscribe to EventSub WebSocket for channel point redemptions
- Add scope validation in
_resolve_token(): compare validated token scopes against required set; force re-login on mismatch
- Skip subscription and log warning if
HINT_REWARD_NAME == ""
bot/bot.py:
- EventSub redemption handler: normalise reward title (
.strip().lower()), call next_hint(), dispatch chat message
- Auto-cancel redemption via Helix on
LIMIT_REACHED / POOL_EXHAUSTED / no game running
- Rename
hint command → top; update _HELP_TEXT
- Add new
hint command (broadcaster only): calls next_hint() directly
- Update
start_game to call engine.get_hints() and pass hint_pool to start_new_game()
Phase 4 — Overlay
overlay/state.py: add "hints": state.hints_revealed to serialize_game_state()
overlay/static/index.html: persistent hints panel showing all revealed hint words
Phase 5 — Tests
tests/unit/test_engine.py: get_hints() with mocked KeyedVectors
tests/unit/test_game_state.py: next_hint() — OK path, LIMIT_REACHED, POOL_EXHAUSTED, skip guessed words, reset on new game
tests/unit/test_commands.py: EventSub handler mock (reward match / no match), broadcaster hint command, renamed top command
Files to modify
config.py · game/engine.py · game/state.py · bot/bot.py · main.py · auth/twitch_auth.py · overlay/state.py · overlay/static/index.html · tests/unit/test_engine.py · tests/unit/test_game_state.py · tests/unit/test_commands.py
Identified by
- 👤
[User] Viewer — engagement mechanic, fairness (refund), hint numbering UX
- 👤
[User] Streamer — setup simplicity, live reliability, broadcaster fallback command
- 🏗️
[Tech] Architect — EventSub over PubSub, Scorer protocol boundary, discriminated result, async safety
User Story
As a viewer, I want to spend channel points to receive a semantic hint — a word semantically close to the secret word — so that I can progress in the game when I am stuck, without the hint being free or spammable.
As a streamer, I want to configure a single Twitch custom reward and have the bot handle all logic automatically, so that I can enable hints during a live stream with minimal setup and no risk of breaking the game mid-broadcast.
Description
Streamantix currently has no channel points integration. This feature adds a hint system driven by Twitch Channel Points redemptions:
HINT_REWARD_NAMEin.envHINTS_PER_GAME(default: 5) hints per game; redemptions beyond the limit are automatically cancelled/refundedHint reveal behaviour
HINT_POOL_SIZE=50), reversed (farthest first)@user A hint: "lumière" (hint 1/5)New configuration variables
HINT_REWARD_NAMEHINTS_PER_GAME5HINT_POOL_SIZE50Acceptance Criteria
hint 1/5)HINT_REWARD_NAMEcomparison is case-insensitive and whitespace-trimmedHINT_REWARD_NAMEis empty at startup, EventSub subscription is skipped with a warning logchannel:read:redemptionsandchannel:manage:redemptions; forces re-login if scopes are missingTechnical Specification
Architecture notes (from
[Tech] Architectreview)EventSub WebSocket — not PubSub. Twitch deprecated PubSub; use
twitchio.ext.eventsubwith WebSocket transport. Same user token, no additional infrastructure.Scorer protocol boundary.
GameStatemust not call the engine directly.bot.pypre-computes the hint pool viaengine.get_hints(target), reverses it, and passes it tostart_new_game(hint_pool=...).GameStatereceives plainlist[str]data.Discriminated hint result.
next_hint()returns a(HintResult, str | None)tuple whereHintResultis an enum:OK,LIMIT_REACHED,POOL_EXHAUSTED. The bot posts a different chat message for each case.Async safety.
next_hint()must stay synchronous (noawait); asyncio cooperative scheduling makes synchronousGameStatemutations atomic.Channel ID resolution. Use
twitchio.Client.fetch_users()(async Helix) to resolvechannel_idat startup — not a blockingurllibcall.Implementation phases
Phase 1 — Semantic engine
game/engine.py: addSemanticEngine.get_hints(word: str, n: int = 50) -> list[str]model.most_similar(vocab_key, topn=n + buffer)_cleaned_key_mapncleaned wordsPhase 2 — Game state
game/state.py:HintResultenum:OK / LIMIT_REACHED / POOL_EXHAUSTED_hint_pool: list[str],_hints_revealed: list[str]start_new_game(target, difficulty, hint_pool: list[str] | None = None)next_hint() -> tuple[HintResult, str | None]hints_revealed: list[str]start_new_game()resets all hint statePhase 3 — Twitch integration
config.py: addHINT_REWARD_NAME,HINTS_PER_GAME,HINT_POOL_SIZEauth/twitch_auth.py: addchannel:read:redemptionsandchannel:manage:redemptionsto defaultTWITCH_SCOPESmain.py:channel_idvia async Helix client_resolve_token(): compare validated token scopes against required set; force re-login on mismatchHINT_REWARD_NAME == ""bot/bot.py:.strip().lower()), callnext_hint(), dispatch chat messageLIMIT_REACHED/POOL_EXHAUSTED/ no game runninghintcommand →top; update_HELP_TEXThintcommand (broadcaster only): callsnext_hint()directlystart_gameto callengine.get_hints()and passhint_pooltostart_new_game()Phase 4 — Overlay
overlay/state.py: add"hints": state.hints_revealedtoserialize_game_state()overlay/static/index.html: persistent hints panel showing all revealed hint wordsPhase 5 — Tests
tests/unit/test_engine.py:get_hints()with mockedKeyedVectorstests/unit/test_game_state.py:next_hint()— OK path, LIMIT_REACHED, POOL_EXHAUSTED, skip guessed words, reset on new gametests/unit/test_commands.py: EventSub handler mock (reward match / no match), broadcasterhintcommand, renamedtopcommandFiles to modify
config.py·game/engine.py·game/state.py·bot/bot.py·main.py·auth/twitch_auth.py·overlay/state.py·overlay/static/index.html·tests/unit/test_engine.py·tests/unit/test_game_state.py·tests/unit/test_commands.pyIdentified by
[User] Viewer— engagement mechanic, fairness (refund), hint numbering UX[User] Streamer— setup simplicity, live reliability, broadcaster fallback command[Tech] Architect— EventSub over PubSub, Scorer protocol boundary, discriminated result, async safety