Skip to content

Tutorial: RPG Basics

Build the essential systems behind an RPG:

  • Inventory with stackable items and drag-and-drop UI
  • Equippable gear that modifies player stats
  • NPC interaction with branching dialogue
  • A simple quest system
  • Persistent save/load with slot selection

Estimated time: 90 minutes
Difficulty: Intermediate

1. Item Data

Items are defined as plain data (no entity needed until they're in the world):

typescript
// src/data/items.ts
export type ItemId = string;

export interface ItemDef {
  id: ItemId;
  name: string;
  description: string;
  icon: string; // path to 32×32 icon sprite
  stackable: boolean;
  maxStack: number;
  slot?: 'weapon' | 'armor' | 'accessory';
  stats?: Partial<PlayerStats>;
}

export const ITEMS: Record<ItemId, ItemDef> = {
  'health-potion': {
    id: 'health-potion',
    name: 'Health Potion',
    description: 'Restores 30 HP.',
    icon: 'items/potion-red.png',
    stackable: true,
    maxStack: 99,
  },
  'iron-sword': {
    id: 'iron-sword',
    name: 'Iron Sword',
    description: '+15 Attack.',
    icon: 'items/sword-iron.png',
    stackable: false,
    maxStack: 1,
    slot: 'weapon',
    stats: { attack: 15 },
  },
  'leather-armor': {
    id: 'leather-armor',
    name: 'Leather Armor',
    description: '+10 Defense.',
    icon: 'items/armor-leather.png',
    stackable: false,
    maxStack: 1,
    slot: 'armor',
    stats: { defense: 10 },
  },
};

2. Inventory Component

typescript
// src/components/inventory.ts
import { defineComponent } from '@web-engine-dev/ecs';
import type { ItemId } from '../data/items.js';

export interface InventorySlot {
  itemId: ItemId | null;
  quantity: number;
}

export const Inventory = defineComponent('Inventory', {
  slots: [] as InventorySlot[], // fixed-size grid
  size: 24, // number of slot squares
});

export const Equipment = defineComponent('Equipment', {
  weapon: null as ItemId | null,
  armor: null as ItemId | null,
  accessory: null as ItemId | null,
});

3. Inventory Manager (Resource)

typescript
// src/resources/inventory-manager.ts
import { defineResource } from '@web-engine-dev/resources';
import { ITEMS, type ItemId } from '../data/items.js';
import type { InventorySlot } from '../components/inventory.js';

export const InventoryManager = defineResource('InventoryManager', {
  addItem(slots: InventorySlot[], itemId: ItemId, quantity = 1): boolean {
    const def = ITEMS[itemId];
    if (!def) return false;

    if (def.stackable) {
      // Find existing stack
      const existing = slots.find((s) => s.itemId === itemId && s.quantity < def.maxStack);
      if (existing) {
        existing.quantity = Math.min(existing.quantity + quantity, def.maxStack);
        return true;
      }
    }
    // Find empty slot
    const empty = slots.find((s) => s.itemId === null);
    if (!empty) return false; // inventory full
    empty.itemId = itemId;
    empty.quantity = quantity;
    return true;
  },

  removeItem(slots: InventorySlot[], itemId: ItemId, quantity = 1): boolean {
    const slot = slots.find((s) => s.itemId === itemId && s.quantity >= quantity);
    if (!slot) return false;
    slot.quantity -= quantity;
    if (slot.quantity === 0) slot.itemId = null;
    return true;
  },
});

4. Dialogue System

typescript
// src/data/dialogues.ts
export interface DialogueNode {
  id: string;
  speaker: string;
  text: string;
  choices?: DialogueChoice[];
  next?: string; // if no choices, auto-advance to this node
  action?: string; // trigger a named game event
}

export interface DialogueChoice {
  text: string;
  next: string;
  condition?: string; // variable name that must be truthy
  action?: string;
}

export const DIALOGUES: Record<string, DialogueNode[]> = {
  'merchant-intro': [
    {
      id: 'start',
      speaker: 'Merchant',
      text: 'Greetings, traveler! What can I get you?',
      choices: [
        { text: 'Show me your wares.', next: 'shop' },
        { text: 'Tell me about the dungeon.', next: 'lore' },
        { text: 'Goodbye.', next: 'end' },
      ],
    },
    {
      id: 'shop',
      speaker: 'Merchant',
      text: 'Ah yes, take a look!',
      action: 'open-shop',
      next: 'end',
    },
    {
      id: 'lore',
      speaker: 'Merchant',
      text: 'The dungeon to the east is cursed... many have entered, none returned.',
      next: 'end',
    },
    { id: 'end', speaker: '', text: '' }, // sentinel
  ],
};
typescript
// src/resources/dialogue-runner.ts
import { defineResource } from '@web-engine-dev/resources';
import { DIALOGUES, type DialogueNode } from '../data/dialogues.js';
import { Signals } from '@web-engine-dev/signals';

export const DialogueRunner = defineResource('DialogueRunner', {
  active: false,
  treeId: '' as string,
  currentNodeId: 'start' as string,
  onAction: null as ((action: string) => void) | null,

  start(this: typeof DialogueRunner, treeId: string, onAction?: (a: string) => void) {
    this.active = true;
    this.treeId = treeId;
    this.currentNodeId = 'start';
    this.onAction = onAction ?? null;
  },

  current(this: typeof DialogueRunner): DialogueNode | null {
    return DIALOGUES[this.treeId]?.find((n) => n.id === this.currentNodeId) ?? null;
  },

  advance(this: typeof DialogueRunner, choiceIndex?: number) {
    const node = this.current();
    if (!node) return;
    if (node.action) this.onAction?.(node.action);
    const nextId = node.choices ? node.choices[choiceIndex ?? 0]?.next : node.next;
    if (!nextId || nextId === 'end') {
      this.active = false;
      return;
    }
    this.currentNodeId = nextId;
  },
});

5. Dialogue UI Component

typescript
// src/ui/dialogue-box.ts
import { UIPlugin, UINode, bindSignal } from '@web-engine-dev/ui';
import { DialogueRunner } from '../resources/dialogue-runner.js';

export function createDialogueBox(world: typeof World): UINode {
  const runner = world.getResource(DialogueRunner);

  return {
    type: 'panel',
    style: {
      position: 'absolute',
      bottom: '24px',
      left: '10%',
      width: '80%',
      background: 'rgba(0,0,0,0.85)',
      borderRadius: '8px',
      padding: '16px',
    },
    visible: bindSignal(() => runner.active),
    children: [
      {
        type: 'text',
        style: { color: '#ffd700', fontWeight: 'bold', marginBottom: '8px' },
        content: bindSignal(() => runner.current()?.speaker ?? ''),
      },
      {
        type: 'text',
        style: { color: '#fff', marginBottom: '12px' },
        content: bindSignal(() => runner.current()?.text ?? ''),
      },
      {
        type: 'flex',
        style: { gap: '8px', flexDirection: 'column' },
        children: bindSignal(() =>
          (runner.current()?.choices ?? [{ text: 'Continue', next: '' }]).map((c, i) => ({
            type: 'button',
            content: c.text,
            onClick: () => runner.advance(i),
            style: { background: '#333', color: '#fff', padding: '6px 12px', borderRadius: '4px' },
          }))
        ),
      },
    ],
  };
}

6. Quest System

typescript
// src/data/quests.ts
export interface Quest {
  id: string;
  name: string;
  description: string;
  objectives: QuestObjective[];
  rewardItems?: Array<{ itemId: string; quantity: number }>;
  rewardXp?: number;
}

export interface QuestObjective {
  id: string;
  description: string;
  type: 'collect' | 'kill' | 'reach';
  target: string; // item id, enemy type, or location name
  required: number;
  current: number;
}

export const QUESTS: Record<string, Quest> = {
  'gather-herbs': {
    id: 'gather-herbs',
    name: "Herbalist's Request",
    description: 'Collect 5 healing herbs for the herbalist.',
    objectives: [
      {
        id: 'herbs',
        description: 'Collect healing herbs',
        type: 'collect',
        target: 'healing-herb',
        required: 5,
        current: 0,
      },
    ],
    rewardItems: [{ itemId: 'health-potion', quantity: 3 }],
    rewardXp: 50,
  },
};
typescript
// src/resources/quest-log.ts
import { defineResource } from '@web-engine-dev/resources';
import { QUESTS, type Quest } from '../data/quests.js';

export const QuestLog = defineResource('QuestLog', {
  active: [] as Quest[],
  completed: [] as string[], // quest ids

  accept(this: typeof QuestLog, questId: string) {
    if (this.completed.includes(questId)) return;
    if (this.active.find((q) => q.id === questId)) return;
    const quest = structuredClone(QUESTS[questId]);
    if (quest) this.active.push(quest);
  },

  progress(this: typeof QuestLog, type: string, target: string, amount = 1) {
    for (const quest of this.active) {
      for (const obj of quest.objectives) {
        if (obj.type === type && obj.target === target) {
          obj.current = Math.min(obj.current + amount, obj.required);
        }
      }
    }
  },

  complete(this: typeof QuestLog, questId: string) {
    this.active = this.active.filter((q) => q.id !== questId);
    this.completed.push(questId);
  },

  isComplete(this: typeof QuestLog, questId: string): boolean {
    const quest = this.active.find((q) => q.id === questId);
    if (!quest) return false;
    return quest.objectives.every((o) => o.current >= o.required);
  },
});

7. Save & Load

typescript
// src/save.ts
import { SaveManager } from '@web-engine-dev/save';
import type { World } from '@web-engine-dev/ecs';
import { Player, PlayerStats, Inventory, Equipment } from './components/index.js';
import type { InventorySlot } from './components/inventory.js';
import type { ItemId } from './data/items.js';
import type { Quest } from './data/quests.js';
import { QuestLog } from './resources/quest-log.js';
import { GameFlags } from './resources/game-flags.js';

interface GameSaveData {
  stats: PlayerStats;
  inventory: InventorySlot[];
  equipment: { weapon: ItemId | null; armor: ItemId | null; accessory: ItemId | null };
  quests: { active: Quest[]; completed: string[] };
  flags: Record<string, boolean>;
}

export const saveManager = new SaveManager<GameSaveData>({
  backend: 'localStorage',
  version: 1,
  maxSlots: 3,
});

export async function saveGame(world: World, slotIndex: number, name: string): Promise<void> {
  const q = world.query().with(Player, PlayerStats, Inventory, Equipment).build();
  const [first] = [...world.run(q)];
  const [, stats, inv, equip] = first!.components;
  const ql = world.getResource(QuestLog);

  await saveManager.save(
    slotIndex,
    {
      stats,
      inventory: inv.slots,
      equipment: equip,
      quests: { active: ql.active, completed: ql.completed },
      flags: world.getResource(GameFlags).flags,
    },
    { name }
  );
}

export async function loadGame(world: World, slotIndex: number): Promise<void> {
  const result = await saveManager.load(slotIndex);
  if (!result.success || !result.data) return;
  const data = result.data;

  const pq = world.query().with(Player).build();
  const [first] = [...world.run(pq)];
  const entity = first!.entity;

  world.insert(entity, PlayerStats, data.stats);
  world.insert(entity, Inventory, { slots: data.inventory, size: 24 });
  world.insert(entity, Equipment, data.equipment);

  const ql = world.getResource(QuestLog);
  ql.active = data.quests.active;
  ql.completed = data.quests.completed;
  world.getResource(GameFlags).flags = data.flags;
}

What to Try Next

  • Combat system, turn-based or real-time using the ECS system ordering
  • Procedural dungeon generation using @web-engine-dev/procgen
  • NPC schedules, NPCs that move between locations at different times of day
  • Crafting recipes, combine two inventory items into a new one

Continue with the Puzzle Game Tutorial to learn grid-based state management.

Proprietary software. All rights reserved.