MVP shipped in six phases, then eight more
Built for
content creators who want a world, not a Discord, for their community
Project
Arcadia
Live multiplayer town square, taverns, coworking tents, an academy with real courses, and the Scribe: an AI course maker that drafts outline, lessons, and images from uploaded material across four approvable stages.
Demo
Live beta. Open the door and walk the square; enrolment is free.
How it works
01
Monorepo: Next.js web app on Vercel, Colyseus game server on Railway, shared package for protocol types.
02
Rooms auto-shard at 20 clients and filter by building, so three taverns with the same art stay socially distinct.
03
The Scribe streams Gemini output in four stages (outline, lessons, images, seal) and only writes real course rows on seal.
04
An AI guide NPC shipped in Phase 11 and was retired in Phase 13 for a static welcome: zero recurring cost, ADR recorded.
05
Every phase has an ADR and a changelog; 298 commits with the decision trail intact.
Screenshots



Stack and code
- Next.js
- TypeScript
- Phaser
- Colyseus
- Supabase
- Gemini 2.5 + Imagen 4
- TipTap
- Vercel
- Railway
- vitest
scribe.ts— Server actions for the AI course maker: satchel, streaming stages, seal.
'use server';
// Server actions for the AI course maker ("the scribe").
//
// Phase 10 sub-phase layering:
// 10.2 · satchel — getOrCreateDraft, uploadDraftSource,
// removeDraftSource, updateDraftPrompt
// 10.3 · streaming — streamOutline, streamLesson, generateImage
// 10.7 · seal — sealDraft (materialize into courses rows)
//
// Every action returns a discriminated Result so the client can
// surface errors without guessing.
import { randomUUID } from 'crypto';
import { revalidatePath } from 'next/cache';
import type {
CourseDraft,
DraftImage,
DraftLessonBody,
DraftOutlineSection,
DraftSource,
DraftStage,
} from '@/lib/types/course-drafts';
import {
allImagesApproved,
allLessonsApproved,
canAdvance,
totalSourceChars,
} from '@/lib/types/course-drafts';
import type { CreatorPreferencesInput } from '@/lib/types/creator-preferences';
import { AUDIENCE_MAX, IMAGE_STYLE_MAX, VOICE_GUIDE_MAX } from '@/lib/types/creator-preferences';
import { getSupabaseAdminClient } from '@/lib/supabase/admin';
import { isSupportedMime, parseSourceBuffer } from '@/lib/scribe/parse';
import { getSupabaseServerClient } from '@/lib/supabase/server';
type Result<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors migration
const MAX_FILES_PER_DRAFT = 5;
const MAX_TOTAL_CHARS_PER_DRAFT = 600_000; // ADR 0012
const USER_PROMPT_MAX = 2_000;
async function requireCreator(): Promise<
Result<{ readonly userId: string; readonly realmId: string }>
> {
const supabase = getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: 'not signed in' };
const { data: membership } = await supabase
.from('memberships')
.select('realm_id, role')
.eq('member_id', user.id)
.maybeSingle();
if (!membership?.realm_id) return { ok: false, error: 'no realm membership' };
if (membership.role !== 'creator' && membership.role !== 'admin') {
return { ok: false, error: 'only creators can conjure courses' };
}
return { ok: true, value: { userId: user.id, realmId: membership.realm_id } };
}
function rowToDraft(row: Record<string, unknown>): CourseDraft {
return {
id: row.id as string,
creator_id: row.creator_id as string,
realm_id: row.realm_id as string,
title: (row.title as string | null) ?? null,
user_prompt: (row.user_prompt as string | null) ?? '',
stage: (row.stage as DraftStage) ?? 'satchel',
outline: (row.outline as CourseDraft['outline']) ?? [],
sources: (row.sources as CourseDraft['sources']) ?? [],
lessons: (row.lessons as CourseDraft['lessons']) ?? [],
images: (row.images as CourseDraft['images']) ?? [],
tokens_used: (row.tokens_used as number) ?? 0,
sealed_course_id: (row.sealed_course_id as string | null) ?? null,
created_at: row.created_at as string,
updated_at: row.updated_at as string,
};
}
/**
* Returns the creator's current unsealed draft, or creates a fresh
* one if none exists. Used by /dashboard/courses/conjure on page
* load — lets the creator resume wherever they left off.
github/arcadia/apps/web/app/_actions/scribe.ts
RealmRoom.ts— Colyseus room that syncs avatars, chat, XP, and building filters.
import { Room, type AuthContext, type Client } from 'colyseus';
import { MSG, RealmRoomState } from '@arcadia/shared';
import { supabaseAdmin } from '../lib/supabase-admin';
import { authenticateJoin, type AuthSupabase } from './realm-auth';
import { getRoomConfig } from './room-config';
import {
applyMove,
applySetFocus,
applySetJukebox,
applyStartPomodoro,
applyStopPomodoro,
applyUpdateLevel,
createAvatarState,
parseBuildingPayload,
tickPomodoro,
type AuthInfo,
} from './realm-handlers';
// Phase 2 Step 2 — state + handlers wired. onAuth (JWT validation) lands in
// Step 3; until then this class relies on onAuth returning AuthInfo-shaped
// data (tests construct the AuthInfo directly).
export class RealmRoom extends Room<RealmRoomState> {
// 20-cap auto-shard (decided 2026-04-22): once a room hits 20 occupants,
// Colyseus's matchmaker routes new joiners to the next room with the same
// filter (or creates a fresh one). Applies to world / tavern / coworking.
// See `docs/changelog/2026-04-22_image-backed-world.md` for the reasoning.
override maxClients = 20;
override onCreate(_options: unknown): void {
this.setState(new RealmRoomState());
const { bounds } = getRoomConfig(this.roomName);
this.onMessage(MSG.MOVE, (client, payload) => {
const avatar = this.state.avatars.get(client.sessionId);
if (!avatar) return;
if (!applyMove(avatar, payload, bounds)) {
console.warn(`[${this.roomName}] rejected MOVE from ${client.sessionId}`);
}
});
this.onMessage(MSG.UPDATE_LEVEL, (client, payload) => {
const avatar = this.state.avatars.get(client.sessionId);
if (!avatar) return;
if (!applyUpdateLevel(avatar, payload)) {
console.warn(`[${this.roomName}] rejected UPDATE_LEVEL from ${client.sessionId}`);
}
});
this.onMessage(MSG.ENTER_BUILDING, (client, payload) => {
const building = parseBuildingPayload(payload);
if (!building) return;
console.log(`[${this.roomName}] ${client.sessionId} ENTER_BUILDING ${building}`);
});
this.onMessage(MSG.LEAVE_BUILDING, (client, payload) => {
const building = parseBuildingPayload(payload);
if (!building) return;
console.log(`[${this.roomName}] ${client.sessionId} LEAVE_BUILDING ${building}`);
});
// Phase 12 — coworking productivity messages. Only meaningful in
// the coworking-realm1 room type, but the handlers are harmless
// in other rooms (state.jukebox + state.pomodoro exist on every
// RealmRoomState — they just go unused outside the tents).
this.onMessage(MSG.SET_JUKEBOX, (client, payload) => {
const avatar = this.state.avatars.get(client.sessionId);
if (!avatar) return;
applySetJukebox(this.state.jukebox, payload, avatar.memberId, Date.now());
});
this.onMessage(MSG.START_POMODORO, (client, payload) => {
const avatar = this.state.avatars.get(client.sessionId);
if (!avatar) return;
applyStartPomodoro(this.state.pomodoro, payload, avatar.memberId, Date.now());
});
this.onMessage(MSG.STOP_POMODORO, () => {
applyStopPomodoro(this.state.pomodoro);
});
this.onMessage(MSG.SET_FOCUS, (client, payload) => {
const avatar = this.state.avatars.get(client.sessionId);
if (!avatar) return;
applySetFocus(avatar, payload);
});
// Server tick — once per second, advance pomodoro phases when
github/arcadia/apps/game-server/src/rooms/RealmRoom.ts
README.md— Status and what is live, phase by phase.
# Arcadia
Browser-based 2.5D isometric virtual world platform for content creators and their communities.
Each community (**Realm**) gives members an avatar, a space to gather (**Tavern**), a course viewer (**Academy**), and a course catalogue (**Market**).
---
## Status
🚀 **MVP shipped 2026-04-21; post-MVP feature work continued 2026-04-22 → 25.** The original 6-phase build (0–5) in `/docs/mvp/phase-plan.md` is verified on prod. Eight extension phases shipped on top: world rendering rebuild (image-backed), Phase 8 UI wire-up, Phase 9 feed + events, Phase 10 AI scribe, Phase 11 sage + UI legibility audit (the AI portion was retired in Phase 13 in favour of a static welcome — see ADR 0017), Phase 12.A coworking productivity, Phase 13 sage as static welcome, Phase 14 persistent player HUD. 15 of 17 Phase-5 steps landed in code; the two remaining (60 FPS measurement + demo-cut rehearsal) are manual QA before the demo recording.
**What's live at `arcadia-web-swart.vercel.app`:**
- **`/`** — role-aware hub (World / Market / Dashboard).
- **`/world`** — image-backed town square, Colyseus-synced on `world-realm1` (auto-shards at 20 clients). Walk off any edge to the neighbour: N→`/academy-outside`, E→`/tavern-outside`, S→`/market`, W→`/coworking`. Capacity HUD top-right. *(2026-04-22: replaced the ADR-0007 Tiled square with this image-backed scene.)*
- **`/academy-outside`** — single-player outdoor area. Walk up to the gate, press ENTER → `/academy`.
- **`/tavern-outside`** — single-player outdoor area. Three distinct tavern doors (The Three Ravens / The Iron Chalice / The Sleeping Hollow); ENTER opens the matching tavern (`?b=tavern-a/b/c`).
- **`/coworking`** — single-player outdoor area. Five tents; ENTER enters the matching tent (`?b=tent-1..5`).
- **`/coworking/inside?b=<id>`** — tent interior, multiplayer. Colyseus `coworking-realm1` with `filterBy(['building'])` — members in different tents never meet even with same interior art. Auto-shards at 20 clients.
- **`/tavern?b=<id>`** — image-backed bar, Tab-to-chat with speech bubbles, live XP leaderboard. Same interior visuals across all three tavern buildings; `filterBy(['building'])` keeps the social spaces distinct.
- **`/academy`** — Phaser course hall with walkable podiums.
- **`/academy/[courseId]`** — YouTube IFrame Player (resume + 80% completion) + `react-markdown` lessons (scroll-to-complete).
- **`/market`** — Phaser interior with a central crystal. Walk to the crystal + ENTER → fullscreen four-stall dashboard overlay (Courses · Templates · Tools · Exclusives). Picker is a 3-up top row + a wide gilt-haloed Exclusives card on the bottom. **Courses** are real DB rows (published + same-realm via RLS) and use the existing `enrolInCourse` server action. **Templates / Tools / Exclusives** are simulated fixtures (mix of free + priced); ownership is in-memory, no real payments. Pricing displays as `$X`. Overlay header is just **✕ close** (closing returns the player to the Phaser scene; logout lives on the dashboards). See [PR #59–#61 changelog trail](docs/changelog/2026-05-02_market-and-audio-polish.md).
- **`/dashboard`** — creator studio (role-gated). 6 tabs: studio · courses · events · folk · payouts · settings. Scriptorium-styled per Phase 8.
- **`/dashboard/courses/[id]`** — drag-reorder section/lesson tree + TipTap WYSIWYG lesson editor (Phase 10) + YouTube editor + publish toggle + Analytics pill. Replaced the old `@uiw/react-md-editor` split-pane.
- **`/dashboard/courses/conjure`** *(Phase 10 — the Scribe)* — staged AI course maker. Satchel accepts PDFs/DOCX/TXT/MD as grounding context; Gemini drafts outline → lesson bodies → images across four approvable streaming stages; seal materializes real courses + sections + lessons rows. Per-creator "scribe's memory" (voice, image style, audience) persists across drafts.
- **`/dashboard/events`** *(Phase 9)* — schedule + manage live events. KPI strip + live/upcoming/past sections; events drive the tavern's "live now" banner + YouTube stage overlay.
- **`/dashboard/courses/[id]/analytics`** — enrolment count, completion rate, active-in-7d, recent activity.
- **The Wanderer (`/world`)** *(Phase 11 → revised Phase 13)* — bearded merchant on the rug in the top-left of the square. Walk near him + ENTER → "welcome, traveller" panel + 4 flip cards (Academy / Square / Tavern / Coworking — Tavern replaced Dashboard in PR #39 as the more member-relevant surface). Static; no AI calls (ADR 0017 supersedes 0014).
- **Tavern feed** *(Phase 9)* — tablet on the back wall of every tavern; ENTER opens an async feed showing creator posts + scheduled events. Live events get a verdigris banner + stage embed.
- **Gamification** — lesson completion → +25 XP via DB trigger → level recomputes → banner animates → peer badges sync via Colyseus `UPDATE_LEVEL`.
**Video host is YouTube unlisted** (ADR 0006, demo-only scope — swap to a real host required before paying creators).
**Beyond the MVP plan:**
- 2026-04-22 (morning): world swap to orthogonal top-down Tiled square (ADR 0007 implemented; see [changelog](docs/changelog/2026-04-22_world-swap-orthogonal-square.md)).
- 2026-04-22 (evening): image-backed world + 3 outdoor neighbour scenes + per-building Colyseus sharding + capacity HUD (see [changelog](docs/changelog/2026-04-22_image-backed-world.md)).
- 2026-04-23: shipped to prod with extensive polish — ENTER-key gates, archway-proximity exits, Georgia-serif nameplate, door-aware returns, zoom/walk-speed tuning, Phaser physics-body fix (ADR 0008). Full write-up in [changelog](docs/changelog/2026-04-23_image-backed-world-complete.md).
- 2026-04-24: **Phase 8 · UI wire-up** — every React surface rebuilt on the midnight-scriptorium design system (tokens + primitives + simulation toggle + 26-item kit-backfill pass across dashboard / academy / market / login / landing). See [changelog](docs/changelog/2026-04-24_phase-08-ui-wireup.md). Public `/kit` renders the live design system; `/preview/*` routes render every dashboard tab against fixtures for design review without a session.
github/arcadia/README.md