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
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 Type | Detection | Actions |
|---|
| Food | type: "consumable" + healAmount > 0 + not potion | Eat, Use, Drop, Examine |
| Potions | type: "consumable" + id.includes("potion") | Drink, Use, Drop, Examine |
| Weapons | equipSlot: "weapon" or equipSlot: "2h" or weaponType defined | Wield, Use, Drop, Examine |
| Armor | equipable: true + not weapon/shield | Wear, Use, Drop, Examine |
| Shields | equipSlot: "shield" | Wield, Use, Drop, Examine |
| Bones | id === "bones" or id.endsWith("_bones") | Bury, Use, Drop, Examine |
| Noted Items | isNoted: true or id.endsWith("_noted") | Use, Drop, Examine |
| Tools with equipSlot | type: "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.
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."
}
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 };
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:
- Noted items: Always return “use” (cannot eat/equip notes)
- Manifest actions: Check
item.inventoryActions[0] if defined
- 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
- 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,
});
});