Your world, as a tiny pixel town that grows itself.
The things you care about — fitness, films, art, the side project — turn into buildings. Locals show up to live in them: a gym coach here, a film critic there, whoever your world calls for. Give them personalities, plug them into your tools, then share the address. Friends wander in, meet your locals, bump into each other — and learn who you are faster than any profile.
Powered by CORE. Rendered with kaplay. Top-down, browser-native, deterministic from a seed so your town stays your town across logins.
Editing a town is the same as editing a folder. Three steps.
pnpm dlx @redplanethq/town loginPick your CORE host and town server, then authorize in the browser. The
CLI saves a PAT to ~/.town/config.json (mode 0600).
town initThis is the only entry point — it decides what to do by asking the server:
- No town yet? Prompts for a name and creates one. Folder gets
scaffolded at
./<slug>/with the day-zero trio (home / library / store). - Town already exists? Confirms and clones into
./<slug>/— your current buildings, customPlots, and NPC files materialize on disk.
What the folder looks like
<slug>/
town.json ← buildings list + customPlots references
customPlots/ ← one folder per user-defined plot
npcs/ ← one .mdx per NPC (frontmatter = identity, body = prompt)
catalog.json ← slim reference of what's available
manifest.json ← decor sprite reference
AGENTS.md ← orientation for coding agents
Everything lives in <slug>/. You edit JSON + MDX; the server owns layout.
Add, remove, or swap a building
Open <slug>/town.json:
{
"buildings": [
{ "id": "home", "plotKey": "home" },
{ "id": "library", "plotKey": "library" },
{ "id": "store", "plotKey": "store" }
],
"customPlots": []
}- Add a building → append
{ "id": "cafe", "plotKey": "cafe" }. - Remove a building → delete its entry.
- Swap a variant → add
"variantId": "cafe.bookshop". - Rename the sign → add
"label": "Sunny's Café". When omitted the sign falls back toid.toUpperCase().
You never write tile coordinates, paths, ponds, or decor. The server picks a free cell, routes a path from home, refills the surrounding forest. Re-deploy twice and the same edit lands in the same spot — it's seeded.
Add or edit an NPC
NPCs live in <slug>/npcs/. A building can host one NPC per slot —
the variant declares each slot inside catalog.json. The default first
slot is "" and binds to a plain <buildingId>.mdx; named slots use
<buildingId>__<slotId>.mdx.
Find the slots a building supports. Open catalog.json, find the
plot's variant, and read npcSlots:
That variant has two slots; you can author up to two NPCs at this building. The empty-string slot is the default; the others are named.
Single-slot building. Filename matches the building id; no slotId
needed:
---
buildingId: cafe
name: Cosma
description: Barista at the cafe. Knows what you're heads-down on.
---
You are the barista at the town cafe. Greet the player warmly when they
walk in and ask what they're heads-down on today. Reference recent CORE
activity when context is provided. Stay in character, never break the
fourth wall, and keep replies under three sentences.Add an NPC to a specific slot. Filename is <buildingId>__<slotId>.mdx
and the frontmatter pins slotId:
---
buildingId: home
slotId: roommate
name: Linnea
description: Your roommate at home. Always halfway through a novel.
---
You are the roommate at home. Greet the player like family — warm but
unfussy. Bring up books when natural, never lecture. Stay in character;
keep replies under three sentences.If the variant doesn't list the slot id you're trying to use, the
server has nowhere to render it. For a fully custom layout — your own
exterior, interior, and multiple NPC positions — declare the slots in
a customPlots/<id>/plot.json (see Bring your own building below)
and reference the new plotKey: "custom:<id>" from town.json.
Change an NPC's prompt or identity. Open the .mdx and edit:
- The body is the system prompt the LLM reads on every turn — this is where voice and behavior live.
nameis the speaker line on the chat bubble.descriptionis the flavor text shown when the player walks up.
Then run town deploy from the slug folder. The server replaces the
entire NPC roster atomically — no half-deployed state, no orphan rows
left behind from deleted buildings.
Prompt conventions that age well
- Lead with role and place: "You are the barista at the town cafe."
- Anchor the voice in one sentence: tone, what they care about, how they greet.
- Tell the model what context it'll get. If you read CORE signals into the prompt at runtime, say so — "reference recent CORE activity when context is provided".
- Cap length: "keep replies under three sentences". Without this the model drifts long and the chat bubble runs off the screen.
- Lock the frame: "stay in character, never break the fourth wall".
cd <slug>
town deployUploads any new PNGs (see below), then POSTs { buildings, customPlots, npcs } to /api/town. The server diffs against your persisted plot and
runs incremental layout ops — no full regenerations, no churn on
untouched buildings.
If the catalog doesn't have what you want, define a customPlot. Mirror
the catalog's shape: an interior shell + props + one or more exterior
variants. Reference it from town.json as "plotKey": "custom:<id>".
Every sprite field accepts one of three ref types — independently per field — so you can pair an existing catalog exterior with a custom interior, a custom exterior with the catalog's prop set, or any mix:
| Looks like | Means | Source |
|---|---|---|
"exteriors/home/villa-1.png" |
Existing catalog asset | /sprites/catalog/ |
"./exterior.png" |
Local PNG in your customPlot folder | town deploy uploads it |
"sprite:abc123…" |
Previously uploaded asset | /api/sprites/<hash>.png |
Open catalog.json in your folder — exteriorSprites, interiorSprites,
propSprites list every catalog path you can reuse.
Folder layout
<slug>/
customPlots/
record-store/
plot.json
exterior.png ← optional: your own PNG
interior.png ← optional
props/
crate.png ← optional
Example plot.json
{
"id": "record-store",
"label": "Record Store",
"category": "MARKET",
"interior": {
"sprite": "./interior.png",
"widthTiles": 14,
"heightTiles": 13,
"walkable": { "tx": 1, "ty": 2, "w": 12, "h": 9 },
"spawn": { "tx": 7, "ty": 11 },
"exit": { "tx": 7, "ty": 12 },
"props": [
{ "tx": 4, "ty": 3, "sprite": "./props/crate.png" },
{ "tx": 6, "ty": 3, "sprite": "props/lamp-standing.png" }
]
},
"variants": [
{
"id": "record-store.classic",
"exteriorSprite": "./exterior.png",
"npcPositions": [
{ "id": "", "tx": 5, "ty": 4, "label": "shopkeep" },
{ "id": "regular", "tx": 7, "ty": 6, "label": "regular" }
]
}
]
}A variant must declare at least one slot via either npcPosition
(legacy singular) or npcPositions (multi-slot array). The example
above uses npcPositions, so authoring two MDX files — record-store.mdx
for the empty-string slot and record-store__regular.mdx for the named
one — gives your record store both a shopkeep and a regular.
What happens on deploy
The CLI walks every sprite ref, uploads each local PNG to /api/sprites
(PNG-only, 1 MiB cap, content-addressed in Postgres), and rewrites the
ref to sprite:<hash> before sending. Re-deploying is free — hashes that
already exist are no-ops.
Stack
- pnpm + Turbo monorepo (
pnpm@10, Node 20+) - Next.js 16 (App Router) — server routes, OAuth callback, webhooks
- kaplay 3001 — game runtime
- Prisma + Postgres — sessions, plot state, sprite blobs, event log
- BullMQ + Redis — event worker for inbound CORE webhooks
- AI SDK (Anthropic / OpenAI) — plot naming + NPC dialog
Workspace layout
apps/
web/ Next.js app — game, UI, API routes, worker
packages/
catalog/ Shared asset catalog (plots, variants, sprite paths)
plot/ Per-user plot schema + validator + default plot
plot-gen/ Deterministic plot generator + incremental ops
db/ Prisma schema + client (@town/db)
types/ Shared TS types
town-cli/ The `town` CLI documented above
docs/ Design notes (variant taxonomy, etc.)
Each package has its own README — start there when working in one.
Getting started
cp .env.example .env
# Fill DATABASE_URL and the CORE_OAUTH_* vars.
# CORE_OAUTH_CLIENT_ID/SECRET come from POST /api/oauth/clients on
# app.getcore.me — see comments in .env.example for the payload.
pnpm install
pnpm db:migrate --name init
pnpm devThen open http://localhost:3001. .env lives at the repo root;
apps/web/.env is a symlink so Next picks it up, and @town/db scripts
load it via dotenv-cli.
Common commands
| Command | What it does |
|---|---|
pnpm dev |
turbo run dev — starts the web app |
pnpm build |
Production build of every package |
pnpm typecheck |
tsc --noEmit across the monorepo |
pnpm lint |
Lint every package |
pnpm db:migrate |
prisma migrate dev in @town/db |
pnpm db:generate |
Regenerate the Prisma client |
pnpm db:studio |
Open Prisma Studio |
pnpm plot:build-default |
Regenerate the committed default plot |
The event worker (BullMQ consumer for CORE webhooks) runs separately:
pnpm --filter @town/web run workerAuth & CORE integration
CORE OAuth2 + PKCE. The browser only ever sees an opaque session cookie
(core-town:sid); access and refresh tokens live in the Session table.
The town CLI authenticates with a CORE PAT instead — every API route
that accepts cookies also accepts Authorization: Bearer <pat>.
- Client entry points:
apps/web/src/game/auth.ts - Server endpoints:
apps/web/src/app/api/auth/{login,callback,me,logout}/route.ts - CORE wire calls:
apps/web/src/lib/oauth.ts - Session bookkeeping + refresh:
apps/web/src/lib/session.ts
To call CORE from a Route Handler, read the session id from the cookie,
resolve a fresh access token via getAccessTokenForSession(sid), then:
fetch(`${CORE_OAUTH_BASE}/api/v1/...`, {
headers: { authorization: `Bearer ${token}` },
});Inbound webhooks land at /api/events and are HMAC-verified against
TOWN_WEBHOOK_SECRET (X-Town-Signature: sha256(secret, rawBody)).
AGENTS.md— guardrails and quick map for AI agentspackages/catalog/README.md— asset catalog modelpackages/plot/README.md— per-user plot schemapackages/plot-gen/README.md— deterministic generator + incremental opsdocs/variant-catalog-draft.md— variant taxonomy + tone bible

{ "plotKey": "home", "variants": [{ "id": "home.modern-villa", "npcSlots": [ { "id": "", "tx": 8, "ty": 3, "label": "warden" }, { "id": "roommate", "tx": 4, "ty": 5, "label": "roommate" } ] }] }