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 ;
}
Client Overview Client architecture and rendering systems
Inventory System Item management and drag-and-drop
Combat System Combat mechanics and action bar integration
Mobile Guide Mobile-specific UI and controls