A gamified learning platform built for Uganda's Competency-Based Curriculum, designed so students keep practising even when school is out and structure disappears.
A few years ago, Uganda rolled out something called the Competency-Based Curriculum — CBC for short. The idea was good: stop making students memorise facts for exams, and start teaching them how to actually think, solve problems, and apply what they know.
But rolling out a new curriculum is easy on paper and messy in practice. Teachers got a new way of teaching with almost no digital tools built to match it — and during school holidays, when structure disappears, students lose whatever momentum they built during the term.
So I asked a simple question: what if learning didn't have to stop when school did? And what if the tool that kept it going actually felt like something a student wanted to open — not a dull revision app, but something closer to a game they'd choose to play?
CBC Learn isn't one experience — it's four. A school administrator, a teacher, and a student open the same platform and see almost entirely different tools, built around what each of them actually needs to get done.
Onboards new schools, manages subscriptions, and keeps an eye on platform-wide usage and content quality.
Registers teachers and students, sets up classes, and tracks how their school is engaging with the platform.
Assigns quizzes, and — if they're an "expert" teacher — writes and reviews the questions other teachers use.
Practises past-paper style questions, earns streaks and badges, and climbs a termly leaderboard with their class.
Most quiz apps let anyone dump in questions and call it content. I didn't want that. Every question on CBC Learn passes through a review flow before a student ever sees it — the same way an actual exam board would work, just smaller.
This is where expert teachers review submitted questions before they go live to students.
Not every teacher can write official questions — that would make quality impossible to control. Instead, each subject has a small group of expert teachers, marked by a single flag on their profile. Only they can move a question from "draft" to "published."
A regular teacher can still submit a question as a draft. It sits in a review queue until an expert either approves it, or sends it back with a comment explaining what needs to change — closer to a lightweight editorial process than a typical CRUD form.
This one small design decision — a boolean flag and a queue — ended up shaping a lot of the platform: it's the difference between "a place where anyone can post a quiz" and "a place where the content is actually trustworthy enough for a school to pay for."
The frontend is Next.js, the backend is NestJS, and PostgreSQL sits underneath both — a stack chosen less for novelty and more because it lets one person keep the whole system in their head.
Subscriptions are modelled per school, not per user — a school pays once and every teacher and student under it gets access. That single decision shaped a lot of the schema, since almost every table needs to trace back to a school for both billing and data isolation.
Marking is spread fairly using a round-robin assignment system: when a batch of student submissions needs grading, the platform rotates them across available teachers instead of dumping the whole pile on whoever's online first.
Auth runs on JWTs, with roles (platform admin, school admin, teacher, student) checked at the route level in NestJS using guards — so a teacher's token simply can't reach a school-admin-only endpoint, regardless of what the frontend shows or hides.
For weeks, users would get logged out mid-session for no obvious reason — not on every request, not for every user, just often enough to be a real problem and rare enough to be hard to catch.
My first assumption was the JWT itself — maybe it was expiring too early, or the refresh logic had an edge case. I spent a day there and found nothing wrong with the token lifetime at all.
The real issue was more subtle: I'd cached the auth token in memory when the app first loaded, on the theory that reading from memory would be faster than reading from storage on every request.
That worked fine — until the token refreshed in the background, or the user had the app open in two tabs. The in-memory copy would silently go stale while the real token had already changed, and the next request would go out with a token the server no longer recognised.
The fix was to stop being clever: read the token fresh from storage on every request instead of caching it. It cost a tiny bit of performance and fixed the bug completely — a good reminder that a "smart" optimisation is only smart if it's also correct.
The biggest lesson wasn't technical — it was learning to design around constraints that don't show up in a textbook: patchy connectivity, shared devices, school procurement cycles, and a curriculum that's still finding its feet. The next step is piloting CBC Learn in a handful of real schools and letting actual usage tell me what I got wrong.