Reddit weekly trivia bot tutorial for 2026 API#
A weekly trivia thread is the highest-engagement community ritual for game-dev (and adjacent) subreddits. Tuesday post asks the question, Thursday post reveals the answer with a leaderboard of first-correct commenters. The subreddit gets recurring activity, your bigger project (the game you’re actually building) gets a soft promotion in the bot’s signature line. This guide ships the bot in ~120 lines of Node — no third-party Reddit SDK, just the OAuth2 script-app flow over native fetch + node-cron.
We skip snoowrap on purpose: the most-cited Reddit wrapper for Node hasn’t shipped a release in years and drags in dependencies that fight modern Node. Talking to Reddit’s REST API directly is ~30 lines of auth code, and it never rots out from under you. This guide is current as of 2026: you’ll register a script app, authenticate with the OAuth2 password grant, and run the bot on your own infrastructure (Reddit doesn’t host this for you). Cost: ~$5/mo for a small VM + Reddit API base tier (free for personal-use bots under the standard rate limit).
What you’ll build#
A Reddit bot that:
- Tuesday 10am UTC — posts a new thread in your target subreddit titled “Weekly Trivia: $TOPIC”
- Reads top-level comments for the next 48 hours, scores them by first-correct
- Thursday 4pm UTC — replies to the thread with the answer + a leaderboard of top 5 commenters
- No-repeat dedup — tracks question IDs in a JSON file so the subreddit never sees the same question twice
- Per-question attribution — Source / Author / License surfaced in the post body
- Configurable category —
?tags=programmingfor r/gamedev,?category=filmfor r/movies
The mechanic — week-long round + stable IDs#
The bot has two cron jobs (Tuesday post + Thursday reveal) and an on-disk round.json holding { id, correctAnswer, postId, scores }. Tuesday it fetches GET /api/v1/questions/random, posts to Reddit, saves the round to disk (in case the bot restarts mid-week). Wednesday-Thursday it polls the thread’s comments, scoring the first-correct per user.
The stable QuizBase id is persisted across the week — if the bot reboots Wednesday at 3am, it reads round.json from disk, knows it’s mid-round, knows which question is currently live. Without the stable ID we’d lose state on every restart. See /docs/api/questions-by-id for the full stable-ID pattern catalogue.
Stack#
- Node.js 20+ (global
fetch,Buffer,URLSearchParams— no polyfills) + node-cron + fs/promises for the JSON store - Reddit app credentials — register a script app at reddit.com/prefs/apps, owned by your bot account
- QuizBase publishable key —
qb_pk_*, free tier covers weekly cadence trivially - Small VM — DigitalOcean droplet, Railway worker, Fly machine. Needs to run 24/7 to make the cron triggers fire.
Step 1 — Reddit app credentials + dependencies#
Go to reddit.com/prefs/apps, scroll to “Are you a developer? create an app…”, choose script type. For a script app, set the redirect URI to http://localhost:8080 (unused by the password grant, but the form requires one). You get a client ID (the string under the app name) and a client secret.
mkdir reddit-trivia && cd reddit-trivia
npm init -y
npm install node-cron dotenv .env:
REDDIT_USER=yourbotaccount
REDDIT_PASS=password
REDDIT_CLIENT_ID=abc123
REDDIT_CLIENT_SECRET=secret456
SUBREDDIT=gamedev
QUIZBASE_KEY=qb_pk_... The bot account is a normal Reddit account you create — give it some karma first (post a couple times manually) so it’s not shadowbanned for fresh-account behaviour. If the account has 2FA enabled, append the current 6-digit code to the password as password:123456 — which is why a dedicated bot account without 2FA is simpler for an unattended cron.
Step 2 — Reddit OAuth2 client (no SDK)#
A script app authenticates with the password grant: HTTP Basic auth (client_id:client_secret) against https://www.reddit.com/api/v1/access_token, which returns a bearer token valid for one hour. All API calls then go to https://oauth.reddit.com with that bearer. This little module caches the token and refreshes it automatically when it’s about to expire.
// reddit.ts — minimal OAuth2 client for a Reddit "script" app, zero deps
const USER_AGENT = 'weekly-trivia-bot/1.0 by /u/yourbotaccount';
let token: { value: string; expiresAt: number } | null = null;
async function getToken(): Promise<string> {
if (token && Date.now() < token.expiresAt - 60_000) return token.value;
const basic = Buffer.from(
`${process.env.REDDIT_CLIENT_ID}:${process.env.REDDIT_CLIENT_SECRET}`
).toString('base64');
const res = await fetch('https://www.reddit.com/api/v1/access_token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT
},
body: new URLSearchParams({
grant_type: 'password',
username: process.env.REDDIT_USER!,
password: process.env.REDDIT_PASS! // append ":123456" TOTP if the account has 2FA
})
});
if (!res.ok) throw new Error(`Reddit auth failed: ${res.status} ${await res.text()}`);
const json = (await res.json()) as { access_token: string; expires_in: number };
token = { value: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 };
return token.value;
}
// Thin wrapper: prefixes the oauth host, attaches the bearer + User-Agent, throws on non-2xx.
export async function redditFetch<T = any>(path: string, init: RequestInit = {}): Promise<T> {
const bearer = await getToken();
const res = await fetch(`https://oauth.reddit.com${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${bearer}`,
'User-Agent': USER_AGENT
}
});
if (!res.ok) throw new Error(`Reddit ${path} failed: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
} Step 3 — QuizBase fetch + round state#
// state.ts
import fs from 'node:fs/promises';
const STATE_FILE = './round.json';
export interface Round {
id: string;
correctAnswer: string;
choicesText: string; // pre-formatted A/B/C/D string
postId: string; // Reddit "fullname", e.g. "t3_abc123"
scores: Record<string, number>; // username → wins this week
}
export async function loadState(): Promise<Round | null> {
try {
return JSON.parse(await fs.readFile(STATE_FILE, 'utf-8'));
} catch {
return null;
}
}
export async function saveState(round: Round | null) {
if (!round) {
await fs.unlink(STATE_FILE).catch(() => {});
return;
}
await fs.writeFile(STATE_FILE, JSON.stringify(round, null, 2));
} // quizbase.ts
const KEY = process.env.QUIZBASE_KEY!;
const seenIds = new Set<string>(); // load from disk on startup if you want long-term dedup
export async function fetchUnique(retries = 3) {
for (let i = 0; i < retries; i++) {
const r = await fetch(
`https://quizbase.runriva.com/api/v1/questions/random?category=general-knowledge`,
{ headers: { 'X-API-Key': KEY } }
);
const { data } = (await r.json()) as {
data: Array<{
id: string;
text: string;
correctAnswer: string;
incorrectAnswers: string[];
attribution: { author: string; license: string };
}>;
};
const q = data[0];
if (q && !seenIds.has(q.id)) {
seenIds.add(q.id);
return q;
}
}
throw new Error('No fresh question after 3 retries');
} Step 4 — Tuesday: post the trivia thread#
POST /api/submit with kind: 'self' creates a self-post; the response’s json.data.name is the new thread’s fullname (t3_…), which we persist for scoring and the Thursday reveal.
import cron from 'node-cron';
import { redditFetch } from './reddit';
import { fetchUnique } from './quizbase';
import { saveState } from './state';
cron.schedule('0 10 * * 2', async () => {
// Tuesday 10am UTC
const q = await fetchUnique();
const choices = [q.correctAnswer, ...q.incorrectAnswers].sort(() => Math.random() - 0.5);
const choicesText = choices.map((c, i) => `${'ABCD'[i]}. ${c}`).join('\n\n');
const body = `**Weekly Trivia**
${q.text}
${choicesText}
Reply with the letter (A/B/C/D). First correct answer per user counts. Reveal Thursday 4pm UTC.
---
*Source: ${q.attribution.author} (${q.attribution.license})*
*Trivia content from [QuizBase](https://quizbase.runriva.com) — open API, free tier for personal use.*`;
const result = await redditFetch<{ json: { data: { name: string } } }>('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
sr: process.env.SUBREDDIT!,
kind: 'self',
title: `Weekly Trivia — ${new Date().toLocaleDateString()}`,
text: body,
api_type: 'json'
})
});
await saveState({
id: q.id,
correctAnswer: q.correctAnswer,
choicesText,
postId: result.json.data.name, // "t3_…"
scores: {}
});
console.log(`Posted to r/${process.env.SUBREDDIT}: ${result.json.data.name}`);
}); The Source: ... line + the QuizBase backlink in the post body are required. CC BY-SA / CC BY / MIT compliance — see /data. Skipping these puts your bot at risk of moderator removal for “uncredited content”.
Step 5 — Continuously score comments#
Reddit’s comment listings aren’t pushed, so we poll every 15 minutes. GET /comments/{id} returns a two-element array — [postListing, commentsListing]; we only care about top-level (t1) children. First correct per user counts:
import { loadState, saveState } from './state';
cron.schedule('*/15 * * * *', async () => {
const round = await loadState();
if (!round) return; // No active round
const id = round.postId.replace(/^t3_/, '');
const [, commentsListing] = await redditFetch<
[unknown, { data: { children: Array<{ kind: string; data: { author: string; body: string } }> } }]
>(`/comments/${id}?limit=200&depth=1`);
// Recover the shuffled choices actually shown to users (strip the "A. " prefix)
const choices = round.choicesText.split('\n\n').map((s) => s.slice(3));
const correctIndex = choices.indexOf(round.correctAnswer);
const correctLetter = 'abcd'[correctIndex];
for (const child of commentsListing.data.children) {
if (child.kind !== 't1') continue; // skip "more" / non-comment nodes
const { author, body } = child.data;
if (author === '[deleted]' || round.scores[author]) continue;
const lower = body.trim().toLowerCase();
if (lower === correctLetter || lower === round.correctAnswer.toLowerCase()) {
round.scores[author] = (round.scores[author] ?? 0) + 1;
}
}
await saveState(round);
}); Step 6 — Thursday: reveal + leaderboard#
POST /api/comment with thing_id set to the thread’s fullname posts the reveal as a top-level reply.
cron.schedule('0 16 * * 4', async () => {
// Thursday 4pm UTC
const round = await loadState();
if (!round) return;
const top = Object.entries(round.scores)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([user, score], i) => `${i + 1}. /u/${user} — ${score}`)
.join('\n');
const replyBody = `**Answer:** ${round.correctAnswer}
**This week's leaderboard:**
${top || '(No correct answers this week)'}
---
Next round Tuesday. Trivia from [QuizBase](https://quizbase.runriva.com).`;
await redditFetch('/api/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ thing_id: round.postId, text: replyBody, api_type: 'json' })
});
await saveState(null); // Clear round
console.log('Reveal posted, week reset.');
}); Pitfalls#
- Reddit shadowbans aggressive bot behaviour — your bot account needs natural karma history (post some genuine comments before launch). Fresh accounts posting bot output get filtered out of the subreddit’s view automatically.
- Set a real
User-Agent— Reddit blocks generic ones with HTTP 429. Use theplatform:appid:version (by /u/you)convention, exactly as inreddit.ts. - Subreddit moderators must approve bots — message the mods of your target sub explaining what the bot does before launching. Most game-dev subs are bot-friendly with permission.
- Bot uptime requirement — cron jobs only fire if the process is running. Use a VM with
systemdauto-restart or a managed worker (Fly Machines, Railway Worker tier). - 15-minute scoring poll means rapid back-to-back comments after the Tuesday post might be missed if your bot crashes mid-poll. Solution: track
lastSeenCommentIdper scoring run and paginate via Reddit’sbeforeparameter.
What next#
- Multi-subreddit deployment — same bot in r/movies, r/history, r/gamedev with different
?category=per subreddit - Persistent all-time leaderboard — track wins across weeks in a SQLite file, surface monthly champions
- Weekly digest cross-post — auto-cross-post the Thursday reveal to your own subreddit / Discord / Slack
- Pair with Slack/Discord variant — same trivia content, different community surface
Ready to ship? Grab a free publishable key, register a Reddit script app, copy the snippets, deploy to a small VM. Bug in a question? A Reddit-side POST /api/v1/report integration is one extra cron job — listen for /u/yourbot !report mentions and forward the report by question ID.