Java Course Hub
The platform my ICT class runs on. Students practise SQL right in the browser, and the demo sets up a classroom for you to try.
What it is
A classroom platform I built for the Java course I teach: lessons released one day at a time, practised through in-browser games that execute real SQL, with per-activity turn-ins I read to see who's stuck.
Why I built it
Convenience, plainly. I wanted the course I teach — lessons, practice, turn-ins — in one place I control instead of spread across four different tools.
Convenience, not necessity, and it's worth being exact about that: the version of this story with an obstacle in it would be a better story and wouldn't be true. My students' machines are fine. Nothing here was blocked without it. I teach this course and write most of it the night before I deliver it, and I wanted the whole thing in one place I control — lessons I can release a day at a time, practice that opens in a browser tab, turn-ins that land somewhere I can read between classes. The engineering underneath is real. The reason for it is just that it's easier this way.
In the app
Captured from the public demo, which seeds a throwaway cohort per visitor and deletes it within 24 hours. Every person in these frames — the student, the teacher, the classmates — is invented by the seeder.




By the numbers
- lines in the SQL engine
- 1,024
- tests: SQL engine + combat rules
- 59
- game engines on one shell
- 8
- lines of TypeScript
- ~18,500
Counted against the source on 12 August 2026, not estimated.
The engine, in one exchange
This is the shape of every lab in the course: the error is the lesson. The engine reproduces MySQL’s real error codes and wording — the exchange below is its verbatim behaviour, and clearing a “break it on purpose” task means producing exactly this error for real.
sql> SELECT * FROM students WHERE name = Ramon;
Error 1054: Unknown column ‘Ramon’ in ‘where clause’
sql> SELECT * FROM students WHERE name = ‘Ramon’;
1 row in set — task cleared ✓
Decisions that mattered
7 of them. Each opens onto what I did and the reasoning behind it.
A small SQL engine in the browser, graded by the result
What I did, and why
I wrote a 1,024-line parser and interpreter that runs the course's SQL command set in the page — DDL, DML, SELECT with WHERE and ORDER BY, SQL_SAFE_UPDATES — reproducing MySQL's real error codes and wording. Tasks are marked by comparing resulting state against a canonical solution, not by matching query text.
Marking on text means one blessed spelling of a correct answer, which teaches students to guess what the grader wants. Marking on effect means any correct query passes. It also let me build 'break it on purpose' labs: you clear the exercise by producing the same real MySQL error, which you can't fake without understanding it.
The demo builds you a disposable classroom, then proves its own security
What I did, and why
Clicking the demo mints a whole cohort — a teacher, a student for you, three classmates with work already turned in — stamped with one cohort id and swept after 24 hours. I made every teacher-side policy read is_teacher() AND same_cohort(). The seeded submissions are written by those demo accounts themselves, not injected with the service-role key.
A shared demo account is worthless for a classroom app: the first visitor's mess becomes the second visitor's first impression, and you can't show the teacher side without showing someone real. Per-visitor cohorts fix both. Seeding as the users is the part I'd defend hardest. Rows written with a service-role key prove nothing, but rows that had to satisfy the same policies as a real turn-in mean every demo launch re-runs a smoke test of the authorization rules. If the policies break, the demo breaks before a student does. The entrance is an unlisted route, not a button on the login page, because a demo cohort has every lesson released and my own students would have used it to read ahead.
Signup is gated by a database trigger, not a redirect
What I did, and why
I made enrolment invite-only against a teacher-managed class list, enforced by a Postgres trigger that aborts any signup for an unlisted email at the transaction level. Supabase's own 'allow new signups' setting stays on.
It makes the public /register page safe by construction: there's no route, guard, or middleware ordering that can be wrong, because the check isn't in the application. Leaving signups open and letting the database refuse them is what lets allow-listed students register themselves without me minting accounts by hand.
Content is data, not pages
What I did, and why
I made a week a plain object, one file per day, rendered by a single template. Adding a week of material involves no JSX.
I write this course while teaching it, usually the night before. Anything that makes publishing a lesson a code change is something that will eventually stop me publishing the lesson.
Handing work back re-locks the day, against cached progress
What I did, and why
Days release from a single-row course_state table; steps inside a day unlock as work is turned in. When I hand a submission back, that step re-locks even if the student's browser has it cached as complete.
Progress cached client-side is a performance decision that quietly becomes an authority decision. The server has to be able to take a step back, or 'returned for revision' is advisory and the student never sees it.
When a student's upload fails, the error comes to me
What I did, and why
I built an error log into the teacher side. When a turn-in upload fails, the student's browser posts what actually went wrong — where it happened, the error message, the week and day, the browser — to a signed-in API route. It shows up on a class-wide Error Logs page, newest first, and on that student's own submissions page. Students can write their own entries and never read them back; I can read and dismiss them only for my own cohort.
A student who can't upload has no reason to open devtools, and wouldn't recognise the error if they did. Without the log, the only way to learn what broke was to ask them to relay a message they didn't understand. Now I read what their browser reported and diagnose it myself. The report is fire-and-forget: it's never awaited, so a failed log can't break the upload's own error handling, and it keeps the request alive past a navigation, because a student who hits an error usually gives up and clicks away. I scoped it to the upload path, the one that actually failed for a student, and kept it off a global error handler, which would log browser-extension noise as if it were the app's fault.
One submission path, after eight copies of it silently drifted
What I did, and why
Every game used to carry its own copy of the same six lines: build a summary, POST it, set a saved flag, mark the step complete. When I added paste-tracking and a started-at timestamp to that payload, they reached only the three games open that day. No error, no type failure, just a signal I believed was on everywhere and was on for three. I moved the eight games onto one `useTurnIn` hook that builds the payload once, and in the same pass pulled a boss battle's combat rules into a pure `combat.ts` with no React or fetch in it.
A duplicated literal doesn't announce the copies you didn't edit. The bug stayed invisible until I noticed which games' submissions were missing a field, which is a slow way to find out a feature is two-thirds absent. Centralizing the payload means a future field is one edit. Pulling the combat rules into pure functions is what made them testable at all, since the win/loss logic had been spread across three `useState` setters where the only way to check it was to play the fight.
Stack
- Next.js 16 (App Router)
- React 19
- TypeScript (strict)
- Tailwind v4
- Supabase (Postgres + RLS)
- Vercel
- Parsers/interpretersSQL tokeniser, executor, and MySQL-accurate errors
- Postgres RLSsecurity boundary for all student data
- Database triggersinvite-only enrolment enforced in-transaction
- Next.js 16server components, teacher and student route groups
What is honest about it
- I wrote it fast, for a class that had already started. The engine is careful; parts of the surrounding UI aren't.
- The SQL engine and the boss battle's combat rules have test suites; the React components and the turn-in flow itself don't. Their behaviour is verified by a classroom using them, not by CI.
- The error log only covers uploads. The games turn work in through a different path that doesn't report to it, so a game that breaks in a student's browser is invisible to me unless they tell me.
- The demo cohort is disposable by design, so anything you build in it disappears within a day. That's the point, but it does mean you can't come back to your own work.
Want this kind of work on your codebase? Email me and tell me what you’re trying to build.