Menus as State and Command Interfaces
See how a settings menu expresses feature state, sends commands to a worker, and shuts down without leaving stale changes.
Cheatsheet for Lesson 8.6
A one-page summary of “Menus as State and Command Interfaces” you can keep beside your notes. Plain Markdown.
Lesson 8.6 — Menus as State and Command Interfaces: cheatsheet
- Remember: The menu sends owned requests; the worker owns process operations and reports whether they actually succeeded.
- Also useful: Immediate mode redraws a description from saved settings; retained mode keeps the interface objects themselves. Both still need somewhere to keep the chosen values.
Key terms
MenuApp: TheMenuAppstores the setting between frames; the widget does not.- state synchronization: This is state synchronization in miniature.
settings:settingsis the proposed state.last_applied:last_appliedis the last state the UI knows it submitted.
Review
- Q: Why should an egui menu send an owned Settings command instead of writing game memory directly during every UI frame? A: Repainting stays side-effect free, and one worker owns validated handles and cleanup
- Q: Which four questions should a game-tool menu answer clearly? A: What features exist, each one's state, what reached the worker, and how shutdown restores normal
A menu is a control surface, not the hack itself
Section titled “A menu is a control surface, not the hack itself”A useful game-tool menu answers four questions clearly:
- What features exist?
- What state is each feature in?
- Which changes have actually reached the worker?
- How does everything return to normal during shutdown?
The menu should not own a process handle, execute pointer chains while painting a checkbox, or patch memory sixty times per second. Its job is to edit ordinary settings and send deliberate commands to a worker. That separation makes the interface responsive and the low-level code reviewable. 🎛️
This tutorial builds a separate desktop window. That is the best first menu: it is easy to debug, does not run under loader lock, and can stay open beside Wesnoth, AssaultCube, or another local course target. Integrating the same widgets into an injected renderer is a later graphics-backend problem.
Create the project
Section titled “Create the project”menu_lab.rs
Show the codeHide the code
use std::{
sync::mpsc::{self, Receiver, Sender},
thread,
};
use eframe::egui;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum OverlayMode {
#[default]
Off,
TeamOnly,
AllActors,
}
#[derive(Clone, Debug, PartialEq)]
struct Settings {
overlay: OverlayMode,
show_names: bool,
show_distance: bool,
field_of_view: f32,
observation_only: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
overlay: OverlayMode::Off,
show_names: true,
show_distance: true,
field_of_view: 75.0,
observation_only: true,
}
}
}
#[derive(Debug)]
enum Command {
Apply(Settings),
RestoreDefaults,
Shutdown,
}
struct MenuApp {
settings: Settings,
last_applied: Settings,
commands: Sender<Command>,
status: String,
}
impl MenuApp {
fn new(commands: Sender<Command>) -> Self {
Self {
settings: Settings::default(),
last_applied: Settings::default(),
commands,
status: "Ready — no target changes have been requested.".into(),
}
}
fn apply_if_changed(&mut self) {
if self.settings == self.last_applied {
self.status = "Nothing changed.".into();
return;
}
// 📬 The UI sends owned data. The worker never borrows widget state.
match self.commands.send(Command::Apply(self.settings.clone())) {
Ok(()) => {
self.last_applied = self.settings.clone();
self.status = "Settings sent to the tool worker.".into();
}
Err(_) => self.status = "Worker stopped; no settings were applied.".into(),
}
}
}
impl eframe::App for MenuApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("Game Hacking Academy — lab menu");
ui.label("A separate settings window for the offline target.");
ui.separator();
egui::ComboBox::from_label("Overlay mode")
.selected_text(format!("{:?}", self.settings.overlay))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.settings.overlay, OverlayMode::Off, "Off");
ui.selectable_value(
&mut self.settings.overlay,
OverlayMode::TeamOnly,
"Teammates only",
);
ui.selectable_value(
&mut self.settings.overlay,
OverlayMode::AllActors,
"All local actors",
);
});
ui.checkbox(&mut self.settings.show_names, "Show names");
ui.checkbox(&mut self.settings.show_distance, "Show distance");
ui.add(
egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)
.text("Field of view"),
);
// 🔒 Observation-only is the safe default and stays visually clear.
ui.checkbox(
&mut self.settings.observation_only,
"Observation only (no writes or synthetic input)",
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Apply changes").clicked() {
self.apply_if_changed();
}
if ui.button("Restore defaults").clicked() {
self.settings = Settings::default();
let _ = self.commands.send(Command::RestoreDefaults);
self.last_applied = self.settings.clone();
self.status = "Defaults restored.".into();
}
});
ui.separator();
ui.label(&self.status);
ui.small("Tip: the worker owns process handles; this window owns only settings.");
});
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
// 🧹 Give the worker one explicit cleanup path before the sender drops.
let _ = self.commands.send(Command::Shutdown);
}
}
fn lab_worker(commands: Receiver<Command>) {
while let Ok(command) = commands.recv() {
match command {
Command::Apply(settings) => {
// 🧪 Replace this print with the book's verified local-lab
// adapter. Keep every raw handle and memory write in that worker.
println!("apply: {settings:?}");
}
Command::RestoreDefaults => println!("restore defaults"),
Command::Shutdown => {
// 🧹 Restore patches and release held input here, then exit.
println!("worker shutdown");
break;
}
}
}
}
fn main() -> eframe::Result {
let (command_tx, command_rx) = mpsc::channel();
let worker = thread::spawn(move || lab_worker(command_rx));
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([620.0, 470.0])
.with_min_inner_size([440.0, 360.0]),
centered: true,
..Default::default()
};
let result = eframe::run_native(
"GHA tool menu",
options,
Box::new(move |_creation| Ok(Box::new(MenuApp::new(command_tx)))),
);
// ✅ `on_exit` stops the worker; joining proves cleanup completed.
let _ = worker.join();
result
}The buildable course version lives in
menu_lab.rs. It uses
eframe 0.36, whose egui UI is well suited to tools that redraw interactive
controls every frame:
[dependencies]eframe = { version = "0.36", default-features = false, features = [ "default_fonts", "glow",] }From the windows-labs directory, run:
cargo run --bin menu_labThe first build downloads and compiles the windowing and OpenGL support, so it will take longer than later runs.
Put the settings in one type
Section titled “Put the settings in one type”Avoid one unrelated global boolean per feature. A settings value is easier to copy, compare, validate, save, and send:
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]enum OverlayMode { #[default] Off, TeamOnly, AllActors,}
#[derive(Clone, Debug, PartialEq)]struct Settings { overlay: OverlayMode, show_names: bool, show_distance: bool, field_of_view: f32, observation_only: bool,}The enum is important. Three booleans named overlay_off, team_overlay, and
all_overlay could all become true at once. One OverlayMode value can hold
exactly one variant. ✅
observation_only defaults to true. A safe default is a design feature, not a
warning paragraph the user has to remember.
Understand immediate-mode UI
Section titled “Understand immediate-mode UI”In a retained UI toolkit, you create a checkbox object and the framework keeps
it. In egui’s immediate mode, your ui function describes the interface
again each frame:
ui.checkbox(&mut self.settings.show_names, "Show names");ui.add( egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0) .text("Field of view"),);The checkbox borrows show_names for this call. It reads the current value,
draws the correct box, and changes the value if the user clicks. The MenuApp
stores the setting between frames; the widget does not.
This is why low-level work must not happen unconditionally in ui:
This intentionally bad menu variant draws a slider and immediately writes its current value to the owned lab process. It assumes a Process in self.process and a verified FOV_ADDRESS; the real course menu below only sends commands. Immediate-mode UI redraws repeatedly, so repainting the bad variant repeats that side effect.
impl MenuApp { fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> { ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)); self.process.write_exact( FOV_ADDRESS, &self.settings.field_of_view.to_le_bytes(), )?; // Every frame. Ok(()) }}Make the user event explicit: clicking Apply sends one command to the worker. The worker still validates the request and reports whether it was applied; queuing a command is not proof that a write succeeded.
Improved code
impl MenuApp { fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> { ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)); if ui.button("Apply changes").clicked() { self.commands.send(Command::Apply(self.settings.clone()))?; // One request. } Ok(()) }}Show the diff
impl MenuApp { fn draw_settings(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> { ui.add(egui::Slider::new(&mut self.settings.field_of_view, 30.0..=140.0)); self.process.write_exact( FOV_ADDRESS, &self.settings.field_of_view.to_le_bytes(), )?; // Every frame. if ui.button("Apply changes").clicked() { self.commands.send(Command::Apply(self.settings.clone()))?; // One request. } Ok(()) }}Takeaway: a redraw displays state; an explicit command requests a state change.
The first version repeats a side effect merely because the menu repainted. The second version names the user action that triggers one update.
Design the event before drawing the widget
Section titled “Design the event before drawing the widget”An event-driven tool waits for something meaningful to happen, then runs the small handler that owns that event. Describe each interaction before choosing colors, icons, or panel layouts:
| User event | UI command | Worker result | Visible feedback |
|---|---|---|---|
| clicks Apply changes | Apply(settings) |
accepted or rejected | exact status and reason |
| clicks Restore defaults | RestoreDefaults |
features disabled and state restored | “Restored” only after acknowledgement |
| presses Escape | close the menu | no game-state change | menu disappears |
| closes the app | Shutdown |
keys released, patches restored, handles closed | window exits after worker joins |
This table prevents a common UI logic bug: the button changes its label or color, but no one can say whether the worker completed the requested action. The worker owns the game-hacking side effect; the menu owns the request and the truthful report of its result.
Give the worker commands, not widget references
Section titled “Give the worker commands, not widget references”The menu and worker may run on different threads. Send owned data through a channel:
#[derive(Debug)]enum Command { Apply(Settings), RestoreDefaults, Shutdown,}
let (command_tx, command_rx) = std::sync::mpsc::channel();let worker = std::thread::spawn(move || lab_worker(command_rx));Apply(Settings) owns a snapshot. The UI can keep changing its copy without
racing with the worker. The worker is the only place that should own Windows
handles and call the verified adapters from earlier lessons.
The course lab intentionally prints settings instead of modifying a game:
fn lab_worker(commands: Receiver<Command>) { while let Ok(command) = commands.recv() { match command { Command::Apply(settings) => println!("apply: {settings:?}"), Command::RestoreDefaults => println!("restore defaults"), Command::Shutdown => break, } }}Replace only that adapter with a version-checked game bridge. The UI architecture stays the same whether the worker reads a snapshot, controls an overlay, or applies the guarded Wesnoth gold change from Chapter 3.
Do not resend unchanged settings
Section titled “Do not resend unchanged settings”Keep both the currently edited value and the last value sent:
The method below implements the decision in that diagram. It sends a command only when settings differ from the last sent values; a successful send updates the remembered copy and the message the user sees. The worker still has to report whether it actually applied the command.
fn apply_if_changed(&mut self) { if self.settings == self.last_applied { self.status = "Nothing changed.".into(); return; }
if self.commands.send(Command::Apply(self.settings.clone())).is_ok() { self.last_applied = self.settings.clone(); self.status = "Settings sent to the tool worker.".into(); }}This is state synchronization in miniature. settings is the proposed
state. last_applied is the last state the UI knows it submitted. In a more
advanced tool, the worker can answer with an acknowledgement so the menu shows
“applied,” “rejected,” or “target closed” instead of guessing.
Add a menu hotkey without accidental repeats
Section titled “Add a menu hotkey without accidental repeats”Use the rising-edge helper from Lesson 6.9 for VK_INSERT. Polling
GetAsyncKeyState(VK_INSERT) and toggling on every frame while it is negative
will make the menu flicker open and closed. Toggle only when the key changes
from up to down:
if insert_edge.pressed_now(VK_INSERT) { menu_open = !menu_open;}When the menu is open, decide who owns input:
- a separate menu window receives input only while the user focuses it;
- an injected overlay should ask egui whether it wants pointer or keyboard input before forwarding those events to the game;
- closing the menu must release any synthetic key or mouse button the worker still owns;
- losing target focus should pause active input features.
Do not “fix” focus by continuously forcing the game window to the foreground. Let the user choose which application receives input.
Make the menu readable
Section titled “Make the menu readable”Good menus are boring in the best way:
- use verbs such as Apply changes and Restore defaults;
- group read-only, overlay, and input features separately;
- show units on numbers (
degrees,pixels,milliseconds); - disable controls that cannot currently work and explain why;
- display the target build and connection state near the top;
- keep dangerous actions behind an explicit confirmation;
- never use color as the only sign of success or failure.
- keep Stop, Restore defaults, and Escape easy to reach;
- preserve keyboard navigation and readable contrast at every theme setting;
- remember cosmetic preferences without silently remembering an unsafe active feature.
The full example sets a minimum window size. Otherwise, a narrow window can wrap a label such as Restore defaults into a column of one-letter lines while the button is still technically present. Desktop tool menus need enough width for their actual text and controls.
Shut down in the reverse order of setup
Section titled “Shut down in the reverse order of setup”MenuApp::on_exit sends Command::Shutdown. The worker should then:
- stop accepting new feature commands;
- release held keys and mouse buttons;
- restore bytes or graphics state it changed;
- unhook only hooks it installed;
- close its owned handles;
- report completion and exit.
The main thread joins the worker before returning. A window disappearing is not proof that cleanup finished; joining turns cleanup into something the program actually waits for.
From tool window to transparent overlay
Section titled “From tool window to transparent overlay”Once this separate menu behaves correctly, reuse its Settings and Command
types in an overlay. The overlay-specific layer must additionally:
- track the game client rectangle with
GetClientRectandClientToScreen; - respond to DPI and resize changes;
- switch between click-through mode when closed and interactive mode when open;
- save and restore the game’s graphics state if drawing inside its renderer;
- keep the target’s update thread free of file I/O, sleeping, and channel waits.
Build the control model first. Each feature still needs an owner, a defined set of commands, and a way to restore any bytes it changes when the menu moves into the game. The tool-architecture lesson combines those responsibilities with snapshots, feature lifecycles, version checks, and failure handling. Chapter 7 then follows the camera and drawing state that an in-game display would need.
Reference
Section titled “Reference”Keep painting separate from side effects
Why should an egui menu send an owned Settings command instead of writing game memory directly during every UI frame?
Immediate-mode UI runs repeatedly. Sending deliberate snapshots prevents repeated writes and keeps low-level ownership in one reviewable worker.
Finished Lesson 8.6?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.