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:
- Smelting - Combine ores at a furnace to create bars
- 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
- Have ores in your inventory
- Click a furnace
- Select bar type from the interface
- Choose quantity (1, 5, 10, X, All)
- 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
| Bar | Level | Ores Required | XP | Success Rate | Ticks |
|---|
| Bronze | 1 | 1 Copper + 1 Tin | 6.25 | 100% | 4 |
| Iron | 15 | 1 Iron Ore | 12.5 | 50% | 4 |
| Steel | 30 | 1 Iron Ore + 2 Coal | 17.5 | 100% | 4 |
| Mithril | 50 | 1 Mithril Ore + 4 Coal | 30 | 100% | 4 |
| Adamant | 70 | 1 Adamantite Ore + 6 Coal | 37.5 | 100% | 4 |
| Rune | 85 | 1 Runite Ore + 8 Coal | 50 | 100% | 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
- Have bars in your inventory
- Have a hammer in your inventory (required, not consumed)
- Click an anvil
- Select item from the interface
- Choose quantity (1, 5, 10, X, All)
- 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:
| Item | Level | Bars | XP | Category |
|---|
| Bronze Dagger | 1 | 1 Bronze | 12.5 | dagger |
| Bronze Sword | 4 | 1 Bronze | 12.5 | sword |
| Bronze Scimitar | 5 | 2 Bronze | 25 | scimitar |
| Iron Sword | 19 | 1 Iron | 25 | sword |
| Steel Sword | 34 | 1 Steel | 37.5 | sword |
| Mithril Sword | 54 | 1 Mithril | 50 | sword |
| Adamant Sword | 74 | 1 Adamant | 62.5 | sword |
| Rune Sword | 89 | 1 Rune | 75 | sword |
Armor:
| Item | Level | Bars | XP | Category |
|---|
| Bronze Platebody | 18 | 5 Bronze | 62.5 | platebody |
| Iron Platebody | 33 | 5 Iron | 125 | platebody |
| Steel Platebody | 48 | 5 Steel | 187.5 | platebody |
| Mithril Platebody | 68 | 5 Mithril | 250 | platebody |
Tools:
| Item | Level | Bars | XP | Category |
|---|
| Bronze Hatchet | 1 | 1 Bronze | 12.5 | hatchet |
| Bronze Pickaxe | 1 | 1 Bronze | 12.5 | pickaxe |
| Iron Hatchet | 16 | 1 Iron | 25 | hatchet |
| Iron Pickaxe | 16 | 1 Iron | 25 | pickaxe |
| Steel Hatchet | 31 | 1 Steel | 37.5 | hatchet |
| Steel Pickaxe | 31 | 1 Steel | 37.5 | pickaxe |
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
| Event | Data | Description |
|---|
SMITHING_INTERACT | playerId, anvilId | Player clicked anvil |
SMITHING_INTERFACE_OPEN | playerId, anvilId, availableRecipes | Show smithing UI |
PROCESSING_SMITHING_REQUEST | playerId, recipeId, anvilId, quantity | Start smithing |
SMITHING_START | playerId, recipeId, anvilId | Smithing session started |
SMITHING_COMPLETE | playerId, recipeId, totalSmithed, totalXp | Session finished |
INVENTORY_ITEM_REMOVED | playerId, itemId, quantity | Bars consumed |
INVENTORY_ITEM_ADDED | playerId, item | Smithed item added |
SKILLS_XP_GAINED | playerId, skill, amount | XP 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
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:
- Recipe validation - Server checks recipe exists
- Level checks - Server validates smithing level
- Material checks - Server verifies bars in inventory
- Hammer check - Server confirms hammer present
- 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");
});
});