Wolfathon is a branded subathon toolkit for Twitch streamers. It pairs a reward tracker that shows your current goal as a name only, never a number, with a countdown timer that auto-adds time from subs, gifts, bits, and channel points via Twitch EventSub. It ships as transparent OBS overlays plus a private control panel, deployed on Cloudflare.
Keep the rewards flowing. Keep the clock ticking.
- Demo
- Features
- Getting Started
- Usage
- Tech Stack
- Development
- Deploying to Cloudflare
- Project Structure
- License
- Contact
- Wolfathon timer - A timestamp-driven countdown that auto-adds time from Twitch subs (per tier), gifted subs, bits (prorated, so any cheer counts), and channel-point redemptions. Every amount is configurable; the overlay counts down to the frame and survives an OBS refresh. Channel-point rewards can be created straight from the Twitch API (up to two), and the time-add emote burst has a 1x / 2x / 3x size control.
- Twitch auto-time (EventSub) - Connect once with the OAuth redirect flow. Events arrive at the server Worker as HMAC-verified webhooks, so there is no bot to babysit and no browser that has to stay open.
- Reward names only - The rewards overlay shows the current reward name and already-unlocked names. Amounts, totals, and future goals are never sent to the browser.
- One-at-a-time unlocks - Goals unlock top to bottom with a short glow-and-scale celebration (no audio), then settle on the next reward.
- Private notes - Each goal has an internal
note(for example, "10 subs") that is stripped server-side and never reaches the overlay. - Combined backup - One JSON file bundles your rewards and timer config: validate, replace in one click, export, or copy a ready-made prompt so you can have Claude edit the config and paste it back to restore.
- Wheel of dares (Howlwheel) - A weighted spinner of chat dares. Edit, weight, colour, and drag-reorder slots from the dashboard, then spin to a weighted-random result or send the wheel to a specific slot. The token-gated OBS overlay stays hidden until you spin (then whirls a long, settling spin and reveals the result — a "Keep wheel on screen" toggle parks it permanently), with the centre logo spinning along. It can also auto-spin every N counted subs (default 10, configurable), announcing the dare in chat.
- Chat bot - Connect a separate bot account and it answers chat commands
(
!wolfathon,!timer,!goals,!wheel,!giveaway) from the server, reusing the EventSub webhook — no process to babysit. Live commands pick from ready-made phrasings (no free text to fumble), and it announces gifted subs in chat, batched per burst so a sub-train never spams. Toggle each command and rate-limit normal viewers (mods/VIPs/broadcaster bypass). - Giveaway tracker - A two-phase prize draw. Hit Start and the first
viewers to gift a threshold of subs are captured as gift-sub winners (you
confirm each). Then open
!enterwhen you're ready, watch the live entrant pool, and draw raffle winners with the crypto CSPRNG. A drawn winner must type!claimwithin five minutes or you redraw (the bot announces the draw, claim, and timeout). Set a rules/TOS link (a gist or any URL) that auto-fills!giveaway. Gift and raffle winners stay in separate lists; any pick can be rerolled, the pool can be cleared on its own, and nobody wins twice. - Cloudflare Access security - The control panel and its API sit behind Cloudflare Zero Trust. The overlays stay open (OBS can't sign in to Access) but are gated by a secret token in their URL, resettable from the control panel. Twitch secrets never reach a public response.
- Installable PWA - The control panel installs as a standalone app.
- Customizer - Tune the overlay look from Settings: colours, font, corner radius, and the eyebrow label, plus per-overlay show/hide toggles and per-overlay size sliders (timer / rewards / wheel) so each fits a 1080p scene, with a live preview of both the timer and rewards surfaces side by side.
- Brand-ready - MrDemonWolf navy and cyan, Montserrat and Roboto, with macOS-style rounded panels.
-
Install dependencies:
bun install
-
Generate the database migration:
bun run db:generate
-
Start the web app and the API together:
bun run dev
-
Open the surfaces:
- Control panel:
http://localhost:3001/dashboard - Overlay URLs (tokenized): control panel → Settings → Overlays
- API (overlay data + Twitch webhook):
http://localhost:3000
Local development (
bun run dev) bypasses Cloudflare Access automatically, so the control panel works without Zero Trust on your machine. A real deploy always enforces Access. The database seeds sample goals and a default timer on first run. - Control panel:
The control panel lives at /dashboard. Its top nav holds the four live
sections — Rewards, Timer, Giveaways, and Wheel — and the gear
opens Settings, where the set-once panes live: Twitch, Bot,
Overlays, Customizer, and Backup.
Open the control panel → Settings → Overlays and copy each source URL straight into OBS. Add each as a Browser source with a transparent background, sized as noted below — the overlays paint only the panel, nothing else full-screen.
Each URL carries a secret ?t=<token> — the public overlay API serves nothing
without it, so an OBS source works while a guessed bare path does not. The
Reset button on Settings → Overlays rotates the token and instantly kills
the old URLs (re-paste the new ones into OBS). If a source ever shows a small
"Overlay token invalid" hint in the corner, its URL is stale — re-copy it.
Each source also has an Open in new tab button to preview the live overlay
in a browser without wiring up OBS first.
| Source | URL | Size (W×H) | Shows |
|---|---|---|---|
| Timer | /overlay/timer?t=… |
1310×200 |
Compact countdown bar (D/H/M/S); emotes flood it on each add |
| Rewards | /overlay/rewards?t=… |
1920×1080 |
Current reward name + unlock celebration |
| Wheel | /overlay/wheel?t=… |
1080×1080 |
Wheel of dares; hidden until you spin, then reveals the dare |
Each overlay is its own source — drag them where you want in OBS, and use the per-overlay size sliders in Settings → Customizer to fit them to a 1080p scene. The timer is a self-contained widget that fills its source, so you can also resize the source itself.
Both poll every 2 seconds, so control-panel edits and Twitch events appear on stream within about 2 seconds (the timer keeps counting smoothly between polls).
The control panel's Timer tab has two halves:
- Controls - Start / Pause / Reset, quick add buttons (+1, +5, +10, +30, -5 minutes) and a custom amount, plus "simulate event" buttons (Sub T1/T2/T3, Gift, 100 bits) that apply the configured minutes for testing.
- Time rules - Edit every amount: starting time, cap (0 = no cap), minutes
per sub tier (T1/T2/T3/Prime), per gifted sub, and per 100 bits — bits are
prorated, so a small cheer still adds its share of a minute. Pick the time-add
emote size (1x / 2x / 3x). And manage up to two channel-point rewards,
created right on Twitch from here (this needs the
channel:manage:redemptionsscope, so reconnect Twitch once to grant it); remove either at any time.
Auto-time uses Twitch EventSub, which needs a Twitch app and a one-time authorization. The Twitch tab walks you through it:
- Create an application at dev.twitch.tv/console/apps.
Set the OAuth Redirect URL to
<web origin>/api/twitch/callback(the Twitch tab shows the exact value). Client Type Confidential. Copy the Client ID and generate a Client Secret. - Put them in the environment as
TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET(repo secrets orapps/web/.env) and redeploy. Keep/api/twitch/callbackoutside the Cloudflare Access app (Access covers only/dashboard+/api/trpc). - On Settings → Twitch (it shows "Loaded from environment ✓"), click Connect Twitch. You're redirected to Twitch to approve, then back — the panel flips to Connected.
- On connect, the server creates EventSub webhook subscriptions for
channel.subscribe,channel.subscription.message,channel.subscription.gift,channel.cheer,channel.channel_points_custom_reward_redemption.add,channel.chat.message(raffle entries), andstream.offline/stream.online. The Twitch status shows the live count as "X of N subscriptions"; if a partial connect leaves some out, it names exactly which types failed so you can reconnect to retry just those.
The stream.offline / stream.online subscriptions auto-pause the timer
when your stream ends and resume it when you go live again, so an outage or
a forgotten "end stream" doesn't burn Wolfathon time. Auto-resume only fires if
the pause was automatic — a manual pause is never overridden. Toggle the whole
behavior with Auto-pause when the stream goes offline on the Timer tab
(default on).
Scopes requested: channel:read:subscriptions, bits:read,
channel:read:redemptions. The EventSub callback is your API Worker at
/twitch/eventsub; every event is HMAC-verified, deduplicated, and rejected
if the signature is wrong or older than 10 minutes. The app credentials come
from the Worker env (TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET); only the
resulting OAuth tokens are stored in D1, and none of it appears in any public
response.
Settings → Backup exports and restores everything in one combined file — your rewards and timer config bundled together — so a single file fully restores the tracker:
- Validate - Checks pasted/uploaded JSON without writing. Shows a preview or a list of human-readable errors.
- Import (replace) - Validates, asks you to confirm, then replaces both halves. Nothing is written unless validation passes (no partial writes).
- Export / Copy current JSON - Download or copy the current backup,
pretty-printed (
wolfathon-backup-…json). - Copy Claude prompt - Copies a ready prompt (schema + your current config) to paste into claude.ai. Ask for the change you want, paste the JSON it returns back into the box, and import.
The backup file wraps the two documents below under a version tag. The rewards half (minimal form):
{
"goals": [
{ "reward": "Q&A", "note": "1 sub" },
{ "reward": "Onesie reveal", "note": "10 subs" },
{ "reward": "Stretch goal", "note": "dream" }
]
}The timer half:
{
"startMinutes": 60,
"maxMinutes": 0,
"sub": { "t1": 5, "t2": 10, "t3": 25, "prime": 5 },
"giftSubMinutes": 5,
"bitsPer100Minutes": 1,
"channelPoints": [{ "rewardTitle": "Add 5 minutes", "minutes": 5 }]
}| Element | Shown on stream |
|---|---|
| Current reward | The next locked goal's reward name, prominently |
| Unlocked rewards | A dimmed row of already-unlocked reward names |
| Future goals | Hidden entirely |
| Numbers / amounts | Never shown |
note field |
Never sent to the browser |
The control panel's Wheel tab manages a spinner of chat dares. Each slot has a label, a weight (a higher weight = a bigger slice and better odds), an optional colour, and an enable toggle; drag the handle to reorder. Spin (random) picks a weighted-random enabled slot server-side, and each slot has a Spin to this for a hand-picked result. The wheel seeds with a default set of dares on first run, and the last 25 spins show under Recent spins.
Add the Wheel OBS source (square, 1080×1080) from Settings →
Overlays. By default the overlay stays hidden until you spin: it whirls a
long, settling multi-turn spin (the centre logo spinning with it), reveals the
dare under the fixed top pointer, then hides again — flip Keep wheel on
screen in the Customizer to park it permanently. It honours
prefers-reduced-motion (lands without the whirl), shows only enabled slots,
and never receives the token or any internal field.
The wheel can also auto-spin every N counted subs — set the cadence on the Wheel tab (default 10, or Off to spin only by hand). When a sub milestone is crossed the overlay plays the spin and, if the chat bot is connected, it announces the dare it landed on.
Wolfathon can answer chat commands from a separate bot account. It runs on the server (no extra process — it reuses the Twitch EventSub webhook), so it keeps working after you close the dashboard.
Connect it from Settings → Bot: log into Twitch as your bot account
first (in another browser/profile, or log out of your main account), then
click Connect bot and approve. The bot grants only user:write:chat +
user:bot; it hears chat through the broadcaster's existing chat
subscription, so the broadcaster account must stay connected.
Five built-in commands ship, each with an enable toggle and editable triggers:
| Command | Aliases | Reply |
|---|---|---|
!wolfathon |
!subathon !wolf !about |
live status line (intro + time + subs + goal) |
!giveaway |
!gw !giveaways |
your giveaway rules/TOS link (set it once) |
!timer |
!time |
live time left on the Wolfathon |
!goals |
!goal |
live next-reward progress (next target only) |
!wheel |
!dares |
how the Howlwheel works + the live dare count |
Every reply is built from live data — there's no free text to fumble. For each
command you pick one of a few built-in reply formats (e.g. the !wheel
"How it works" explainer, or which parts the !wolfathon status line includes);
!giveaway fills in the rules link you set on the Giveaway tab. The bot also
announces gifted subs in chat — turn it on with Announce gift subs, and
a sub-train is batched into one line (e.g. "🎁 14 subs gifted by 3 people · +28m
on the clock!") so chat never spams. A master switch turns the whole bot on/off,
and a per-command cooldown rate-limits normal viewers; broadcaster, mods,
and VIPs bypass it. If the bot's sign-in is later revoked, the Bot tab shows a
reconnect prompt instead of going silently dead.
The control panel's Giveaway tab runs a prize draw in two phases.
- Start the round. Only gift subs that arrive after Start count, so pre-show hype gifts don't pre-decide the winners. Once started, the header shows a live "Tracking gift subs" state and the qualifying gifters appear in the order they crossed the threshold; confirm the first N as gift-sub winners.
- Open
!enter(the raffle command is configurable) when you want the raffle. Chat entries are ignored until you open the window — the toggle is disabled until the round is started — and each login can enter once. The raffle pool lists entrants live (newest first, with a filter and Twitch links); Draw winner picks from the pool with the same crypto CSPRNG used for the wheel, so a real draw can't be predicted or rigged.
A drawn raffle winner has to claim before they keep it: the bot posts
"🎉 @them you won — type !claim within 5 minutes or I redraw", confirms with
"✅ @them claimed" when they do, and on the next chat line after the window
posts "⏰ didn't claim in time" so you can redraw. The panel shows a live
countdown and a Redraw button.
Gift-sub winners and raffle winners show in separate lists, each with a
shipped checkbox and a private shipping note (never sent anywhere public). A
Reroll on any raffle winner swaps them for a fresh draw without re-picking
the person rerolled out, and anyone who has already won (either phase) is
excluded from new draws. Clear pool empties just the entrants (and any
pending claim) so !enter can fill a fresh wave while existing winners stand;
Reset round wipes everything and un-starts for a clean next one.
Configuration is one-tap presets (no fiddly form): pick the raffle command
(!enter / !join / !giveaway, or Custom) and the gift threshold (3 / 5 / 10,
or Custom), and set a rules/TOS link — a GitHub gist or any URL — that the
!giveaway command auto-fills. Nothing in this tab is ever exposed publicly —
it is operator-only behind Cloudflare Access.
Settings → Customizer tunes how the overlays paint: accent colours, font, corner radius, the eyebrow label, and per-overlay show/hide toggles (units, progress bar, unlocked row, status, and the rest). It also has per-overlay size sliders (timer / rewards / wheel) so each fits your 1080p scene, and a Keep wheel on screen toggle (off by default — the wheel only appears when it spins). A live preview renders the timer and rewards surfaces with sample data so you can compare before saving; the wheel overlay inherits the same theme.
Drop your wolf mark (head only) at apps/web/public/logo.svg and it is used
automatically across the overlays and panel. Put your favicon at
apps/web/src/app/favicon.ico (Next serves it automatically). If logo.svg
is missing, a built-in brand SVG mark is shown instead, so nothing ever
renders broken.
| Layer | Technology |
|---|---|
| Monorepo | Turborepo |
| Web | Next.js (overlays, control panel, PWA) |
| Server | Hono on Cloudflare Workers |
| API | tRPC |
| Database | Cloudflare D1 (SQLite) |
| ORM | Drizzle ORM |
| Auth | Cloudflare Access (Zero Trust) |
| Twitch | EventSub webhooks + OAuth redirect flow |
| Styling | Tailwind CSS, Montserrat and Roboto |
| Deploy | Alchemy (Cloudflare Workers and D1) |
| CI/CD | GitHub Actions |
| Runtime | Bun (deploy runs under Node via tsx) |
- Bun 1.3 or newer
- A Cloudflare account (for deployment)
- The Cloudflare Wrangler CLI authenticated, or a scoped API token (Workers + D1 edit)
-
Install dependencies:
bun install
-
Copy the environment templates and fill them in:
cp apps/web/.env.example apps/web/.env cp apps/server/.env.example apps/server/.env cp packages/infra/.env.example packages/infra/.env
-
Generate the database migration:
bun run db:generate
-
Start development:
bun run dev
bun run dev- Start the web app and server together (via Alchemy).bun run dev:web- Start only the web app.bun run dev:server- Start only the server.bun run build- Build all applications.bun run check-types- Type-check across the monorepo.bun run test- Run the domain test suite (packages/api/src).bun run check- Lint with ESLint and check formatting with Prettier.bun run format- Format the repo with Prettier.bun run db:generate- Generate the Drizzle migration from the schema.bun run deploy- Deploy web, server, and D1 to Cloudflare.bun run destroy- Tear down the deployed Cloudflare resources.
- End-to-end TypeScript with strict settings.
- Shared design tokens and components in
packages/ui. - Domain logic and validation centralized in
packages/apiand reused by the overlays, the control panel, and the Twitch webhook. - Pure-function domain modules (timer, wheel, giveaway, theme, backup) covered
by
bun test, kept separate from persistence so they stay easy to test.
Deployment uses Alchemy to provision the Workers, the Next app, and the D1
database, and to apply migrations. The deploy runs under Node via tsx (Bun
segfaults executing the Alchemy program), which bun run deploy handles for
you.
-
Authenticate Cloudflare with a scoped API token (Alchemy uses its own auth, not Wrangler's). Create a token with Workers and D1 edit permissions, then set it in
packages/infra/.env:CLOUDFLARE_API_TOKEN=your-token CLOUDFLARE_ACCOUNT_ID=your-account-id ALCHEMY_PASSWORD=a-strong-secret
-
Deploy:
bun run deploy
The Worker names are fixed, so the deploy lands on:
- Web:
https://wolfathon.mrdemonwolf.workers.dev - API:
https://wolfathon-api.mrdemonwolf.workers.dev
Infra wires the web app's
NEXT_PUBLIC_SERVER_URLto the API Worker and defaults the server'sCORS_ORIGINto the web URL automatically. - Web:
Two workflows in .github/workflows deploy on every push to main:
- CI (
ci.yml) - type-checks and builds on pull requests and pushes. - Deploy (
deploy.yml) - after CI succeeds onmain, runsbun run deployand smoke-tests the API.
Set these as repository secrets (Settings → Secrets and variables → Actions):
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, ALCHEMY_PASSWORD,
(optionally) CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD, and — for Twitch —
TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET. All Cloudflare resources use
adopt: true so the runner reconciles the live resources without sharing
local Alchemy state. (Turbo strict env mode requires these to be declared as
passThroughEnv in turbo.json — already configured.)
The app itself has no login. Security is enforced at the edge by Cloudflare
Access plus a server-side JWT check. You protect the /dashboard control
panel and the /api/trpc operator API on the web app; the overlays and the
Twitch webhook stay public.
-
In the Cloudflare dashboard, open Zero Trust → Access → Applications and add a Self-hosted application.
-
Set the application paths on the web domain:
wolfathon.mrdemonwolf.workers.dev/dashboardwolfathon.mrdemonwolf.workers.dev/dashboard/*wolfathon.mrdemonwolf.workers.dev/api/trpc/*
Do not add
/api/twitch/callbackhere — Twitch must reach it without an Access login (it is protected by a CSRFstatetoken instead). -
Add a policy that allows only your email (or your team).
-
Copy the team domain (for example
your-team.cloudflareaccess.com) and the application Audience (AUD) tag. -
Put them in
packages/infra/.env(and the repo secrets for CI):CF_ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com CF_ACCESS_AUD=your-application-aud-tag
-
Redeploy with
bun run deploy.
Access enforcement is automatic: a real deploy always enforces it, local
alchemy dev always bypasses it. If CF_ACCESS_TEAM_DOMAIN /
CF_ACCESS_AUD are blank on a deploy, the operator API fails closed and
denies everything.
Overlays poll the public server Worker (state.getPublic, timer.getPublic).
The control panel calls protected procedures on the same-origin /api/trpc
route in the web app, where Cloudflare Access injects the verified identity.
Twitch posts EventSub webhooks to the public server Worker, which verifies the
HMAC and adds time. All three share one D1 database (rewards, timer, and
Twitch secrets live in separate rows). For instant push instead of polling, a
Durable Object plus WebSocket can replace the 2-second refetch later.
wolfathon/
├── apps/
│ ├── web/ # Next.js: /overlay/{timer,rewards,wheel} (OBS), /dashboard, /api/trpc
│ └── server/ # Hono on Cloudflare Workers: public API + Twitch EventSub webhook
├── packages/
│ ├── api/ # tRPC routers, timer/Twitch/wheel/giveaway domain, Access verification
│ ├── db/ # Drizzle schema, D1 client, migrations
│ ├── env/ # Typed environment access
│ ├── ui/ # Shared design system (brand tokens, components)
│ ├── infra/ # Alchemy deploy config (Workers, D1, bindings)
│ └── config/ # Shared TypeScript config
├── .github/workflows/ # CI + Deploy
├── turbo.json
└── package.json
Questions or feedback? Join the community:
- Discord: Join my server
Made with love by MrDemonWolf, Inc.
