Build an NPC shop
An NPC shopkeeper who talks first and trades second, combining npcInteract, a dialogue and a vendor with a purse that can run dry.
You will build /stall, which puts a grocer in front of you. Press use on him and he greets you; from the conversation you can open his trade screen or ask whether he is buying, and he says so when his 400-coin purse runs low.
What you will learn
Section titled “What you will learn”- Spawning an interactable, invulnerable NPC with
Npc.create. - Reacting to the use key with
npcInteract. - Running a multi-page conversation with
Dialogue, shared safely with other resources. - Creating a
Vendorwith stock, buy prices and a purse, and reacting tovendorTrade. - Placing and facing an entity with positions and rotations.
market-stall/ package.json tsconfig.json from Use TypeScript src/server/ index.ts /stall, npcInteract, cleanup place.ts a spot in front of the player wares.ts prices and purse stall.ts keeper + vendor pairs, vendorTrade talk.ts the conversationNPCs, dialogues and vendors do not know about each other: the stall is the glue you write. The default gamemode has the halves separately, in src/server/commands/vendor.ts and src/server/commands/dialogue.ts.
1. Write the manifest
Section titled “1. Write the manifest”It is all server code.
{ "name": "market-stall", "version": "1.0.0", "scripts": { "build": "tsc -p tsconfig.json" }, "devDependencies": { "@kingdomsconnected/types": "latest", "typescript": "^5.9.2" }, "mafiahub": { "serverScripts": ["dist/server/index.js"] }}2. Find a spot in front of the player
Section titled “2. Find a spot in front of the player”The stall goes a couple of metres ahead of whoever typed the command, with the keeper turned to face them. The world is Z up and an unrotated entity faces +Y.
/** `distance` metres ahead of the player on the ground, turned back to face them. */export function inFrontOf(player: Player, distance: number): { position: Vector3; rotation: Quaternion } { const facing = (player.rotation as Quaternion).rotateVector(new Vector3(0, 1, 0)); const length = Math.hypot(facing.x, facing.y); const aheadX = length < 1e-4 ? 0 : facing.x / length; const aheadY = length < 1e-4 ? 1 : facing.y / length; const from = player.position; return { position: new Vector3(from.x + aheadX * distance, from.y + aheadY * distance, from.z), rotation: Quaternion.fromAxisAngle(new Vector3(0, 0, 1), Math.atan2(aheadX, -aheadY)), };}3. Set the prices
Section titled “3. Set the prices”Prices are in money units (the game’s money item). Item names are the game’s own, the same ones player.giveItem takes.
export const STOCK: VendorStockRow[] = [ { item: "bread", amount: 20, price: 30 }, { item: "apple", amount: 30, price: 10 }, { item: "beer", amount: 12, price: 45 },];
/** What he pays for. Anything not listed shows at no value and cannot be sold to him. */export const BUYS: VendorBuyRow[] = [ { item: "apple", price: 4 }, { item: "carrot", price: 3 },];
/** All he can pay out. Deals keep it current: what players pay goes in, what they are paid comes out. */export const PURSE = 400;4. Open the stall
Section titled “4. Open the stall”A stall is two ids, the keeper’s NPC id and the vendor id, kept together in one map. It is looked up by keeper when somebody presses use, and by vendor when a deal settles.
import { BUYS, PURSE, STOCK } from "./wares.js";
export interface Stall { readonly keeper: number; readonly vendor: number;}
const stalls = new Map<number, Stall>();
export function openStall(position: Vector3, rotation: Quaternion): Npc { const keeper = Npc.create({ soul: "townsman", position, rotation, name: "Ondra the grocer", interactable: true, nametag: true, invulnerable: true, }); const vendor = Vendor.create({ name: `stall ${keeper.id}`, purse: PURSE, buys: true }); Vendor.setStock(vendor, STOCK); Vendor.setBuyPrices(vendor, BUYS); stalls.set(keeper.id, { keeper: keeper.id, vendor }); return keeper;}
export function stallOf(keeper: number): Stall | undefined { return stalls.get(keeper);}
/** Takes down every stall: the vendor first, which closes anyone's open trade screen. */export function closeAll(): number { const count = stalls.size; for (const stall of stalls.values()) { Vendor.destroy(stall.vendor); Npc.getById(stall.keeper)?.remove(); } stalls.clear(); return count;}
export function installTrading(): void { Events.on("vendorTrade", (vendor, player, bought, sold, balance) => { const stall = [...stalls.values()].find((candidate) => candidate.vendor === vendor); const keeper = stall && Npc.getById(stall.keeper); if (!keeper) return;
const purse = Vendor.getPurse(vendor) ?? 0; if (purse < 20) keeper.say("That is the last of my coin. I am buying nothing more today."); else if (balance < 0) keeper.say(`Enjoy it, ${player.nickname}.`); else keeper.say("Pleasure doing business.");
console.log(`${player.nickname} bought ${bought.length} line(s), sold ${sold.length}, balance ${balance}, purse now ${purse}`); });}interactable: trueis what makes pressing use on the keeper raisenpcInteractat all.invulnerable: truekeeps a bored customer from killing the shop.- What players sell does not join the vendor’s stock. To resell it, call
Vendor.setStockinvendorTrade; its lines name items by GUID initemand by game name inname.
5. Write the conversation
Section titled “5. Write the conversation”A conversation belongs to a player, so the map is keyed by player id and each entry remembers its stall. Dialogue.update swaps the page inside the same session instead of opening a new one.
import type { Stall } from "./stall.js";
interface Talk { readonly session: number; readonly stall: Stall;}
const talks = new Map<number, Talk>();
function greeting(): DialoguePage { return { line: "Fresh bread, apples, beer. What will it be?", onRight: false, options: [ { id: "trade", text: "Show me your wares.", enabled: true }, { id: "purse", text: "Are you buying today?", enabled: true }, { id: "leave", text: "Nothing, thanks.", enabled: true }, ], };}
function purseAnswer(stall: Stall): DialoguePage { const coin = Vendor.getPurse(stall.vendor) ?? 0; return { line: coin > 0 ? `Apples and carrots. I have ${coin} coins left for them.` : "Not today. My purse is empty.", onRight: false, options: [ { id: "trade", text: "Then let us trade.", enabled: true }, { id: "leave", text: "Another time.", enabled: true }, ], };}
export function startTalk(player: Player, stall: Stall): void { const session = Dialogue.open(player.id, greeting()); if (session !== 0) talks.set(player.id, { session, stall });}
export function endAllTalks(): void { for (const talk of talks.values()) Dialogue.close(talk.session); talks.clear();}
export function installTalk(): void { Events.on("dialogueChoice", (session, player, optionId) => { const talk = talks.get(player.id); // Another resource's conversation, or an answer to one that already ended. if (!talk || talk.session !== session) return;
if (optionId === "trade") { Dialogue.close(session); Vendor.open(talk.stall.vendor, player.id, talk.stall.keeper); } else if (optionId === "purse") { Dialogue.update(session, purseAnswer(talk.stall)); } else { Dialogue.close(session); } });
Events.on("dialogueClosed", (session, player) => { if (talks.get(player.id)?.session === session) talks.delete(player.id); });}- Typing each page as
DialoguePagewithline,onRightandenabledspelled out keeps the compiler happy: the contract requires them even though the runtime treats them as optional. - Choices come back by option id, never by index, so a rebuilt page still routes correctly.
- The
talk.sessioncheck is what lets this resource sharedialogueChoicewith every other resource.
6. Wire it up
Section titled “6. Wire it up”The entry point connects the command, the use key and cleanup.
import { inFrontOf } from "./place.js";import { closeAll, installTrading, openStall, stallOf } from "./stall.js";import { endAllTalks, installTalk, startTalk } from "./talk.js";
const RESOURCE = "market-stall";
installTrading();installTalk();
Events.on("playerCommand", (player, command, args) => { if (command !== "stall") return;
if (args[0] === "clear") { Chat.sendToPlayer(player, `Closed ${closeAll()} stall(s).`); return; } // A body with no pose yet would put the stall at the world origin. if (!player.ready) { Chat.sendToPlayer(player, "Your position has not reached the server yet. Try again in a moment."); return; } const { position, rotation } = inFrontOf(player, 2.5); const keeper = openStall(position, rotation); Chat.sendToPlayer(player, `${keeper.name} has set up shop. Walk up to him and press use.`);});
Events.on("npcInteract", (npc, player) => { const stall = stallOf(npc.id); if (!stall) return; npc.lookAt(player); startTalk(player, stall);});
// Vendors, conversations and NPCs outlive the resource that made them.Events.on("resourceStop", (name) => { if (name !== RESOURCE) return; endAllTalks(); closeAll();});- Without the
resourceStophandler, every reload leaves behind a mute grocer whose shop nobody can open: stopping a resource removes its handlers and timers, not its vendors, conversations or NPCs. - No
playerDisconnecthandler is needed. A disconnect closes the player’s conversation (reason 3) and trade screen (reason 4), anddialogueClosedtidies the map.
Try it
Section titled “Try it”Build the resource and run ensure market-stall in the server console. In game:
/stall. Ondra appears in front of you, facing you.- Walk up and press use. He turns to you and the conversation opens.
- Pick “Are you buying today?”, then “Then let us trade.” The game’s trade screen opens with Ondra as the trader.
- Buy a loaf. He thanks you by name over his head.
- With the default gamemode running,
/give apple 150, then sell him apples until his purse gives out. Ask him again and he says it is empty. /stall clearremoves him.
Next steps
Section titled “Next steps”- Refill his purse on a timer with
Vendor.setPurse, or restock what players sold him invendorTrade. See Shops (vendors). - Try another role than
townsmanfrom Spawn NPCs. - Tie a purchase to a journal entry with Quests in the journal.
- Make NPCs move and talk on their own in Script an NPC cutscene.