Adding a whole new content category
Items, NPCs, spells, and shops are each a ContentStore — a
generic id→record collection backed by one Prisma model. Adding a sixth
category (this page uses Quests as a worked example)
follows the same pattern. Unlike the behavior registries, this genuinely
does touch a few engine-side files — a new content type needs a database
table, which can't be purely runtime-pluggable.
This is a worked example, not a shipped feature. MS6 does not include a Quests system — the steps below show the pattern you'd follow, using a plausible new content type as the illustration.
The pattern, step by step
Prisma schema. Add a model and run a migration:
// packages/server/prisma/schema.prisma
model GameQuest {
id Int @id
name String @db.VarChar(128)
description String @db.Text
giverNpcId Int @default(0)
rewardExp Int @default(0)
rewardItem Json
}
pnpm --filter @ms6/server db:generate
pnpm --filter @ms6/server db:push
Shared type. Define the in-memory record shape:
// packages/shared/src/types/quest.ts
export interface QuestRec {
id: number;
name: string;
description: string;
giverNpcId: number;
rewardExp: number;
rewardItem: { itemId: number; quantity: number };
}
Export it from packages/shared/src/types/index.ts.
Descriptor + store. One ContentDescriptor
(translating between the Prisma row and QuestRec), then
wire a ContentStore into ContentManager:
// packages/server/src/content/descriptors.ts (add alongside the existing 4)
export const questDescriptor: ContentDescriptor<QuestRec, GameQuest> = {
idOf: (rec) => rec.id,
fromRow: (row) => ({
id: row.id, name: row.name, description: row.description,
giverNpcId: row.giverNpcId, rewardExp: row.rewardExp,
rewardItem: row.rewardItem as unknown as QuestRec['rewardItem'],
}),
toRow: (rec) => ({
name: rec.name, description: rec.description, giverNpcId: rec.giverNpcId,
rewardExp: rec.rewardExp, rewardItem: rec.rewardItem as object,
}),
prismaDelegate: asContentDelegate(prisma.gameQuest),
};
// packages/server/src/content/ContentManager.ts
private readonly questStore = new ContentStore(questDescriptor);
// ...in loadFromDb(): await this.questStore.loadFromDb();
getQuest(id: number) { return this.questStore.get(id); }
getAllQuests() { return this.questStore.getAll(); }
async saveQuest(q: QuestRec) { await this.questStore.save(q); }
maxQuestId() { return this.questStore.maxId(); }
There's no automatic JSON seeding to wire up — a fresh database
starts with zero quests, same as every other content type, and
they're created through the admin editor from there.
ContentStore.seedFromJsonFile() is still available if
you want to ship optional starter content for a type, but nothing
calls it automatically; see Getting
Started for how the shipped demo content is distributed instead
(a plain SQL import, not JSON auto-seeding).
REST endpoints for the standalone editor, mirroring the existing item/NPC/spell/shop routes:
// packages/server/src/routes/content.ts
router.get('/quests', (_req, res) => res.json(ContentManager.getAllQuests()));
router.put('/quests/:id', async (req, res) => {
const access = await getAccess(req);
if (!requireDev(access, res)) return;
const quest: QuestRec = { ...req.body, id: parseInt(req.params.id) };
await ContentManager.saveQuest(quest);
broadcastToLiveRoom(ServerPacket.UpdateQuest, { quest }); // if you want a live-update push
res.json(quest);
});
// ...router.post('/quests', ...) for creating new ones
Seed file (optional): content/quests.json, an array of QuestRec objects — picked up automatically on first boot against an empty database, same as the built-in types.
Editor UI. Add packages/editor/src/editors/QuestEditor.ts
following the same list→edit-page shape as
ItemEditor.ts or ShopEditor.ts (the
simplest existing examples to copy from) — a
render<X>Editor(root, records[]) function with a
search/list view and a save-form view, calling a typed
api.saveQuest() you add to packages/editor/src/api.ts.
Wire it into the sidebar nav in packages/editor/src/main.ts.
Gameplay behavior (only if quests need to actually
do something at runtime — award exp/items on completion,
etc.): a new QuestSystem under
packages/server/src/systems/, constructed the same way
as the existing systems (new QuestSystem(this) in
GameRoom.onCreate()) and wired into
registerHandlers() for whatever new
ClientPacket/ServerPacket entries it needs.
That's 4–5 files with one clear pattern to copy from an existing content type, plus an optional system if the content is more than static data — a smaller lift than it might look, since steps 3–4 are almost entirely mechanical once you've looked at how items/NPCs/spells/shops already do it.