quizbase
Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs
by Maciej Dzierżek · published May 15, 2026 · updated Jun 2, 2026 · 45 min read · Beginner
reddit-weekly-trivia-bot hero illustration
Illustration for: Reddit weekly trivia bot tutorial for 2026 API · Generated with Nano Banana, brand style

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=programming for r/gamedev, ?category=film for 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 keyqb_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 the platform:appid:version (by /u/you) convention, exactly as in reddit.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 systemd auto-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 lastSeenCommentId per scoring run and paginate via Reddit’s before parameter.

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.