Skip to main content

Smithing System

The smithing system implements OSRS-accurate smelting and smithing mechanics with manifest-driven recipes, tick-based timing, and auto-smithing support.
Smithing code lives in packages/shared/src/systems/shared/interaction/SmithingSystem.ts and uses recipes from ProcessingDataProvider.

Overview

Smithing is a two-step process:
  1. Smelting - Combine ores at a furnace to create bars
  2. Smithing - Use bars at an anvil to create equipment
Both steps:
  • Use tick-based timing (4 ticks = 2.4 seconds per action)
  • Support “Make X” functionality (auto-craft multiple items)
  • Are 100% success rate (except iron ore smelting)
  • Grant Smithing XP per item created

Smelting (Furnaces)

How to Smelt

  1. Have ores in your inventory
  2. Click a furnace
  3. Select bar type from the interface
  4. Choose quantity (1, 5, 10, X, All)
  5. System auto-smelts until out of materials

Smelting Requirements

// From ProcessingDataProvider.ts
interface SmeltingRecipe {
  barItemId: string;        // Output bar ID (e.g., "bronze_bar")
  barName: string;          // Display name
  oreRequirements: {        // Input ores
    [oreId: string]: number;
  };
  levelRequired: number;    // Smithing level needed
  xp: number;               // XP per bar
  successRate: number;      // 1.0 = 100%, 0.5 = 50%
  ticks: number;            // Ticks per smelt (default 4)
}

Smelting Recipes

BarLevelOres RequiredXPSuccess RateTicks
Bronze11 Copper + 1 Tin6.25100%4
Iron151 Iron Ore12.550%4
Steel301 Iron Ore + 2 Coal17.5100%4
Mithril501 Mithril Ore + 4 Coal30100%4
Adamant701 Adamantite Ore + 6 Coal37.5100%4
Rune851 Runite Ore + 8 Coal50100%4
Iron ore has a 50% failure rate when smelting. Failed attempts consume the ore but grant no bar or XP. This matches OSRS mechanics.

Iron Smelting Failure

// From SmeltingSystem.ts
if (recipe.successRate < 1.0) {
  const roll = Math.random();
  if (roll >= recipe.successRate) {
    // Failed smelt - consume ore, no bar, no XP
    this.emitTypedEvent(EventType.UI_MESSAGE, {
      playerId,
      message: SMITHING_CONSTANTS.MESSAGES.IRON_SMELT_FAIL,
      type: "error",
    });
    
    // Still consume the ore
    this.consumeOres(playerId, recipe.oreRequirements);
    
    // Schedule next smelt
    this.scheduleNextSmelt(playerId);
    return;
  }
}

Smithing (Anvils)

How to Smith

  1. Have bars in your inventory
  2. Have a hammer in your inventory (required, not consumed)
  3. Click an anvil
  4. Select item from the interface
  5. Choose quantity (1, 5, 10, X, All)
  6. System auto-smiths until out of bars

Smithing Requirements

interface SmithingRecipe {
  itemId: string;           // Output item ID (e.g., "bronze_sword")
  name: string;             // Display name
  barType: string;          // Input bar ID (e.g., "bronze_bar")
  barsRequired: number;     // Bars consumed per item
  levelRequired: number;    // Smithing level needed
  xp: number;               // XP per item
  category: string;         // UI category (sword, hatchet, pickaxe, etc.)
  ticks: number;            // Ticks per smith (default 4)
}

Smithing Recipes

Weapons:
ItemLevelBarsXPCategory
Bronze Dagger11 Bronze12.5dagger
Bronze Sword41 Bronze12.5sword
Bronze Scimitar52 Bronze25scimitar
Iron Sword191 Iron25sword
Steel Sword341 Steel37.5sword
Mithril Sword541 Mithril50sword
Adamant Sword741 Adamant62.5sword
Rune Sword891 Rune75sword
Armor:
ItemLevelBarsXPCategory
Bronze Platebody185 Bronze62.5platebody
Iron Platebody335 Iron125platebody
Steel Platebody485 Steel187.5platebody
Mithril Platebody685 Mithril250platebody
Tools:
ItemLevelBarsXPCategory
Bronze Hatchet11 Bronze12.5hatchet
Bronze Pickaxe11 Bronze12.5pickaxe
Iron Hatchet161 Iron25hatchet
Iron Pickaxe161 Iron25pickaxe
Steel Hatchet311 Steel37.5hatchet
Steel Pickaxe311 Steel37.5pickaxe

Tick-Based Timing

Both smelting and smithing use 4-tick actions (2.4 seconds):
// From SmithingConstants.ts
export const SMITHING_CONSTANTS = {
  DEFAULT_SMELTING_TICKS: 4,  // 4 ticks = 2.4s
  DEFAULT_SMITHING_TICKS: 4,  // 4 ticks = 2.4s
  TICK_DURATION_MS: 600,      // 600ms per tick
};

Session Processing

