Skip to content

RedPlanetHQ/town

Repository files navigation

town

town

Your world, as a tiny pixel town that grows itself.

An overworld screenshot of a town named Tenkai — three buildings nestled in a dense forest, signpost reading 'Tenkai · Harshith Mullapudi · 2 miles'.

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.


Make it yours

Editing a town is the same as editing a folder. Three steps.

1. Log in

pnpm dlx @redplanethq/town login

Pick your CORE host and town server, then authorize in the browser. The CLI saves a PAT to ~/.town/config.json (mode 0600).

2. Create or clone

town init

This 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

3. Edit

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 to id.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:

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

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.
  • name is the speaker line on the chat bubble.
  • description is 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".

4. Deploy

cd <slug>
town deploy

Uploads 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.


Bring your own building

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.


Hack on the repo

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 dev

Then 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 worker
Auth & 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)).

Where to read next

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors