Skip to main content

Context Menu System

Hyperscape features OSRS-accurate right-click context menus for inventory items, world entities, NPCs, and players with colored entity names and manifest-driven action ordering.
Context menu code lives in packages/client/src/game/systems/InventoryActionDispatcher.ts and packages/shared/src/utils/item-helpers.ts.

Overview

Context menus provide:
  • Manifest-driven actions - Actions defined in item/entity manifests
  • OSRS-accurate ordering - Primary action first, then secondary actions
  • Colored entity names - Yellow NPCs, cyan scenery, orange items
  • Cancel option - Always last in the menu
  • Left-click primary action - First action in the list

Inventory Context Menus

Item Actions

Right-click items in your inventory to see available actions:
// Example: Bronze sword
inventoryActions: ["Wield", "Drop", "Examine"]

// Example: Cooked shrimp
inventoryActions: ["Eat", "Drop", "Examine"]

// Example: Tinderbox
inventoryActions: ["Use", "Drop", "Examine"]

Action Ordering

Actions are ordered by priority (lower = higher priority):
// From item-helpers.ts
export const ACTION_PRIORITY = {
  eat: 1,      // Food primary action
  drink: 1,    // Potion primary action
  wield: 1,    // Weapon/shield primary action
  wear: 1,     // Armor primary action
  bury: 1,     // Bones primary action
  use: 2,      // Generic use action
  drop: 9,     // Always near bottom
  examine: 10, // Always last (before Cancel)
  cancel: 11,  // Always absolute last
};

Manifest-Driven Actions

Items define their actions in items.json:
{
  "id": "bronze_sword",
  "name": "Bronze sword",
  "type": "weapon",
  "equipSlot": "weapon",
  "inventoryActions": ["Wield", "Drop", "Examine"],
  "examine": "A basic bronze sword."
}
The first action becomes the left-click default.

Heuristic Detection

If inventoryActions is not specified, the system detects actions based on item properties:
Item TypeDetectionActions
Foodtype: "consumable" + healAmount > 0 + not potionEat, Use, Drop, Examine
Potionstype: "consumable" + id.includes("potion")Drink, Use, Drop, Examine
WeaponsequipSlot: "weapon" or equipSlot: "2h" or weaponType definedWield, Use, Drop, Examine
Armorequipable: true + not weapon/shieldWear, Use, Drop, Examine
ShieldsequipSlot: "shield"Wield, Use, Drop, Examine
Bonesid === "bones" or id.endsWith("_bones")Bury, Use, Drop, Examine
Noted ItemsisNoted: true or id.endsWith("_noted")Use, Drop, Examine
Tools with equipSlottype: "tool" + equipSlot: "weapon"Wield, Use, Drop, Examine
Tools like hatchets and pickaxes can be equipped as weapons. The system checks equipSlot first before falling back to type-based detection.

Action Handlers

The InventoryActionDispatcher provides centralized handling for all inventory actions:
export const HANDLED_INVENTORY_ACTIONS = new Set<string>([
  "eat",
  "drink",
  "bury",
  "wield",
  "wear",
  "drop",
  "examine",
  "use",
]);
Supported actions:
  • eat: Sends useItem network packet → server validates eat delay → consumes food → heals player
  • drink: Sends useItem network packet → server validates → applies potion effects
  • bury: Sends buryBones network message
  • wield: Sends equipItem network message (weapons/shields)
  • wear: Sends equipItem network message (armor)
  • drop: Calls world.network.dropItem()
  • examine: Shows examine text in chat and toast
  • use: Enters targeting mode for item-on-item/item-on-object interactions
  • cancel: No-op, menu already closed
Food Consumption: The eat action is server-authoritative with 3-tick (1.8s) eat delay and combat attack delay integration. See Inventory System for details.

Entity Context Menus

Colored Entity Names