// From SmithingSystem.ts
update(_dt: number): void {
  const currentTick = this.world.currentTick ?? 0;

  // Only process once per tick
  if (currentTick === this.lastProcessedTick) return;
  this.lastProcessedTick = currentTick;

  // Process all active sessions
  for (const [playerId, session] of this.activeSessions) {
    if (currentTick >= session.completionTick) {
      this.completeSmith(playerId);
    }
  }
}

Auto-Smithing

The system supports auto-smithing - once started, it continues until:
  • Target quantity reached
  • Out of bars
  • Player moves
  • Player disconnects
/**
 * Schedule the next smith action for a session.
 * Called after each successful smith to queue the next one.
 */
private scheduleNextSmith(playerId: string): void {
  const session = this.activeSessions.get(playerId);
  if (!session) return;

  // Check if we've reached the target quantity
  if (session.smithed >= session.quantity) {
    this.completeSmithing(playerId);
    return;
  }

  // Check materials (bars)
  if (!this.hasRequiredBars(playerId, recipe.barType, recipe.barsRequired)) {
    this.emitTypedEvent(EventType.UI_MESSAGE, {
      playerId,
      message: "You have run out of bars.",
      type: "info",
    });
    this.completeSmithing(playerId);
    return;
  }

  // Set completion tick for next smith action
  const currentTick = this.world.currentTick ?? 0;
  session.completionTick = currentTick + recipe.ticks;
}

Hammer Requirement

Smithing at anvils requires a hammer in your inventory:
/**
 * Check if player has a hammer in inventory
 */
private hasHammer(playerId: string): boolean {
  const inventory = this.world.getInventory?.(playerId);
  if (!inventory || !Array.isArray(inventory)) return false;

  return inventory.some(
    (item) => isLooseInventoryItem(item) && item.itemId === HAMMER_ITEM_ID
  );
}
The hammer is not consumed - it’s a permanent tool.

Manifest Integration

Smelting Recipes

Defined in items.json with smeltingRecipe property:
{
  "id": "bronze_bar",
  "name": "Bronze bar",
  "type": "resource",
  "smeltingRecipe": {
    "oreRequirements": {
      "copper_ore": 1,
      "tin_ore": 1
    },
    "levelRequired": 1,
    "xp": 6.25,
    "successRate": 1.0,
    "ticks": 4
  }
}

Smithing Recipes

Defined in items.json with smithingRecipe property:
{
  "id": "bronze_sword",
  "name": "Bronze sword",
  "type": "weapon",
  "smithingRecipe": {
    "barType": "bronze_bar",
    "barsRequired": 1,
    "levelRequired": 4,
    "xp": 12.5,
    "category": "sword",
    "ticks": 4
  }
}

Station 3D Models

Anvils and furnaces now use manifest-driven 3D models:
{
  "id": "anvil",
  "name": "Anvil",
  "type": "station",
  "modelPath": "/assets/models/stations/anvil.glb",
  "scale": 1.0,
  "interactionType": "smithing"
}
This allows easy customization of station appearances without code changes.

Events

EventDataDescription
SMITHING_INTERACTplayerId, anvilIdPlayer clicked anvil
SMITHING_INTERFACE_OPENplayerId, anvilId, availableRecipesShow smithing UI
PROCESSING_SMITHING_REQUESTplayerId, recipeId, anvilId, quantityStart smithing
SMITHING_STARTplayerId, recipeId, anvilIdSmithing session started
SMITHING_COMPLETEplayerId, recipeId, totalSmithed, totalXpSession finished
INVENTORY_ITEM_REMOVEDplayerId, itemId, quantityBars consumed
INVENTORY_ITEM_ADDEDplayerId, itemSmithed item added
SKILLS_XP_GAINEDplayerId, skill, amountXP granted

API Reference

SmithingSystem

class SmithingSystem extends SystemBase {
  /**
   * Check if player is currently smithing
   */
  isPlayerSmithing(playerId: string): boolean;

  /**
   * Update method - processes tick-based smithing sessions
   * Called each frame, but only processes once per game tick
   */
  update(_dt: number): void;
}

ProcessingDataProvider

class ProcessingDataProvider {
  /**
   * Get smithing recipe by output item ID
   */
  getSmithingRecipe(itemId: string): SmithingRecipe | null;

  /**
   * Get all smithable items with availability info
   * Checks player's bars and level
   */
  getSmithableItemsWithAvailability(
    inventory: Array<{ itemId: string; quantity: number }>,
    smithingLevel: number
  ): Array<SmithingRecipe & { meetsLevel: boolean; hasBars: boolean }>;

  /**
   * Get smelting recipe by bar item ID
   */
  getSmeltingRecipe(barItemId: string): SmeltingRecipe | null;
}

Configuration

Constants

