SLAY CITY · EDTECH PLATFORM · NEXT.JS · SUPABASE

32 game task types.
Writing them was the easy part.

A gamified learning platform for a foreign-language school: a content-driven game engine, four role-separated consoles, an AI content pipeline and a progress economy that runs entirely inside Postgres. 238 components, 34 tables, 42 database functions. Built and shipped by two engineers.

Client
Slay City language school
Our role
Architecture, build, delivery
Team
Two senior engineers
32
playable task types
238
React components
34
Postgres tables
42
database functions
66
reviewed migrations
4
role-separated consoles

The constraint

The school had to be able to add content without us.

A learning product is only alive while new material keeps arriving. If every new mission, word list or illustration needs a developer and a deploy, the platform stops growing the week the budget ends.

What it forced

Everything a lesson is made of had to become data.

Districts, locations, missions, tasks, vocabulary and rewards live in Postgres and are authored in an admin console. The code holds the mechanics; the database holds the curriculum. Five engineering problems followed, and they are the interesting part of this project.

What is actually in it.

Four applications on one schema, 42 routes, 16 feature modules. Every item below is shipped and in production.

Student app

  • Teacher-assigned homework and Q&A
  • Installable PWA, playable demo before sign-up

Teacher console

  • AI-drafted content, edited before publish
  • Per-topic Q&A thread with read markers

Parent dashboard

  • Link a student account by email
  • Three interface languages
  • Strictly scoped to linked students only

Admin console

  • Draft and published states for everything
  • Task-type templates with a live tester
  • AI art: map backgrounds, icons, task images

32 task types,
one runner.

Crossword, hangman, word search, spot-the-difference, snake, flashcards, sentence builder, clock reading, analogies: each type is a React component paired with a typed parser that turns a JSON row into a strict model. TaskRunner reads the task type off the row and mounts the right pair.

A malformed row fails in its parser with a typed error instead of crashing a mission halfway through. Puzzle generation (word-search grids, crossword layout, snake boards) sits in pure functions with their own unit tests, separate from rendering.

Adding a 33rd type is one component and one parser. Nothing else in the engine moves.

How one task reaches the screen
mission_tasks row
task_type + JSON content, authored in the console
parseXContent()
validates the shape, returns a typed model
<XTask />
the playable mini-game, mounted by TaskRunner

An economy that cannot be gamed.

XP, coins, streaks and opened locations are computed by SECURITY DEFINER Postgres functions, reached only through Next.js Server Actions. Row level security is on every user table, and the browser is never granted a direct write to progress, stats or the wardrobe.

Purchases, equipping gear, homework completion and study time all take the same route, so there is one place where each rule exists. A stolen anon key still cannot award a single coin.

Streak rolling runs in the one Deno Edge Function, with its date logic split into a dependency-free module so it can be unit tested outside the runtime.

Browser
renders the task
submits the answer
No write access
complete_mission()
  • validates the submission
  • awards XP and coins
  • updates the streak
  • opens the next location
RLS on every table

One Server Action per feature, one Postgres function per mutation.

Four roles,
enforced in the database.

Role checks live in RLS policies, not UI guards. A request that should not be allowed fails at the database, whatever the client believes it is permitted to do. There is no service-role key anywhere in the project, so nothing can bypass them.

Student

Reads and writes nothing outside their own profile and progress. A level opens only once it has published content.

Teacher

Promotion-only: an admin flips an existing account through a dedicated function. Owns groups, topics and their threads.

Parent

Sees exactly the students linked to their account, and nothing about anyone else’s child.

Admin

Self-claimed from an allowlist, never assignable from the client. Authors content and manages every account.

An AI pipeline with a budget.

Location art, task illustrations, flashcard images and homework drafts are generated by server actions through OpenRouter: one model for images, another for structured JSON content. The key never reaches a browser.

Image requests are pinned to a flex queue: the same model at half the price on a best-effort lane, with provider fallbacks left on, so an unavailable queue degrades to the standard tier instead of failing the editor who is waiting. Every generation is logged with the tier that actually served it and what it cost.

Results land in shared cache tables keyed by prompt, so repeated vocabulary and task art costs nothing the second time.

Built to change weekly.

Every merge runs lint, type-check, unit tests and a production build, then applies pending migrations to the database on its own. 66 migrations so far, each one reviewed in a pull request, each one live the moment it merges.

Database types are generated from the schema and committed with the migration that changed it, so a column rename becomes a type error rather than a runtime surprise. Every game-state mutation has tests asserting the server-side validation actually runs.

Shared UI primitives are documented in Storybook with an accessibility addon, and component tests run in a real browser through Playwright.

The stack,
by layer.

Frontend
  • Next.js App Router
  • React 19
  • TypeScript strict
  • Tailwind CSS
  • PWA + service worker
Backend
  • Server Actions
  • Supabase Postgres
  • Row level security
  • SECURITY DEFINER RPCs
  • Deno Edge Function
Auth
  • Supabase Auth
  • Email and password
  • Google OAuth
  • Reset and confirm flows
  • Role-scoped routing
AI
  • OpenRouter
  • Image generation
  • Structured JSON drafting
  • Flex-tier routing
  • Prompt-keyed caches
Quality
  • Vitest
  • Playwright browser tests
  • Storybook + a11y
  • GitHub Actions CI
  • Vercel deploys

Next phase

Native apps on React Native, over the same backend.

The engine, the content model and the server-authoritative rules do not move. The client does. Everything the database decides today it keeps deciding for the mobile apps.

Slay City logo

Building something with this much state to get wrong?

Fixed price · Fixed Scope · Senior only

2 client slots open