Entity names are colored by type (OSRS-accurate):
// From CONTEXT_MENU_COLORS constants
export const CONTEXT_MENU_COLORS = {
  NPC: "#ffff00",      // Yellow - NPCs (shopkeepers, quest givers)
  SCENERY: "#00ffff",  // Cyan - Interactive objects (trees, rocks, anvils)
  ITEM: "#ff9040",     // Orange - Ground items
  PLAYER: "#ffffff",   // White - Other players
  MOB: "#ff0000",      // Red - Hostile mobs (goblins, etc.)
};

Entity Actions

Entities define actions in their manifests:
{
  "id": "goblin",
  "name": "Goblin",
  "type": "mob",
  "entityActions": ["Attack", "Examine"],
  "examine": "An ugly green creature."
}

Context Menu Example

Right-clicking a goblin shows:
Goblin (level 2)
  Attack
  Examine
  Cancel
The name “Goblin” is colored red (#ff0000) because it’s a mob.

InventoryActionDispatcher

The InventoryActionDispatcher is the single source of truth for handling inventory actions. Both context menu selections and left-click primary actions route through this dispatcher.

Dispatcher Flow

export function dispatchInventoryAction(
  action: string,
  ctx: InventoryActionContext,
): ActionResult {
  const { world, itemId, slot, quantity = 1 } = ctx;

  switch (action) {
    case "eat":
    case "drink":
      // Server-authoritative consumption
      world.network?.send("useItem", { itemId, slot });
      return { success: true };

    case "wield":
    case "wear":
      world.network?.send("equipItem", {
        playerId: localPlayer.id,
        itemId,
        inventorySlot: slot,
      });
      return { success: true };

    case "drop":
      world.network?.send("dropItem", { itemId, slot, quantity });
      return { success: true };

    case "examine":
      const examineText = itemData?.examine || `It's a ${itemId}.`;
      world.emit(EventType.UI_TOAST, { message: examineText, type: "info" });
      return { success: true };

    case "use":
      // Enter targeting mode for "Use X on Y" interactions
      world.emit(EventType.ITEM_ACTION_SELECTED, {
        playerId: localPlayer.id,
        actionId: "use",
        itemId,
        slot,
      });
      return { success: true };

    case "cancel":
      // Intentional no-op - menu already closed
      return { success: true };

    default:
      console.warn(`Unhandled action: "${action}" for item "${itemId}"`);
      return { success: false, message: `Unhandled action: ${action}` };
  }
}

Primary Action Detection

Manifest-First Approach

The system uses a manifest-first approach with heuristic fallback:
/**
 * Get primary action using manifest-first approach.
 * OSRS-accurate: reads from inventoryActions if available.
 */
export function getPrimaryAction(
  item: Item | null,
  isNoted: boolean,
): PrimaryActionType {
  if (isNoted) return "use";

  // Try manifest first
  const manifestAction = getPrimaryActionFromManifest(item);
  if (manifestAction) return manifestAction;

  // Fallback to heuristic detection
  if (isFood(item)) return "eat";
  if (isPotion(item)) return "drink";
  if (isBone(item)) return "bury";
  if (usesWield(item)) return "wield";
  if (usesWear(item)) return "wear";

  return "use";
}

Item Type Detection

Helper functions determine item types:
/** Food items - have healAmount and are consumable */
export function isFood(item: Item | null): boolean {
  if (!item) return false;
  return (
    item.type === "consumable" &&
    typeof item.healAmount === "number" &&
    item.healAmount > 0 &&
    !item.id.includes("potion")
  );
}

/** Weapons - equipSlot is weapon or 2h */
export function isWeapon(item: Item | null): boolean {
  if (!item) return false;
  return (
    item.equipSlot === "weapon" ||
    item.equipSlot === "2h" ||
    item.is2h === true ||
    item.weaponType != null
  );
}

/** Equipment that uses "Wield" (weapons + shields) */
export function usesWield(item: Item | null): boolean {
  return isWeapon(item) || isShield(item);
}

/** Equipment that uses "Wear" (armor: head, body, legs, etc.) */
export function usesWear(item: Item | null): boolean {
  if (!item) return false;
  if (!item.equipable && !item.equipSlot) return false;
  return !usesWield(item);
}

Cancel Option

All context menus include a Cancel option at the bottom (OSRS-accurate):
// From InventoryPanel.tsx
const actions = [
  ...itemActions,
  { id: "cancel", label: "Cancel", color: "#ffffff" }
];
The Cancel action is a silent no-op - it just closes the menu:
// From InventoryActionDispatcher.ts
case "cancel":
  // Intentional no-op - menu already closed by EntityContextMenu
  return { success: true };

Context Menu Colors

Color Constants

// From packages/shared/src/utils/item-helpers.ts
import { 
  isFood, 
  isPotion, 
  isBone, 
  usesWield, 
  usesWear, 
  isNotedItem,
  getPrimaryAction,
  getPrimaryActionFromManifest,
  HANDLED_INVENTORY_ACTIONS
} from '@hyperscape/shared';

// Detect item types
const canEat = isFood(item);           // true for food items (healAmount > 0, not potion)
const canDrink = isPotion(item);       // true for potions (id contains "potion")
const canBury = isBone(item);          // true for bones (id is "bones" or ends with "_bones")
const shouldWield = usesWield(item);   // true for weapons/shields
const shouldWear = usesWear(item);     // true for armor (not weapons/shields)
const isNoted = isNotedItem(item);     // true for bank notes (isNoted flag or "_noted" suffix)

// Get primary action (manifest-first with heuristic fallback)
const action = getPrimaryAction(item, isNoted);
// Returns: "eat" | "drink" | "bury" | "wield" | "wear" | "use"

// Get action from manifest only (returns null if not defined)
const manifestAction = getPrimaryActionFromManifest(item);

// Check if action has a handler
const isHandled = HANDLED_INVENTORY_ACTIONS.has(action);

Detection Logic

The getPrimaryAction function uses a manifest-first approach:
  1. Noted items: Always return “use” (cannot eat/equip notes)
  2. Manifest actions: Check item.inventoryActions[0] if defined
  3. Heuristic fallback: Detect based on item properties
    • Food: type === "consumable" + healAmount > 0 + not potion
    • Potions: type === "consumable" + id.includes("potion")
    • Bones: id === "bones" or id.endsWith("_bones")
    • Weapons/Shields: usesWield() checks equipSlot and weaponType
    • Armor: usesWear() checks equipable and equipSlot
  4. Final fallback: Return “use”

Adding Custom Actions

// EntityContextMenu.tsx
const entityColor = CONTEXT_MENU_COLORS[entityType] || "#ffffff";

<div style={{ color: entityColor }}>
  {entityName} {level && `(level ${level})`}
</div>

Testing

Context menus have unit test coverage:
// From item-helpers.test.ts
describe("item-helpers", () => {
  it("detects food items correctly", () => {
    const shrimp = { id: "cooked_shrimp", type: "consumable", healAmount: 3 };
    expect(isFood(shrimp)).toBe(true);
    
    const potion = { id: "strength_potion", type: "consumable", healAmount: 0 };
    expect(isFood(potion)).toBe(false);
  });

  it("determines primary action from manifest", () => {
    const sword = { inventoryActions: ["Wield", "Drop", "Examine"] };
    expect(getPrimaryActionFromManifest(sword)).toBe("wield");
    
    const food = { inventoryActions: ["Eat", "Drop", "Examine"] };
    expect(getPrimaryActionFromManifest(food)).toBe("eat");
  });
});
Integration tests verify the full dispatcher flow:
// From InventoryActionDispatcher.test.ts
it("dispatches eat action to server", () => {
  const mockSend = vi.fn();
  const world = { network: { send: mockSend } };
  
  dispatchInventoryAction("eat", {
    world,
    itemId: "cooked_shrimp",
    slot: 5,
  });
  
  expect(mockSend).toHaveBeenCalledWith("useItem", {
    itemId: "cooked_shrimp",
    slot: 5,
  });
});