// From SmithingConstants.ts
export const SMITHING_CONSTANTS = {
  HAMMER_ITEM_ID: "hammer",
  COAL_ITEM_ID: "coal",
  DEFAULT_SMELTING_TICKS: 4,
  DEFAULT_SMITHING_TICKS: 4,
  TICK_DURATION_MS: 600,
  MAX_QUANTITY: 10000,
  MIN_QUANTITY: 1,
};

Messages

All user-facing messages are centralized:
MESSAGES: {
  ALREADY_SMITHING: "You are already smithing.",
  NO_HAMMER: "You need a hammer to work the metal on this anvil.",
  NO_BARS: "You don't have the bars to smith anything.",
  LEVEL_TOO_LOW_SMITH: "You need level {level} Smithing to make that.",
  SMITHING_START: "You begin smithing {item}s.",
  OUT_OF_BARS: "You have run out of bars.",
  SMITH_SUCCESS: "You hammer the {metal} and make a {item}.",
}

Security Features

Input Validation

All inputs are validated server-side:
/**
 * Validate and clamp quantity to safe bounds
 */
export function clampQuantity(quantity: unknown): number {
  if (typeof quantity !== "number" || !Number.isFinite(quantity)) {
    return SMITHING_CONSTANTS.MIN_QUANTITY;
  }
  return Math.floor(
    Math.max(
      SMITHING_CONSTANTS.MIN_QUANTITY,
      Math.min(quantity, SMITHING_CONSTANTS.MAX_QUANTITY),
    ),
  );
}

/**
 * Validate a string ID (barItemId, furnaceId, recipeId, anvilId)
 */
export function isValidItemId(id: unknown): id is string {
  return (
    typeof id === "string" &&
    id.length > 0 &&
    id.length <= SMITHING_CONSTANTS.MAX_ITEM_ID_LENGTH
  );
}

Server-Authoritative

All smithing logic runs server-side:
  1. Recipe validation - Server checks recipe exists
  2. Level checks - Server validates smithing level
  3. Material checks - Server verifies bars in inventory
  4. Hammer check - Server confirms hammer present
  5. Consumption - Server removes bars and adds items

Type Safety

The smithing system uses strong typing with type guards:
/**
 * Loose inventory item type - matches items from inventory lookups
 */
export interface LooseInventoryItem {
  itemId: string;
  quantity?: number;
  slot?: number;
  metadata?: Record<string, unknown> | null;
}

/**
 * Type guard to validate an object is a valid inventory item
 */
export function isLooseInventoryItem(
  item: unknown,
): item is LooseInventoryItem {
  if (typeof item !== "object" || item === null) return false;
  if (!("itemId" in item)) return false;
  if (typeof (item as LooseInventoryItem).itemId !== "string") return false;

  const qty = (item as LooseInventoryItem).quantity;
  if (qty !== undefined && typeof qty !== "number") return false;

  return true;
}

/**
 * Get quantity from an inventory item, defaulting to 1 if not present
 */
export function getItemQuantity(item: LooseInventoryItem): number {
  return item.quantity ?? 1;
}

Testing

Smithing has comprehensive test coverage:
// From SmithingSystem.test.ts
describe("SmithingSystem", () => {
  it("requires hammer in inventory", async () => {
    const { world, player, anvil } = await setupSmithingTest();
    
    // Try to smith without hammer
    world.emit(EventType.SMITHING_INTERACT, {
      playerId: player.id,
      anvilId: anvil.id,
    });
    
    // Should show error message
    expect(lastMessage).toContain("need a hammer");
  });

  it("auto-smiths multiple items", async () => {
    const { world, player } = await setupSmithingTest();
    
    // Give player 10 bronze bars and a hammer
    giveItem(player.id, "bronze_bar", 10);
    giveItem(player.id, "hammer", 1);
    
    // Start smithing 5 swords
    world.emit(EventType.PROCESSING_SMITHING_REQUEST, {
      playerId: player.id,
      recipeId: "bronze_sword",
      anvilId: "anvil_1",
      quantity: 5,
    });
    
    // Process ticks until complete
    for (let i = 0; i < 25; i++) {
      world.tick();
    }
    
    // Should have 5 swords, 5 bars remaining
    expect(countItem(player.id, "bronze_sword")).toBe(5);
    expect(countItem(player.id, "bronze_bar")).toBe(5);
  });

  it("stops when out of bars", async () => {
    const { world, player } = await setupSmithingTest();
    
    // Give player 3 bars (can only make 3 swords)
    giveItem(player.id, "bronze_bar", 3);
    giveItem(player.id, "hammer", 1);
    
    // Try to smith 10 swords
    world.emit(EventType.PROCESSING_SMITHING_REQUEST, {
      playerId: player.id,
      recipeId: "bronze_sword",
      quantity: 10,
    });
    
    // Process until complete
    for (let i = 0; i < 50; i++) {
      world.tick();
    }
    
    // Should only have 3 swords (ran out of bars)
    expect(countItem(player.id, "bronze_sword")).toBe(3);
    expect(lastMessage).toContain("run out of bars");
  });
});