UI Systems
Hyperscape’s client UI features a customizable interface system with draggable windows, action bars, chat filtering, and mobile support. Recent updates include RS3-style keyboard shortcuts, vertical action bars for mobile, and enhanced edit mode.
Location: packages/client/src/game/interface/ and packages/client/src/ui/
Action Bar System
The Action Bar provides quick access to items and skills with RS3-style keyboard shortcuts supporting up to 5 bars.
Features
- 5 Action Bars: Each with 14 slots (1-9, 0, -, =, Backspace, Insert)
- Modifier Keys: Ctrl, Shift, Alt for bars 2-4
- Bar 5: Q, W, E, R, T, Y, U, I, O, P, [, ], \
- Drag & Drop: Drag items/skills from inventory/skills panel
- Context Menu: Right-click to remove or configure slots
- Mobile Support: Vertical layout for touch devices
Keyboard Shortcuts
// From packages/client/src/game/panels/ActionBarPanel/utils.ts
export const ACTION_BAR_KEYBINDS: Record<number, string[]> = {
1: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "Backspace", "Insert"],
2: ["Ctrl+1", "Ctrl+2", ..., "Ctrl+Insert"],
3: ["Shift+1", "Shift+2", ..., "Shift+Insert"],
4: ["Alt+1", "Alt+2", ..., "Alt+Insert"],
5: ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "[", "]", "\\"],
};
Keybind Display
Shortcuts are displayed on slots with modifier symbols:
- ^1 — Ctrl+1 (Bar 2)
- ⇧2 — Shift+2 (Bar 3)
- ⌥3 — Alt+3 (Bar 4)
- Q — Q key (Bar 5)
// From packages/client/src/game/panels/ActionBarPanel/utils.ts
export function formatKeybindForDisplay(keybind: string): string {
if (keybind.startsWith("Ctrl+")) return `^${keybind.slice(5)}`;
if (keybind.startsWith("Shift+")) return `⇧${keybind.slice(6)}`;
if (keybind.startsWith("Alt+")) return `⌥${keybind.slice(4)}`;
return keybind;
}
Mobile Layout
Action bars support vertical orientation for mobile devices:
// From packages/client/src/game/panels/ActionBarPanel/index.tsx
interface ActionBarPanelProps {
orientation?: "horizontal" | "vertical"; // Default: horizontal
showShortcuts?: boolean; // Hide keyboard hints on mobile
showControls?: boolean; // Hide +/- and lock buttons on mobile
}
// Mobile usage
<ActionBarPanel
orientation="vertical"
showShortcuts={false}
showControls={false}
/>
Drag & Drop
Action bars use the centralized drag system:
// From packages/client/src/game/panels/ActionBarPanel/useActionBarDragDrop.ts
export function useActionBarDragDrop(barNumber: number) {
const { draggedItem, setDraggedItem } = useDragStore();
const handleDrop = useCallback((slotIndex: number, item: DraggedItem) => {
if (item.type === "item") {
// Add item to action bar
world.network?.send("actionBar:setSlot", {
barNumber,
slotIndex,
itemId: item.itemId,
quantity: 1,
});
} else if (item.type === "skill") {
// Add skill to action bar
world.network?.send("actionBar:setSlot", {
barNumber,
slotIndex,
skillId: item.skillId,
});
}
}, [barNumber, world]);
return { handleDrop };
}
Chat System
The chat system supports multiple channels with filtering and clickable messages.
Chat Channels
| Tab | Messages Shown |
|---|
| All | All messages (game, trade, clan, private) |
| Game | System messages, combat, skills, loot |
| Clan | Clan chat messages |
| Private | Private messages and whispers |
Message Types
// From packages/client/src/game/panels/ChatPanel.tsx
interface ChatMessage {
id: string;
from?: string;
body: string;
createdAt: string;
type?:
| "chat" // Normal chat
| "system" // System messages
| "combat" // Combat notifications
| "loot" // Loot drops
| "skill" // Skill XP/level ups
| "trade_request" // Clickable trade request
| "duel_challenge" // Clickable duel challenge
| "private" // Private messages
| "clan" // Clan chat
| "guild"; // Guild chat
tradeId?: string; // For trade_request messages
challengeId?: string; // For duel_challenge messages
channel?: string; // For filtering (clan, private, etc.)
}
Message Colors
// From packages/client/src/game/panels/ChatPanel.tsx
const MESSAGE_COLORS = {
chat: "#FFFFFF", // White for normal chat
system: "#00FFFF", // Cyan for system
combat: "#FF6B6B", // Red for combat
loot: "#FFD700", // Gold for loot
skill: "#00FF00", // Green for skills
trade_request: "#FF00FF", // Pink for trade requests
duel_challenge: "#FF4444", // Red for duel challenges
private: "#ff66ff", // Pink for private messages
clan: "#00ff00", // Green for clan
guild: "#00ff00", // Green for guild
};
Clickable Messages
Trade requests and duel challenges are clickable to accept:
// From packages/client/src/game/panels/ChatPanel.tsx
const handleDuelChallengeClick = useCallback((challengeId: string) => {
if (chatWorld.network?.send) {
chatWorld.network.send("duel:challenge:respond", {
challengeId,
accept: true,
});
}
}, [chatWorld]);
// Render clickable message
<div
onClick={isDuelChallenge ? () => handleDuelChallengeClick(msg.challengeId!) : undefined}
style={{
cursor: isClickable ? "pointer" : "default",
textDecoration: isClickable ? "underline" : "none",
color: msgColor,
}}
title={isDuelChallenge ? "Click to accept duel challenge" : undefined}
>
{msg.body}
</div>
Message Filtering
// From packages/client/src/game/panels/ChatPanel.tsx
function filterMessagesByTab(
messages: ChatMessage[],
tab: "all" | "game" | "clan" | "private",
): ChatMessage[] {
if (tab === "all") return messages;
return messages.filter((msg) => {
switch (tab) {
case "game":
return ["system", "combat", "loot", "skill", "trade_request", "duel_challenge"].includes(msg.type || "chat");
case "clan":
return msg.type === "clan" || msg.type === "guild" || msg.channel === "clan";
case "private":
return msg.type === "private" || msg.channel === "private";
default:
return true;
}
});
}
Edit Mode
Edit mode allows players to customize their UI layout with drag-and-drop window positioning.
Features
- Hold-to-Toggle: Hold Ctrl key to enter edit mode (with progress indicator)
- Escape to Exit: Press Escape to exit edit mode
- Drag Windows: Reposition any panel
- Delete Zone: Drag panels to trash icon to remove them
- Alignment Guides: Visual guides for snapping windows
- Grid Snapping: Windows snap to grid for clean layouts
Edit Mode Keyboard
// From packages/client/src/ui/core/edit/useEditModeKeyboard.ts
export function useEditModeKeyboard() {
const { isEditMode, setEditMode, isHolding, setIsHolding, holdProgress, setHoldProgress } = useEditStore();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Hold Ctrl to enter edit mode
if (e.key === "Control" && !isEditMode && !isHolding) {
setIsHolding(true);
// Start progress animation
const startTime = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / 500, 1); // 500ms hold duration
setHoldProgress(progress);
if (progress >= 1) {
setEditMode(true);
setIsHolding(false);
setHoldProgress(0);
clearInterval(interval);
}
}, 16);
}
// Escape to exit edit mode
if (e.key === "Escape" && isEditMode) {
setEditMode(false);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control" && isHolding) {
setIsHolding(false);
setHoldProgress(0);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [isEditMode, isHolding]);
}
Delete Zone
// From packages/client/src/game/interface/InterfaceManager.tsx
<DeleteZone
visible={isEditMode && isDraggingWindow}
onDrop={(windowId) => {
// Remove window from layout
removeWindow(windowId);
}}
/>
The menu bar features responsive layout that adapts to container size.
Dynamic Layout
// From packages/client/src/game/panels/MenuBarPanel/index.tsx
function calculateMenuBarLayout(
containerWidth: number,
containerHeight: number,
buttonCount: number,
): { rows: number; cols: number; buttonSize: number } {
// Try different row counts (1-5) and find optimal layout
for (let rows = 1; rows <= 5; rows++) {
const cols = Math.ceil(buttonCount / rows);
const buttonWidth = containerWidth / cols;
const buttonHeight = containerHeight / rows;
const buttonSize = Math.min(buttonWidth, buttonHeight);
// Check if buttons fit
if (buttonSize >= MIN_BUTTON_SIZE && buttonSize <= MAX_BUTTON_SIZE) {
return { rows, cols, buttonSize };
}
}
// Fallback to single row
return { rows: 1, cols: buttonCount, buttonSize: MIN_BUTTON_SIZE };
}
Responsive Behavior
- 1 row: Wide containers (desktop)
- 2-3 rows: Medium containers
- 4-5 rows: Narrow containers (mobile portrait)
- Auto-reflow: Uses ResizeObserver to detect container changes
Panel Sizing
Recent updates increased panel sizes by ~30% for better readability:
| Panel | Old Size | New Size | Increase |
|---|
| Inventory | 240×320 | 320×420 | +33% |
| Equipment | 200×280 | 260×360 | +30% |
| Stats | 210×285 | 275×370 | +31% |
| Skills | 250×310 | 325×400 | +30% |
| Combat | 240×280 | 310×360 | +29% |
| Chat | 400×450 | 520×585 | +30% |
| Minimap | 420×420 | 550×550 | +31% |
// From packages/client/src/game/panels/InventoryPanel.tsx
export const INVENTORY_PANEL_CONFIG = {
minWidth: 260,
minHeight: 340,
preferredWidth: 320,
preferredHeight: 420,
maxWidth: 400,
maxHeight: 520,
};
Theme System
The theme system provides consistent colors across all UI components.
Theme Colors
// From packages/client/src/ui/theme/themes.ts
export const hyperscapeTheme: Theme = {
colors: {
background: {
primary: "#1a1a1a",
secondary: "#2a2a2a",
tertiary: "#3a3a3a",
},
panelPrimary: "#2a2a2a", // Panel backgrounds
panelSecondary: "#3a3a3a", // Nested panel backgrounds
border: {
default: "#4a4a4a",
hover: "#5a5a5a",
active: "#6a6a6a",
},
text: {
primary: "#ffffff",
secondary: "#cccccc",
muted: "#999999",
},
state: {
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
info: "#3b82f6",
},
accent: {
primary: "#8b5cf6",
gold: "#ffd700",
},
},
};
Panel Background Updates
All panels now use theme colors for consistency:
// From packages/client/src/game/panels/InventoryPanel.tsx
const panelStyle: CSSProperties = {
background: theme.colors.panelPrimary,
border: `1px solid ${theme.colors.border.default}`,
borderRadius: theme.borderRadius.md,
};
Inventory Cross-Tab Isolation
Recent fixes prevent inventory updates from affecting other tabs:
Problem
When multiple browser tabs were open, inventory updates in one tab would trigger updates in all tabs, causing items to appear/disappear incorrectly.
Solution
// From packages/client/src/hooks/usePlayerData.ts
const handleInventory = (data: unknown) => {
const invData = data as {
playerId: string;
items: InventorySlotViewItem[];
coins: number;
};
// Only update if this inventory belongs to the local player
if (playerId && invData.playerId && invData.playerId !== playerId) {
return; // Ignore updates for other players
}
setInventory(invData.items || []);
if (typeof invData.coins === "number") {
setCoins(invData.coins);
}
};
Window Management
The window system provides draggable, resizable panels with snapping and constraints.
Window Features
- Drag & Drop: Move windows anywhere on screen
- Resize: Drag edges/corners to resize
- Snap to Edges: Windows snap to viewport edges
- Constraints: Min/max width/height per panel
- Z-Index Management: Clicked windows come to front
- Viewport Clamping: Windows stay on screen during resize
Window Store
// From packages/client/src/ui/stores/windowStore.ts
export interface WindowState {
id: string;
position: { x: number; y: number };
size: { width: number; height: number };
zIndex: number;
minimized: boolean;
locked: boolean;
}
export const useWindowStore = create<WindowStore>((set) => ({
windows: new Map<string, WindowState>(),
updateWindow(id: string, updates: Partial<WindowState>): void {
set((state) => {
const window = state.windows.get(id);
if (!window) return state;
state.windows.set(id, { ...window, ...updates });
return { windows: new Map(state.windows) };
});
},
bringToFront(id: string): void {
set((state) => {
const maxZ = Math.max(...Array.from(state.windows.values()).map(w => w.zIndex));
const window = state.windows.get(id);
if (!window) return state;
state.windows.set(id, { ...window, zIndex: maxZ + 1 });
return { windows: new Map(state.windows) };
});
},
}));
Responsive Design
The UI adapts to different screen sizes and orientations.
Breakpoints
// From packages/client/src/ui/core/responsive/useBreakpoint.ts
export const BREAKPOINTS = {
mobile: 768,
tablet: 1024,
desktop: 1280,
wide: 1920,
};
export function useBreakpoint() {
const [breakpoint, setBreakpoint] = useState<"mobile" | "tablet" | "desktop" | "wide">("desktop");
useEffect(() => {
const updateBreakpoint = () => {
const width = window.innerWidth;
if (width < BREAKPOINTS.mobile) setBreakpoint("mobile");
else if (width < BREAKPOINTS.tablet) setBreakpoint("tablet");
else if (width < BREAKPOINTS.desktop) setBreakpoint("desktop");
else setBreakpoint("wide");
};
updateBreakpoint();
window.addEventListener("resize", updateBreakpoint);
return () => window.removeEventListener("resize", updateBreakpoint);
}, []);
return breakpoint;
}
Mobile Interface Manager
// From packages/client/src/game/interface/MobileInterfaceManager.tsx
export function MobileInterfaceManager({ world }: InterfaceManagerProps) {
const breakpoint = useBreakpoint();
const isMobile = breakpoint === "mobile";
return (
<>
{/* Compact status HUD */}
<CompactStatusHUD world={world} />
{/* Vertical action bar */}
<ActionBarPanel
orientation="vertical"
showShortcuts={false}
showControls={false}
/>
{/* Mobile drawer for panels */}
<MobileDrawer>
<InventoryPanel world={world} />
<EquipmentPanel world={world} />
<SkillsPanel world={world} />
</MobileDrawer>
</>
);
}
Type Safety Improvements
Recent updates added strong typing for event payloads:
Action Bar Event Types
// From packages/client/src/game/panels/ActionBarPanel/types.ts
export interface PrayerStateSyncEventPayload {
activePrayers: string[];
}
export interface PrayerToggledEventPayload {
prayerId: string;
active: boolean;
}
export interface AttackStyleUpdateEventPayload {
style: string;
}
export interface ActionBarStatePayload {
bars: Record<number, ActionBarSlot[]>;
}
export interface ActionBarSlotSwapPayload {
barNumber: number;
fromIndex: number;
toIndex: number;
}
Network Cache Extensions
// From packages/client/src/game/panels/ActionBarPanel/types.ts
export interface ActionBarNetworkExtensions {
actionBars?: Record<number, ActionBarSlot[]>;
activePrayers?: string[];
attackStyle?: string;
}
Memoized Styles
UI components use useMemo to prevent unnecessary style recalculations:
// From packages/client/src/game/panels/DuelPanel/RulesScreen.tsx
function useRulesScreenStyles(theme: Theme, myAccepted: boolean) {
return useMemo(() => {
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: theme.spacing.md,
};
const acceptButtonStyle: CSSProperties = {
background: myAccepted
? `${theme.colors.state.success}88`
: theme.colors.state.success,
opacity: myAccepted ? 0.7 : 1,
};
return { containerStyle, acceptButtonStyle, ... };
}, [theme, myAccepted]); // Only recalculate when dependencies change
}
Race Condition Fixes
// From packages/client/src/game/panels/ActionBarPanel/ActionBarSlot.tsx
// BEFORE: Race condition between null check and usage
if (holdStartTimeRef.current !== null) {
const elapsed = now - holdStartTimeRef.current; // holdStartTimeRef.current could be null here!
}
// AFTER: Capture value in local variable
const holdStartTime = holdStartTimeRef.current;
if (holdStartTime !== null) {
const elapsed = now - holdStartTime; // Safe - holdStartTime is captured
}
Accessibility
Title Attributes
All interactive elements have descriptive titles:
// From packages/client/src/game/panels/ChatPanel.tsx
<div
onClick={handleClick}
title={
isTradeRequest
? "Click to accept trade request"
: isDuelChallenge
? "Click to accept duel challenge"
: undefined
}
>
{msg.body}
</div>
Keyboard Navigation
- Tab: Navigate between focusable elements
- Enter: Activate buttons
- Escape: Close modals and exit edit mode
- Arrow Keys: Navigate context menus
Debug Logging Cleanup
Recent commits removed debug console.log statements in favor of structured logging:
// BEFORE
console.log("[InteractionRouter] Entering targeting mode:", mode);
// AFTER
Logger.debug("InteractionRouter", "Entering targeting mode", { mode });
Logger Service
// From packages/server/src/systems/ServerNetwork/services/Logger.ts
export class Logger {
static debug(system: string, message: string, data?: Record<string, unknown>): void;
static info(system: string, message: string, data?: Record<string, unknown>): void;
static warn(system: string, message: string, data?: Record<string, unknown>): void;
static error(system: string, message: string, error?: Error, data?: Record<string, unknown>): void;
}