Case study · Guild RPG · October 2026
Guild RPG is a multiplayer play-by-post role-playing game where an AI runs the game. Claude Code built it from a written specification in about ten working days, from a phone. In 240 commits, bugs were rare and quick to find. It is live in production. This page shows what made that possible: a framework, asasvirtuais, and one instructions file, AGENTS.md, that tells any coding agent where each piece of code goes.
~10
working days, Oct 1–12
240
commits by the agent
19
tables, all read and written through one CRUD interface
50
commits that changed the database schema
48
commits that changed authorization rules
5
structural refactors, each done in a day or less
This is not a small demo. It has an in-game economy, roles and permissions, approval workflows and AI calls throughout. Apps with these parts are usually the first to become hard to change.
SPECS.md describes the product in plain words. PLAN.md splits it into phases, and the agent builds each phase in one or a few commits. Design work was entirely optional: when the spec didn't say how a screen should look, the agent asked, and the answer was added to the spec.
The framework already decides how data, forms and permissions are written, so the only questions left for the human were about the product: what it does and what each screen shows.
A few times, the human changed a rule of the framework itself: one folder per component, each table's permission rules in its own middleware, pages that fetch on the server and pass their rows down. Each time, the agent applied the new rule to the whole app in a day or less.
Bugs were extremely rare. When one appeared, it was obvious which file it was in: a table's rules are in its middleware, its data shape in its schema, its writes in its forms. The agent went straight to that file instead of searching the whole codebase.
When an agent works on a codebase for weeks, it usually starts solving the same problem in different ways, and the code gets harder to change. Below are ten problems of that kind that this app never had. Each one shows what the code does instead, with trimmed code from the app.
Instead: Each table has one zod schema, and it serves as the TypeScript type, the validation and the form fields. readable lists what the app reads, writable what a client may send. When a column changes, every hook, form and middleware that uses it gets the new type automatically.
50 commits changed the database schema in ten days. Splitting the store's items into cards and owned copies renamed tables across the whole economy, in a single commit.
packages/memberships/schema.ts
// packages/memberships/schema.ts
export const roles = ['owner', 'staff', 'counselor', 'emissary', 'member'] as const
export const readable = z.object({
id: z.string(),
guild: z.string(),
user: z.string(),
role: z.enum(roles),
status: z.enum(['pending', 'active', 'rejected', 'removed']),
treasure: z.number().int(),
created: z.date(),
updated: z.date(),
})
// What a client may send
export const writable = readable.pick({
guild: true, user: true, role: true, status: true,
}).partial({ role: true, status: true })
export const schema = { readable, writable }
export type Readable = z.infer<typeof readable>
Instead: There is one CRUD interface: find, list, create, update, remove. Each operation passes the call to the middleware of the table it is for. Any other table is refused. To review security, you read this one file and one middleware file per table. There are no scattered endpoints to find.
Adding a table to the app means adding one line to this switch.
app/actions.ts
// app/actions.ts: the only way into the database
'use server'
function middleware(table?: string) {
switch (table) {
case 'users': return users
case 'guilds': return guilds
case 'memberships': return memberships
case 'characters': return characters
case 'missions': return missions
// … one line per table, 19 in all
default: throw new Error('Forbidden')
}
}
const crud = makeSchemaTableInterface(schema, null, {
find: async (props) => middleware(props.table).find(props),
list: async (props) => middleware(props.table).list(props),
create: async (props) => middleware(props.table).create(props),
update: async (props) => middleware(props.table).update(props),
remove: async (props) => middleware(props.table).remove(props),
})!
export const find = action(crud.find)
export const list = action(crud.list)
export const create = action(crud.create)
export const update = action(crud.update)
export const remove = action(crud.remove)
Instead: Writes go through the framework's forms: CreateForm, UpdateForm, FilterForm. A flag or a role is an UpdateForm with one field. The saved row goes into a shared, in-memory index of the table's rows, and every component showing that table updates from it. Nobody keeps a useState copy of a row that can go out of date.
Changing a member's role from a select updates the members list. No reload and no hand-written optimistic update.
members/component.tsx · packages/memberships/forms.tsx
// A member's role, changed from a select: one column, one form
<UpdateMembership id={membership.id}>
{(change) => (
<select
value={membership.role}
disabled={change.loading}
onChange={(e) => change.callback({ role: e.target.value as Role })}
>
{assignable(actor).map((role) => (
<option key={role} value={role}>{roleLabel[role]}</option>
))}
</select>
)}
</UpdateMembership>
// packages/memberships/forms.tsx
export function RemoveMembership({ children }) {
const { single: membership } = useMembership()
return (
<UpdateMembership id={membership.id} defaults={{ status: 'removed' }}>
{children}
</UpdateMembership>
)
}
Instead: Each table has one middleware.ts, next to its schema, with every rule for that table: who can read rows (find, list), and what is checked before and done after each write (create, update, remove). To know who may change a membership, you read the memberships middleware. Nothing else.
48 commits added roles, permissions, approvals and review queues. Each change was made in the middleware of the table it was about.
packages/memberships/middleware.ts
// packages/memberships/middleware.ts: its shape, rules left out
export const memberships = makeSchemaTableInterface({ memberships: schema }, 'memberships', {
find: async (props) => {
const row = await db.find(props)
// read rules: may this user see this row?
return row
},
list: async (props) => {
const rows = await db.list(props)
// read rules: keep the rows this user may see
return rows
},
create: async (props) => {
// pre-flight: who may write, and is the data valid?
const row = await db.create(props)
// side effects: notifications, records that follow
return row
},
update: async (props) => {
// pre-flight
const row = await db.update(props)
// side effects
return row
},
remove: forbidden,
})!
Instead: Each page runs on the server, fetches the rows its components show and passes them down. Components read those rows and filter them. They don't fetch anything when they appear. Every save goes through a form that updates the shared rows, so nothing on screen goes stale and no cache needs clearing.
One commit, Pages fetch on the server and hand rows down with asAbove, moved the whole app to this pattern.
guild/[slug]/members/page.tsx · components/members
// app/(app)/guild/[slug]/members/page.tsx: a Server Component
export default async function GuildMembersPage({ params }) {
// … the guild, and the viewer's permission to manage members
const memberships = ok(await list({ table: 'memberships', query: { guild: guild.id, status: { $in: ['active', 'pending'] } } }))
const users = ok(await list({ table: 'users', query: { id: { $in: memberships.map((row) => row.user) } } }))
return (
<MembershipsProvider asAbove={index(memberships)}>
<UsersProvider asAbove={index(users)}>
<Applications />
<MembersList />
</UsersProvider>
</MembershipsProvider>
)
}
// components/members/component.tsx: reads the table, never fetches
export function MembersList() {
const { single: guild } = useGuild()
const { single: actor } = useMembership()
const { array } = useMemberships()
const members = array.filter((m) => m.guild === guild.id && m.status === 'active')
return members.map((membership) => (
<MembershipProvider key={membership.id} id={membership.id}>
<UserProvider id={membership.user}>
<MemberItem actor={actor.role} />
</UserProvider>
</MembershipProvider>
))
}
Instead: A table's middleware does three things only: it checks who may do the operation and what was sent, it writes, and it runs what must follow the write. Everything else (AI generation, previews, calls to outside services) is its own server action, kept next to the component that uses it. It returns a result and saves nothing. A form saves the result, through the same CRUD as everything else.
The CRUD stayed small and predictable while the AI features changed constantly. The default text model alone changed six times in ten days, a few lines each time.
components/describe (trimmed)
// components/describe/actions.tsx: business logic, outside the CRUD
'use server'
export const draftCharacter = action(async (params) => {
// check who is asking
const draft = await generateObject({ /* task, prompt, schema */ })
return draft // returned, not saved: a form saves it
})
// components/describe/component.tsx: the form that saves it
<CreateCharacter>
{(character) => (
<Draft action={draftCharacter} onResult={(draft) => character.setFields(draft)}>
{/* … */}
</Draft>
)}
</CreateCharacter>
Instead: When an operation could be split into steps, the steps ran on the client. Each step is a small action nested inside the form that saves the record, and its result fills the form (onResult). The user sees each result as soon as it is ready, can change or redo it, and one ordinary save writes the record.
Creating a mission shows the AI's draft first, then its cover, instead of one long request that ends with everything or nothing. Steps stayed on the server only where an interruption could leave a record in a stale state.
missions/new/components/form/component.tsx
// missions/new: the AI steps nested inside the form that saves
<CreateMission
defaults={{ guild: guild.id, title: '', premise: '', goal: '', cover: null }}
onSuccess={(mission) => router.push(`/mission/${mission.id}`)}
>
{(mission) => (
<ActionProvider
action={drawCover}
params={{ title: mission.fields.title, premise: mission.fields.premise }}
onResult={(cover) => mission.setField('cover', cover)}
>
{(cover) => (
<>
{/* 1. The AI drafts the mission into the form */}
<PromptFill
action={(prompt) => draftMission({ prompt, guild: guild.id })}
onResult={(draft) => mission.setFields({ ...mission.fields, ...draft })}
/>
<TitleField />
<PremiseField />
{/* 2. The AI draws the cover into the form */}
<button onClick={cover.submit} disabled={cover.loading}>Draw Cover</button>
{/* 3. One write, through the CRUD */}
<button onClick={mission.submit} disabled={mission.loading}>Create Mission</button>
</>
)}
</ActionProvider>
)}
</CreateMission>
Instead: New code has a fixed place before it is written. Every table gets a package with the same files as every other table. Every page keeps its components in components/, one folder per component, with its server actions in the same folder. Each agent session started with no memory of the last one, and each one still put code in the same places.
19 table packages with the same files. Read one, and you know where everything is in the others.
packages/ · app/
packages/memberships/ one table, one directory
schema.ts readable / writable
middleware.ts read rules, pre-flight, side effects
providers.tsx MembershipsProvider, MembershipProvider
hooks.tsx useMemberships(), useMembership()
forms.tsx UpdateMembership, RemoveMembership
fields.tsx inputs for the forms
app/(app)/mission/[id]/ one route
page.tsx fetches its rows, provides its tables
components/
brief/component.tsx one directory per component
closing/component.tsx
closing/actions.tsx its server actions, next to it
engine/actions.tsx
engine/hooks.tsx
participants/component.tsx
Instead: Every server action is wrapped in action(). In production, Next.js hides the message of a thrown error, so action() returns the message instead and the client throws it again. Every form shows errors the same way. Checks fail with Forbidden or Unauthorized, and the player sees that message.
One small file handles errors for every action in the app.
lib/action.ts · app/providers.tsx
// lib/action.ts: every server action is wrapped the same way
export function action(work) {
return async (...params) => {
try {
return await work(...params)
} catch (error) {
unstable_rethrow(error) // redirects and notFound() still throw
return { error: error.message } // production would hide the message
}
}
}
// app/providers.tsx: the client throws it again, so every form shows it
const crud = unwrap(actions)
<InterfaceProvider {...crud}>{children}</InterfaceProvider>
// in any form
{order.error && <p>{order.error.message}</p>}
Instead: Keeping the docs current is one of the rules, so the agent updates them in the same commit as the code. SPECS.md says what the product is, in plain words, and every rule in the code can be traced back to it. PLAN.md says what comes next, CHANGELOG.md what changed, and the in-app What's New tells players.
After ten days, the specification still matches the app in production.
SPECS.md (excerpt)
### 3.3 Approvals
Anything that needs approval is created as a row in a waiting state,
on the table where it will eventually live. Approving it moves that
same row to its final state, and rejecting it moves it to `rejected`.
There is no separate approvals table.
| What | Row created as | On approval |
|-------------|-----------------------|-----------------------------|
| New mission | mission `pending` | mission `open` |
| Card order | card `requested` | `offered` to the player |
| Sheet edit | revision `pending` | the sheet is replaced |
An architecture is really tested when the product changes its mind. Each of these structural changes was done in a day or less, while new features kept shipping.
The point
A multi-tenant app with guilds, roles and permissions, an AI game master, a card economy and approval workflows: 19 tables and 240 commits in about ten working days.
All of it maintained by two things: the framework's intelligent design and machine intelligence. The framework decided where every piece of code goes, the agents wrote it, and the architecture never broke.