Skip to content
SiteEmail

The ability to send data seamlessly between JavaScript and the game engine is a core feature of Gameface. However, treating the backend as a global state manager for every UI element creates a severe performance bottleneck. Cross-boundary communication carries a cost.

Sending events to the C++ engine 60 times a second simply to update the visual fill of a dragging slider steals CPU cycles from core game logic. Because Gameface is built on web standards, you can leverage the same state-rich frontend practices used in modern web development to hold intermediate state.

Visual toggles like opening a settings dropdown or expanding a collapsible menu do not require backend validation. The game engine only needs to know the final outcome of the interaction.

For a dropdown menu, you can keep the isOpen state entirely within local JavaScript variables. You do not need to bind the UI structure to a backend model.

GraphicsDropdown.tsx
import { createSignal } from 'solid-js';
import Dropdown from '@components/Basic/Dropdown/Dropdown';
const QUALITY_OPTIONS = ['Low', 'Medium', 'High', 'Ultra'];
const GraphicsDropdown = () => {
const [isOpen, setIsOpen] = createSignal(false);
const handleChange = (value: string) => {
engine.trigger('ApplyGraphicsSetting', value);
setIsOpen(false);
};
return (
<Dropdown onChange={handleChange}>
<Dropdown.Options>
{QUALITY_OPTIONS.map((q) => (
<Dropdown.Option value={q.toLowerCase()}>{q}</Dropdown.Option>
))}
</Dropdown.Options>
</Dropdown>
);
};

By managing the interaction locally, the frontend intercepts rapid clicks, state reversals, and hover effects. The C++ backend remains entirely unaware of the dropdown menu’s existence until the user makes a concrete decision that affects the game world.

Interactive elements like sliders generate continuous streams of data while the user manipulates them. A common mistake is configuring a two-way data binding between the slider’s value and the game engine. This hands the continuous drag data directly to the C++ backend, firing dozens of events per second as the user searches for the right volume level.

You must separate the visual representation of the input from the data submission. Update the UI locally during the dragging phase and push the final value to the engine only when the interaction concludes.

VolumeSlider.tsx
import { createSignal } from 'solid-js';
import Slider from '@components/Basic/Slider/Slider';
import Block from '@components/Layout/Block/Block';
const VolumeSlider = () => {
const [volume, setVolume] = createSignal(50);
let pendingValue = 50;
const handleChange = (value: number) => {
pendingValue = value;
setVolume(value);
};
const handleRelease = () => {
engine.call('SetMasterVolume', pendingValue).then((success: boolean) => {
if (!success) {
// revert or show error state via signal
}
});
};
return (
<Block onMouseUp={handleRelease}>
<Slider
min={0}
max={100}
value={volume()}
step={1}
onChange={handleChange}
/>
</Block>
);
};

For one-off interactable elements, local variables are sufficient. However, when dealing with complex menus like an options screen, the local state becomes too complex for scattered variables.

For massive lists of inputs, it’s usually better to utilize a reactive UI store (like a SolidJS store).

First, read the initial configuration from the game engine, copy it exactly into your local store, bind your complex UI to that local store, and send the updated object back to the engine only when the user applies the changes.

SettingsMenu.jsx
import { createStore, unwrap } from "solid-js/store";
import { onMount } from "solid-js";
// Custom input components
import { Slider, Toggle, Select } from "./settings-components";
export function SettingsMenu() {
// 1. Define the local UI store
const [settings, setSettings] = createStore({
masterVolume: 50,
sfxVolume: 75,
invertYAxis: false,
subtitlesEnabled: true,
fullscreen: false,
textureQuality: "Medium",
});
// 2. Fetch the true game state on load and populate the local store
onMount(() => {
engine.whenReady.then(() => {
engine.call("GetGameSettings").then((backendSettings) => {
setSettings(backendSettings);
});
});
});
// 3. Send the modified store back to the engine only upon confirmation
const applyChanges = () => {
// unwrap() converts the SolidJS proxy back into a plain JavaScript object
const finalData = unwrap(settings);
engine.call("ApplyGameSettings", finalData).then((success) => {
if (success) console.log("Settings applied successfully.");
});
};
return (
<div class="settings-menu">
{/* All mutations stay in the local store until the Apply button is pressed */}
<Slider label="Master Volume" value={settings.masterVolume} onChange={(value) => setSettings("masterVolume", value)} />
<Slider label="SFX Volume" value={settings.sfxVolume} onChange={(value) => setSettings("sfxVolume", value)} />
<Toggle label="Invert Y-Axis" checked={settings.invertYAxis} onChange={(checked) => setSettings("invertYAxis", checked)} />
<Toggle label="Subtitles" checked={settings.subtitlesEnabled} onChange={(checked) => setSettings("subtitlesEnabled", checked)} />
<Toggle label="Fullscreen" checked={settings.fullscreen} onChange={(checked) => setSettings("fullscreen", checked)} />
<Select
label="Texture Quality"
value={settings.textureQuality}
options={["Low", "Medium", "High", "Ultra"]}
onChange={(value) => setSettings("textureQuality", value)}
/>
<button class="apply-btn" onClick={applyChanges}>
Apply Changes
</button>
</div>
);
}

Many UI features involve temporary lifecycles, such as toast notifications, hit markers, or floating combat text. The game engine should never be responsible for counting seconds to determine when a UI element must fade out.

The game engine issues a single, fire-and-forget command. The frontend catches this command, renders the visual, manages the active duration, and executes the cleanup animation locally.

toast-logic.js
const toastContainer = document.getElementById("toast-container");
// The engine fires this event once and immediately forgets about it
engine.on("PlayerEarnedAchievement", (achievementName) => {
const toast = createToast(achievementName);
toastContainer.appendChild(toast);
// JavaScript owns the UI timer
setTimeout(() => {
toast.classList.replace("toast--enter", "toast--exit");
// Wait for the CSS fade-out animation to finish before removing the DOM node
setTimeout(() => toast.remove(), 300);
}, 4000);
});