UI Architecture
Copy
┌─────────────────────────────────────────────┐
│ Game Client (Lua) │
│ ┌──────────────────────────────────────┐ │
│ │ Client Scripts │ │
│ │ • Event handlers │ │
│ │ • NUI communication │ │
│ │ • Game logic │ │
│ └──────────────────────────────────────┘ │
│ ↕ SendNUIMessage │
│ ↕ RegisterNUICallback │
│ ┌──────────────────────────────────────┐ │
│ │ NUI Layer (Chromium Browser) │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ React Application │ │ │
│ │ │ • Components (JSX) │ │ │
│ │ │ • Redux Store │ │ │
│ │ │ • Actions & Reducers │ │ │
│ │ │ • Material-UI │ │ │
│ │ └────────────────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Technology Stack
React 17.0.2
Component-based UI framework for building interfaces
Redux + Thunk
State management and async action handling
Material-UI v5
Component library for consistent, beautiful UIs
Emotion
CSS-in-JS styling solution
Webpack 5
Module bundler for building production UI
Babel
JavaScript transpiler for modern syntax
UI Resources
Mythic Framework includes several UI resources:mythic-hud
mythic-hud
Main heads-up display showing:
- Health and armor bars
- Status effects (hunger, thirst, stress)
- Vehicle information (speed, fuel)
- Minimap information
- Notifications
mythic-phone
mythic-phone
Fully functional smartphone with apps:
- Contacts
- Messages
- Phone calls
- Browser
- Camera
- Settings
mythic-inventory
mythic-inventory
Inventory management interface:
- Drag and drop items
- Item tooltips
- Weight management
- Crafting interface
- Shop interface
mythic-mdt
mythic-mdt
Mobile Data Terminal for police:
- Warrant search
- Person lookup
- Vehicle lookup
- Dispatch
- Reports
mythic-laptop
mythic-laptop
Generic laptop interface:
- Email client
- Documents
- Apps
- Browser
mythic-characters
mythic-characters
Character creation and selection:
- Appearance customization
- Character information
- Character selection
mythic-menu
mythic-menu
Context menu system:
- Right-click style menus
- Nested submenus
- Keyboard navigation
mythic-admin
mythic-admin
Admin panel:
- Player management
- Server controls
- Resource management
- Debug tools
UI Structure
Standard UI resource structure:Copy
mythic-[name]/
├── ui/
│ ├── src/ # Source code
│ │ ├── components/ # React components
│ │ │ ├── App.jsx # Main component
│ │ │ ├── Header.jsx
│ │ │ ├── ItemSlot.jsx
│ │ │ └── ...
│ │ ├── reducers/ # Redux reducers
│ │ │ └── index.js
│ │ ├── actions/ # Redux actions
│ │ │ └── index.js
│ │ ├── hooks/ # Custom hooks
│ │ │ ├── useNuiEvent.js
│ │ │ └── useKeyPress.js
│ │ ├── utils/ # Utility functions
│ │ │ ├── fetchNui.js
│ │ │ └── formatters.js
│ │ ├── assets/ # Images, fonts
│ │ ├── styles/ # Global styles
│ │ └── index.jsx # Entry point
│ ├── dist/ # Built files (webpack output)
│ │ ├── index.html
│ │ └── main.js
│ ├── public/ # Static files
│ │ └── assets/
│ ├── package.json
│ ├── webpack.config.js
│ └── .babelrc
Client-UI Communication
Lua → React (SendNUIMessage)
Send data from client Lua to React UI:Copy
-- client/main.lua
-- Open UI
SendNUIMessage({
type = 'OPEN_INVENTORY',
data = {
inventory = inventoryData,
maxSlots = 50,
maxWeight = 100
}
})
-- Update specific data
SendNUIMessage({
type = 'UPDATE_MONEY',
data = {
cash = 5000,
bank = 25000
}
})
-- Close UI
SendNUIMessage({
type = 'CLOSE_INVENTORY'
})
Copy
// ui/src/App.jsx
import { useEffect } from 'react';
function App() {
useEffect(() => {
const handleMessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'OPEN_INVENTORY':
// Open inventory with data
setInventory(data.inventory);
setVisible(true);
break;
case 'UPDATE_MONEY':
// Update money display
setMoney(data);
break;
case 'CLOSE_INVENTORY':
setVisible(false);
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// ...
}
React → Lua (RegisterNUICallback)
Send data from React back to Lua:Copy
// ui/src/utils/fetchNui.js
export const fetchNui = async (eventName, data = {}) => {
const resourceName = window.GetParentResourceName
? window.GetParentResourceName()
: 'mythic-inventory';
const response = await fetch(`https://${resourceName}/${eventName}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return await response.json();
};
// Usage in component
import { fetchNui } from '../utils/fetchNui';
const handleUseItem = (slot) => {
fetchNui('useItem', { slot })
.then((response) => {
if (response.success) {
console.log('Item used successfully');
}
});
};
Copy
-- client/ui.lua
RegisterNUICallback('useItem', function(data, cb)
local slot = data.slot
-- Request server to use item
TriggerServerEvent('mythic-inventory:server:UseItem', slot)
-- Respond to UI
cb({ success = true })
end)
RegisterNUICallback('closeInventory', function(data, cb)
SetNuiFocus(false, false)
cb('ok')
end)
Custom Hooks
useNuiEvent Hook
Simplify NUI message handling:Copy
// ui/src/hooks/useNuiEvent.js
import { useEffect } from 'react';
export const useNuiEvent = (action, handler) => {
useEffect(() => {
const handleMessage = (event) => {
const { type, data } = event.data;
if (type === action) {
handler(data);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [action, handler]);
};
// Usage
import { useNuiEvent } from './hooks/useNuiEvent';
function InventoryApp() {
const [inventory, setInventory] = useState([]);
const [visible, setVisible] = useState(false);
useNuiEvent('SET_INVENTORY', (data) => {
setInventory(data.inventory);
});
useNuiEvent('OPEN_INVENTORY', () => {
setVisible(true);
});
useNuiEvent('CLOSE_INVENTORY', () => {
setVisible(false);
});
// ...
}
useKeyPress Hook
Handle keyboard input:Copy
// ui/src/hooks/useKeyPress.js
import { useEffect } from 'react';
export const useKeyPress = (targetKey, handler) => {
useEffect(() => {
const handleKeyPress = (event) => {
if (event.key === targetKey) {
handler(event);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [targetKey, handler]);
};
// Usage
import { useKeyPress } from './hooks/useKeyPress';
function InventoryApp() {
const [visible, setVisible] = useState(false);
useKeyPress('Escape', () => {
if (visible) {
fetchNui('closeInventory');
setVisible(false);
}
});
// ...
}
Redux State Management
Store Setup
Copy
// ui/src/store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../reducers';
const store = createStore(
rootReducer,
applyMiddleware(thunk)
);
export default store;
Reducer Example
Copy
// ui/src/reducers/inventoryReducer.js
const initialState = {
items: [],
maxSlots: 50,
maxWeight: 100,
visible: false
};
export default function inventoryReducer(state = initialState, action) {
switch (action.type) {
case 'SET_INVENTORY':
return {
...state,
items: action.payload.items,
maxSlots: action.payload.maxSlots,
maxWeight: action.payload.maxWeight
};
case 'UPDATE_SLOT':
const newItems = [...state.items];
newItems[action.payload.slot] = action.payload.item;
return {
...state,
items: newItems
};
case 'TOGGLE_VISIBLE':
return {
...state,
visible: !state.visible
};
default:
return state;
}
}
Actions
Copy
// ui/src/actions/inventoryActions.js
export const setInventory = (inventory) => ({
type: 'SET_INVENTORY',
payload: inventory
});
export const updateSlot = (slot, item) => ({
type: 'UPDATE_SLOT',
payload: { slot, item }
});
export const toggleVisible = () => ({
type: 'TOGGLE_VISIBLE'
});
// Async action with thunk
export const useItem = (slot) => {
return async (dispatch) => {
const result = await fetchNui('useItem', { slot });
if (result.success) {
dispatch(updateSlot(slot, null));
}
};
};
Using Redux in Components
Copy
// ui/src/components/Inventory.jsx
import { useSelector, useDispatch } from 'react-redux';
import { setInventory, useItem } from '../actions/inventoryActions';
function Inventory() {
const items = useSelector(state => state.inventory.items);
const visible = useSelector(state => state.inventory.visible);
const dispatch = useDispatch();
const handleUseItem = (slot) => {
dispatch(useItem(slot));
};
if (!visible) return null;
return (
<div className="inventory">
{items.map((item, index) => (
<ItemSlot
key={index}
item={item}
onClick={() => handleUseItem(index)}
/>
))}
</div>
);
}
Material-UI Theming
Copy
// ui/src/theme.js
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#8B5CF6',
light: '#A78BFA',
dark: '#6D28D9'
},
secondary: {
main: '#10B981'
},
background: {
default: '#0F0F1E',
paper: '#1A1A2E'
},
text: {
primary: '#FFFFFF',
secondary: 'rgba(255, 255, 255, 0.7)'
}
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 600
}
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: 'none'
}
}
}
}
});
export default theme;
// App.jsx
import { ThemeProvider } from '@mui/material/styles';
import theme from './theme';
function App() {
return (
<ThemeProvider theme={theme}>
{/* Your app components */}
</ThemeProvider>
);
}
Building & Deployment
package.json
Copy
{
"name": "mythic-inventory-ui",
"version": "1.0.0",
"scripts": {
"dev": "webpack serve --mode development",
"build": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"redux": "^4.1.1",
"react-redux": "^7.2.5",
"redux-thunk": "^2.3.0",
"@mui/material": "^5.0.0",
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/preset-react": "^7.14.5",
"babel-loader": "^8.2.2",
"webpack": "^5.52.0",
"webpack-cli": "^4.8.0",
"webpack-dev-server": "^4.1.1",
"html-webpack-plugin": "^5.3.2",
"css-loader": "^6.2.0",
"style-loader": "^3.2.1"
}
}
webpack.config.js
Copy
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = (env, argv) => {
const isDevelopment = argv.mode === 'development';
return {
entry: './src/index.jsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
clean: true
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react']
}
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
resolve: {
extensions: ['.js', '.jsx']
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html'
})
],
devServer: {
static: path.join(__dirname, 'dist'),
port: 3000,
hot: true
},
devtool: isDevelopment ? 'inline-source-map' : false
};
};
Building the UI
Copy
# Development mode (with hot reload)
npm run dev
# Watch mode (rebuilds on changes)
npm run watch
# Production build
npm run build
Best Practices
1. Visibility Control
1. Visibility Control
Always control UI visibility properly:
Copy
function MyUI() {
const [visible, setVisible] = useState(false);
useNuiEvent('OPEN_UI', () => setVisible(true));
useNuiEvent('CLOSE_UI', () => setVisible(false));
// Don't render if not visible
if (!visible) return null;
return <div>...</div>;
}
2. ESC Key Handling
2. ESC Key Handling
Always handle ESC to close UI:
Copy
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && visible) {
fetchNui('close');
setVisible(false);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [visible]);
3. NUI Focus Management
3. NUI Focus Management
Properly manage NUI focus in Lua:
Copy
-- Open UI
SendNUIMessage({ type = 'OPEN_UI' })
SetNuiFocus(true, true) -- Enable cursor and input
-- Close UI
RegisterNUICallback('close', function(data, cb)
SetNuiFocus(false, false) -- Disable cursor and input
cb('ok')
end)
4. Performance
4. Performance
Optimize React performance:
Copy
// Use React.memo for expensive components
const ItemSlot = React.memo(({ item, onClick }) => {
return <div onClick={onClick}>{item.label}</div>;
});
// Use useCallback for event handlers
const handleClick = useCallback((slot) => {
dispatch(useItem(slot));
}, [dispatch]);
// Use useMemo for expensive calculations
const totalWeight = useMemo(() => {
return items.reduce((sum, item) => sum + (item.weight * item.count), 0);
}, [items]);
5. Development vs Production
5. Development vs Production
Handle development vs production mode:
Copy
const isDevelopment = !window.GetParentResourceName;
// Mock data for development
if (isDevelopment) {
useEffect(() => {
setInventory(mockInventoryData);
setVisible(true);
}, []);
}
// Fetch helper that works in both modes
export const fetchNui = async (eventName, data) => {
if (isDevelopment) {
console.log('Dev mode - would call:', eventName, data);
return { success: true };
}
// Production code
const resourceName = window.GetParentResourceName();
const response = await fetch(`https://${resourceName}/${eventName}`, {
method: 'POST',
body: JSON.stringify(data)
});
return await response.json();
};
Debugging UI
Copy
// Add debug panel in development
{isDevelopment && (
<div style={{ position: 'absolute', top: 0, left: 0, background: 'black', padding: 10 }}>
<h3>Debug Panel</h3>
<button onClick={() => setVisible(!visible)}>Toggle Visible</button>
<button onClick={() => setItems(mockData)}>Load Mock Data</button>
<pre>{JSON.stringify({ visible, itemCount: items.length }, null, 2)}</pre>
</div>
)}
// Console logging
useEffect(() => {
console.log('Inventory state changed:', { items, visible });
}, [items, visible]);
// Redux DevTools
import { createStore, applyMiddleware, compose } from 'redux';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
);
Next Steps
UI Development Guide
Complete guide to building UIs for Mythic
React Setup
Setting up a new React UI resource
NUI Communication
Deep dive into Lua ↔ React communication
Example UIs
Build your first custom UI
Start with an existing UI as a template. Copy
mythic-hud or mythic-menu and modify it for your needs. This is faster than building from scratch.